From 5667fec74716cb76707bf4ee7e98266740f81057 Mon Sep 17 00:00:00 2001 From: Makelangel00 Date: Wed, 30 Sep 2026 17:53:23 -0500 Subject: [PATCH 1/2] fix(wallet): hide unusable linked US banks and allow relinking A linked US bank only counts as an ACH source when both the account and the Plaid link are active. Failed links are hidden from account lists and no longer block linking a new bank to the same pocket. --- src/domain/accounts/external-us-bank.test.ts | 119 +++++++++++++++++++ src/domain/accounts/external-us-bank.ts | 42 +++++++ src/hooks/accounts/use-account-picker.ts | 25 ++-- src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/routes/_authed/accounts/$urn.tsx | 18 ++- src/routes/_authed/accounts/index.tsx | 3 +- src/routes/_authed/topup/us-banks/index.tsx | 30 ++--- 8 files changed, 203 insertions(+), 40 deletions(-) create mode 100644 src/domain/accounts/external-us-bank.test.ts create mode 100644 src/domain/accounts/external-us-bank.ts diff --git a/src/domain/accounts/external-us-bank.test.ts b/src/domain/accounts/external-us-bank.test.ts new file mode 100644 index 0000000..5216c6e --- /dev/null +++ b/src/domain/accounts/external-us-bank.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'bun:test'; +import { + isFailedUsBank, + isLinkingUsBank, + isUsableUsBank, + isUsBankLinkSettled, + visibleProducts, +} from './external-us-bank'; +import type { ExternalUsBankProduct, Product } from './types'; + +function bank( + status: string, + linkStatus: ExternalUsBankProduct['linkStatus'], +): Product { + return { + kind: 'external-us-bank', + urn: `bank-${status}-${linkStatus}`, + status, + linkStatus, + label: 'Bank', + balances: [], + }; +} + +const pocket: Product = { + kind: 'pocket', + urn: 'pocket-1', + status: 'active', + label: 'Pocket', + balances: [], +}; + +describe('isUsableUsBank', () => { + test('accepts an active account with an active link', () => { + expect(isUsableUsBank(bank('active', 'active'))).toBe(true); + }); + + test('rejects an active link whose account is still being created', () => { + expect(isUsableUsBank(bank('creation_in_progress', 'active'))).toBe(false); + }); + + test('rejects an active account still pending its link', () => { + expect(isUsableUsBank(bank('active', 'pending_link'))).toBe(false); + }); + + test('rejects other product kinds', () => { + expect(isUsableUsBank(pocket)).toBe(false); + }); +}); + +describe('isFailedUsBank', () => { + test('accepts a failed account creation', () => { + expect(isFailedUsBank(bank('creation_failed', 'pending_link'))).toBe(true); + }); + + test('accepts a failed link', () => { + expect(isFailedUsBank(bank('creation_in_progress', 'link_failed'))).toBe( + true, + ); + }); + + test('rejects an account still in progress', () => { + expect(isFailedUsBank(bank('creation_in_progress', 'active'))).toBe(false); + }); + + test('rejects other product kinds', () => { + expect(isFailedUsBank({ ...pocket, status: 'creation_failed' })).toBe( + false, + ); + }); +}); + +describe('isLinkingUsBank', () => { + test('accepts an active link whose account is still being created', () => { + expect(isLinkingUsBank(bank('creation_in_progress', 'active'))).toBe(true); + }); + + test('rejects a usable bank', () => { + expect(isLinkingUsBank(bank('active', 'active'))).toBe(false); + }); + + test('rejects a link still pending', () => { + expect(isLinkingUsBank(bank('creation_in_progress', 'pending_link'))).toBe( + false, + ); + }); +}); + +describe('isUsBankLinkSettled', () => { + test('is settled once usable or failed', () => { + expect(isUsBankLinkSettled(bank('active', 'active'))).toBe(true); + expect(isUsBankLinkSettled(bank('creation_failed', 'pending_link'))).toBe( + true, + ); + }); + + test('is not settled while linking or pending', () => { + expect(isUsBankLinkSettled(bank('creation_in_progress', 'active'))).toBe( + false, + ); + expect( + isUsBankLinkSettled(bank('creation_in_progress', 'pending_link')), + ).toBe(false); + }); +}); + +describe('visibleProducts', () => { + test('hides failed US bank links and keeps everything else', () => { + const usable = bank('active', 'active'); + const linking = bank('creation_in_progress', 'active'); + const failed = bank('creation_failed', 'pending_link'); + + expect(visibleProducts([pocket, usable, linking, failed])).toEqual([ + pocket, + usable, + linking, + ]); + }); +}); diff --git a/src/domain/accounts/external-us-bank.ts b/src/domain/accounts/external-us-bank.ts new file mode 100644 index 0000000..2749680 --- /dev/null +++ b/src/domain/accounts/external-us-bank.ts @@ -0,0 +1,42 @@ +import type { ExternalUsBankProduct, Product } from './types'; + +function isExternalUsBank(product: Product): product is ExternalUsBankProduct { + return product.kind === 'external-us-bank'; +} + +/** A linked US bank that can source an ACH payin right now. */ +export function isUsableUsBank(product: Product): boolean { + return ( + isExternalUsBank(product) && + product.status === 'active' && + product.linkStatus === 'active' + ); +} + +/** A linking attempt that will never become usable. */ +export function isFailedUsBank(product: Product): boolean { + return ( + isExternalUsBank(product) && + (product.status === 'creation_failed' || + product.linkStatus === 'link_failed') + ); +} + +/** Plaid finished linking but the backend hasn't activated the account yet. */ +export function isLinkingUsBank(product: Product): boolean { + return ( + isExternalUsBank(product) && + product.status === 'creation_in_progress' && + product.linkStatus === 'active' + ); +} + +/** A linking attempt that won't change state anymore without user action. */ +export function isUsBankLinkSettled(product: Product): boolean { + return isUsableUsBank(product) || isFailedUsBank(product); +} + +/** Products worth showing to the user — failed US bank links are hidden. */ +export function visibleProducts(products: Product[]): Product[] { + return products.filter((product) => !isFailedUsBank(product)); +} diff --git a/src/hooks/accounts/use-account-picker.ts b/src/hooks/accounts/use-account-picker.ts index 0aaf830..11a469f 100644 --- a/src/hooks/accounts/use-account-picker.ts +++ b/src/hooks/accounts/use-account-picker.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { isUsableUsBank } from '~/domain/accounts/external-us-bank'; import type { Account, Product } from '~/domain/accounts/types'; import { isActiveVirtualAccount } from '~/domain/accounts/virtual-account'; import { useAccounts } from './use-accounts'; @@ -11,14 +12,11 @@ type AccountPickerOptions = { /** Only include accounts that have at least one product of this kind. */ requireProductKind?: Product['kind']; /** - * Only include accounts with an `external-us-bank` product in this Plaid - * `linkStatus` — distinct from `requireActive`, which checks the primary - * product's top-level `status`, not Plaid's linking state. + * Only include accounts with a linked US bank that is both active and + * Plaid-linked — distinct from `requireActive`, which checks the primary + * product's `status`, not the bank's. */ - requireLinkStatus?: Extract< - Product, - { kind: 'external-us-bank' } - >['linkStatus']; + requireUsableUsBank?: boolean; /** Only include accounts anchored by an active pocket (virtual account). */ requireVirtualAccount?: boolean; }; @@ -45,7 +43,7 @@ export function useAccountPicker(options: AccountPickerOptions = {}) { requireActive = true, asset, requireProductKind, - requireLinkStatus, + requireUsableUsBank = false, requireVirtualAccount = false, } = options; @@ -66,14 +64,7 @@ export function useAccountPicker(options: AccountPickerOptions = {}) { ) { return false; } - if ( - requireLinkStatus && - !account.products.some( - (product) => - product.kind === 'external-us-bank' && - product.linkStatus === requireLinkStatus, - ) - ) { + if (requireUsableUsBank && !account.products.some(isUsableUsBank)) { return false; } @@ -84,7 +75,7 @@ export function useAccountPicker(options: AccountPickerOptions = {}) { requireActive, asset, requireProductKind, - requireLinkStatus, + requireUsableUsBank, requireVirtualAccount, ]); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5556c9d..4474029 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -98,7 +98,8 @@ "addProductCardLabel": "Card (Global)", "addProductBrebLabel": "BRE-B key (COP)", "addProductPlaidLabel": "Plaid (USD)", - "addProductNeedsVirtualAccount": "You can only add products to an active pocket." + "addProductNeedsVirtualAccount": "You can only add products to an active pocket.", + "usBankLinking": "Linking in progress" } }, "brebKeys": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index abd64f7..d66a0ae 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -98,7 +98,8 @@ "addProductCardLabel": "Tarjeta (Global)", "addProductBrebLabel": "Llave BRE-B (COP)", "addProductPlaidLabel": "Plaid (USD)", - "addProductNeedsVirtualAccount": "Solo puedes agregar productos a un bolsillo activo." + "addProductNeedsVirtualAccount": "Solo puedes agregar productos a un bolsillo activo.", + "usBankLinking": "Vinculación en proceso" } }, "brebKeys": { diff --git a/src/routes/_authed/accounts/$urn.tsx b/src/routes/_authed/accounts/$urn.tsx index 65faa28..7b23dda 100644 --- a/src/routes/_authed/accounts/$urn.tsx +++ b/src/routes/_authed/accounts/$urn.tsx @@ -34,6 +34,10 @@ import { US_RAILS_ENABLED, USD_ENABLED, } from '~/config/features'; +import { + isLinkingUsBank, + visibleProducts, +} from '~/domain/accounts/external-us-bank'; import type { AssetBalance, Product } from '~/domain/accounts/types'; import { isActiveVirtualAccount } from '~/domain/accounts/virtual-account'; import { useAccountMovements } from '~/hooks/accounts/use-account-movements'; @@ -192,9 +196,10 @@ function RouteComponent() { const primaryProduct = account?.products.find( (product) => product.urn === account.primaryUrn, ); - const associatedProducts = - account?.products.filter((product) => product.urn !== account.primaryUrn) ?? - []; + const shownProducts = visibleProducts(account?.products ?? []); + const associatedProducts = shownProducts.filter( + (product) => product.urn !== account?.primaryUrn, + ); const Icon = getProductKindIcon(primaryProduct?.kind ?? 'other'); const canAddProduct = !!account && isActiveVirtualAccount(account); @@ -437,7 +442,10 @@ function RouteComponent() { {product.label}

- {getProductKindLabel(product.kind)} • {product.status} + {getProductKindLabel(product.kind)} •{' '} + {isLinkingUsBank(product) + ? t('accounts.detail.usBankLinking') + : product.status}

{link ? ( @@ -565,7 +573,7 @@ function RouteComponent() { ] .filter( (option) => - !(account?.products ?? []).some( + !shownProducts.some( (product) => product.kind === option.matchKind, ), ) diff --git a/src/routes/_authed/accounts/index.tsx b/src/routes/_authed/accounts/index.tsx index 1941e7d..4ba00e0 100644 --- a/src/routes/_authed/accounts/index.tsx +++ b/src/routes/_authed/accounts/index.tsx @@ -9,6 +9,7 @@ import { } from '~/components/account/product-presentation'; import { BackButton } from '~/components/back-button'; import { Button } from '~/components/ui/button'; +import { visibleProducts } from '~/domain/accounts/external-us-bank'; import type { Account } from '~/domain/accounts/types'; import { useAccounts } from '~/hooks/accounts/use-accounts'; import i18n from '~/i18n/config'; @@ -42,7 +43,7 @@ function formatAssetBalanceChip(asset: string, current: string): string { } function getCompositionLabel(account: Account) { - const associated = account.products.filter( + const associated = visibleProducts(account.products).filter( (product) => product.urn !== account.primaryUrn, ); diff --git a/src/routes/_authed/topup/us-banks/index.tsx b/src/routes/_authed/topup/us-banks/index.tsx index cafc894..80a1567 100644 --- a/src/routes/_authed/topup/us-banks/index.tsx +++ b/src/routes/_authed/topup/us-banks/index.tsx @@ -7,6 +7,11 @@ import { AccountCarousel } from '~/components/account/account-carousel'; import { ComingSoonScreen } from '~/components/coming-soon'; import { US_RAILS_ENABLED } from '~/config/features'; import { useAuth } from '~/contexts/auth/auth-context'; +import { + isFailedUsBank, + isUsableUsBank, + isUsBankLinkSettled, +} from '~/domain/accounts/external-us-bank'; import type { ExecutionOutcome } from '~/domain/payments/types'; import { useAccountPicker } from '~/hooks/accounts/use-account-picker'; import { useAccounts } from '~/hooks/accounts/use-accounts'; @@ -119,13 +124,13 @@ function RouteComponent() { // suspended pocket's linked bank is deliberately excluded here, not just // for the deposit destination: it isn't a selectable option at all. const { accounts: linkedActivePockets } = useAccountPicker({ - requireProductKind: 'external-us-bank', - requireLinkStatus: 'active', + requireUsableUsBank: true, requireVirtualAccount: true, }); // Which pocket the new Plaid link itself gets associated with. Only one - // `external-us-bank` product per pocket, same rule as card/BRE-B. + // `external-us-bank` product per pocket, same rule as card/BRE-B — a failed + // link doesn't count, so the pocket can be linked again. const [selectedLinkLedgerId, setSelectedLinkLedgerId] = useState< string | null >(null); @@ -136,7 +141,9 @@ function RouteComponent() { () => allPockets.filter( (account) => - !account.products.some((p) => p.kind === 'external-us-bank'), + !account.products.some( + (p) => p.kind === 'external-us-bank' && !isFailedUsBank(p), + ), ), [allPockets], ); @@ -168,9 +175,7 @@ function RouteComponent() { (account) => account.ledgerId === selectedSourceLedgerId, ) ?? null); - const sourceBankProduct = chosenSourceAccount?.products.find( - (p) => p.kind === 'external-us-bank', - ); + const sourceBankProduct = chosenSourceAccount?.products.find(isUsableUsBank); // A linked bank can be `linkStatus: 'active'` yet still need Plaid // re-authentication (`needsUpdate`) — not a usable ACH source until relinked. const bankNeedsUpdate = @@ -188,18 +193,14 @@ function RouteComponent() { // data on every tick to know when to stop — a value computed once per // render would keep re-scheduling forever once the account settles into // `link_failed` without any further data change to trigger a re-render. + // Keeps polling past Plaid's `active` until the account itself is active. const accountsQuery = useAccounts({ refetchInterval: (query) => { if (!pendingUrn) return false; const product = (query.state.data ?? []) .flatMap((account) => account.products) .find((p) => p.urn === pendingUrn); - if ( - product?.kind === 'external-us-bank' && - product.linkStatus !== 'pending_link' - ) { - return false; - } + if (product && isUsBankLinkSettled(product)) return false; return 3000; }, }); @@ -211,8 +212,7 @@ function RouteComponent() { : undefined; const linkStepStatus: 'idle' | 'linking' | 'failed' | 'needs_update' = - pendingProduct?.kind === 'external-us-bank' && - pendingProduct.linkStatus === 'link_failed' + pendingProduct && isFailedUsBank(pendingProduct) ? 'failed' : pendingUrn ? 'linking' From b2c1b1bf6304e3fa0d65bafa1985e988aa9987cf Mon Sep 17 00:00:00 2001 From: Makelangel00 Date: Wed, 30 Sep 2026 18:31:30 -0500 Subject: [PATCH 2/2] fix(wallet): show bank activation state without link timeout --- src/i18n/locales/en.json | 2 + src/i18n/locales/es.json | 2 + .../topup/-components/link-bank-step.tsx | 28 ++++++++++++- src/routes/_authed/topup/us-banks/index.tsx | 39 +++++++++++++++---- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4474029..a80c8a9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -575,6 +575,8 @@ "cta": "Link bank account", "linking": "Linking your bank account…", "linkingDescription": "This can take a few seconds. We'll continue automatically once it's done.", + "activating": "Your bank is linked", + "activatingDescription": "We’re activating your account. This can take a while; you can leave and come back later.", "checkAgain": "Check again", "cancel": "Cancel and start over", "failed": "We couldn't link your bank account.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d66a0ae..b0d047f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -575,6 +575,8 @@ "cta": "Vincular cuenta bancaria", "linking": "Vinculando tu cuenta bancaria…", "linkingDescription": "Esto puede tardar unos segundos. Continuaremos automáticamente cuando termine.", + "activating": "Tu banco quedó vinculado", + "activatingDescription": "Estamos activando tu cuenta. Puede tardar un poco; puedes salir y volver más tarde.", "checkAgain": "Verificar de nuevo", "cancel": "Cancelar y volver a empezar", "failed": "No pudimos vincular tu cuenta bancaria.", diff --git a/src/routes/_authed/topup/-components/link-bank-step.tsx b/src/routes/_authed/topup/-components/link-bank-step.tsx index 5685f26..5c5e3ca 100644 --- a/src/routes/_authed/topup/-components/link-bank-step.tsx +++ b/src/routes/_authed/topup/-components/link-bank-step.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Button } from '~/components/ui/button'; interface LinkBankStepProps { - status: 'idle' | 'linking' | 'failed' | 'needs_update'; + status: 'idle' | 'linking' | 'activating' | 'failed' | 'needs_update'; isStarting: boolean; /** Disables the idle-state CTA only — e.g. no destination pocket chosen yet. */ disabled?: boolean; @@ -61,6 +61,32 @@ export function LinkBankStep({ ); } + if (status === 'activating') { + return ( +
+
+ +
+
+

+ {t('topup.usBanks.linkStep.activating')} +

+

+ {t('topup.usBanks.linkStep.activatingDescription')} +

+
+ +
+ ); + } + if (status === 'failed') { return (
diff --git a/src/routes/_authed/topup/us-banks/index.tsx b/src/routes/_authed/topup/us-banks/index.tsx index 80a1567..c370397 100644 --- a/src/routes/_authed/topup/us-banks/index.tsx +++ b/src/routes/_authed/topup/us-banks/index.tsx @@ -9,6 +9,7 @@ import { US_RAILS_ENABLED } from '~/config/features'; import { useAuth } from '~/contexts/auth/auth-context'; import { isFailedUsBank, + isLinkingUsBank, isUsableUsBank, isUsBankLinkSettled, } from '~/domain/accounts/external-us-bank'; @@ -34,6 +35,9 @@ const PENDING_URN_STARTED_AT_KEY = 'bloque:pendingExternalUsBankUrnStartedAt'; // from `pending_link` (there's no "user exited" event) — without this cap the // 3s poll below would run forever with no way out. const LINK_TIMEOUT_MS = 5 * 60 * 1000; +// Once Plaid is done, account activation has no known upper bound — poll +// slower and never time out. +const ACTIVATION_POLL_MS = 10_000; const MIN_TOPUP_AMOUNT_USD = 10; const FROM_ASSET = 'USD/2'; const TO_ASSET = 'DUSD/6'; @@ -201,6 +205,7 @@ function RouteComponent() { .flatMap((account) => account.products) .find((p) => p.urn === pendingUrn); if (product && isUsBankLinkSettled(product)) return false; + if (product && isLinkingUsBank(product)) return ACTIVATION_POLL_MS; return 3000; }, }); @@ -211,14 +216,32 @@ function RouteComponent() { .find((product) => product.urn === pendingUrn) : undefined; - const linkStepStatus: 'idle' | 'linking' | 'failed' | 'needs_update' = + // With nothing usable and nothing left to link, a bank still activating is + // the only thing to wait on — even after leaving and coming back. + const hasActivatingBank = + !contextLinkLedgerId && + linkedActivePockets.length === 0 && + linkablePockets.length === 0 && + allPockets.some((account) => account.products.some(isLinkingUsBank)); + + const linkStepStatus: + | 'idle' + | 'linking' + | 'activating' + | 'failed' + | 'needs_update' = pendingProduct && isFailedUsBank(pendingProduct) ? 'failed' - : pendingUrn - ? 'linking' - : bankNeedsUpdate - ? 'needs_update' - : 'idle'; + : pendingProduct && isLinkingUsBank(pendingProduct) + ? 'activating' + : pendingUrn + ? 'linking' + : bankNeedsUpdate + ? 'needs_update' + : hasActivatingBank + ? 'activating' + : 'idle'; + const isActivating = linkStepStatus === 'activating'; const resetPendingLink = useCallback(() => { window.sessionStorage.removeItem(PENDING_URN_KEY); @@ -246,7 +269,7 @@ function RouteComponent() { // Safety net for a cancelled/abandoned Plaid session: the backend has no // "user exited" signal, so without this the poll above runs forever. useEffect(() => { - if (!pendingUrn) return; + if (!pendingUrn || isActivating) return; const startedAtRaw = window.sessionStorage.getItem( PENDING_URN_STARTED_AT_KEY, ); @@ -262,7 +285,7 @@ function RouteComponent() { toast.error(t('topup.usBanks.linkStep.timeoutToast')); }, remaining); return () => window.clearTimeout(timer); - }, [pendingUrn, resetPendingLink, t]); + }, [pendingUrn, isActivating, resetPendingLink, t]); // Plaid's hosted page echoes `?status=...` on redirect back — check // immediately instead of waiting for the first 3s poll tick. Only on