Skip to content

fix(kyc): stop selling a bank ID check to residences no bank onboards - #2947

Merged
innolope-dev merged 8 commits into
devfrom
fix/kyc-intent-from-residence
Sep 3, 2026
Merged

fix(kyc): stop selling a bank ID check to residences no bank onboards#2947
innolope-dev merged 8 commits into
devfrom
fix/kyc-intent-from-residence

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

The bug

The six bank KYC entry points derived the Sumsub region intent from the destination country, never from the user's residence:

call site line
AddWithdrawCountriesList.tsx 264, 379
add-money/[country]/bank/page.tsx 294
withdraw/[country]/bank/page.tsx 607
BankFlowManager.view.tsx 324, 620

So a UK, Japanese or sanctioned resident tapping Withdraw → Spain → SEPA was routed to bridge-requirements — a Bridge level that, for their residence, can only end on a terminal rejection. They uploaded a document and did liveness before anything told them.

The residence-aware helper already existed and was already correct (regionIntentForResidence, routing RESTRICTED_RESIDENCE_ISO2 and BANKING_RESTRICTED_RESIDENCE_ISO2 to ROW) — it just had no call site on this path.

Why the existing guards never fired

Both read post-hoc signals:

  • isRegionRestricted is status === 'failed' && reason.code === 'identity_region_restricted' — it needs a Sumsub rejection to have already happened, so the InitiateKycModal choke point could not help a user who had not yet been rejected.
  • The capability model's UK block reads sumsubGeo (peanut-api-ts kyc/capabilities/load.ts), which is null until KYC approves.

Pre-KYC a restricted resident therefore has no rail to carry uk_resident_blocked and no rejection to flag, reads as plain needs-identity, and gets offered "Unlock now". Meanwhile user.residenceRestrictions — the server's own answer, derived from the KYC-reported residence or the one declared at signup — was sitting right there unused.

The change

1. useBankRegionIntent — the destination stays in charge, because it is what decides which provider's rails the verification opens; a Brazilian resident really does need the Bridge level to withdraw to a Spanish IBAN. It forces ROW only when every residence we know rules bank rails out.

It reads useResidenceRestrictions().banking rather than the country code, which buys three things for free: the server's answer wins over the local declaration, the KYC-reported residence wins over the declared one, and the dual-resident intersection applies — someone who declared GB and BR keeps the destination intent, because a bank level is still winnable for them.

2. InitiateKycModal — the same decision made once, next to the outage and region-restricted short-circuits, so a future call site cannot miss it. All eight render sites are bank/Manteca money-movement gates, so no escape hatch is needed. Ordering: outage > region-restricted (a document-jurisdiction block is the more specific ending) > region-unavailable (the UK copy is more specific than the new country-neutral pair) > this.

New copy in all four catalogs, country-neutral for the same reason restricted-regions.ts gives — one string covers every restricted residence, and compliance changing the list needs no re-translation. CTA reuses useRegionRestrictedCta: no retry, no contact-support (nobody can lift a residence block), just the part of the app that still works.

3. Get-started checklist — decided yes, drop it. The row's subtitle read "Bank transfer or crypto · bank needs a one-time ID check" for everyone, naming the ID check as the price of a rail these residences cannot have. It now reads "Crypto from any wallet or exchange". This follows the ruling the signup residence step already makes for its congrats copy: where no rail exists, the ID-check clause is dropped entirely rather than promising a rail verification cannot deliver.

The two surfaces the guard above does not reach

qr-pay renders its own CTAs rather than InitiateKycModal, so the choke point never covered it — and it derives every state from rail status, never reading the identity verdict. A user Sumsub refused on jurisdiction owns no Manteca rail at all, so the candidate list is empty, they fall through to REQUIRES_IDENTITY_VERIFICATION, and are offered the verification that just turned them down. Indefinitely.

It now short-circuits on isRegionRestricted above every rail-derived state, rendering the same ending the drawer and InitiateKycModal already give.

Keyed on the post-hoc Sumsub signal and deliberately not on residence. The QR pool is residence-agnostic by design (rails.consts.ts: "any Sumsub-approved user can pay QR codes via the corporate pool account, regardless of residence"), and the resolver's UK block only touches provider === 'bridge' — so Japanese, Algerian, Burundian, Tunisian and UK residents all legitimately hold QR pay. Gating this on banking would have removed a capability those users really have. Only a jurisdictional document refusal makes the offer dishonest here, and that list stays in Sumsub where restricted-regions.ts insists it belongs.

The add-money / withdraw country list keeps every entry — the residence sets are advisory offer-shaping, and hiding the list would leave a near-empty screen — but names the restriction once above it, instead of letting the user discover it three taps into a flow that cannot finish. Mirrors the note Unlock payments already carries.

Still not in scope — worth a separate task

  • BANKING_RESTRICTED_RESIDENCE (DZ/BI/JP/TN) has no backend enforcement at all. No capability block analogous to the UK one, and POST /users/identity never consults residence — it mints verificationLevelForIntent(regionIntent) verbatim. The residence-aware canonicalIntentForResidence exists in kyc/residence-intent.ts but is only wired into restart-identity.ts. These residents complete bridge-requirements and land on a raw Bridge provider rejection → "contact support", a ticket nobody can resolve. This PR closes the client half; the server still accepts whatever intent an older build sends it.

Verification

  • tsc --noEmit clean; eslint clean on all touched files (remaining warnings are pre-existing fetchUser / useCallback dep warnings on untouched code, confirmed by stashing); prettier --check clean; ds-lint --check at baseline on every ratchet.
  • New tests: useBankRegionIntent.test.tsx (8 cases — destination precedence, the three restricted tiers, card-only pass-through, dual-residence softening, server-over-local), InitiateKycModal.residence.test.tsx (6 — including that no path reaches the SDK, and both yield-to orderings), plus one case each for the qr-pay ending and the list notice.
  • Full unit suite: 435 suites / 5585 tests green. Four existing suites needed a useResidenceRestrictions stub now that the shared modal, the bank pages and the router view read it.

The six bank entry points derived the Sumsub level from the DESTINATION
country, so a UK, Japanese or sanctioned resident tapping "Withdraw →
Spain → SEPA" was routed to `bridge-requirements` and walked through a
full document check that could only end on a terminal rejection. Nothing
on that path consulted the residence they declared at signup, even though
the server has been returning `residenceRestrictions` all along.

The two guards that exist both read post-hoc signals and so never fire
pre-KYC: `isRegionRestricted` needs a Sumsub rejection to have happened,
and the capability model's UK block reads `sumsubGeo`, which is null until
approval. A restricted resident therefore reads as plain `needs-identity`
and gets offered "Unlock now".

- useBankRegionIntent keeps the destination in charge — a Brazilian
  resident really does need the Bridge level for a SEPA destination — and
  forces ROW only when every residence we know rules bank rails out. It
  reads `banking` rather than the country code, so it inherits the server's
  answer and the dual-resident intersection for free.
- InitiateKycModal decides the same thing once, next to the outage and
  region-restricted short-circuits, so a future call site cannot miss it.
  It yields to `region-unavailable`, whose UK copy is more specific than
  the new country-neutral pair.
- The Get-started checklist stops naming the ID check as the price of a
  bank rail these residences cannot have, the same ruling the signup
  residence step already makes for its congrats copy.
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
peanut-wallet Ready Ready Preview Sep 3, 2026 2:07pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 4207afcf-2bf7-44ec-9aab-83c07c7da46b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7064.01 → 7052.99 (-11.02)
Findings: +1 net (+93 new, -92 resolved)

🆕 New findings (93)

  • critical complexity — src/app/(mobile-ui)/qr-pay/page.tsx — CC 328, MI 52.66, SLOC 1208
  • critical complexity — src/app/(mobile-ui)/add-money/[country]/bank/page.tsx — CC 121, MI 57.15, SLOC 408
  • critical complexity — src/components/AddWithdraw/AddWithdrawCountriesList.tsx — CC 121, MI 56.18, SLOC 377
  • critical complexity — src/components/Claim/Link/views/BankFlowManager.view.tsx — CC 111, MI 46.73, SLOC 434
  • critical complexity — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — CC 106, MI 53.01, SLOC 397
  • critical complexity — src/components/AddWithdraw/AddWithdrawRouterView.tsx — CC 99, MI 58.19, SLOC 279
  • critical method-complexity — src/app/(mobile-ui)/qr-pay/page.tsx:102 — QRPayPage CC 83 SLOC 446
  • critical complexity — src/components/Kyc/InitiateKycModal.tsx — CC 71, MI 52.31, SLOC 171
  • high hotspot — src/app/(mobile-ui)/qr-pay/page.tsx — 142 commits, +1952/-1649 lines since 6 months ago
  • high hotspot — src/app/(mobile-ui)/add-money/[country]/bank/page.tsx — 73 commits, +711/-468 lines since 6 months ago
  • high hotspot — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — 65 commits, +598/-318 lines since 6 months ago
  • high hotspot — src/components/AddWithdraw/AddWithdrawCountriesList.tsx — 53 commits, +738/-507 lines since 6 months ago
  • high method-complexity — src/app/(mobile-ui)/add-money/[country]/bank/page.tsx:66 — BridgeBankOnrampPage CC 40 SLOC 198
  • high method-complexity — src/components/Claim/Link/views/BankFlowManager.view.tsx:351 — CC 37 SLOC 115
  • high method-complexity — src/components/Kyc/InitiateKycModal.tsx:71 — CC 31 SLOC 67
  • high method-complexity — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:63 — WithdrawBankPage CC 30 SLOC 168
  • high complexity — src/components/Home/GettingStartedChecklist.tsx — CC 30, MI 61.81, SLOC 102
  • medium react-long-component — src/app/(mobile-ui)/qr-pay/page.tsx:102 — QRPayPage is 1787 lines — split it
  • medium react-long-component — src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:63 — WithdrawBankPage is 575 lines — split it
  • medium high-mdd — src/app/(mobile-ui)/qr-pay/page.tsx:102 — QRPayPage: MDD 486.3 (uses across many lines from declarations)

…and 73 more.

✅ Resolved (92)

  • src/app/(mobile-ui)/qr-pay/page.tsx — CC 326, MI 52.75, SLOC 1200
  • src/app/(mobile-ui)/add-money/[country]/bank/page.tsx — CC 121, MI 57.2, SLOC 406
  • src/components/AddWithdraw/AddWithdrawCountriesList.tsx — CC 121, MI 56.24, SLOC 375
  • src/components/Claim/Link/views/BankFlowManager.view.tsx — CC 110, MI 46.95, SLOC 425
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — CC 106, MI 53.06, SLOC 395
  • src/components/AddWithdraw/AddWithdrawRouterView.tsx — CC 98, MI 58.34, SLOC 275
  • src/app/(mobile-ui)/qr-pay/page.tsx:100 — QRPayPage CC 82 SLOC 443
  • src/components/Kyc/InitiateKycModal.tsx — CC 64, MI 53.6, SLOC 154
  • src/app/(mobile-ui)/qr-pay/page.tsx — 139 commits, +1911/-1627 lines since 6 months ago
  • src/app/(mobile-ui)/add-money/[country]/bank/page.tsx — 72 commits, +708/-466 lines since 6 months ago
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx — 64 commits, +594/-316 lines since 6 months ago
  • src/components/AddWithdraw/AddWithdrawCountriesList.tsx — 52 commits, +734/-504 lines since 6 months ago
  • src/app/(mobile-ui)/add-money/[country]/bank/page.tsx:66 — BridgeBankOnrampPage CC 40 SLOC 196
  • src/components/Claim/Link/views/BankFlowManager.view.tsx:313 — CC 36 SLOC 112
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:62 — WithdrawBankPage CC 30 SLOC 166
  • src/app/(mobile-ui)/qr-pay/page.tsx:100 — QRPayPage is 1770 lines — split it
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:62 — WithdrawBankPage is 574 lines — split it
  • src/app/(mobile-ui)/qr-pay/page.tsx:100 — QRPayPage: MDD 481.7 (uses across many lines from declarations)
  • src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx:62 — WithdrawBankPage: MDD 212.8 (uses across many lines from declarations)
  • src/app/(mobile-ui)/add-money/[country]/bank/page.tsx:66 — BridgeBankOnrampPage: MDD 184.8 (uses across many lines from declarations)

…and 72 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/useBankRegionIntent.ts 0.0 3.7 +3.7
src/components/Kyc/InitiateKycModal.tsx 10.3 11.0 +0.7

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 5597 ran, 0 failed, 0 skipped, 1.7m

📊 Coverage (unit)

metric %
statements 73.9%
branches 59.3%
functions 67.5%
lines 74.9%
⏱ 10 slowest test cases
time test
🐢 9.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Network failure keeps loading while retries remain, then shows the generic error
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › User KYC not approved fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_SOURCE_OVER_MONTHLY_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_RECENT_REFUND fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_MERCHANT_VOLUME_NEAR_CAP fails fast with copy that names the real cause
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › a refused idempotency key tells the user to scan again, not to contact support
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › routes the KYC rejection on its wire code, and does not retry it
4.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › MANTECA_USER_NOT_PROVISIONED fails fast with copy that names the real cause
3.2s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
3.0s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Going offline blames the connection, and reconnecting clears it for the recovered scan
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

The Bridge onramp page renders without a redux Provider in this suite, so
useResidenceRestrictions' useSetupStore threw for all of GROUP 5.
Four branches each re-checked the residence; one resolved variant reads
the same and drops the component back under its prior complexity.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🖼 Visual diff — 1 screen moved

1 of 60 shots changed · 59 identical · baseline c2811a2 → head d2b8d67

worst % screen widths
2.83% profile 320
new screens (1)
  • reconsent

job summary · before/after/diff images — artifact

Fixture screenshots, no backend. Advisory — this check never blocks a merge. Posted from the default branch by ds-shots-comment.yml; the report it renders is untrusted data.

qr-pay never reads the identity verdict — every state it shows is derived
from rail status — so a user Sumsub refused on jurisdiction owns no Manteca
rail at all, falls through to REQUIRES_IDENTITY_VERIFICATION and is offered
the same verification that just turned them down, indefinitely. It now
short-circuits on `isRegionRestricted` above every rail-derived state and
renders the one honest ending the drawer and InitiateKycModal already give.

Deliberately keyed on the post-hoc Sumsub signal and NOT on residence: the
QR pool is residence-agnostic by design (rails.consts.ts — any Sumsub-approved
user can pay via the corporate account regardless of where they live), so
gating it on `banking` would strip QR pay from Japanese, Algerian, Burundian,
Tunisian and UK residents who legitimately have it. Only a jurisdictional
document refusal makes the offer dishonest here.

The add-money / withdraw country list keeps every entry — the residence sets
are advisory, and hiding the list would leave a near-empty screen — but names
the restriction once above it instead of letting the user find out three taps
later. Mirrors the note Unlock payments already carries.
…C chrome

A sibling wrapper meant a second className site in a page file, which the
ds-lint ratchet counts. The state belongs in needsKycVerification anyway —
the modals there already self-gate on visible.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

Two defect classes from the four prior findings remain: the direct bank-claim path still starts verification for bank-restricted residences, and page presentation still hides the bank-unavailable title.

Findings

  • MAJOR · src/components/Claim/Link/views/BankFlowManager.view.tsx:325 · Block the direct bank-claim verification path
    ReceiverKycNeeded submits through this branch without rendering InitiateKycModal, so a resident with banking === true still calls /users/identity. bankRegionIntent only changes the argument to ROW; the backend maps ROW to the general Sumsub level and returns a token, so the SDK still opens for a bank claim that this residence cannot complete. Gate this branch on the residence restriction and render the same bank-unavailable ending before calling handleInitiateKyc; add a regression test asserting that the SDK handler is not called.

  • MINOR · src/components/Kyc/InitiateKycModal.tsx:118 · Show the unavailable title in page presentation
    On the add-money bank step=verify page, the pre-KYC prop variant is default, then this code resolves it to bank-unavailable. Page rendering still computes titleIsGeneric from the raw variant, so it suppresses getTitle() and leaves the generic Unlock payments nav title above the unavailable description and CTA. Compute titleIsGeneric from resolvedVariant and cover presentation="page" with banking: true.

  • MAJOR · src/components/Claim/Link/views/BankFlowManager.view.tsx:325 · Block the direct bank-claim KYC path
    ReceiverKycNeeded submits through this branch without rendering InitiateKycModal, so a resident with banking === true still calls /users/identity. bankRegionIntent only changes the argument to ROW; the backend maps ROW to the general Sumsub level and returns a token, so the SDK still opens for a bank claim that this residence cannot complete. Gate this branch on the residence restriction and render the same bank-unavailable ending before calling handleInitiateKyc; add a regression test asserting that the SDK handler is not called.

  • MINOR · src/components/Kyc/InitiateKycModal.tsx:118 · Show the unavailable title in page mode
    On the add-money bank step=verify page, the pre-KYC prop variant is default, then this code resolves it to bank-unavailable. Page rendering still computes titleIsGeneric from the raw variant, so it suppresses getTitle() and leaves the generic Unlock payments nav title above the unavailable description and CTA. Compute titleIsGeneric from resolvedVariant and cover presentation="page" with banking: true.

  • MAJOR · src/i18n/app/messages/es-AR.json:1064 · [moonshotai/kimi-k3] es-AR catalog missing kyc.initiate.titleBankUnavailable
    InitiateKycModal now calls t('initiate.titleBankUnavailable') for the new bank-unavailable branch, and en, es-419 and pt-BR all add the key — but es-AR only adds descriptionBankUnavailable. next-intl throws on a missing message key at render time, so an es-AR user whose residence restricts banking (exactly the users this PR targets) hits a runtime error instead of the 'Not available in your country' screen. Fix: add "titleBankUnavailable": "No disponible en tu país" (or equivalent) to kyc.initiate in es-AR.json.

  • MINOR · src/constants/residence.consts.ts:31 · [claude-opus] Banking-restricted residence list misses Guinea-Bissau
    This PR makes BANKING_RESTRICTED_RESIDENCE_ISO2 the authority that decides whether Peanut sells a bank ID check, across six bank entry points plus the InitiateKycModal choke point and useBankRegionIntent (whose docblock says it covers "the sanctioned set, the UK rule, Bridge's banking exclusions"). The list holds DZ, BI, JP, TN.

Product truth names six countries where Bridge onboards but no rail is ever Yes: product/providers/fiat/eligibility.md:463-464 and :1027-1029 — "Algeria, Burundi, China, Guinea-Bissau, Japan, Tunisia", repeated at :1539-1541 and :1966-1967. The code covers five of them (CN sits in RESTRICTED_RESIDENCE_ISO2), and omits GW. eligibility.md:1029 explains exactly how this happens: Bridge's prose note omits Guinea-Bissau while Bridge's own table includes it, and the code mirrored the prose.

The code is the wrong side here. A Guinea-Bissau resident keeps getting the outcome this PR set out to remove: the checklist row still names "Bank transfer or crypto · bank needs a one-time ID check", InitiateKycModal still offers "Unlock now", useBankRegionIntent still returns EU/NA, and the Sumsub run ends on a rail that can never enable.

The gap predates this PR and both repos agree (peanut-api-ts src/kyc/residence-restrictions.ts:53 has the same four), so nothing breaks cross-repo. Fix: add 'GW' to BANKING_RESTRICTED_RESIDENCE_ISO2 here and to BANKING_RESTRICTED_RESIDENCE in peanut-api-ts — the API-served /config/residence-restrictions list is what production actually reads, so the backend half is the one that takes effect without a release.

Checked clean

  • Confirmed the detached worktree HEAD, PR author, base ref, base SHA, and head SHA match the supplied review target.
  • Rechecked P1 and P3 through the ReceiverKycNeeded submit path and the sibling API's ROW-to-general verification mapping; both remain present.
  • Rechecked P2 and P4 through the add-money bank verify page and page-presentation title logic; both remain present.
  • Reviewed every changed production file and associated tests across bank entry points, QR pay, method-list messaging, checklist copy, restriction intent mapping, and translations.
  • Exact-head CI checks were successful or intentionally skipped; no PR-caused failing gate was present.
  • Security and adversarial passes found no new secret exposure, injection surface, privilege change, or unsafe workflow behavior beyond the reported correctness paths.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Third opinion by claude-opus: 1 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: abfc5f84d2f2 · Context: repo, sibling-api, ci · Took 12m (queued 9m)

Comment thread src/components/Claim/Link/views/BankFlowManager.view.tsx
Comment thread src/components/Kyc/InitiateKycModal.tsx
Comment thread src/components/Claim/Link/views/BankFlowManager.view.tsx
Comment thread src/components/Kyc/InitiateKycModal.tsx
Chip found two live defects in the residence guard.

The bank-claim form submits through handleSuccess straight into the SDK,
never rendering InitiateKycModal, so the choke point could not see it: a
bank-restricted resident still got a general-level token for a verification
their residence can never turn into a rail. That branch now refuses and
hands over to the modal.

Which meant hoisting the modal: it was rendered only by the confirm step,
so setting its state from the form step would have shown nothing at all —
a silent no-op on submit, worse than the wrong SDK. It is now defined once
and rendered by both steps, and the new test fails without that.

Page presentation computed titleIsGeneric from the caller's variant, so a
resolved bank-unavailable ending kept the generic "Unlock payments" nav
title above an unavailable description. It reads the resolved variant now.

Not taken: the es-AR missing-key finding is wrong — es-AR is a deltas
overlay on es-419 (loadMessages/deepMerge), so titleBankUnavailable
resolves through it, and the title needs no voseo delta. The diff-only
reviewer could not see the overlay; the catalog-parity test already covers
this and passes.
Bridge's table blanks every rail for GW; the prose note on the same page
omits it, and this list mirrored the prose (product/providers/fiat/
eligibility.md is explicit that the table is the authority). Harmless while
the set was advisory display, but this PR makes it the authority for whether
we sell a bank ID check — so a GW resident was still getting the outcome the
PR exists to remove.

peanut-api-ts BANKING_RESTRICTED_RESIDENCE needs the same entry; that is the
copy production actually serves, and this one is only the bundled fallback.

@chip-peanut-bot chip-peanut-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chip review — no blocking findings — this is not an approval

The earlier direct-claim and page-title findings are fixed. Two major gaps remain: an enabled QR rail bypasses the new regional terminal state, and Guinea-Bissau is absent from the server-authoritative banking restriction set.

Findings

  • MAJOR · src/app/(mobile-ui)/qr-pay/page.tsx:287 · Check regional rejection before allowing an existing QR pay rail
    A user can already own an ENABLED QR-pool rail from a prior Sumsub approval; a residence-change or reverification can later change the Sumsub identity row to REJECTED without revoking that user rail. In that state canDo('pay', { provider: 'manteca' }) returns before this new branch, so the terminal screen is skipped and the payment path remains available. Check isRegionRestricted before the enabled-pay return and add a case with both a jurisdictional rejection and an enabled pay rail.

  • MAJOR · src/constants/residence.consts.ts:36 · Add Guinea-Bissau to the server-authoritative restriction set
    GW is added only to the bundled fallback, while useResidenceRestrictions gives /users/me.residenceRestrictions precedence and the public config replaces this set after load. The current dev/staging API still returns bankingOnly: [BI, DZ, JP, TN], so an authenticated Guinea-Bissau resident resolves banking: false; every new guard and notice disappears and the bank KYC offer remains. Update the server's canonical residence set before or with this head and cover the server-authoritative GW case.

  • MAJOR · src/i18n/app/messages/es-AR.json:1064 · [moonshotai/kimi-k3] es-AR catalog missing titleBankUnavailable (and addMoneyRoutesNoBank)
    The diff adds kyc.initiate.descriptionBankUnavailable to es-AR.json but never adds the matching titleBankUnavailable, while InitiateKycModal.getTitle() calls t('initiate.titleBankUnavailable') for the bank-unavailable variant. next-intl throws on missing message keys by default, so an es-AR user with a bank-restricted residence hitting any of the eight InitiateKycModal render sites crashes the modal instead of seeing the honest ending — the exact users this PR is for. Same gap for home.checklist.addMoneyRoutesNoBank, which GettingStartedChecklist now renders for restricted residences. Add titleBankUnavailable and addMoneyRoutesNoBank to es-AR.json (en, es-419 and pt-BR all have both).

  • MAJOR · src/constants/residence.consts.ts:36 · [claude-opus] GW banking restriction is inert without the paired peanut-api-ts change
    BANKING_RESTRICTED_RESIDENCE_ISO2 in this repo is only the bundled fallback — useResidenceRestrictionSets.ts documents it as "the offline/pre-fetch fallback, never the source of truth" and swaps to GET /config/residence-restrictions once it loads, and useResidenceRestrictions prefers user.residenceRestrictions from /users/me (user?.residenceRestrictions ?? deriveResidenceRestrictionsFrom(...)) over any local derivation. Both of those come from peanut-api-ts src/kyc/residence-restrictions.ts:53, where BANKING_RESTRICTED_RESIDENCE is still new Set(['DZ','BI','JP','TN']) — no GW; the route that serves the tier lists is src/routes/config/residence-restrictions.ts:35. Failure scenario: a logged-in Guinea-Bissau resident gets residenceRestrictions.banking === false from the API, so useBankRegionIntent keeps returning the destination intent, InitiateKycModal keeps rendering the "Unlock now" offer, the checklist keeps naming the bank half, and the BankFlowManager refusal never fires — the exact sale this PR exists to stop. The pre-account signup window is no different, because the server config list (also GW-less) replaces the mirror as soon as it arrives. Nothing in peanut-api-ts breaks on merge; the UI half is simply a no-op for GW. This normally ships as a pair, and the API-side addition of 'GW' to BANKING_RESTRICTED_RESIDENCE is likely an open PR that this pinned policy-branch checkout cannot see — if that PR exists, this is already handled and only needs a merge-order note (API first, or the UI change is dead on arrival); if it does not, GW needs to be added there before this claim is true in production.

  • MINOR · src/constants/residence.consts.ts:25 · [claude-opus] The new GW row claims the card still works; the authoritative card denylist says it does not
    The added GW entry lands in the banking-only tier, so deriveResidenceRestrictions('GW') returns { banking: true, card: false } — asserted as expected in the new test case in src/hooks/__tests__/useResidenceRestrictions.test.tsx and reinforced by the copy this PR adds (descriptionBankUnavailable: "you can still send and receive money with other people"), while the residence step keeps offering the card. product/support-answers/card-country-restrictions.md (last_validated 2026-08-17, residence-based, from Aleks 2026-07-04 with GH/NG removals applied) lists GW in the restricted set and says to treat that list as authoritative. Failure scenario: a Guinea-Bissau resident is told bank transfers are unavailable but is still walked into a card application the issuer refuses on residence, so the honest ending this PR is trying to give them is only half honest. The code is the wrong side here, but note the gap is systemic and pre-existing rather than introduced by this diff: that doc's list has 53 entries and both CARD_RESTRICTED_RESIDENCE_ISO2 here and CARD_RESTRICTED_RESIDENCE / RAIN_PROHIBITED_COUNTRIES in peanut-api-ts carry only the same 9-country subset, and the doc itself flags product/card.md as stale. Fix is either to add GW to the card tier in both repos alongside the banking one, or to leave it and reconcile the whole list separately — but the GW row is the one this PR is ruling on, so it is worth deciding here.

Checked clean

  • P1/P3 fixed: the direct bank-claim form now stops restricted residences before invoking Sumsub and mounts the unavailable modal in the form step.
  • P2/P4 fixed: page presentation derives heading visibility from the resolved unavailable variant.
  • All six bank KYC entry points route through useBankRegionIntent; destination precedence and residence intersections were checked.
  • Locale catalogs and es-AR fallback were checked; all new keys resolve.
  • Exact-head CI checks for analysis, review, bot approval, and Vercel comments succeeded; Deploy Preview was still in progress.
  • Security boundaries and money-moving claim changes were inspected; no additional defect was found.

Second opinion by moonshotai/kimi-k3: 1 finding(s), marked with the model name. It reads the diff only, so treat its findings as advice.

Third opinion by claude-opus: 2 finding(s), marked with the model name. It answers only product truth, missing tests and the cross-repo contract, so treat its findings as advice.

Exact head: 35c791176a5f · Context: repository, product, api-contract, lexicon, provider-docs, ci · Took 8m (queued 11m)

Comment thread src/app/(mobile-ui)/qr-pay/page.tsx
Comment thread src/constants/residence.consts.ts
…tcut

Nothing revokes a pool rail granted by an earlier approval, so a residence
change that re-verifies into a region rejection leaves an ENABLED rail
behind. With `canDo('pay')` ranked first, that user kept the money path open
on an identity we can no longer verify, and never saw the terminal screen
every other surface gives them.

Deliberately unlike deriveGate's ready-wins hoist: that exists to stop a
STUCK SIBLING rail from blocking a working one (the 2026-06-01 guard), and a
terminally refused identity is not a sibling rail. Flagging it here because
it is the one ordering in this PR that closes a path rather than opening an
honest ending, so it is a compliance-leaning call worth a second opinion.
@innolope-dev
innolope-dev merged commit 45e11ba into dev Sep 3, 2026
21 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant