Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/domain/accounts/external-us-bank.test.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
42 changes: 42 additions & 0 deletions src/domain/accounts/external-us-bank.ts
Original file line number Diff line number Diff line change
@@ -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));
}
25 changes: 8 additions & 17 deletions src/hooks/accounts/use-account-picker.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
};
Expand All @@ -45,7 +43,7 @@ export function useAccountPicker(options: AccountPickerOptions = {}) {
requireActive = true,
asset,
requireProductKind,
requireLinkStatus,
requireUsableUsBank = false,
requireVirtualAccount = false,
} = options;

Expand All @@ -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;
}

Expand All @@ -84,7 +75,7 @@ export function useAccountPicker(options: AccountPickerOptions = {}) {
requireActive,
asset,
requireProductKind,
requireLinkStatus,
requireUsableUsBank,
requireVirtualAccount,
]);

Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -574,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.",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -574,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.",
Expand Down
18 changes: 13 additions & 5 deletions src/routes/_authed/accounts/$urn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -437,7 +442,10 @@ function RouteComponent() {
{product.label}
</p>
<p className="text-xs text-muted-foreground">
{getProductKindLabel(product.kind)} • {product.status}
{getProductKindLabel(product.kind)} •{' '}
{isLinkingUsBank(product)
? t('accounts.detail.usBankLinking')
: product.status}
</p>
</div>
{link ? (
Expand Down Expand Up @@ -565,7 +573,7 @@ function RouteComponent() {
]
.filter(
(option) =>
!(account?.products ?? []).some(
!shownProducts.some(
(product) => product.kind === option.matchKind,
),
)
Expand Down
3 changes: 2 additions & 1 deletion src/routes/_authed/accounts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
);

Expand Down
28 changes: 27 additions & 1 deletion src/routes/_authed/topup/-components/link-bank-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -61,6 +61,32 @@ export function LinkBankStep({
);
}

if (status === 'activating') {
return (
<div className="flex flex-col items-center gap-6 py-8">
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-primary/25 bg-primary/[0.06]">
<Building2 className="h-7 w-7 text-primary" strokeWidth={1.5} />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<h2 className="text-lg font-bold text-foreground">
{t('topup.usBanks.linkStep.activating')}
</h2>
<p className="text-sm text-muted-foreground">
{t('topup.usBanks.linkStep.activatingDescription')}
</p>
</div>
<Button
onClick={onCheckAgain}
variant="outline"
className="h-12 w-full gap-2 rounded-2xl text-sm font-medium bg-transparent"
>
<RefreshCw className="h-4 w-4" />
{t('topup.usBanks.linkStep.checkAgain')}
</Button>
</div>
);
}

if (status === 'failed') {
return (
<div className="flex flex-col items-center gap-6 py-8">
Expand Down
Loading
Loading