diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 663f4f3e1..b50589828 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -384,6 +384,11 @@ type BankAccount """ERPNext bank account identifier""" id: ID isDefault: Boolean! + + """ + The account's in-flight update request when it needs the user's attention — Pending (awaiting review) or Rejected (declined). Null once approved/closed, or when none exists. + """ + pendingUpdate: BankAccountUpdateRequest } input BankAccountInput @@ -396,6 +401,58 @@ input BankAccountInput currency: String! } +""" +A pending request to change the details of an approved bank account, awaiting admin review. +""" +type BankAccountUpdateRequest + @join__type(graph: PUBLIC) +{ + """Proposed new account number""" + accountNumber: String! + + """Proposed new account type""" + accountType: String! + + """Proposed new bank branch""" + bankBranch: String! + + """Proposed new bank name""" + bankName: String! + + """Account currency (unchanged from the current account)""" + currency: String! + + """Reviewer note, set when status is Rejected""" + rejectionReason: String + + """Pending | Approved | Rejected | Closed""" + status: String! +} + +input BankAccountUpdateRequestInput + @join__type(graph: PUBLIC) +{ + accountNumber: AccountNumber! + accountType: String! + + """ERPNext identifier of the account to update""" + bankAccountId: ID! + bankBranch: String! + bankName: String! + + """Must match the account's current currency (currency is locked)""" + currency: String! +} + +type BankAccountUpdateRequestPayload + @join__type(graph: PUBLIC) +{ + errors: [Error] + + """Status of the created request (Pending on success)""" + status: String +} + type BridgeAddExternalAccountPayload @join__type(graph: PUBLIC) { @@ -458,6 +515,20 @@ type BridgeDeleteExternalAccountPayload externalAccount: BridgeExternalAccount } +input BridgeExchangePlaidPublicTokenInput + @join__type(graph: PUBLIC) +{ + linkToken: String! + publicToken: String! +} + +type BridgeExchangePlaidPublicTokenPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + message: String +} + type BridgeExternalAccount @join__type(graph: PUBLIC) { @@ -472,7 +543,8 @@ type BridgeExternalAccountLink @join__type(graph: PUBLIC) { expiresAt: String! - linkUrl: String! + linkToken: String! + linkUrl: String @deprecated(reason: "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.") } input BridgeInitiateKycInput @@ -1486,11 +1558,13 @@ type Mutation Rotate an API key: a replacement with a new secret (and keyId) is created with the same name, scopes, and expiry, and the old key is revoked. The new raw key is only shown once. """ apiKeyRotate(input: ApiKeyRotateInput!): ApiKeyRotatePayload! + bankAccountUpdateRequest(input: BankAccountUpdateRequestInput!): BankAccountUpdateRequestPayload! bridgeAddExternalAccount: BridgeAddExternalAccountPayload! bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload! + bridgeExchangePlaidPublicToken(input: BridgeExchangePlaidPublicTokenInput!): BridgeExchangePlaidPublicTokenPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index 65211ab37..3c919862c 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -60,7 +60,7 @@ mutation BridgeCreateVirtualAccount { ### `bridgeAddExternalAccount` -Returns a hosted URL for the user to link their external bank account (via Plaid/Bridge). +Returns a Plaid `linkToken` so the app can open the Plaid Link SDK and connect a bank account via Bridge. **Request:** ```graphql @@ -70,7 +70,7 @@ mutation BridgeAddExternalAccount { message } externalAccount { - linkUrl + linkToken expiresAt } } @@ -78,8 +78,33 @@ mutation BridgeAddExternalAccount { ``` **Response:** -- `linkUrl`: URL to the bank linking flow. -- `expiresAt`: Expiration timestamp for the link. +- `linkToken`: Plaid Link token for the client SDK. +- `expiresAt`: Expiration timestamp for the token. +- `linkUrl` *(deprecated)*: Hosted bank-linking URL, served best-effort so already-shipped clients keep working; may be `null`. New clients must use `linkToken`. + +--- + +### `bridgeExchangePlaidPublicToken` + +Exchanges the Plaid `publicToken` (from Link `onSuccess`) with Bridge. Call from the app backend path only — Flash keeps the Bridge Api-Key server-side. External accounts are created asynchronously and arrive via webhook. + +**Request:** +```graphql +mutation BridgeExchangePlaidPublicToken( + $input: BridgeExchangePlaidPublicTokenInput! +) { + bridgeExchangePlaidPublicToken(input: $input) { + errors { + message + } + message + } +} +``` + +**Input:** +- `linkToken`: Same token returned by `bridgeAddExternalAccount` (must be exchanged by the same account that requested it; tokens are one-time-use). +- `publicToken`: Token from Plaid Link `onSuccess`. --- diff --git a/docs/bridge-integration/FLOWS.md b/docs/bridge-integration/FLOWS.md index b1b8e7a40..ea9b9f014 100644 --- a/docs/bridge-integration/FLOWS.md +++ b/docs/bridge-integration/FLOWS.md @@ -70,7 +70,7 @@ This flow allows users to withdraw USDT from their Flash wallet to their externa ```ascii User Flash App Flash Backend Bridge.xyz Bank - | (Check for KYC, if complete, skip to 12. | | + | (Check for KYC, if complete, skip to Link Bank.) | | | 1. Start KYC | | | | |----------------->| 2. bridgeInitKyc | | | | |------------------>| 3. Create Customer | | @@ -86,33 +86,41 @@ User Flash App Flash Backend Bridge.xyz | | |<--------------------| | | 8. Link Bank | | | | |----------------->| 9. bridgeAddExtAcc| | | - | |------------------>| 10. Get Link URL | | + | |------------------>| 10. plaid_link_reqs | | | | |-------------------->| | - | | 11. Link URL | | | + | | 11. linkToken | | | | |<------------------| | | - | 12. Auth Bank | | | | + | 12. Plaid Link | | | | + | SDK / Auth | | | | |----------------->| | | | | (Plaid Flow) | | | | - | | | 13. ext_acc.verified| | + | | 13. bridgeExchange| | | + | | PlaidPublicTok| 14. Exchange public | | + | |------------------>| token | | + | | |-------------------->| | + | | 15. Exchange OK | | | + | |<------------------| | | + | | | 16. ext_acc created | | + | | | (async webhook) | | | | |<--------------------| | - | 14. Withdraw | | | | - |----------------->| 15. bridgeRequest | | | + | 17. Withdraw | | | | + |----------------->| 18. bridgeRequest | | | | | Withdrawal | | | - | |------------------>| 16. Store pending | | - | | 17. Confirm screen| withdrawal | | + | |------------------>| 19. Store pending | | + | | 20. Confirm screen| withdrawal | | | |<------------------| | | - | 18. Confirm | | | | - |----------------->| 19. bridgeInitWith| | | - | | (withdrawalId)| 20. Create Transfer | | + | 21. Confirm | | | | + |----------------->| 22. bridgeInitWith| | | + | | (withdrawalId)| 23. Create Transfer | | | |------------------>|-------------------->| | - | | 21. Pending | | | + | | 24. Pending | | | |<-----------------| | | | - | | | | 22. Convert USDT | - | | | | 23. Send ACH | + | | | | 25. Convert USDT | + | | | | 26. Send ACH | | | | |------------------>| - | | | 24. trans.completed | | + | | | 27. trans.completed | | | | |<--------------------| | - | 25. Funds Arrive | | | | + | 28. Funds Arrive | | | | |<-------------------------------------------------------------------------------| ``` @@ -120,22 +128,23 @@ User Flash App Flash Backend Bridge.xyz 1. **Link Bank**: User chooses to add a bank account. 2. **GraphQL Mutation**: App calls `bridgeAddExternalAccount`. -3. **Link URL**: Flash requests a hosted link URL from Bridge. -4. **Redirect**: App opens the Bridge/Plaid flow. -5. **Authentication**: User logs into their bank and selects an account. -6. **Verification Webhook**: Bridge notifies Flash when the external account is verified. -7. **Request Withdrawal**: User enters amount and selects the linked bank account. -8. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`. -9. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused. -10. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review. -11. **User Confirms or Cancels**: +3. **Link Token**: Flash requests a Plaid `link_token` from Bridge (`POST …/plaid_link_requests`), binds it to the caller’s account in Redis (TTL = `link_token_expires_at`), and returns `{ linkToken, expiresAt }`. +4. **Plaid Link SDK**: App opens Plaid Link with `linkToken` (not a hosted Bridge URL). +5. **Authentication**: User logs into their bank and selects an account; Plaid returns a `publicToken` via `onSuccess`. +6. **Exchange Mutation**: App calls `bridgeExchangePlaidPublicToken` with `linkToken` + `publicToken`. Flash verifies the token was issued to this account (and consumes it), then exchanges with Bridge server-side (Api-Key never leaves the backend). +7. **Verification Webhook**: Bridge creates External Accounts asynchronously and notifies Flash (`external_account` webhook). App may poll `bridgeExternalAccounts` until the bank appears as verified. +8. **Request Withdrawal**: User enters amount and selects the linked bank account. +9. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`. +10. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused. +11. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review. +12. **User Confirms or Cancels**: - **Confirm**: App calls `bridgeInitiateWithdrawal` with `withdrawalId`. Flash re-checks balance, then creates a transfer in Bridge from the user's Ethereum USDT address to the external account. - **Cancel**: App calls `bridgeCancelWithdrawalRequest` with `withdrawalId`. The pending record is marked `cancelled` and a push notification is sent. -12. **Pending State**: After initiation, app shows the withdrawal as "Pending". -13. **Conversion**: Bridge converts USDT from the user's balance to USD. -14. **ACH Transfer**: Bridge sends USD to the user's bank via ACH. -15. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash. -16. **Completion**: User receives funds in their bank account (usually 1-3 business days). +13. **Pending State**: After initiation, app shows the withdrawal as "Pending". +14. **Conversion**: Bridge converts USDT from the user's balance to USD. +15. **ACH Transfer**: Bridge sends USD to the user's bank via ACH. +16. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash. +17. **Completion**: User receives funds in their bank account (usually 1-3 business days). ## Fee Structure diff --git a/src/domain/api-keys/scope-map.ts b/src/domain/api-keys/scope-map.ts index 0ea01c591..a5649721a 100644 --- a/src/domain/api-keys/scope-map.ts +++ b/src/domain/api-keys/scope-map.ts @@ -48,6 +48,7 @@ export const apiKeyScopeForField: Readonly> = bridgeInitiateKyc: "BLOCKED", bridgeCreateVirtualAccount: "BLOCKED", bridgeAddExternalAccount: "BLOCKED", + bridgeExchangePlaidPublicToken: "BLOCKED", bridgeCreateExternalAccount: "BLOCKED", bridgeSetDefaultExternalAccount: "BLOCKED", bridgeDeleteExternalAccount: "BLOCKED", diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 1905dfd8c..0e7b37169 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -662,6 +662,13 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message, }) + case "BridgeInvalidPlaidTokenError": + message = error.message || "linkToken and publicToken are required" + return bridgeGqlError({ + code: "BRIDGE_INVALID_PLAID_TOKEN", + message, + }) + case "BridgeError": message = error.message || "Bridge API error" return bridgeGqlError({ code: "BRIDGE_ERROR", message }) diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index a02f23055..b43216a57 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -65,6 +65,7 @@ import UpdateExternalWalletMutation from "./root/mutation/update-external-wallet import BridgeInitiateKycMutation from "./root/mutation/bridge-initiate-kyc" import BridgeCreateVirtualAccountMutation from "./root/mutation/bridge-create-virtual-account" import BridgeAddExternalAccountMutation from "./root/mutation/bridge-add-external-account" +import BridgeExchangePlaidPublicTokenMutation from "./root/mutation/bridge-exchange-plaid-public-token" import BridgeCreateExternalAccountMutation from "./root/mutation/bridge-create-external-account" import BridgeSetDefaultExternalAccountMutation from "./root/mutation/bridge-set-default-external-account" import BridgeDeleteExternalAccountMutation from "./root/mutation/bridge-delete-external-account" @@ -133,6 +134,7 @@ export const mutationFields = { bridgeInitiateKyc: BridgeInitiateKycMutation, bridgeCreateVirtualAccount: BridgeCreateVirtualAccountMutation, bridgeAddExternalAccount: BridgeAddExternalAccountMutation, + bridgeExchangePlaidPublicToken: BridgeExchangePlaidPublicTokenMutation, bridgeCreateExternalAccount: BridgeCreateExternalAccountMutation, bridgeSetDefaultExternalAccount: BridgeSetDefaultExternalAccountMutation, bridgeDeleteExternalAccount: BridgeDeleteExternalAccountMutation, diff --git a/src/graphql/public/root/mutation/bridge-exchange-plaid-public-token.ts b/src/graphql/public/root/mutation/bridge-exchange-plaid-public-token.ts new file mode 100644 index 000000000..9741e9275 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-exchange-plaid-public-token.ts @@ -0,0 +1,48 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeExchangePlaidPublicTokenInput from "@graphql/public/types/input/bridge-exchange-plaid-public-token-input" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeExchangePlaidPublicTokenPayload = GT.Object({ + name: "BridgeExchangePlaidPublicTokenPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + message: { type: GT.String }, + }), +}) + +const bridgeExchangePlaidPublicToken = GT.Field({ + type: GT.NonNull(BridgeExchangePlaidPublicTokenPayload), + args: { + input: { type: GT.NonNull(BridgeExchangePlaidPublicTokenInput) }, + }, + resolve: async ( + _, + { input }: { input: { linkToken: string; publicToken: string } }, + { domainAccount }: GraphQLPublicContextAuth, + ) => { + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.exchangePlaidPublicToken( + domainAccount.id, + input.linkToken, + input.publicToken, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { message: result.message, errors: [] } + }, +}) + +export default bridgeExchangePlaidPublicToken diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index e94c19a1f..b25627017 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -450,6 +450,16 @@ type BridgeDeleteExternalAccountPayload { externalAccount: BridgeExternalAccount } +input BridgeExchangePlaidPublicTokenInput { + linkToken: String! + publicToken: String! +} + +type BridgeExchangePlaidPublicTokenPayload { + errors: [Error!]! + message: String +} + type BridgeExternalAccount { accountNumberLast4: String! bankName: String! @@ -460,7 +470,8 @@ type BridgeExternalAccount { type BridgeExternalAccountLink { expiresAt: String! - linkUrl: String! + linkToken: String! + linkUrl: String @deprecated(reason: "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.") } input BridgeInitiateKycInput { @@ -1227,6 +1238,7 @@ type Mutation { bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload! + bridgeExchangePlaidPublicToken(input: BridgeExchangePlaidPublicTokenInput!): BridgeExchangePlaidPublicTokenPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! diff --git a/src/graphql/public/types/input/bridge-exchange-plaid-public-token-input.ts b/src/graphql/public/types/input/bridge-exchange-plaid-public-token-input.ts new file mode 100644 index 000000000..ae3e6d004 --- /dev/null +++ b/src/graphql/public/types/input/bridge-exchange-plaid-public-token-input.ts @@ -0,0 +1,11 @@ +import { GT } from "@graphql/index" + +const BridgeExchangePlaidPublicTokenInput = GT.Input({ + name: "BridgeExchangePlaidPublicTokenInput", + fields: () => ({ + linkToken: { type: GT.NonNull(GT.String) }, + publicToken: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeExchangePlaidPublicTokenInput diff --git a/src/graphql/public/types/object/bridge-external-account-link.ts b/src/graphql/public/types/object/bridge-external-account-link.ts index 5d51c664d..1fc743cbb 100644 --- a/src/graphql/public/types/object/bridge-external-account-link.ts +++ b/src/graphql/public/types/object/bridge-external-account-link.ts @@ -3,7 +3,12 @@ import { GT } from "@graphql/index" const BridgeExternalAccountLink = GT.Object({ name: "BridgeExternalAccountLink", fields: () => ({ - linkUrl: { type: GT.NonNull(GT.String) }, + linkToken: { type: GT.NonNull(GT.String) }, + linkUrl: { + type: GT.String, + deprecationReason: + "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.", + }, expiresAt: { type: GT.NonNull(GT.String) }, }), }) diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index 9cf852251..db4a23aa9 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -201,10 +201,22 @@ export interface ExternalAccount { } export interface ExternalAccountLinkUrl { + /** @deprecated Prefer Plaid Link SDK flow via createPlaidLinkRequest. */ link_url: string + /** @deprecated Prefer Plaid Link SDK flow via createPlaidLinkRequest. */ expires_at: string } +export interface PlaidLinkRequest { + link_token: string + link_token_expires_at: string + callback_url: string +} + +export interface PlaidExchangePublicTokenResponse { + message: string +} + export interface ListResponse { data: T[] has_more: boolean @@ -515,6 +527,10 @@ export class BridgeClient { ) } + /** + * @deprecated Use {@link createPlaidLinkRequest} + {@link exchangePlaidPublicToken} + * (Plaid Link SDK). Hosted `/external_accounts/link` is the legacy Bridge UI path. + */ async getExternalAccountLinkUrl( customerId: BridgeCustomerId, ): Promise { @@ -524,6 +540,28 @@ export class BridgeClient { ) } + // ============ Plaid Link ============ + + async createPlaidLinkRequest(customerId: BridgeCustomerId): Promise { + // request() generates a fresh Idempotency-Key per call, so consecutive + // calls mint distinct one-time link tokens. + return this.request( + "POST", + `/customers/${customerId}/plaid_link_requests`, + ) + } + + async exchangePlaidPublicToken( + linkToken: string, + publicToken: string, + ): Promise { + return this.request( + "POST", + `/plaid_exchange_public_token/${encodeURIComponent(linkToken)}`, + { public_token: publicToken }, + ) + } + async listExternalAccounts( customerId: BridgeCustomerId, ): Promise> { diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index 3166f20f3..e586fa641 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -141,6 +141,12 @@ export class BridgePlaidNotAvailableError extends BridgeError { } } +export class BridgeInvalidPlaidTokenError extends BridgeError { + constructor(message: string = "linkToken and publicToken are required") { + super(message) + } +} + /** * Maps HTTP status codes from Bridge API to domain error types * diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 8cd4f50df..684da09e5 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -14,6 +14,7 @@ import { BridgeVirtualAccount } from "@services/mongoose/schema" import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" +import { CacheServiceError } from "@domain/cache" import { RepositoryError } from "@domain/errors" import { toBridgeCustomerId, @@ -44,6 +45,7 @@ import { BridgeWithdrawalNotFoundError, BridgeWithdrawalAlreadyInitiatedError, BridgePlaidNotAvailableError, + BridgeInvalidPlaidTokenError, BridgeDepositInstructionsMissingError, BridgeWithdrawalNetAmountTooLowError, } from "./errors" @@ -51,6 +53,7 @@ import BridgeApiClient, { type CreateExternalAccountRequest, type ExternalAccount, } from "./client" +import { PlaidLinkTokenStore } from "./plaid-link-token-store" import { presentBridgeWithdrawal, receiptFeesFromTransfer, @@ -89,10 +92,15 @@ type CreateVirtualAccountResult = { } type AddExternalAccountResult = { - linkUrl: string + linkToken: string + linkUrl: string | null expiresAt: string } +type ExchangePlaidPublicTokenResult = { + message: string +} + type WithdrawalRequestResult = PresentedBridgeWithdrawal type InitiateWithdrawalResult = PresentedBridgeWithdrawal @@ -595,7 +603,7 @@ const createVirtualAccount = async ( } /** - * Returns Bridge hosted bank linking URL for adding external accounts + * Requests a Plaid link_token from Bridge for adding external accounts. */ const addExternalAccount = async ( accountId: AccountId, @@ -619,11 +627,39 @@ const addExternalAccount = async ( ) } - const linkUrl = await BridgeApiClient.getExternalAccountLinkUrl(customerId) + const plaid = await BridgeApiClient.createPlaidLinkRequest(customerId) + + // Bind token → account before returning it so exchange can enforce ownership. + const saved = await PlaidLinkTokenStore.save(plaid.link_token, { + accountId, + bridgeCustomerId: customerId, + expiresAt: plaid.link_token_expires_at, + }) + if (saved instanceof CacheServiceError) { + baseLogger.error( + { accountId, operation: "addExternalAccount", error: saved }, + "Failed to bind Plaid link token", + ) + return new BridgeError("Unable to start bank linking — please try again") + } + + // Deployed clients still select the deprecated linkUrl field, so keep + // serving the hosted flow best-effort until the fleet is on linkToken. + let linkUrl: string | null = null + try { + const hosted = await BridgeApiClient.getExternalAccountLinkUrl(customerId) + linkUrl = hosted.link_url + } catch (hostedError) { + baseLogger.warn( + { accountId, operation: "addExternalAccount", error: hostedError }, + "Hosted link URL unavailable; returning linkToken only", + ) + } const result: AddExternalAccountResult = { - linkUrl: linkUrl.link_url, - expiresAt: linkUrl.expires_at, + linkToken: plaid.link_token, + linkUrl, + expiresAt: plaid.link_token_expires_at, } baseLogger.info( @@ -649,6 +685,98 @@ const addExternalAccount = async ( } } +/** + * Exchanges a Plaid public_token with Bridge after the user completes Link. + * External accounts are created asynchronously and arrive via webhook. + */ +const exchangePlaidPublicToken = async ( + accountId: AccountId, + linkToken: string, + publicToken: string, +): Promise => { + baseLogger.info( + { accountId, operation: "exchangePlaidPublicToken" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + const trimmedLinkToken = linkToken?.trim() ?? "" + const trimmedPublicToken = publicToken?.trim() ?? "" + if (!trimmedLinkToken || !trimmedPublicToken) { + return new BridgeInvalidPlaidTokenError() + } + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const binding = await PlaidLinkTokenStore.consumeForAccount( + trimmedLinkToken, + accountId, + ) + if (binding instanceof BridgeInvalidPlaidTokenError) return binding + if (binding instanceof CacheServiceError) { + baseLogger.error( + { accountId, operation: "exchangePlaidPublicToken", error: binding }, + "Failed to load Plaid link token binding", + ) + return new BridgeInvalidPlaidTokenError( + "Unable to verify Plaid link token — restart bank linking", + ) + } + + const exchanged = await BridgeApiClient.exchangePlaidPublicToken( + trimmedLinkToken, + trimmedPublicToken, + ) + + const result: ExchangePlaidPublicTokenResult = { + message: exchanged.message, + } + + baseLogger.info( + { + accountId, + operation: "exchangePlaidPublicToken", + bridgeCustomerId: binding.bridgeCustomerId, + }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "exchangePlaidPublicToken", error }, + "Bridge operation failed", + ) + + if (error instanceof BridgeApiError) { + if (error.statusCode === 401 || error.statusCode === 403) { + return new BridgePlaidNotAvailableError() + } + // These mean the token pair was rejected — expired link_token, + // already-exchanged public_token, or a mismatched pair. + if ([400, 404, 409, 422].includes(error.statusCode)) { + const detail = (error.response as { message?: string } | null)?.message + return new BridgeInvalidPlaidTokenError( + detail || "Invalid or expired Plaid token — restart bank linking", + ) + } + } + + return error instanceof Error ? error : new Error(String(error)) + } +} + /** * Creates an external account directly via Bridge API (bypassing Plaid Link). * Used as a fallback when Plaid Link is unavailable. @@ -1678,6 +1806,7 @@ export default wrapAsyncFunctionsToRunInSpan({ initiateKyc, createVirtualAccount, addExternalAccount, + exchangePlaidPublicToken, createExternalAccount, setDefaultExternalAccount, deleteExternalAccount, diff --git a/src/services/bridge/plaid-link-token-store.ts b/src/services/bridge/plaid-link-token-store.ts new file mode 100644 index 000000000..a5f9ad544 --- /dev/null +++ b/src/services/bridge/plaid-link-token-store.ts @@ -0,0 +1,95 @@ +/** + * Binds Bridge-issued Plaid link tokens to the Flash account that requested them. + * Tokens live only as long as Bridge says they do; exchange rejects strangers / reuse. + */ + +import { CacheServiceError, CacheUndefinedError } from "@domain/cache" +import { RedisCacheService, consumeCacheKey } from "@services/cache" + +import { BridgeInvalidPlaidTokenError } from "./errors" + +export type PlaidLinkTokenBinding = { + accountId: AccountId + bridgeCustomerId: string + expiresAt: string +} + +const KEY_PREFIX = "plaid:link:" +/** Plaid’s default link_token lifetime when expires_at is unparsable. */ +const FALLBACK_TTL_SECS = 4 * 60 * 60 + +const cacheKey = (linkToken: string): string => `${KEY_PREFIX}${linkToken}` + +const ttlSecsFromExpiresAt = (expiresAt: string): Seconds => { + const ms = Date.parse(expiresAt) + if (Number.isNaN(ms)) return FALLBACK_TTL_SECS as Seconds + const secs = Math.floor((ms - Date.now()) / 1000) + return Math.max(1, secs) as Seconds +} + +export const PlaidLinkTokenStore = { + save: async ( + linkToken: string, + binding: PlaidLinkTokenBinding, + ): Promise => { + const result = await RedisCacheService().set({ + key: cacheKey(linkToken), + value: binding, + ttlSecs: ttlSecsFromExpiresAt(binding.expiresAt), + }) + if (result instanceof CacheServiceError) return result + return true + }, + + /** + * Load binding, enforce ownership, then atomically consume so the token is + * one-time-use even under concurrent exchanges (see consumeCacheKey). Ownership + * is checked on a non-destructive read, so a stranger cannot burn the owner's + * token by attempting to exchange it. + */ + consumeForAccount: async ( + linkToken: string, + accountId: AccountId, + ): Promise< + PlaidLinkTokenBinding | BridgeInvalidPlaidTokenError | CacheServiceError + > => { + const key = cacheKey(linkToken) + const cached = await RedisCacheService().get({ key }) + + if (cached instanceof CacheUndefinedError) { + return new BridgeInvalidPlaidTokenError( + "Unknown or already-used Plaid link token — restart bank linking", + ) + } + if (cached instanceof CacheServiceError) return cached + + if (cached.accountId !== accountId) { + return new BridgeInvalidPlaidTokenError( + "Plaid link token was not issued for this account — restart bank linking", + ) + } + + const expiresMs = Date.parse(cached.expiresAt) + if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) { + await RedisCacheService().clear({ key }) + return new BridgeInvalidPlaidTokenError( + "Plaid link token has expired — restart bank linking", + ) + } + + // Atomic single-use gate. A plain get-then-clear is a TOCTOU race: two + // concurrent exchanges of the same token can both read the binding before + // either clears it, so both would proceed. consumeCacheKey issues one DEL + // and returns true only for the caller that actually removed the key — among + // concurrent exchanges of the same token, exactly one wins; the rest are + // rejected here, before the Bridge call. + const consumed = await consumeCacheKey({ key }) + if (consumed instanceof CacheServiceError) return consumed + if (!consumed) { + return new BridgeInvalidPlaidTokenError( + "Unknown or already-used Plaid link token — restart bank linking", + ) + } + return cached + }, +} diff --git a/src/services/cache/redis-cache.ts b/src/services/cache/redis-cache.ts index 5da0ca9a9..e7f17b8c9 100644 --- a/src/services/cache/redis-cache.ts +++ b/src/services/cache/redis-cache.ts @@ -73,3 +73,25 @@ export const RedisCacheService = (): ICacheService => { fns: { set, get, getOrSet, clear }, }) } + +/** + * Atomically deletes `key`, returning `true` only for the caller whose DEL + * actually removed it. Redis executes each DEL serially, so among concurrent + * callers exactly one sees a removed-count of 1 — the basis for one-time-use + * keys (e.g. Plaid link tokens). `false` means the key was already gone: lost + * the race, expired, or never existed. + * + * `clear` cannot express this: it discards the removed-count and always + * resolves `true`, so get-then-`clear` is a TOCTOU race in which concurrent + * consumers all "succeed". Gate single-use on this function instead. + */ +export const consumeCacheKey = async ({ + key, +}: LocalCacheClearArgs): Promise => { + try { + const removed = await redisCache.deleteCache(key) + return removed === 1 + } catch (err) { + return new UnknownCacheServiceError(err) + } +} diff --git a/test/flash/bridge-sandbox-e2e/README.md b/test/flash/bridge-sandbox-e2e/README.md index d85b493bb..b6dc4472a 100644 --- a/test/flash/bridge-sandbox-e2e/README.md +++ b/test/flash/bridge-sandbox-e2e/README.md @@ -101,7 +101,7 @@ IBEX_ENVIRONMENT=sandbox yarn test:bridge-sandbox-e2e | Preflight | Source-code check of `checkAccountLevel()` allows level ≥ 1 | `src/services/bridge/index.ts` guard must be `level < 1`, not `level < 2` | | KYC spec | `bridgeInitiateKyc` returns `{kycLink, tosLink}` URLs | Ensure ENG-345 deployed, sandbox has Bridge customer API set up | | Virtual account | Skipped by default; with `BRIDGE_SANDBOX_VIRTUAL_ACCOUNT_CONFIRMED=true`, `bridgeCreateVirtualAccount` returns account details | Requires a Bridge-side KYC-approved sandbox customer; local webhook injection alone does not approve the hosted Bridge customer | -| External account | Skipped by default; with `BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true`, `bridgeAddExternalAccount` returns `{linkUrl, expiresAt}` | Requires Bridge sandbox API key/customer entitlement for hosted bank-linking | +| External account | Skipped by default; with `BRIDGE_SANDBOX_EXTERNAL_ACCOUNT_LINK_CONFIRMED=true`, `bridgeAddExternalAccount` returns `{linkToken, expiresAt}` | Requires Bridge sandbox API key/customer entitlement for Plaid link-token generation | | Deposit webhook | Injected webhook processes and persists deposit | Verify webhook secret in config.yaml | | Withdrawal error paths | Validation errors returned for invalid inputs | Check withdrawal schema deployed (ENG-348) | | Withdrawal **success** path | ⚠️ **Not expected to pass first run** — requires real KYC-approved sandbox customer, funded wallet, and verified external account. | The full withdrawal flow only runs with `BRIDGE_SANDBOX_WITHDRAWAL_CONFIRMED=true`; error-path tests run without it | diff --git a/test/flash/bridge-sandbox-e2e/external-account.spec.ts b/test/flash/bridge-sandbox-e2e/external-account.spec.ts index 412eefd03..dfb3bb8f9 100644 --- a/test/flash/bridge-sandbox-e2e/external-account.spec.ts +++ b/test/flash/bridge-sandbox-e2e/external-account.spec.ts @@ -6,17 +6,15 @@ * * Verified shapes (from source audit): * - bridgeAddExternalAccount returns - * { errors, externalAccount: { linkUrl: string!, expiresAt: string! } } + * { errors, externalAccount: { linkToken: string!, expiresAt: string! } } * - externalAccountHandler accepts * { event_id, event_object: { id, customer_id, bank_name, last_4, active } } * and returns { status: "success" } or { status: "already_processed" } on 200 * - * ⚠️ Plaid sandbox linking is a manual step — the test generates the link URL + * ⚠️ Plaid sandbox linking is a manual step — the test generates a link_token * and verifies it's well-formed, then simulates the webhook that follows - * successful Plaid linking. The test does NOT automate the Plaid browser UI. - * - * ⚠️ Some sandboxes return "link_url" instead of "linkUrl" — check actual response - * against the configured return shape and update assertions if needed. + * successful Plaid linking. The test does NOT automate the Plaid browser UI + * or bridgeExchangePlaidPublicToken. */ import { @@ -65,18 +63,17 @@ describe("Bridge External Account", () => { ;(EXTERNAL_ACCOUNT_LINK_TESTS ? describe : describe.skip)( "Plaid Link URL Generation", () => { - it("generates a Plaid link URL when called", async () => { + it("generates a Plaid link token when called", async () => { const result = await addExternalAccount(user.accountId) expect(result.errors).toBeDefined() expect(result.errors).toHaveLength(0) expect(result.externalAccount).toBeDefined() - expect(result.externalAccount!.linkUrl).toBeTruthy() - expect(result.externalAccount!.linkUrl).toMatch(/^https:\/\//) + expect(result.externalAccount!.linkToken).toBeTruthy() expect(result.externalAccount!.expiresAt).toBeTruthy() }) - it("link URL is different on each call (one-time use tokens)", async () => { + it("link token is different on each call (one-time use tokens)", async () => { const result1 = await addExternalAccount(user.accountId) const result2 = await addExternalAccount(user.accountId) @@ -84,10 +81,10 @@ describe("Bridge External Account", () => { expect(result2.errors).toHaveLength(0) // Plaid link tokens are one-time use; consecutive calls should differ - expect(result1.externalAccount?.linkUrl).toBeTruthy() - expect(result2.externalAccount?.linkUrl).toBeTruthy() - expect(result1.externalAccount!.linkUrl).not.toBe( - result2.externalAccount!.linkUrl, + expect(result1.externalAccount?.linkToken).toBeTruthy() + expect(result2.externalAccount?.linkToken).toBeTruthy() + expect(result1.externalAccount!.linkToken).not.toBe( + result2.externalAccount!.linkToken, ) }) }, diff --git a/test/flash/bridge-sandbox-e2e/helpers.ts b/test/flash/bridge-sandbox-e2e/helpers.ts index 06ea0c544..cb346290f 100644 --- a/test/flash/bridge-sandbox-e2e/helpers.ts +++ b/test/flash/bridge-sandbox-e2e/helpers.ts @@ -59,7 +59,7 @@ type VirtualAccountResult = GraphQlErrorResponse & { } type ExternalAccountResult = GraphQlErrorResponse & { - externalAccount?: { linkUrl: string; expiresAt: string } + externalAccount?: { linkToken: string; expiresAt: string } } type WithdrawalResult = GraphQlErrorResponse & { @@ -187,7 +187,7 @@ const BRIDGE_ADD_EXTERNAL_ACCOUNT = ` mutation BridgeAddExternalAccount { bridgeAddExternalAccount { errors { message } - externalAccount { linkUrl expiresAt } + externalAccount { linkToken expiresAt } } } ` diff --git a/test/flash/unit/graphql/bridge-error-map.spec.ts b/test/flash/unit/graphql/bridge-error-map.spec.ts index 95b5b7c7a..194237f72 100644 --- a/test/flash/unit/graphql/bridge-error-map.spec.ts +++ b/test/flash/unit/graphql/bridge-error-map.spec.ts @@ -8,6 +8,7 @@ import { BridgeError, BridgeInsufficientFundsError, BridgeInvalidAmountError, + BridgeInvalidPlaidTokenError, BridgeKycOffboardedError, BridgeKycPendingError, BridgeKycRejectedError, @@ -23,6 +24,7 @@ import { describe("error-map: Bridge errors", () => { const cases: Array<[Error, string]> = [ [new BridgeInvalidAmountError(), "BRIDGE_INVALID_AMOUNT"], + [new BridgeInvalidPlaidTokenError(), "BRIDGE_INVALID_PLAID_TOKEN"], [new BridgeBelowMinimumWithdrawalError(10), "BRIDGE_BELOW_MINIMUM_WITHDRAWAL"], [new BridgeDisabledError(), "BRIDGE_DISABLED"], [new BridgeAccountLevelError(), "BRIDGE_ACCOUNT_LEVEL_ERROR"], diff --git a/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts new file mode 100644 index 000000000..335250ae2 --- /dev/null +++ b/test/flash/unit/services/bridge/exchange-plaid-public-token.spec.ts @@ -0,0 +1,407 @@ +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/schema", () => ({ + BridgeVirtualAccount: {}, +})) + +jest.mock("@services/mongoose/wallets", () => ({ + WalletsRepository: jest.fn(), +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: { fns: F }) => fns, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@app/wallets/get-balance-for-wallet", () => ({ + getBalanceForWallet: jest.fn(), +})) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn(), +})) + +jest.mock("@services/kratos", () => ({ + IdentityRepository: jest.fn(), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: {}, +})) + +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeBridgeCashoutPending: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { + exchangePlaidPublicToken: jest.fn(), + createPlaidLinkRequest: jest.fn(), + getExternalAccountLinkUrl: jest.fn(), + }, +})) + +const mockCacheSet = jest.fn() +const mockCacheGet = jest.fn() +const mockCacheClear = jest.fn() +const mockCacheConsume = jest.fn() + +jest.mock("@services/cache", () => ({ + RedisCacheService: () => ({ + set: mockCacheSet, + get: mockCacheGet, + clear: mockCacheClear, + }), + // Lazy wrapper: the factory is hoisted above the const, so reference + // mockCacheConsume only when called, not at factory-eval time. + consumeCacheKey: (args: { key: string }) => mockCacheConsume(args), +})) + +import BridgeService from "@services/bridge" +import BridgeApiClient from "@services/bridge/client" +import { AccountsRepository } from "@services/mongoose/accounts" +import { + BridgeApiError, + BridgeError, + BridgeInvalidPlaidTokenError, + BridgePlaidNotAvailableError, +} from "@services/bridge/errors" +import { CacheUndefinedError, UnknownCacheServiceError } from "@domain/cache" + +const ACCOUNT_ID = "account-001" as AccountId +const OTHER_ACCOUNT_ID = "account-002" as AccountId + +const futureExpiresAt = () => new Date(Date.now() + 60 * 60 * 1000).toISOString() + +const bindTokenForAccount = ( + linkToken: string, + accountId: AccountId = ACCOUNT_ID, + expiresAt = futureExpiresAt(), +) => { + mockCacheGet.mockResolvedValue({ + accountId, + bridgeCustomerId: "cust_1", + expiresAt, + }) + mockCacheClear.mockResolvedValue(true) + mockCacheConsume.mockResolvedValue(true) + return { linkToken, accountId, expiresAt } +} + +const mockAccount = (overrides: Record = {}) => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue({ + id: ACCOUNT_ID, + level: 1, + bridgeCustomerId: "cust_1", + ...overrides, + }), + }) +} + +describe("BridgeService.exchangePlaidPublicToken", () => { + beforeEach(() => { + jest.clearAllMocks() + mockAccount() + mockCacheGet.mockResolvedValue(new CacheUndefinedError()) + mockCacheClear.mockResolvedValue(true) + mockCacheConsume.mockResolvedValue(true) + }) + + it("rejects empty or whitespace tokens with BridgeInvalidPlaidTokenError", async () => { + for (const [linkToken, publicToken] of [ + ["", "public-ok"], + [" ", "public-ok"], + ["link-ok", ""], + ["link-ok", " "], + ]) { + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + linkToken, + publicToken, + ) + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + } + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + expect(mockCacheGet).not.toHaveBeenCalled() + }) + + it("rejects tokens that were never issued (or already consumed)", async () => { + mockCacheGet.mockResolvedValue(new CacheUndefinedError()) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/Unknown or already-used/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + }) + + it("rejects tokens issued to a different Flash account", async () => { + bindTokenForAccount("link-1", OTHER_ACCOUNT_ID) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/not issued for this account/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + // A stranger must not be able to burn the owner's token: ownership is + // checked on the non-destructive get, before the atomic consume. + expect(mockCacheClear).not.toHaveBeenCalled() + expect(mockCacheConsume).not.toHaveBeenCalled() + }) + + it("rejects expired bound tokens without calling Bridge", async () => { + bindTokenForAccount("link-1", ACCOUNT_ID, new Date(Date.now() - 1000).toISOString()) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/expired/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + expect(mockCacheClear).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) + }) + + it("trims tokens, consumes the binding, then exchanges with Bridge", async () => { + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockResolvedValue({ + message: "ok", + }) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + " link-1 ", + " public-1 ", + ) + + expect(mockCacheGet).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) + // Consume goes through the atomic gate, not the non-gating clear(). + expect(mockCacheConsume).toHaveBeenCalledWith({ key: "plaid:link:link-1" }) + expect(mockCacheClear).not.toHaveBeenCalled() + expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledWith( + "link-1", + "public-1", + ) + expect(result).toEqual({ message: "ok" }) + }) + + it("consumes the token: a second exchange of the same token is rejected", async () => { + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockResolvedValue({ + message: "ok", + }) + + // First exchange atomically removes the binding (DEL removed 1 key)... + mockCacheConsume.mockResolvedValueOnce(true) + const first = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + expect(first).toEqual({ message: "ok" }) + + // ...so the second is rejected because its DEL removes nothing (0 keys), + // even though the stateless get still returns a binding. + mockCacheConsume.mockResolvedValueOnce(false) + const second = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + expect(second).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((second as Error).message).toMatch(/Unknown or already-used/) + expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledTimes(1) + }) + + it("lets only one of two concurrent exchanges of the same token through", async () => { + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockResolvedValue({ + message: "ok", + }) + // Model the atomic DEL under concurrency: exactly one caller removes the key. + // (With the old get-then-clear this would resolve true for both and both + // would reach Bridge — the race this test guards against.) + mockCacheConsume.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + const outcomes = await Promise.all([ + BridgeService.exchangePlaidPublicToken(ACCOUNT_ID, "link-1", "public-1"), + BridgeService.exchangePlaidPublicToken(ACCOUNT_ID, "link-1", "public-1"), + ]) + + const successes = outcomes.filter((r) => !(r instanceof Error)) + const rejected = outcomes.filter((r) => r instanceof BridgeInvalidPlaidTokenError) + expect(successes).toEqual([{ message: "ok" }]) + expect(rejected).toHaveLength(1) + // The losing exchange never reaches Bridge. + expect(BridgeApiClient.exchangePlaidPublicToken).toHaveBeenCalledTimes(1) + }) + + it("maps Bridge 400 (rejected token pair) to BridgeInvalidPlaidTokenError with detail", async () => { + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + new BridgeApiError("Bridge API error: 400 Bad Request", 400, { + message: "link_token has expired", + }), + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toBe("link_token has expired") + }) + + it("maps Bridge 400 without response detail to a fallback message", async () => { + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + new BridgeApiError("Bridge API error: 400 Bad Request", 400, null), + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgeInvalidPlaidTokenError) + expect((result as Error).message).toMatch(/restart bank linking/i) + }) + + it("maps Bridge 401/403 to BridgePlaidNotAvailableError", async () => { + for (const status of [401, 403]) { + jest.clearAllMocks() + mockAccount() + bindTokenForAccount("link-1") + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + new BridgeApiError(`Bridge API error: ${status}`, status, null), + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(BridgePlaidNotAvailableError) + } + }) + + it("passes through Bridge 5xx as the raw error (alerting path)", async () => { + bindTokenForAccount("link-1") + const serverError = new BridgeApiError("Bridge API error: 500", 500, null) + ;(BridgeApiClient.exchangePlaidPublicToken as jest.Mock).mockRejectedValue( + serverError, + ) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBe(serverError) + }) + + it("requires a Bridge customer ID (KYC) before exchanging", async () => { + mockAccount({ bridgeCustomerId: undefined }) + + const result = await BridgeService.exchangePlaidPublicToken( + ACCOUNT_ID, + "link-1", + "public-1", + ) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toMatch(/Complete KYC first/) + expect(BridgeApiClient.exchangePlaidPublicToken).not.toHaveBeenCalled() + expect(mockCacheGet).not.toHaveBeenCalled() + }) +}) + +describe("BridgeService.addExternalAccount (deprecated linkUrl compat)", () => { + beforeEach(() => { + jest.clearAllMocks() + mockAccount() + mockCacheSet.mockResolvedValue({ accountId: ACCOUNT_ID }) + ;(BridgeApiClient.createPlaidLinkRequest as jest.Mock).mockResolvedValue({ + link_token: "link-token-1", + link_token_expires_at: "2026-07-15T00:00:00Z", + callback_url: "https://example.test/cb", + }) + }) + + it("returns both linkToken and the hosted linkUrl while clients migrate", async () => { + ;(BridgeApiClient.getExternalAccountLinkUrl as jest.Mock).mockResolvedValue({ + link_url: "https://hosted.bridge.test/link", + expires_at: "2026-07-15T00:00:00Z", + }) + + const result = await BridgeService.addExternalAccount(ACCOUNT_ID) + + expect(mockCacheSet).toHaveBeenCalledWith({ + key: "plaid:link:link-token-1", + value: { + accountId: ACCOUNT_ID, + bridgeCustomerId: "cust_1", + expiresAt: "2026-07-15T00:00:00Z", + }, + ttlSecs: expect.any(Number), + }) + expect(result).toEqual({ + linkToken: "link-token-1", + linkUrl: "https://hosted.bridge.test/link", + expiresAt: "2026-07-15T00:00:00Z", + }) + }) + + it("still succeeds with linkUrl null when the hosted endpoint fails", async () => { + ;(BridgeApiClient.getExternalAccountLinkUrl as jest.Mock).mockRejectedValue( + new BridgeApiError("Bridge API error: 404", 404, null), + ) + + const result = await BridgeService.addExternalAccount(ACCOUNT_ID) + + expect(result).toEqual({ + linkToken: "link-token-1", + linkUrl: null, + expiresAt: "2026-07-15T00:00:00Z", + }) + }) + + it("fails closed when the link-token binding cannot be persisted", async () => { + mockCacheSet.mockResolvedValue(new UnknownCacheServiceError("redis down")) + + const result = await BridgeService.addExternalAccount(ACCOUNT_ID) + + expect(result).toBeInstanceOf(BridgeError) + expect((result as Error).message).toMatch(/Unable to start bank linking/) + expect(BridgeApiClient.getExternalAccountLinkUrl).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 0461a99d4..25edf5d6c 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -142,6 +142,15 @@ jest.mock("@app/bridge/send-withdrawal-notification", () => ({ sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), })) +// Plaid link-token store pulls RedisCacheService; keep unit suites offline. +jest.mock("@services/cache", () => ({ + RedisCacheService: () => ({ + set: jest.fn().mockResolvedValue(true), + get: jest.fn().mockResolvedValue(undefined), + clear: jest.fn().mockResolvedValue(true), + }), +})) + import BridgeService, { deriveWithdrawalIdempotencyKey } from "@services/bridge" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import BridgeClient from "@services/bridge/client"