From fbf1dcffb960c949dd72aa8f0157d30927df9b1f Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:04:21 -0400 Subject: [PATCH 1/2] feat(bridge): manage external account defaults --- dev/apollo-federation/supergraph.graphql | 29 ++++ src/graphql/public/mutations.ts | 4 + .../bridge-delete-external-account.ts | 54 +++++++ .../bridge-set-default-external-account.ts | 54 +++++++ src/graphql/public/schema.graphql | 21 +++ .../types/object/bridge-external-account.ts | 1 + src/services/bridge/client.ts | 11 ++ src/services/bridge/index.ts | 145 +++++++++++++++++- src/services/mongoose/bridge-accounts.ts | 120 ++++++++++++++- src/services/mongoose/schema.ts | 2 + .../mutation/bridge-external-account.spec.ts | 108 +++++++++++++ .../types/object/bridge-contract.spec.ts | 11 ++ test/flash/unit/services/bridge/index.spec.ts | 76 +++++++++ 13 files changed, 628 insertions(+), 8 deletions(-) create mode 100644 src/graphql/public/root/mutation/bridge-delete-external-account.ts create mode 100644 src/graphql/public/root/mutation/bridge-set-default-external-account.ts create mode 100644 test/flash/unit/graphql/public/root/mutation/bridge-external-account.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 06ee976e4..903bfed77 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -313,12 +313,26 @@ type BridgeCreateVirtualAccountPayload virtualAccount: BridgeVirtualAccount } +input BridgeDeleteExternalAccountInput + @join__type(graph: PUBLIC) +{ + externalAccountId: ID! +} + +type BridgeDeleteExternalAccountPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + type BridgeExternalAccount @join__type(graph: PUBLIC) { accountNumberLast4: String! bankName: String! id: ID! + isDefault: Boolean! status: String! } @@ -378,6 +392,19 @@ type BridgeRequestWithdrawalPayload withdrawal: BridgeWithdrawal } +input BridgeSetDefaultExternalAccountInput + @join__type(graph: PUBLIC) +{ + externalAccountId: ID! +} + +type BridgeSetDefaultExternalAccountPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + type BridgeVirtualAccount @join__type(graph: PUBLIC) { @@ -1304,9 +1331,11 @@ type Mutation bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! + bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! + bridgeSetDefaultExternalAccount(input: BridgeSetDefaultExternalAccountInput!): BridgeSetDefaultExternalAccountPayload! businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): AccountUpgradePayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index a1f91bd05..bd5e36506 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -66,6 +66,8 @@ 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 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" import BridgeRequestWithdrawalMutation from "./root/mutation/bridge-request-withdrawal" import BridgeInitiateWithdrawalMutation from "./root/mutation/bridge-initiate-withdrawal" import BridgeCancelWithdrawalRequestMutation from "./root/mutation/bridge-cancel-withdrawal-request" @@ -127,6 +129,8 @@ export const mutationFields = { bridgeCreateVirtualAccount: BridgeCreateVirtualAccountMutation, bridgeAddExternalAccount: BridgeAddExternalAccountMutation, bridgeCreateExternalAccount: BridgeCreateExternalAccountMutation, + bridgeSetDefaultExternalAccount: BridgeSetDefaultExternalAccountMutation, + bridgeDeleteExternalAccount: BridgeDeleteExternalAccountMutation, bridgeRequestWithdrawal: BridgeRequestWithdrawalMutation, bridgeInitiateWithdrawal: BridgeInitiateWithdrawalMutation, bridgeCancelWithdrawalRequest: BridgeCancelWithdrawalRequestMutation, diff --git a/src/graphql/public/root/mutation/bridge-delete-external-account.ts b/src/graphql/public/root/mutation/bridge-delete-external-account.ts new file mode 100644 index 000000000..ce23e6f9f --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-delete-external-account.ts @@ -0,0 +1,54 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeDeleteExternalAccountInput = GT.Input({ + name: "BridgeDeleteExternalAccountInput", + fields: () => ({ + externalAccountId: { type: GT.NonNull(GT.ID) }, + }), +}) + +const BridgeDeleteExternalAccountPayload = GT.Object({ + name: "BridgeDeleteExternalAccountPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + externalAccount: { type: BridgeExternalAccount }, + }), +}) + +const bridgeDeleteExternalAccount = GT.Field({ + type: GT.NonNull(BridgeDeleteExternalAccountPayload), + args: { + input: { type: GT.NonNull(BridgeDeleteExternalAccountInput) }, + }, + resolve: async ( + _, + { input }: { input: { externalAccountId: 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.deleteExternalAccount( + domainAccount.id, + input.externalAccountId, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { externalAccount: result, errors: [] } + }, +}) + +export default bridgeDeleteExternalAccount diff --git a/src/graphql/public/root/mutation/bridge-set-default-external-account.ts b/src/graphql/public/root/mutation/bridge-set-default-external-account.ts new file mode 100644 index 000000000..f9254ae5a --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-set-default-external-account.ts @@ -0,0 +1,54 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeSetDefaultExternalAccountInput = GT.Input({ + name: "BridgeSetDefaultExternalAccountInput", + fields: () => ({ + externalAccountId: { type: GT.NonNull(GT.ID) }, + }), +}) + +const BridgeSetDefaultExternalAccountPayload = GT.Object({ + name: "BridgeSetDefaultExternalAccountPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + externalAccount: { type: BridgeExternalAccount }, + }), +}) + +const bridgeSetDefaultExternalAccount = GT.Field({ + type: GT.NonNull(BridgeSetDefaultExternalAccountPayload), + args: { + input: { type: GT.NonNull(BridgeSetDefaultExternalAccountInput) }, + }, + resolve: async ( + _, + { input }: { input: { externalAccountId: 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.setDefaultExternalAccount( + domainAccount.id, + input.externalAccountId, + ) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { externalAccount: result, errors: [] } + }, +}) + +export default bridgeSetDefaultExternalAccount diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 77adfc8ed..45ac3bea4 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -278,10 +278,20 @@ type BridgeCreateVirtualAccountPayload { virtualAccount: BridgeVirtualAccount } +input BridgeDeleteExternalAccountInput { + externalAccountId: ID! +} + +type BridgeDeleteExternalAccountPayload { + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + type BridgeExternalAccount { accountNumberLast4: String! bankName: String! id: ID! + isDefault: Boolean! status: String! } @@ -325,6 +335,15 @@ type BridgeRequestWithdrawalPayload { withdrawal: BridgeWithdrawal } +input BridgeSetDefaultExternalAccountInput { + externalAccountId: ID! +} + +type BridgeSetDefaultExternalAccountPayload { + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + type BridgeVirtualAccount { accountNumber: String accountNumberLast4: String @@ -1016,9 +1035,11 @@ type Mutation { bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! + bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! + bridgeSetDefaultExternalAccount(input: BridgeSetDefaultExternalAccountInput!): BridgeSetDefaultExternalAccountPayload! businessAccountUpgradeRequest(input: BusinessAccountUpgradeRequestInput!): AccountUpgradePayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! diff --git a/src/graphql/public/types/object/bridge-external-account.ts b/src/graphql/public/types/object/bridge-external-account.ts index 92cae18ad..f56dedb12 100644 --- a/src/graphql/public/types/object/bridge-external-account.ts +++ b/src/graphql/public/types/object/bridge-external-account.ts @@ -7,6 +7,7 @@ const BridgeExternalAccount = GT.Object({ bankName: { type: GT.NonNull(GT.String) }, accountNumberLast4: { type: GT.NonNull(GT.String) }, status: { type: GT.NonNull(GT.String) }, + isDefault: { type: GT.NonNull(GT.Boolean) }, }), }) diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index a33526e27..9cf852251 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -9,6 +9,7 @@ import { BridgeConfig } from "@config" import { BridgeCustomerId, + BridgeExternalAccountId, BridgeTransferId, BridgeVirtualAccountId, } from "@domain/primitives/bridge" @@ -532,6 +533,16 @@ export class BridgeClient { ) } + async deleteExternalAccount( + customerId: BridgeCustomerId, + externalAccountId: BridgeExternalAccountId, + ): Promise { + return this.request( + "DELETE", + `/customers/${customerId}/external_accounts/${externalAccountId}`, + ) + } + // ============ Transfers ============ async createTransfer( diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 8b55d9713..412fe7e1a 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -15,7 +15,11 @@ import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" import { RepositoryError } from "@domain/errors" -import { toBridgeCustomerId, toBridgeVirtualAccountId } from "@domain/primitives/bridge" +import { + toBridgeCustomerId, + toBridgeExternalAccountId, + toBridgeVirtualAccountId, +} from "@domain/primitives/bridge" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" import { USDTAmount, WalletCurrency } from "@domain/shared" @@ -129,6 +133,7 @@ type ExternalAccountResult = { bankName: string accountNumberLast4: string status: "pending" | "verified" | "failed" + isDefault: boolean } // ============ Helpers ============ @@ -182,11 +187,13 @@ const externalAccountResultFromRecord = (acc: { bankName: string accountNumberLast4: string status: string + isDefault?: boolean }): ExternalAccountResult => ({ bridgeExternalAccountId: acc.bridgeExternalAccountId, bankName: acc.bankName, accountNumberLast4: acc.accountNumberLast4, status: acc.status as "pending" | "verified" | "failed", + isDefault: Boolean(acc.isDefault), }) const syncExternalAccountsFromBridge = async ( @@ -216,6 +223,9 @@ const syncExternalAccountsFromBridge = async ( ) if (staleMarkResult instanceof Error) return staleMarkResult + const defaultResult = await BridgeAccountsRepo.ensureDefaultExternalAccount(accountId) + if (defaultResult instanceof Error) return defaultResult + const localAccounts = await BridgeAccountsRepo.findExternalAccountsByAccountId(accountId) if (localAccounts instanceof Error) return localAccounts @@ -642,7 +652,7 @@ const createExternalAccount = async ( crypto.randomUUID(), ) - const result: ExternalAccountResult = { + const bridgeExternalAccountResult = { bridgeExternalAccountId: externalAccount.id, bankName: externalAccount.bank_name ?? "", accountNumberLast4: bridgeExternalAccountLast4(externalAccount), @@ -652,9 +662,9 @@ const createExternalAccount = async ( // Persist the external account reference in the local repository const persistResult = await BridgeAccountsRepo.createExternalAccount({ accountId, - bridgeExternalAccountId: result.bridgeExternalAccountId, - bankName: result.bankName, - accountNumberLast4: result.accountNumberLast4, + bridgeExternalAccountId: bridgeExternalAccountResult.bridgeExternalAccountId, + bankName: bridgeExternalAccountResult.bankName, + accountNumberLast4: bridgeExternalAccountResult.accountNumberLast4, status: "verified", }) if (persistResult instanceof Error) { @@ -665,6 +675,8 @@ const createExternalAccount = async ( return persistResult } + const result = externalAccountResultFromRecord(persistResult) + baseLogger.info( { accountId, operation: "createExternalAccount", result }, "Bridge operation completed", @@ -680,6 +692,127 @@ const createExternalAccount = async ( } } +const setDefaultExternalAccount = async ( + accountId: AccountId, + externalAccountId: string, +): Promise => { + baseLogger.info( + { accountId, externalAccountId, operation: "setDefaultExternalAccount" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const externalAccounts = await syncExternalAccountsFromBridge( + accountId as string, + customerId, + ) + if (externalAccounts instanceof Error) return externalAccounts + + const targetAccount = externalAccounts.find( + (acc) => acc.bridgeExternalAccountId === externalAccountId, + ) + if (!targetAccount) { + return new BridgeError("External account not found for this account") + } + + const result = await BridgeAccountsRepo.setDefaultExternalAccount( + accountId as string, + externalAccountId, + ) + if (result instanceof Error) return result + + const presented = externalAccountResultFromRecord(result) + baseLogger.info( + { accountId, externalAccountId, operation: "setDefaultExternalAccount" }, + "Bridge operation completed", + ) + + return presented + } catch (error) { + baseLogger.error( + { accountId, externalAccountId, operation: "setDefaultExternalAccount", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + +const deleteExternalAccount = async ( + accountId: AccountId, + externalAccountId: string, +): Promise => { + baseLogger.info( + { accountId, externalAccountId, operation: "deleteExternalAccount" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const externalAccounts = await syncExternalAccountsFromBridge( + accountId as string, + customerId, + ) + if (externalAccounts instanceof Error) return externalAccounts + + const targetAccount = externalAccounts.find( + (acc) => acc.bridgeExternalAccountId === externalAccountId, + ) + if (!targetAccount) { + return new BridgeError("External account not found for this account") + } + + await BridgeApiClient.deleteExternalAccount( + toBridgeCustomerId(customerId), + toBridgeExternalAccountId(externalAccountId), + ) + + const result = await BridgeAccountsRepo.deleteExternalAccount( + accountId as string, + externalAccountId, + ) + if (result instanceof Error) return result + + const presented = externalAccountResultFromRecord(result) + baseLogger.info( + { accountId, externalAccountId, operation: "deleteExternalAccount" }, + "Bridge operation completed", + ) + + return presented + } catch (error) { + baseLogger.error( + { accountId, externalAccountId, operation: "deleteExternalAccount", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } +} + /** * Requests a withdrawal — validates everything and stores a pending record in MongoDB. * Does NOT call the Bridge API. Returns the pending withdrawal so the frontend can @@ -1518,6 +1651,8 @@ export default wrapAsyncFunctionsToRunInSpan({ createVirtualAccount, addExternalAccount, createExternalAccount, + setDefaultExternalAccount, + deleteExternalAccount, requestWithdrawal, initiateWithdrawal, cancelWithdrawalRequest, diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index 06dcf11e8..109675087 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -73,7 +73,15 @@ export const createExternalAccount = async (data: { }, { upsert: true, new: true, setDefaultsOnInsert: true }, ) - return record + const defaultResult = await ensureDefaultExternalAccount(data.accountId) + if (defaultResult instanceof Error) return defaultResult + + return ( + (await BridgeExternalAccount.findOne({ + accountId: data.accountId, + bridgeExternalAccountId, + })) ?? record + ) } catch (error) { return new RepositoryError(String(error)) } @@ -88,6 +96,100 @@ export const findExternalAccountsByAccountId = async (accountId: string) => { } } +export const ensureDefaultExternalAccount = async (accountId: string) => { + try { + const currentDefault = await BridgeExternalAccount.findOne({ + accountId, + isDefault: true, + status: { $ne: "failed" }, + }) + if (currentDefault) return currentDefault + + const nextDefault = await BridgeExternalAccount.findOne({ + accountId, + status: { $ne: "failed" }, + }).sort({ createdAt: 1 }) + if (!nextDefault) { + await BridgeExternalAccount.updateMany( + { accountId }, + { isDefault: false, updatedAt: new Date() }, + ) + return null + } + + await BridgeExternalAccount.updateMany( + { accountId }, + { isDefault: false, updatedAt: new Date() }, + ) + + return await BridgeExternalAccount.findOneAndUpdate( + { _id: nextDefault._id }, + { isDefault: true, updatedAt: new Date() }, + { new: true }, + ) + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const setDefaultExternalAccount = async ( + accountId: string, + bridgeExternalAccountId: string, +) => { + try { + const target = await BridgeExternalAccount.findOne({ + accountId, + bridgeExternalAccountId, + status: { $ne: "failed" }, + }) + if (!target) return new RepositoryError("External account not found") + + await BridgeExternalAccount.updateMany( + { accountId }, + { isDefault: false, updatedAt: new Date() }, + ) + + const record = await BridgeExternalAccount.findOneAndUpdate( + { _id: target._id }, + { isDefault: true, updatedAt: new Date() }, + { new: true }, + ) + return record || new RepositoryError("External account not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const deleteExternalAccount = async ( + accountId: string, + bridgeExternalAccountId: string, +) => { + try { + const target = await BridgeExternalAccount.findOne({ + accountId, + bridgeExternalAccountId, + status: { $ne: "failed" }, + }) + if (!target) return new RepositoryError("External account not found") + + const record = await BridgeExternalAccount.findOneAndUpdate( + { _id: target._id }, + { status: "failed", isDefault: false, updatedAt: new Date() }, + { new: true }, + ) + if (!record) return new RepositoryError("External account not found") + + if (target.isDefault) { + const defaultResult = await ensureDefaultExternalAccount(accountId) + if (defaultResult instanceof Error) return defaultResult + } + + return record + } catch (error) { + return new RepositoryError(String(error)) + } +} + export const markExternalAccountsMissingFromBridge = async ( accountId: string, bridgeExternalAccountIds: string[], @@ -103,6 +205,7 @@ export const markExternalAccountsMissingFromBridge = async ( return await BridgeExternalAccount.updateMany(filter, { status: "failed", + isDefault: false, updatedAt: new Date(), }) } catch (error) { @@ -117,10 +220,21 @@ export const updateExternalAccountStatus = async ( try { const record = await BridgeExternalAccount.findOneAndUpdate( { bridgeExternalAccountId: bridgeId }, - { status }, + { + status, + ...(status === "failed" ? { isDefault: false } : {}), + updatedAt: new Date(), + }, { new: true }, ) - return record || new RepositoryError("External account not found") + if (!record) return new RepositoryError("External account not found") + + if (status === "failed") { + const defaultResult = await ensureDefaultExternalAccount(record.accountId) + if (defaultResult instanceof Error) return defaultResult + } + + return record } catch (error) { return new RepositoryError(String(error)) } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 43d619335..9e8835531 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -39,6 +39,7 @@ interface IBridgeExternalAccountRecord { bankName: string accountNumberLast4: string status: "pending" | "verified" | "failed" + isDefault: boolean createdAt: Date updatedAt: Date } @@ -731,6 +732,7 @@ const BridgeExternalAccountSchema = new Schema({ bankName: { type: String, required: true }, accountNumberLast4: { type: String, required: true }, status: { type: String, enum: ["pending", "verified", "failed"], default: "pending" }, + isDefault: { type: Boolean, default: false }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, }) diff --git a/test/flash/unit/graphql/public/root/mutation/bridge-external-account.spec.ts b/test/flash/unit/graphql/public/root/mutation/bridge-external-account.spec.ts new file mode 100644 index 000000000..1017fa812 --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/bridge-external-account.spec.ts @@ -0,0 +1,108 @@ +jest.mock("@services/bridge", () => ({ + __esModule: true, + default: { + setDefaultExternalAccount: jest.fn(), + deleteExternalAccount: jest.fn(), + }, +})) + +jest.mock("@config", () => ({ + BridgeConfig: { enabled: true }, + getOnChainWalletConfig: jest.fn().mockReturnValue({ dustThreshold: 546 }), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import BridgeService from "@services/bridge" +import BridgeSetDefaultExternalAccountMutation from "@graphql/public/root/mutation/bridge-set-default-external-account" +import BridgeDeleteExternalAccountMutation from "@graphql/public/root/mutation/bridge-delete-external-account" + +const ACCOUNT_ID = "account-001" as AccountId +const EXTERNAL_ACCOUNT_ID = "ext-001" + +const ctx = { + domainAccount: { id: ACCOUNT_ID, level: 2 }, +} as unknown as GraphQLPublicContextAuth + +type BridgeExternalAccountMutationResult = { + errors: Array<{ code?: string; message?: string }> + externalAccount?: { + id?: string + bankName?: string + accountNumberLast4?: string + status?: string + isDefault?: boolean + } +} + +type BridgeExternalAccountMutation = { + resolve?: ( + source: null, + args: { input: { externalAccountId: string } }, + context: GraphQLPublicContextAuth, + info: never, + ) => Promise | unknown +} + +const resolveBridgeMutation = async ( + mutation: BridgeExternalAccountMutation, +): Promise => { + if (!mutation.resolve) throw new Error("Missing resolver") + return (await mutation.resolve( + null, + { input: { externalAccountId: EXTERNAL_ACCOUNT_ID } }, + ctx, + {} as never, + )) as BridgeExternalAccountMutationResult +} + +const externalAccount = { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + isDefault: true, +} + +describe("Bridge external account mutations", () => { + beforeEach(() => jest.clearAllMocks()) + + it("sets the default external account for the current account", async () => { + ;(BridgeService.setDefaultExternalAccount as jest.Mock).mockResolvedValue( + externalAccount, + ) + + const result = await resolveBridgeMutation( + BridgeSetDefaultExternalAccountMutation as BridgeExternalAccountMutation, + ) + + expect(BridgeService.setDefaultExternalAccount).toHaveBeenCalledWith( + ACCOUNT_ID, + EXTERNAL_ACCOUNT_ID, + ) + expect(result.errors).toEqual([]) + expect(result.externalAccount).toEqual(externalAccount) + }) + + it("deletes the external account for the current account", async () => { + ;(BridgeService.deleteExternalAccount as jest.Mock).mockResolvedValue({ + ...externalAccount, + status: "failed", + isDefault: false, + }) + + const result = await resolveBridgeMutation( + BridgeDeleteExternalAccountMutation as BridgeExternalAccountMutation, + ) + + expect(BridgeService.deleteExternalAccount).toHaveBeenCalledWith( + ACCOUNT_ID, + EXTERNAL_ACCOUNT_ID, + ) + expect(result.errors).toEqual([]) + expect(result.externalAccount?.status).toBe("failed") + expect(result.externalAccount?.isDefault).toBe(false) + }) +}) diff --git a/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts index 1bf59a1d8..c704057c0 100644 --- a/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts +++ b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts @@ -17,6 +17,7 @@ jest.mock("@config", () => { }) import BridgeVirtualAccount from "@graphql/public/types/object/bridge-virtual-account" +import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" import { defaultFieldResolver } from "graphql" import { getBridgeWithdrawalFlashFeeNotice } from "@app/bridge/get-withdrawal-flash-fee-notice" @@ -114,4 +115,14 @@ describe("Bridge public GraphQL object contract", () => { idField.resolve?.(virtualAccount, {}, {} as GraphQLPublicContext, {} as never), ).toBe("bridge-va-001") }) + + it("exposes Bridge external account default status", () => { + const fields = BridgeExternalAccount.getFields() + + expect(fields).toHaveProperty("id") + expect(fields).toHaveProperty("bankName") + expect(fields).toHaveProperty("accountNumberLast4") + expect(fields).toHaveProperty("status") + expect(fields).toHaveProperty("isDefault") + }) }) diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 14dc40c8e..a006fdaf7 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -55,6 +55,9 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ findPendingWithdrawalWithoutTransfer: jest.fn(), findExternalAccountsByAccountId: jest.fn(), markExternalAccountsMissingFromBridge: jest.fn(), + ensureDefaultExternalAccount: jest.fn(), + setDefaultExternalAccount: jest.fn(), + deleteExternalAccount: jest.fn(), updateWithdrawalFeeEstimates: jest.fn(), bridgeWithdrawalRecordId: jest.requireActual("@services/mongoose/bridge-accounts") .bridgeWithdrawalRecordId, @@ -71,6 +74,7 @@ jest.mock("@services/bridge/client", () => ({ default: { createVirtualAccount: jest.fn(), createExternalAccount: jest.fn(), + deleteExternalAccount: jest.fn(), createTransfer: jest.fn(), listExternalAccounts: jest.fn(), getCustomer: jest.fn().mockResolvedValue({ status: "active" }), @@ -277,6 +281,7 @@ const setupGuards = () => { bankName: "Test Bank", accountNumberLast4: "1111", status: "verified", + isDefault: true, }, ]) ;(BridgeAccountsRepo.createExternalAccount as jest.Mock).mockResolvedValue({ @@ -284,10 +289,18 @@ const setupGuards = () => { bankName: "Test Bank", accountNumberLast4: "1111", status: "verified", + isDefault: true, }) ;( BridgeAccountsRepo.markExternalAccountsMissingFromBridge as jest.Mock ).mockResolvedValue({ modifiedCount: 0 }) + ;(BridgeAccountsRepo.ensureDefaultExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + isDefault: true, + }) ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ data: [mockBridgeExternalAccount], has_more: false, @@ -864,12 +877,14 @@ describe("getExternalAccounts", () => { bankName: "Deleted Bank", accountNumberLast4: "9999", status: "verified", + isDefault: false, }, { bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, bankName: "Test Bank", accountNumberLast4: "1111", status: "verified", + isDefault: true, }, ]) @@ -886,11 +901,72 @@ describe("getExternalAccounts", () => { bankName: "Test Bank", accountNumberLast4: "1111", status: "verified", + isDefault: true, }, ]) }) }) +describe("setDefaultExternalAccount", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + ;(BridgeAccountsRepo.setDefaultExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + isDefault: true, + }) + }) + + it("syncs with Bridge before setting the default account", async () => { + const result = await BridgeService.setDefaultExternalAccount( + ACCOUNT_ID, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeClient.listExternalAccounts).toHaveBeenCalledWith(CUSTOMER_ID) + expect(BridgeAccountsRepo.setDefaultExternalAccount).toHaveBeenCalledWith( + ACCOUNT_ID as string, + EXTERNAL_ACCOUNT_ID, + ) + expect(expectSuccess(result).isDefault).toBe(true) + }) +}) + +describe("deleteExternalAccount", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + ;(BridgeAccountsRepo.deleteExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "failed", + isDefault: false, + }) + }) + + it("deletes through Bridge before marking the local account failed", async () => { + const result = await BridgeService.deleteExternalAccount( + ACCOUNT_ID, + EXTERNAL_ACCOUNT_ID, + ) + + expect(BridgeClient.listExternalAccounts).toHaveBeenCalledWith(CUSTOMER_ID) + expect(BridgeClient.deleteExternalAccount).toHaveBeenCalledWith( + CUSTOMER_ID, + EXTERNAL_ACCOUNT_ID, + ) + expect(BridgeAccountsRepo.deleteExternalAccount).toHaveBeenCalledWith( + ACCOUNT_ID as string, + EXTERNAL_ACCOUNT_ID, + ) + expect(expectSuccess(result).status).toBe("failed") + }) +}) + // ───────────────────────────────────────────────────────────────────────────── // initiateWithdrawal (refactored) // Step 2A: fetches the pending record by ID, re-checks balance, calls Bridge. From 8736c390fc754e946e35e40d281894aaf8917cb0 Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:14:58 -0400 Subject: [PATCH 2/2] test(bridge): mock default external account helper --- test/flash/unit/services/bridge/return-shapes.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index 372f6840a..aa3070e43 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -58,6 +58,7 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ findPendingWithdrawalWithoutTransfer: jest.fn(), findExternalAccountsByAccountId: jest.fn(), markExternalAccountsMissingFromBridge: jest.fn(), + ensureDefaultExternalAccount: jest.fn(), findWithdrawalsByAccountId: jest.fn(), findWithdrawalById: jest.fn(), updateWithdrawalTransferId: jest.fn(), @@ -202,6 +203,13 @@ const setupGuards = () => { ;( BridgeAccountsRepo.markExternalAccountsMissingFromBridge as jest.Mock ).mockResolvedValue({ modifiedCount: 0 }) + ;(BridgeAccountsRepo.ensureDefaultExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + isDefault: true, + }) ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ data: [ {