From 67c72a20c0f15373e7d81bf3646bfdf32aabd08d Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 19 May 2026 11:36:03 +0100 Subject: [PATCH 01/13] feat: add timeout for Bridge request Add timeout to stop the slow request to Bridge API after 10s pending This commit adds a setTimout to the bridge API request and raises BridgeTimeoutError after the settled timeout --- dev/config/base-config.yaml | 3 ++- src/config/schema.ts | 2 ++ src/config/schema.types.d.ts | 1 + src/services/bridge/client.ts | 45 +++++++++++++++++++++++------------ 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index 3cbc37bee..6a3f5070a 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -18,7 +18,8 @@ bridge: enabled: true apiKey: "sk-test-3bd6463c9cd77c3d8858c60b9997d0c6" baseUrl: "https://api.sandbox.bridge.xyz/v0" - minWithdrawalAmount: 10 + minWithdrawalAmount: 2 + timeoutMs: 10000 webhook: port: 4009 replaySecret: "also-not-so-secret" diff --git a/src/config/schema.ts b/src/config/schema.ts index 0d673fd2b..c4e6077c2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -649,6 +649,8 @@ export const configSchema = { enabled: { type: "boolean" }, apiKey: { type: "string" }, baseUrl: { type: "string" }, + minWithdrawalAmount: { type: "number" }, + timeoutMs: { type: "integer", default: 10000 }, webhook: { type: "object", properties: { diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 4f480b86f..20c1952a4 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -49,6 +49,7 @@ type BridgeConfig = { apiKey: string baseUrl: string minWithdrawalAmount: number + timeoutMs?: number webhook: BridgeWebhook } diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index e5503fccf..9e687e024 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -8,6 +8,7 @@ import crypto from "crypto" import { BridgeConfig } from "@config" import { BridgeCustomerId, BridgeTransferId } from "@domain/primitives/bridge" +import { BridgeTimeoutError } from "./errors" // ============ Error Handling ============ @@ -365,23 +366,37 @@ export class BridgeClient { } } - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }) - - const responseData = await response.json().catch(() => null) + const timeoutMs = BridgeConfig.timeoutMs ?? 10_000 + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + + const responseData = await response.json().catch(() => null) + + if (!response.ok) { + throw new BridgeApiError( + `Bridge API error: ${response.status} ${response.statusText}`, + response.status, + responseData, + ) + } - if (!response.ok) { - throw new BridgeApiError( - `Bridge API error: ${response.status} ${response.statusText}`, - response.status, - responseData, - ) + return responseData as T + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new BridgeTimeoutError() + } + throw err + } finally { + clearTimeout(timeoutId) } - - return responseData as T } // ============ Customers ============ From 12286791b0b288cd60988ed464efcf0c3309c08a Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 19 May 2026 12:39:48 +0100 Subject: [PATCH 02/13] =?UTF-8?q?feat(bridge):=20ENG-350=20=E2=80=94=20res?= =?UTF-8?q?et=20withdrawal=20state=20on=20transfer.failed=20and=20surface?= =?UTF-8?q?=20failure=20reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On transfer.failed (and all Bridge terminal failure states), update the bridgeWithdrawal Mongoose document to failed and persist the failure reason where Bridge documents one (return_reason for refund_failed, reason for transfer.failed). Adds BridgeTransferFailedError for ENG-353 mapping and exposes failureReason on the BridgeWithdrawal GraphQL type. Also aligns completion handling: payment_processed and transfer.payment_processed are now accepted as completion signals alongside transfer.completed. No IBEX ledger operations are touched. --- src/graphql/error-map.ts | 4 ++ .../public/types/object/bridge-withdrawal.ts | 1 + src/services/bridge/errors.ts | 6 +++ src/services/bridge/index.types.d.ts | 50 ++++++++++++++++--- .../bridge/webhook-server/routes/transfer.ts | 36 +++++++++++-- src/services/mongoose/bridge-accounts.ts | 5 +- src/services/mongoose/schema.ts | 2 + 7 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index c53a4b37f..e6176c2a2 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -517,6 +517,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "Request timed out" return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeTransferFailedError": + message = error.message || "Transfer failed" + return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeWebhookValidationError": message = "Invalid webhook signature" return new ValidationInternalError({ message, logger: baseLogger }) diff --git a/src/graphql/public/types/object/bridge-withdrawal.ts b/src/graphql/public/types/object/bridge-withdrawal.ts index f29ba250d..0c6298215 100644 --- a/src/graphql/public/types/object/bridge-withdrawal.ts +++ b/src/graphql/public/types/object/bridge-withdrawal.ts @@ -7,6 +7,7 @@ const BridgeWithdrawal = GT.Object({ amount: { type: GT.NonNull(GT.String) }, currency: { type: GT.NonNull(GT.String) }, status: { type: GT.NonNull(GT.String) }, + failureReason: { type: GT.String }, createdAt: { type: GT.NonNull(GT.String) }, }), }) diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index 6f6572b0c..d56d0cbf9 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -77,6 +77,12 @@ export class BridgeDisabledError extends BridgeError { } } +export class BridgeTransferFailedError extends BridgeError { + constructor(reason: string = "Transfer failed") { + super(reason) + } +} + export class BridgeWebhookValidationError extends BridgeError { constructor(message: string = "Invalid webhook signature") { super(message) diff --git a/src/services/bridge/index.types.d.ts b/src/services/bridge/index.types.d.ts index 430e4b601..728ee499e 100644 --- a/src/services/bridge/index.types.d.ts +++ b/src/services/bridge/index.types.d.ts @@ -48,7 +48,20 @@ interface BridgeTransfer { readonly destinationAccountId: BridgeVirtualAccountId | BridgeExternalAccountId readonly amount: number readonly currency: string - readonly status: "pending" | "processing" | "completed" | "failed" | "cancelled" + readonly status: + | "awaiting_funds" + | "in_review" + | "funds_received" + | "payment_submitted" + | "payment_processed" + | "undeliverable" + | "returned" + | "refund_in_flight" + | "refunded" + | "refund_failed" + | "missing_return_policy" + | "error" + | "canceled" readonly description?: string readonly createdAt: string readonly updatedAt: string @@ -95,21 +108,43 @@ interface BridgeDepositCompletedEvent extends BridgeWebhookEvent { interface BridgeTransferCompletedEvent extends BridgeWebhookEvent { readonly type: "transfer.completed" readonly data: { - readonly transferId: BridgeTransferId + readonly transfer_id: BridgeTransferId readonly customerId: BridgeCustomerId - readonly amount: number + readonly state: "payment_processed" + readonly amount: string + readonly currency: string + } +} + +interface BridgeTransferPaymentProcessedEvent extends BridgeWebhookEvent { + readonly type: "transfer.payment_processed" + readonly data: { + readonly transfer_id: BridgeTransferId + readonly customerId: BridgeCustomerId + readonly state: "payment_processed" + readonly amount: string readonly currency: string - readonly completedAt: string } } interface BridgeTransferFailedEvent extends BridgeWebhookEvent { readonly type: "transfer.failed" readonly data: { - readonly transferId: BridgeTransferId + readonly transfer_id: BridgeTransferId readonly customerId: BridgeCustomerId - readonly reason: string - readonly failedAt: string + readonly state: + | "undeliverable" + | "returned" + | "refunded" + | "refund_in_flight" + | "refund_failed" + | "missing_return_policy" + | "error" + | "canceled" + readonly reason?: string + readonly return_reason?: string + readonly amount: string + readonly currency: string } } @@ -118,4 +153,5 @@ type BridgeWebhookEventType = | BridgeKycRejectedEvent | BridgeDepositCompletedEvent | BridgeTransferCompletedEvent + | BridgeTransferPaymentProcessedEvent | BridgeTransferFailedEvent diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index 0863a074b..b65a16159 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -11,7 +11,7 @@ import { toBridgeTransferId } from "@domain/primitives/bridge" export const transferHandler = async (req: Request, res: Response) => { const { event, data } = req.body - const { transfer_id, state, amount, currency } = data + const { transfer_id, state, amount, currency, reason, return_reason } = data if (!transfer_id || !event) { return res.status(400).json({ error: "Invalid payload" }) @@ -28,8 +28,27 @@ export const transferHandler = async (req: Request, res: Response) => { try { const bridgeTransferId = toBridgeTransferId(transfer_id) + const TERMINAL_FAILURE_STATES = new Set([ + "undeliverable", + "returned", + "refunded", + "refund_in_flight", + "refund_failed", + "missing_return_policy", + "error", + "canceled", + ]) + + const isCompletion = + event === "transfer.completed" || + event === "transfer.payment_processed" || + state === "payment_processed" + + const isFailure = + event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state) + // Update withdrawal status based on event - if (event === "transfer.completed") { + if (isCompletion) { const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, "completed", @@ -46,6 +65,7 @@ export const transferHandler = async (req: Request, res: Response) => { baseLogger.info( { transfer_id, + state, amount, currency, }, @@ -53,10 +73,18 @@ export const transferHandler = async (req: Request, res: Response) => { ) // TODO: Send push notification to user - } else if (event === "transfer.failed") { + } else if (isFailure) { + const failureReason = + state === "refund_failed" + ? (return_reason as string | undefined) + : event === "transfer.failed" + ? (reason as string | undefined) + : undefined + const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, "failed", + failureReason, ) if (result instanceof Error) { @@ -73,6 +101,8 @@ export const transferHandler = async (req: Request, res: Response) => { state, amount, currency, + reason, + return_reason, }, "Bridge transfer failed", ) diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index 234304c2c..a8cafb5f6 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -171,11 +171,14 @@ export const findWithdrawalsByAccountId = async (accountId: string) => { export const updateWithdrawalStatus = async ( bridgeTransferId: BridgeTransferId, status: "pending" | "completed" | "failed", + failureReason?: string, ) => { try { + const update: Record = { status, updatedAt: new Date() } + if (failureReason !== undefined) update.failureReason = failureReason const record = await BridgeWithdrawal.findOneAndUpdate( { bridgeTransferId }, - { status, updatedAt: new Date() }, + update, { new: true }, ) return record || new RepositoryError("Withdrawal not found") diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 15a1fd2f5..c53879778 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -48,6 +48,7 @@ interface IBridgeWithdrawalRecord { amount: string currency: string status: "pending" | "completed" | "failed" + failureReason?: string externalAccountId: string createdAt: Date updatedAt: Date @@ -653,6 +654,7 @@ const BridgeWithdrawalSchema = new Schema({ amount: { type: String, required: true }, currency: { type: String, required: true }, status: { type: String, enum: ["pending", "completed", "failed"], default: "pending" }, + failureReason: { type: String }, externalAccountId: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, From 8b7814b58cc768d13adb7c7f31689cc81d3a15b9 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 19 May 2026 20:41:46 +0100 Subject: [PATCH 03/13] feat(bridge): ENG-350 harden transfer webhook edge cases and notify users Return 503 when the withdrawal row is not ready, treat refund_in_flight as transient, make status updates idempotent with post-success locks, cap failure reason length, route outbound replay to the transfer handler, and send Cashout push notifications on terminal completion or failure. --- .../bridge/send-withdrawal-notification.ts | 112 +++++++++++ src/config/locales/en.json | 11 + src/config/locales/es.json | 11 + .../bridge/webhook-server/routes/replay.ts | 18 +- .../bridge/webhook-server/routes/transfer.ts | 114 ++++++++--- .../webhook-server/transfer-direction.ts | 29 +++ src/services/mongoose/bridge-accounts.ts | 30 ++- src/services/mongoose/schema.ts | 2 +- .../send-withdrawal-notification.spec.ts | 122 +++++++++++ .../bridge/webhook-server/replay.spec.ts | 32 +++ .../bridge/webhook-server/transfer.spec.ts | 189 ++++++++++++++++++ 11 files changed, 640 insertions(+), 30 deletions(-) create mode 100644 src/app/bridge/send-withdrawal-notification.ts create mode 100644 src/services/bridge/webhook-server/transfer-direction.ts create mode 100644 test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts create mode 100644 test/flash/unit/services/bridge/webhook-server/transfer.spec.ts diff --git a/src/app/bridge/send-withdrawal-notification.ts b/src/app/bridge/send-withdrawal-notification.ts new file mode 100644 index 000000000..8adf90f30 --- /dev/null +++ b/src/app/bridge/send-withdrawal-notification.ts @@ -0,0 +1,112 @@ +import { getI18nInstance } from "@config" +import { checkedToAccountId } from "@domain/accounts" +import { getLanguageOrDefault } from "@domain/locale" +import { + DeviceTokensNotRegisteredNotificationsServiceError, + FlashNotificationCategories, + NotificationsServiceError, +} from "@domain/notifications" +import { removeDeviceTokens } from "@app/users/remove-device-tokens" +import { baseLogger } from "@services/logger" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" + +const i18n = getI18nInstance() + +const formatWithdrawalAmount = (amount: string, currency: string): string => + `${amount} ${currency.toUpperCase()}` + +export type BridgeWithdrawalNotificationOutcome = "completed" | "failed" + +export const sendBridgeWithdrawalNotification = async ({ + accountId: accountIdRaw, + amount, + currency, + outcome, + failureReason, +}: { + accountId: string + amount: string + currency: string + outcome: BridgeWithdrawalNotificationOutcome + failureReason?: string +}): Promise => { + const accountId = checkedToAccountId(accountIdRaw) + if (accountId instanceof Error) return accountId + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const user = await UsersRepository().findById(account.kratosUserId) + if (user instanceof Error) return user + + const locale = getLanguageOrDefault(user.language) + const formattedAmount = formatWithdrawalAmount(amount, currency) + const phraseBase = `notification.bridgeWithdrawal.${outcome}` + + const title = i18n.__({ phrase: `${phraseBase}.title`, locale }) + const bodyPhrase = + outcome === "failed" && failureReason + ? `${phraseBase}.bodyWithReason` + : `${phraseBase}.body` + const body = i18n.__( + { phrase: bodyPhrase, locale }, + { + amount: formattedAmount, + reason: failureReason ?? "", + }, + ) + + const result = await PushNotificationsService().sendFilteredNotification({ + deviceTokens: user.deviceTokens, + title, + body, + notificationCategory: FlashNotificationCategories.Cashout, + notificationSettings: account.notificationSettings, + data: { + type: `bridge_withdrawal_${outcome}`, + amount, + currency, + ...(failureReason ? { failureReason } : {}), + }, + }) + + if (result instanceof NotificationsServiceError) return result + + if (result.status === SendFilteredPushNotificationStatus.Filtered) { + return true + } + + return true +} + +export const sendBridgeWithdrawalNotificationBestEffort = async ( + args: Parameters[0], +): Promise => { + const result = await sendBridgeWithdrawalNotification(args) + + if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { + const accountId = checkedToAccountId(args.accountId) + if (accountId instanceof Error) return + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return + + await removeDeviceTokens({ + userId: account.kratosUserId, + deviceTokens: result.tokens, + }) + return + } + + if (result instanceof Error) { + baseLogger.warn( + { accountId: args.accountId, outcome: args.outcome, error: result }, + "Failed to send Bridge withdrawal push notification", + ) + } +} diff --git a/src/config/locales/en.json b/src/config/locales/en.json index c9bd32b95..55eafc82d 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -40,6 +40,17 @@ "cashout": { "body": "Your cashout of {{amount}} has been deposited to your bank account.", "title": "Cashout" + }, + "bridgeWithdrawal": { + "completed": { + "body": "Your withdrawal of {{amount}} has been sent to your bank account.", + "title": "Withdrawal complete" + }, + "failed": { + "body": "Your withdrawal of {{amount}} could not be completed.", + "bodyWithReason": "Your withdrawal of {{amount}} could not be completed: {{reason}}.", + "title": "Withdrawal failed" + } } } } \ No newline at end of file diff --git a/src/config/locales/es.json b/src/config/locales/es.json index 122b16b47..7325c9fe0 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -36,6 +36,17 @@ "bodyDisplayCurrency": "+{{baseCurrencyAmount}}{{baseCurrencyName}} ({{displayCurrencyAmount}})", "title": "Transacción {{walletCurrency}}" } + }, + "bridgeWithdrawal": { + "completed": { + "body": "Su retiro de {{amount}} se envió a su cuenta bancaria.", + "title": "Retiro completado" + }, + "failed": { + "body": "No se pudo completar su retiro de {{amount}}.", + "bodyWithReason": "No se pudo completar su retiro de {{amount}}: {{reason}}.", + "title": "Retiro fallido" + } } } } diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 6316e2c8c..6eb4adab2 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -8,6 +8,11 @@ import { baseLogger } from "@services/logger" import { createBridgeReplayLog } from "@services/mongoose/bridge-replay-log" +import { + isOutboundBridgeWithdrawal, + transferReplayEventTypeForStatus, +} from "../transfer-direction" + import { depositHandler } from "./deposit" import { kycHandler } from "./kyc" import { transferHandler } from "./transfer" @@ -41,14 +46,20 @@ const toRouteKey = (bridgeEventType: string): RouteKey | null => { const resolveReplayEventType = ({ eventType, eventObjectStatus, + eventObject, }: { eventType: string eventObjectStatus?: string + eventObject?: Record }): string => { const routeFromEventType = toRouteKey(eventType) if (routeFromEventType) return eventType if (eventObjectStatus && DEPOSIT_EVENT_TYPES.has(eventObjectStatus)) { + if (isOutboundBridgeWithdrawal(eventObject)) { + const transferEvent = transferReplayEventTypeForStatus(eventObjectStatus) + if (transferEvent) return transferEvent + } return eventObjectStatus } @@ -82,6 +93,8 @@ const toHandlerBody = ({ state: eventObject.state, amount: eventObject.amount, currency: eventObject.currency, + reason: eventObject.reason, + return_reason: eventObject.return_reason, }, } } @@ -138,10 +151,13 @@ export const replayHandler = async (req: Request, res: Response) => { return res.status(400).json({ error: "event_object must be an object" }) } + const eventObjectTyped = event_object as Record + const normalizedEventType = resolveReplayEventType({ eventType: event_type, eventObjectStatus: typeof event_object_status === "string" ? event_object_status : undefined, + eventObject: eventObjectTyped, }) const routeKey = toRouteKey(normalizedEventType) @@ -149,8 +165,6 @@ export const replayHandler = async (req: Request, res: Response) => { if (!routeKey) { return res.status(400).json({ error: "Unsupported event_type for replay" }) } - - const eventObjectTyped = event_object as Record const eventId: string = typeof event_id === "string" ? event_id diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index b65a16159..a24a32fdc 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -4,11 +4,43 @@ */ import { Request, Response } from "express" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" import { toBridgeTransferId } from "@domain/primitives/bridge" +const TERMINAL_FAILURE_STATES = new Set([ + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + +const TRANSIENT_STATES = new Set(["refund_in_flight"]) + +const transferLockKey = ( + transferId: string, + event: string, + state: string | undefined, +): IdempotencyKey => + `bridge-transfer:${transferId}:${event}:${state ?? ""}` as IdempotencyKey + +const markProcessed = async ( + transferId: string, + event: string, + state: string | undefined, +): Promise<"success" | "already_processed"> => { + const lockResult = await LockService().lockIdempotencyKey( + transferLockKey(transferId, event, state), + ) + if (lockResult instanceof Error) return "already_processed" + return "success" +} + export const transferHandler = async (req: Request, res: Response) => { const { event, data } = req.body const { transfer_id, state, amount, currency, reason, return_reason } = data @@ -17,37 +49,29 @@ export const transferHandler = async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid payload" }) } - // Idempotency check using transfer_id as lock key - const lockKey = `bridge-transfer:${transfer_id}` - const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) - if (lockResult instanceof Error) { - baseLogger.info({ transfer_id }, "Duplicate Bridge transfer webhook") - return res.status(200).json({ status: "already_processed" }) - } - try { const bridgeTransferId = toBridgeTransferId(transfer_id) - const TERMINAL_FAILURE_STATES = new Set([ - "undeliverable", - "returned", - "refunded", - "refund_in_flight", - "refund_failed", - "missing_return_policy", - "error", - "canceled", - ]) + if (TRANSIENT_STATES.has(state)) { + baseLogger.info( + { transfer_id, state, event }, + "Bridge transfer in transient state — awaiting terminal event", + ) + return res.status(200).json({ status: "ignored_transient_state" }) + } const isCompletion = event === "transfer.completed" || event === "transfer.payment_processed" || state === "payment_processed" - const isFailure = - event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state) + const isFailure = event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state) + + if (!isCompletion && !isFailure) { + baseLogger.info({ transfer_id, state, event }, "Bridge transfer event not handled") + return res.status(200).json({ status: "ignored" }) + } - // Update withdrawal status based on event if (isCompletion) { const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, @@ -55,6 +79,13 @@ export const transferHandler = async (req: Request, res: Response) => { ) if (result instanceof Error) { + if (result.message === BridgeAccountsRepo.BRIDGE_WITHDRAWAL_NOT_FOUND) { + baseLogger.warn( + { transfer_id }, + "Withdrawal not found for transfer webhook — Bridge may retry after bridgeTransferId is written", + ) + return res.status(503).json({ error: "Withdrawal not ready" }) + } baseLogger.error( { transfer_id, error: result }, "Failed to update withdrawal status", @@ -62,6 +93,11 @@ export const transferHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + const lockStatus = await markProcessed(transfer_id, event, state) + if (lockStatus === "already_processed") { + return res.status(200).json({ status: "already_processed" }) + } + baseLogger.info( { transfer_id, @@ -72,14 +108,19 @@ export const transferHandler = async (req: Request, res: Response) => { "Bridge transfer completed", ) - // TODO: Send push notification to user + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: result.accountId, + amount: result.amount, + currency: result.currency, + outcome: "completed", + }) } else if (isFailure) { const failureReason = state === "refund_failed" ? (return_reason as string | undefined) : event === "transfer.failed" ? (reason as string | undefined) - : undefined + : ((reason as string | undefined) ?? (return_reason as string | undefined)) const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, @@ -88,6 +129,20 @@ export const transferHandler = async (req: Request, res: Response) => { ) if (result instanceof Error) { + if (result.message === BridgeAccountsRepo.BRIDGE_WITHDRAWAL_NOT_FOUND) { + baseLogger.warn( + { transfer_id }, + "Withdrawal not found for transfer webhook — Bridge may retry after bridgeTransferId is written", + ) + return res.status(503).json({ error: "Withdrawal not ready" }) + } + if (result.message.startsWith("Withdrawal already ")) { + baseLogger.info( + { transfer_id, state, error: result.message }, + "Ignoring Bridge transfer failure — withdrawal already terminal", + ) + return res.status(200).json({ status: "already_terminal" }) + } baseLogger.error( { transfer_id, error: result }, "Failed to update withdrawal status", @@ -95,6 +150,11 @@ export const transferHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + const lockStatus = await markProcessed(transfer_id, event, state) + if (lockStatus === "already_processed") { + return res.status(200).json({ status: "already_processed" }) + } + baseLogger.warn( { transfer_id, @@ -107,7 +167,13 @@ export const transferHandler = async (req: Request, res: Response) => { "Bridge transfer failed", ) - // TODO: Send push notification to user + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: result.accountId, + amount: result.amount, + currency: result.currency, + outcome: "failed", + failureReason: result.failureReason ?? failureReason, + }) } return res.status(200).json({ status: "success" }) diff --git a/src/services/bridge/webhook-server/transfer-direction.ts b/src/services/bridge/webhook-server/transfer-direction.ts new file mode 100644 index 000000000..6e785d0ea --- /dev/null +++ b/src/services/bridge/webhook-server/transfer-direction.ts @@ -0,0 +1,29 @@ +/** + * Distinguishes Bridge outbound withdrawals (USDT → ACH) from inbound deposits + * when replaying status_transitioned events that share the same state names. + */ +export const isOutboundBridgeWithdrawal = ( + eventObject: Record | undefined, +): boolean => { + if (!eventObject) return false + const source = eventObject.source as Record | undefined + const destination = eventObject.destination as Record | undefined + return source?.payment_rail === "ethereum" && destination?.payment_rail === "ach" +} + +const OUTBOUND_WITHDRAWAL_REPLAY_STATUSES = new Set([ + "payment_processed", + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + +export const transferReplayEventTypeForStatus = (status: string): string | null => { + if (!OUTBOUND_WITHDRAWAL_REPLAY_STATUSES.has(status)) return null + if (status === "payment_processed") return "transfer.payment_processed" + return "transfer.failed" +} diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index a8cafb5f6..c045295be 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -95,6 +95,18 @@ export const updateExternalAccountStatus = async ( // ============ Withdrawals ============ +export const BRIDGE_WITHDRAWAL_NOT_FOUND = "Withdrawal not found" + +export const BRIDGE_FAILURE_REASON_MAX_LENGTH = 512 + +export const truncateBridgeFailureReason = ( + reason: string | undefined, +): string | undefined => { + if (reason === undefined) return undefined + if (reason.length <= BRIDGE_FAILURE_REASON_MAX_LENGTH) return reason + return `${reason.slice(0, BRIDGE_FAILURE_REASON_MAX_LENGTH - 3)}...` +} + export const createWithdrawal = async (data: { accountId: string bridgeTransferId?: string @@ -175,13 +187,25 @@ export const updateWithdrawalStatus = async ( ) => { try { const update: Record = { status, updatedAt: new Date() } - if (failureReason !== undefined) update.failureReason = failureReason + const truncatedReason = truncateBridgeFailureReason(failureReason) + if (truncatedReason !== undefined) update.failureReason = truncatedReason + const record = await BridgeWithdrawal.findOneAndUpdate( - { bridgeTransferId }, + { bridgeTransferId, status: "pending" }, update, { new: true }, ) - return record || new RepositoryError("Withdrawal not found") + if (record) return record + + const existing = await BridgeWithdrawal.findOne({ bridgeTransferId }) + if (!existing) return new RepositoryError(BRIDGE_WITHDRAWAL_NOT_FOUND) + + // Idempotent: duplicate webhook after we already reached this terminal status. + if (existing.status === status) return existing + + return new RepositoryError( + `Withdrawal already ${existing.status}, cannot transition to ${status}`, + ) } catch (error) { return new RepositoryError(String(error)) } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index c53879778..fb5c2710a 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -654,7 +654,7 @@ const BridgeWithdrawalSchema = new Schema({ amount: { type: String, required: true }, currency: { type: String, required: true }, status: { type: String, enum: ["pending", "completed", "failed"], default: "pending" }, - failureReason: { type: String }, + failureReason: { type: String, maxlength: 512 }, externalAccountId: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, diff --git a/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts new file mode 100644 index 000000000..d00d89dfa --- /dev/null +++ b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts @@ -0,0 +1,122 @@ +import { sendBridgeWithdrawalNotification } from "@app/bridge/send-withdrawal-notification" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" +import { getI18nInstance } from "@config" + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/users", () => ({ + UsersRepository: jest.fn(), +})) + +jest.mock("@services/notifications/push-notifications", () => ({ + PushNotificationsService: jest.fn(), + SendFilteredPushNotificationStatus: { + Filtered: "filtered", + Sent: "sent", + }, +})) + +jest.mock("@app/users/remove-device-tokens", () => ({ + removeDeviceTokens: jest.fn(), +})) + +jest.mock("@config", () => { + const mockI18n = { + __: jest.fn().mockImplementation(({ phrase }, options) => `${phrase} ${JSON.stringify(options)}`), + } + return { + getI18nInstance: jest.fn(() => mockI18n), + } +}) + +describe("sendBridgeWithdrawalNotification", () => { + const accountId = "507f1f77bcf86cd799439011" + const mockAccount = { + id: accountId, + kratosUserId: "user-id", + notificationSettings: { push: { enabled: true, disabledCategories: [] } }, + } + const mockUser = { + deviceTokens: ["token-1"], + language: "en", + } + + const sendFilteredNotification = jest.fn().mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Sent, + }) + const mockI18n = getI18nInstance() + + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + ;(UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockUser), + }) + ;(PushNotificationsService as jest.Mock).mockReturnValue({ + sendFilteredNotification, + }) + ;(getI18nInstance as jest.Mock).mockReturnValue(mockI18n) + }) + + it("sends a completed withdrawal notification with Cashout category", async () => { + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "100.00", + currency: "usdt", + outcome: "completed", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + deviceTokens: mockUser.deviceTokens, + notificationCategory: "Cashout", + data: expect.objectContaining({ type: "bridge_withdrawal_completed" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ phrase: "notification.bridgeWithdrawal.completed.title" }), + ) + }) + + it("uses bodyWithReason when a failure reason is provided", async () => { + await sendBridgeWithdrawalNotification({ + accountId, + amount: "50.00", + currency: "usdt", + outcome: "failed", + failureReason: "ACH return", + }) + + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.failed.bodyWithReason", + }), + expect.objectContaining({ reason: "ACH return" }), + ) + }) + + it("returns true when notification is filtered by user settings", async () => { + sendFilteredNotification.mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Filtered, + }) + + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "10.00", + currency: "usdt", + outcome: "completed", + }) + + expect(result).toBe(true) + }) +}) diff --git a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts index ea6c9610b..9b9988b64 100644 --- a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts @@ -176,6 +176,38 @@ describe("replayHandler", () => { ;(ReplayLog.createBridgeReplayLog as jest.Mock).mockResolvedValue({ id: "log-001" }) }) + it("routes outbound withdrawal payment_processed replay to transfer handler", async () => { + ;(transferHandler as jest.Mock).mockImplementation((_req: Request, res: Response) => { + ;(res.status as jest.Mock)(200) + ;(res.json as jest.Mock)({ status: "success" }) + return Promise.resolve(res) + }) + ;(ReplayLog.createBridgeReplayLog as jest.Mock).mockResolvedValue({ id: "log-wd-001" }) + + const res = makeRes() + await replayHandler( + makeReq({ + ...BASE_BODY, + event_type: "updated.status_transitioned", + event_object_status: "payment_processed", + event_object: { + id: "tr-withdraw-001", + state: "payment_processed", + amount: "100.00", + currency: "usd", + source: { payment_rail: "ethereum", currency: "usdt" }, + destination: { payment_rail: "ach", currency: "usd" }, + }, + }), + res, + ) + + expect(transferHandler).toHaveBeenCalledTimes(1) + expect(depositHandler).not.toHaveBeenCalled() + const handlerReq = (transferHandler as jest.Mock).mock.calls[0][0] + expect(handlerReq.body.event).toBe("transfer.payment_processed") + }) + test.each(cases)( "%s is routed to the correct handler", async (eventType, handler) => { diff --git a/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts new file mode 100644 index 000000000..30bb87f31 --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts @@ -0,0 +1,189 @@ +jest.mock("@services/lock", () => ({ + LockService: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + BRIDGE_WITHDRAWAL_NOT_FOUND: "Withdrawal not found", + updateWithdrawalStatus: jest.fn(), +})) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + +import { Request, Response } from "express" +import { LockService } from "@services/lock" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" +import { transferHandler } from "@services/bridge/webhook-server/routes/transfer" + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} + +const makeReq = (body: Record) => ({ body }) as unknown as Request + +const WITHDRAWAL_RECORD = { id: "wd-1", status: "pending", bridgeTransferId: "tr-abc" } + +const updateFn = BridgeAccountsRepo.updateWithdrawalStatus as jest.Mock +let lockFn: jest.Mock + +beforeEach(() => { + jest.clearAllMocks() + lockFn = jest.fn().mockResolvedValue({}) + updateFn.mockResolvedValue({ ...WITHDRAWAL_RECORD, status: "completed" }) + ;(LockService as jest.Mock).mockReturnValue({ lockIdempotencyKey: lockFn }) +}) + +describe("transferHandler", () => { + it("returns 503 when withdrawal row is not found yet (retryable)", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue(new RepositoryError("Withdrawal not found")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-early", state: "canceled", reason: "rejected" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(503) + expect(lockFn).not.toHaveBeenCalled() + }) + + it("acquires idempotency lock only after a successful status update", async () => { + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(updateFn).toHaveBeenCalled() + expect(lockFn).toHaveBeenCalledWith("bridge-transfer:tr-abc:transfer.completed:payment_processed") + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + }) + + it("does not acquire lock when status update fails", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue(new RepositoryError("mongo error")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-abc", state: "canceled" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + expect(lockFn).not.toHaveBeenCalled() + }) + + it("ignores refund_in_flight without updating withdrawal status", async () => { + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-abc", state: "refund_in_flight" }, + }), + res, + ) + + expect(updateFn).not.toHaveBeenCalled() + expect(lockFn).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ + status: "ignored_transient_state", + }) + }) + + it("returns already_processed when lock is held after a prior successful run", async () => { + lockFn.mockResolvedValue(new Error("already locked")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ status: "already_processed" }) + }) + + it("sends a push notification after a successful completion", async () => { + updateFn.mockResolvedValue({ + ...WITHDRAWAL_RECORD, + status: "completed", + accountId: "acct-1", + amount: "25.00", + currency: "usdt", + }) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(sendBridgeWithdrawalNotificationBestEffort).toHaveBeenCalledWith({ + accountId: "acct-1", + amount: "25.00", + currency: "usdt", + outcome: "completed", + }) + }) + + it("does not send a push notification when the idempotency lock is already held", async () => { + lockFn.mockResolvedValue(new Error("already locked")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("returns 200 already_terminal when failure arrives after completion", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue( + new RepositoryError("Withdrawal already completed, cannot transition to failed"), + ) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-abc", state: "returned", reason: "ACH return" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ status: "already_terminal" }) + expect(lockFn).not.toHaveBeenCalled() + }) +}) From 5d02843a258f0a6565b5265988fa059ea58b89c2 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 20 May 2026 14:24:00 +0100 Subject: [PATCH 04/13] feat(bridge): ENG-350 introduce full KYC status lifecycle with offboarded support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an internal 'open' status (set when the KYC link is created) and maps all 9 Bridge kyc_status values to internal states so the DB always reflects the true lifecycle: open → pending → approved | rejected | offboarded. - open: set by initiateKyc(), formalised in types/schema (was written but not declared) - pending: driven by Bridge webhooks (incomplete, awaiting_questionnaire, awaiting_ubo, under_review, paused) - offboarded: new terminal state distinct from rejected — permanent removal requiring support intervention - Adds BridgeKycOffboardedError with dedicated error-map entry - createVirtualAccount() guards offboarded explicitly before rejected/pending - Replay handler extended to route all 9 Bridge KYC statuses to kycHandler --- src/domain/accounts/index.types.d.ts | 20 +++--- src/graphql/error-map.ts | 4 ++ src/services/bridge/errors.ts | 6 ++ src/services/bridge/index.ts | 12 ++-- .../bridge/webhook-server/routes/kyc.ts | 68 ++++++++++++++++++- .../bridge/webhook-server/routes/replay.ts | 13 +++- src/services/mongoose/schema.ts | 2 +- src/services/mongoose/schema.types.d.ts | 2 +- 8 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 3fed86428..aae1fe9e0 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -88,7 +88,7 @@ type Account = { erpParty?: string // Lookup key to Customer in ERPNext. Required for Account level > 1 // Bridge integration: bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "pending" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string } @@ -135,10 +135,10 @@ type LimitsCheckerFn = (args: LimiterCheckInputs) => Promise[]) => Promise< | { - volumeTotalLimit: UsdPaymentAmount - volumeUsed: UsdPaymentAmount - volumeRemaining: UsdPaymentAmount - } + volumeTotalLimit: UsdPaymentAmount + volumeUsed: UsdPaymentAmount + volumeRemaining: UsdPaymentAmount + } | ValidationError > @@ -150,10 +150,10 @@ type AccountLimitsChecker = { type AccountLimitsVolumes = | { - volumesIntraledger: LimitsVolumesFn - volumesWithdrawal: LimitsVolumesFn - volumesTradeIntraAccount: LimitsVolumesFn - } + volumesIntraledger: LimitsVolumesFn + volumesWithdrawal: LimitsVolumesFn + volumesTradeIntraAccount: LimitsVolumesFn + } | ValidationError type AccountValidator = { @@ -179,7 +179,7 @@ interface IAccountsRepository { id: AccountId, fields: { bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "pending" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string }, ): Promise diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index e6176c2a2..9f93e8875 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -501,6 +501,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "KYC verification was rejected" return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeKycOffboardedError": + message = "Your account has been offboarded from Bridge. Please contact support." + return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeCustomerNotFoundError": message = "Bridge customer not found" return new ValidationInternalError({ message, logger: baseLogger }) diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index d56d0cbf9..3cef54938 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -45,6 +45,12 @@ export class BridgeKycRejectedError extends BridgeError { } } +export class BridgeKycOffboardedError extends BridgeError { + constructor(message: string = "Your account has been offboarded from Bridge. Please contact support.") { + super(message) + } +} + export class BridgeInsufficientFundsError extends BridgeError { constructor(message: string = "Insufficient funds for withdrawal") { super(message) diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index f5f51e2da..24bb8f5e8 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -30,6 +30,7 @@ import { BridgeAccountLevelError, BridgeKycPendingError, BridgeKycRejectedError, + BridgeKycOffboardedError, BridgeCustomerNotFoundError, } from "./errors" import BridgeApiClient from "./client" @@ -70,7 +71,7 @@ type WithdrawalResult = { createdAt: string } -type KycStatusResult = "pending" | "approved" | "rejected" | null +type KycStatusResult = "open" | "pending" | "approved" | "rejected" | "offboarded" | null type VirtualAccountResult = { bridgeVirtualAccountId: string @@ -199,7 +200,7 @@ const initiateKyc = async ({ const updateResult = await AccountsRepository().updateBridgeFields(accountId, { bridgeCustomerId: customerId, - bridgeKycStatus: "pending", + bridgeKycStatus: "open", }) if (updateResult instanceof Error) { @@ -261,12 +262,15 @@ const createVirtualAccount = async ( } // Check KYC status - if (account.bridgeKycStatus === "pending") { - return new BridgeKycPendingError() + if (account.bridgeKycStatus === "offboarded") { + return new BridgeKycOffboardedError() } if (account.bridgeKycStatus === "rejected") { return new BridgeKycRejectedError() } + if (account.bridgeKycStatus === "pending" || account.bridgeKycStatus === "open") { + return new BridgeKycPendingError() + } if (account.bridgeKycStatus !== "approved") { return new BridgeKycPendingError("KYC not yet completed") } diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts index 2706b7499..9718f0453 100644 --- a/src/services/bridge/webhook-server/routes/kyc.ts +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -1,6 +1,14 @@ /** * Bridge KYC Webhook Handler - * Handles kyc.approved and kyc.rejected events from Bridge.xyz + * Handles all kyc.* events from Bridge.xyz + * + * Bridge kyc_status → internal bridgeKycStatus mapping: + * not_started → "open" + * incomplete | awaiting_questionnaire | awaiting_ubo + * | under_review | paused → "pending" + * approved → "approved" + * rejected → "rejected" + * offboarded → "offboarded" */ import { Request, Response } from "express" @@ -37,8 +45,47 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(503).json({ error: "Account not ready" }) } + const PENDING_BRIDGE_STATUSES = new Set([ + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + ]) + // Update KYC status based on event - if (kyc_status === "approved") { + if (kyc_status === "not_started") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "open", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info({ accountId: account.id, customer_id }, "Bridge KYC not started") + } else if (PENDING_BRIDGE_STATUSES.has(kyc_status)) { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "pending", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info( + { accountId: account.id, customer_id, kyc_status }, + "Bridge KYC moved to pending", + ) + } else if (kyc_status === "approved") { const result = await AccountsRepository().updateBridgeFields(account.id, { bridgeKycStatus: "approved", }) @@ -86,6 +133,23 @@ export const kycHandler = async (req: Request, res: Response) => { }, "Bridge KYC rejected", ) + } else if (kyc_status === "offboarded") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "offboarded", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.warn( + { accountId: account.id, customer_id }, + "Bridge KYC offboarded", + ) } return res.status(200).json({ status: "success" }) diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 6eb4adab2..8db8409e6 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -63,7 +63,18 @@ const resolveReplayEventType = ({ return eventObjectStatus } - if (eventObjectStatus === "approved" || eventObjectStatus === "rejected") { + const KYC_BRIDGE_STATUSES = new Set([ + "not_started", + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "approved", + "rejected", + "paused", + "offboarded", + ]) + if (eventObjectStatus && KYC_BRIDGE_STATUSES.has(eventObjectStatus)) { return `kyc.${eventObjectStatus}` } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index fb5c2710a..f0e2be5fa 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -339,7 +339,7 @@ const AccountSchema = new Schema( }, bridgeKycStatus: { type: String, - enum: ["pending", "approved", "rejected"], + enum: ["open", "pending", "approved", "rejected", "offboarded"], required: false, }, bridgeEthereumAddress: { diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index 51c86757c..39d5a3da9 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -96,7 +96,7 @@ interface AccountRecord { // Bridge integration: bridgeCustomerId?: string - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "pending" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string // mongoose in-built functions From c0baa204cea1905790675d4e062e6960b61e345a Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 20 May 2026 15:47:05 +0100 Subject: [PATCH 05/13] refactor(bridge): rename BridgeDepositLog model to BridgeDeposits Renames the Mongoose model and all references across schema, reconciliation, and the deposit webhook handler. Also fixes a pre-existing partial rename bug where bridge-deposit-log.ts was referencing the undefined BridgeDeposit instead of BridgeDepositLog, and deposit.ts was importing the non-existent createBridgeDepositLog instead of createBridgeDeposit. --- src/services/bridge/reconciliation.ts | 6 +++--- src/services/bridge/webhook-server/routes/deposit.ts | 4 ++-- src/services/mongoose/bridge-deposit-log.ts | 6 +++--- src/services/mongoose/schema.ts | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/services/bridge/reconciliation.ts b/src/services/bridge/reconciliation.ts index 891229e10..0f07beebf 100644 --- a/src/services/bridge/reconciliation.ts +++ b/src/services/bridge/reconciliation.ts @@ -4,7 +4,7 @@ import { upsertBridgeReconciliationOrphan, resolveOrphansByTxHash, } from "@services/mongoose/bridge-reconciliation-orphan" -import { BridgeDepositLog, IbexCryptoReceiveLog } from "@services/mongoose/schema" +import { BridgeDeposits, IbexCryptoReceiveLog } from "@services/mongoose/schema" import { PubSubService } from "@services/pubsub" import { PubSubDefaultTriggers } from "@domain/pubsub" @@ -50,7 +50,7 @@ export const reconcileBridgeAndIbexDeposits = async ({ const now = new Date() const since = new Date(now.getTime() - windowMs) - const bridgeDeposits = (await BridgeDepositLog.find({ + const bridgeDeposits = (await BridgeDeposits.find({ createdAt: { $gte: since, $lte: now }, state: "payment_processed", }) @@ -180,7 +180,7 @@ export const reconcileByTxHash = async ({ try { const [bridgeDeposit, ibexReceive] = await Promise.all([ - BridgeDepositLog.findOne({ + BridgeDeposits.findOne({ destinationTxHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, state: "payment_processed", }) diff --git a/src/services/bridge/webhook-server/routes/deposit.ts b/src/services/bridge/webhook-server/routes/deposit.ts index a87352b1a..98256de20 100644 --- a/src/services/bridge/webhook-server/routes/deposit.ts +++ b/src/services/bridge/webhook-server/routes/deposit.ts @@ -9,7 +9,7 @@ import { Request, Response } from "express" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" -import { createBridgeDepositLog } from "@services/mongoose/bridge-deposit-log" +import { createBridgeDeposit } from "@services/mongoose/bridge-deposit-log" import { reconcileByTxHash } from "@services/bridge/reconciliation" export const depositHandler = async (req: Request, res: Response) => { @@ -48,7 +48,7 @@ export const depositHandler = async (req: Request, res: Response) => { "Bridge deposit event", ) - const depositLog = await createBridgeDepositLog({ + const depositLog = await createBridgeDeposit({ eventId: event_id, transferId: id, customerId: on_behalf_of ?? "", diff --git a/src/services/mongoose/bridge-deposit-log.ts b/src/services/mongoose/bridge-deposit-log.ts index f71f37abd..953cd8a3d 100644 --- a/src/services/mongoose/bridge-deposit-log.ts +++ b/src/services/mongoose/bridge-deposit-log.ts @@ -1,6 +1,6 @@ -import { BridgeDepositLog } from "./schema" +import { BridgeDeposits } from "./schema" -export const createBridgeDepositLog = async (data: { +export const createBridgeDeposit = async (data: { eventId: string transferId: string customerId: string @@ -14,7 +14,7 @@ export const createBridgeDepositLog = async (data: { destinationTxHash?: string }): Promise<{ id: string } | Error> => { try { - const log = await BridgeDepositLog.create(data) + const log = await BridgeDeposits.create(data) return { id: log._id.toString() } } catch (error) { return error instanceof Error ? error : new Error(String(error)) diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index f0e2be5fa..c799051f3 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -672,7 +672,7 @@ BridgeWithdrawalSchema.index( }, ) -const BridgeDepositLogSchema = new Schema({ +const BridgeDepositsSchema = new Schema({ eventId: { type: String, required: true, unique: true }, transferId: { type: String, required: true }, customerId: { type: String, required: true }, @@ -687,10 +687,10 @@ const BridgeDepositLogSchema = new Schema({ createdAt: { type: Date, default: Date.now }, }) -BridgeDepositLogSchema.index({ transferId: 1 }) -BridgeDepositLogSchema.index({ customerId: 1, createdAt: -1 }) +BridgeDepositsSchema.index({ transferId: 1 }) +BridgeDepositsSchema.index({ customerId: 1, createdAt: -1 }) -export const BridgeDepositLog = mongoose.model("BridgeDepositLog", BridgeDepositLogSchema) +export const BridgeDeposits = mongoose.model("BridgeDeposits", BridgeDepositsSchema) const IbexCryptoReceiveLogSchema = new Schema({ txHash: { type: String, required: true, unique: true }, From 30c16c965f749b7627941a5396d07d738b6d30fe Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 20 May 2026 16:13:49 +0100 Subject: [PATCH 06/13] refactor(ibex): rename IbexCryptoReceiveLog model to IbexCryptoReceive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the Mongoose model and all references across schema, the ibex-crypto-receive-log service, reconciliation, and the crypto-receive webhook handler. Function names updated to match: createIbexCryptoReceiveLog → createIbexCryptoReceive, findIbexCryptoReceiveLogsSince → findIbexCryptoReceivesSince. --- src/services/bridge/reconciliation.ts | 8 ++++---- .../ibex/webhook-server/routes/crypto-receive.ts | 4 ++-- src/services/mongoose/ibex-crypto-receive-log.ts | 10 +++++----- src/services/mongoose/schema.ts | 12 ++++++------ 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/services/bridge/reconciliation.ts b/src/services/bridge/reconciliation.ts index 0f07beebf..851ebce90 100644 --- a/src/services/bridge/reconciliation.ts +++ b/src/services/bridge/reconciliation.ts @@ -1,10 +1,10 @@ import { baseLogger } from "@services/logger" -import { findIbexCryptoReceiveLogsSince } from "@services/mongoose/ibex-crypto-receive-log" +import { findIbexCryptoReceivesSince } from "@services/mongoose/ibex-crypto-receive-log" import { upsertBridgeReconciliationOrphan, resolveOrphansByTxHash, } from "@services/mongoose/bridge-reconciliation-orphan" -import { BridgeDeposits, IbexCryptoReceiveLog } from "@services/mongoose/schema" +import { BridgeDeposits, IbexCryptoReceive } from "@services/mongoose/schema" import { PubSubService } from "@services/pubsub" import { PubSubDefaultTriggers } from "@domain/pubsub" @@ -57,7 +57,7 @@ export const reconcileBridgeAndIbexDeposits = async ({ .lean() .exec()) as BridgeDepositLike[] - const ibexReceivesResult = await findIbexCryptoReceiveLogsSince({ since, until: now }) + const ibexReceivesResult = await findIbexCryptoReceivesSince({ since, until: now }) if (ibexReceivesResult instanceof Error) return ibexReceivesResult const ibexReceives = ibexReceivesResult as IbexReceiveLike[] @@ -186,7 +186,7 @@ export const reconcileByTxHash = async ({ }) .lean() .exec(), - IbexCryptoReceiveLog.findOne({ + IbexCryptoReceive.findOne({ txHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, }) .lean() diff --git a/src/services/ibex/webhook-server/routes/crypto-receive.ts b/src/services/ibex/webhook-server/routes/crypto-receive.ts index 83282f9a5..d03e72676 100644 --- a/src/services/ibex/webhook-server/routes/crypto-receive.ts +++ b/src/services/ibex/webhook-server/routes/crypto-receive.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from "express" import { AccountsRepository } from "@services/mongoose/accounts" -import { createIbexCryptoReceiveLog } from "@services/mongoose/ibex-crypto-receive-log" +import { createIbexCryptoReceive } from "@services/mongoose/ibex-crypto-receive-log" import { listWalletsByAccountId } from "@app/wallets" import { WalletCurrency, USDTAmount } from "@domain/shared" import { baseLogger } from "@services/logger" @@ -49,7 +49,7 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { return { status: "error", code: "account_not_found" } as CryptoReceiveResult } - const ibexLog = await createIbexCryptoReceiveLog({ + const ibexLog = await createIbexCryptoReceive({ txHash: String(tx_hash), address: String(address), amount: String(amount), diff --git a/src/services/mongoose/ibex-crypto-receive-log.ts b/src/services/mongoose/ibex-crypto-receive-log.ts index e657c9e89..490330f09 100644 --- a/src/services/mongoose/ibex-crypto-receive-log.ts +++ b/src/services/mongoose/ibex-crypto-receive-log.ts @@ -1,6 +1,6 @@ -import { IbexCryptoReceiveLog } from "./schema" +import { IbexCryptoReceive } from "./schema" -export const createIbexCryptoReceiveLog = async (data: { +export const createIbexCryptoReceive = async (data: { txHash: string address: string amount: string @@ -9,7 +9,7 @@ export const createIbexCryptoReceiveLog = async (data: { accountId?: string }): Promise<{ id: string } | Error> => { try { - const log = await IbexCryptoReceiveLog.findOneAndUpdate( + const log = await IbexCryptoReceive.findOneAndUpdate( { txHash: data.txHash }, { ...data, receivedAt: new Date() }, { upsert: true, new: true, setDefaultsOnInsert: true }, @@ -21,7 +21,7 @@ export const createIbexCryptoReceiveLog = async (data: { } } -export const findIbexCryptoReceiveLogsSince = async ({ +export const findIbexCryptoReceivesSince = async ({ since, until = new Date(), }: { @@ -40,7 +40,7 @@ export const findIbexCryptoReceiveLogsSince = async ({ | Error > => { try { - return await IbexCryptoReceiveLog.find({ + return await IbexCryptoReceive.find({ receivedAt: { $gte: since, $lte: until }, }) .sort({ receivedAt: -1 }) diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index c799051f3..d61d20f3d 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -692,7 +692,7 @@ BridgeDepositsSchema.index({ customerId: 1, createdAt: -1 }) export const BridgeDeposits = mongoose.model("BridgeDeposits", BridgeDepositsSchema) -const IbexCryptoReceiveLogSchema = new Schema({ +const IbexCryptoReceiveSchema = new Schema({ txHash: { type: String, required: true, unique: true }, address: { type: String, required: true }, amount: { type: String, required: true }, @@ -702,12 +702,12 @@ const IbexCryptoReceiveLogSchema = new Schema({ receivedAt: { type: Date, default: Date.now }, }) -IbexCryptoReceiveLogSchema.index({ receivedAt: -1 }) -IbexCryptoReceiveLogSchema.index({ address: 1, receivedAt: -1 }) +IbexCryptoReceiveSchema.index({ receivedAt: -1 }) +IbexCryptoReceiveSchema.index({ address: 1, receivedAt: -1 }) -export const IbexCryptoReceiveLog = mongoose.model( - "IbexCryptoReceiveLog", - IbexCryptoReceiveLogSchema, +export const IbexCryptoReceive = mongoose.model( + "IbexCryptoReceive", + IbexCryptoReceiveSchema, ) const BridgeReconciliationOrphanSchema = new Schema({ From 629fe2f95c3a11e44434894c3218a338bfca7058 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Wed, 20 May 2026 16:18:24 +0100 Subject: [PATCH 07/13] refactor(bridge): rename BridgeReplayLog model to BridgeReplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the Mongoose model and all references across schema, bridge-replay-log service, and the replay webhook handler. Function names updated to match: createBridgeReplayLog → createBridgeReplay, findBridgeReplayLogs → findBridgeReplays. --- src/services/bridge/webhook-server/routes/replay.ts | 6 +++--- src/services/mongoose/bridge-replay-log.ts | 10 +++++----- src/services/mongoose/schema.ts | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 8db8409e6..33b499bd9 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -6,7 +6,7 @@ import { BridgeConfig } from "@config" import { baseLogger } from "@services/logger" -import { createBridgeReplayLog } from "@services/mongoose/bridge-replay-log" +import { createBridgeReplay } from "@services/mongoose/bridge-replay-log" import { isOutboundBridgeWithdrawal, @@ -198,7 +198,7 @@ export const replayHandler = async (req: Request, res: Response) => { } if (dry_run) { - const dryRunLog = await createBridgeReplayLog({ + const dryRunLog = await createBridgeReplay({ ...logBase, httpStatus: 0, httpResponse: { dry_run: true }, @@ -242,7 +242,7 @@ export const replayHandler = async (req: Request, res: Response) => { await HANDLERS[routeKey](fakeReq, fakeRes) - const logResult = await createBridgeReplayLog({ + const logResult = await createBridgeReplay({ ...logBase, httpStatus: handlerStatus, httpResponse: handlerBody, diff --git a/src/services/mongoose/bridge-replay-log.ts b/src/services/mongoose/bridge-replay-log.ts index d1842be8c..361994991 100644 --- a/src/services/mongoose/bridge-replay-log.ts +++ b/src/services/mongoose/bridge-replay-log.ts @@ -1,6 +1,6 @@ -import { BridgeReplayLog } from "./schema" +import { BridgeReplay } from "./schema" -export const createBridgeReplayLog = async (data: { +export const createBridgeReplay = async (data: { eventId: string eventType: string eventPayload: Record @@ -14,7 +14,7 @@ export const createBridgeReplayLog = async (data: { dryRun?: boolean }): Promise<{ id: string } | Error> => { try { - const log = await BridgeReplayLog.create(data) + const log = await BridgeReplay.create(data) return { id: log._id.toString() } } catch (error) { @@ -22,7 +22,7 @@ export const createBridgeReplayLog = async (data: { } } -export const findBridgeReplayLogs = async (filter?: { +export const findBridgeReplays = async (filter?: { eventType?: string dryRun?: boolean limit?: number @@ -32,7 +32,7 @@ export const findBridgeReplayLogs = async (filter?: { if (filter?.eventType !== undefined) queryFilter.eventType = filter.eventType if (filter?.dryRun !== undefined) queryFilter.dryRun = filter.dryRun - return await BridgeReplayLog.find(queryFilter) + return await BridgeReplay.find(queryFilter) .sort({ replayedAt: -1 }) .limit(filter?.limit ?? 100) .lean() diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index d61d20f3d..be7165e52 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -744,7 +744,7 @@ export const BridgeReconciliationOrphan = mongoose.model( BridgeReconciliationOrphanSchema, ) -const BridgeReplayLogSchema = new Schema({ +const BridgeReplaySchema = new Schema({ eventId: { type: String, required: true }, eventType: { type: String, required: true }, eventPayload: { type: Schema.Types.Mixed, required: true }, @@ -758,11 +758,11 @@ const BridgeReplayLogSchema = new Schema({ dryRun: { type: Boolean, required: true, default: false }, }) -BridgeReplayLogSchema.index({ eventId: 1 }) -BridgeReplayLogSchema.index({ replayedAt: -1 }) -BridgeReplayLogSchema.index({ eventType: 1, replayedAt: -1 }) +BridgeReplaySchema.index({ eventId: 1 }) +BridgeReplaySchema.index({ replayedAt: -1 }) +BridgeReplaySchema.index({ eventType: 1, replayedAt: -1 }) -export const BridgeReplayLog = mongoose.model("BridgeReplayLog", BridgeReplayLogSchema) +export const BridgeReplay = mongoose.model("BridgeReplay", BridgeReplaySchema) export const BridgeVirtualAccount = mongoose.model( "BridgeVirtualAccount", From 4ca09c4f129ef0c108741f44836e608aaf3d1e00 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 21 May 2026 14:47:31 +0100 Subject: [PATCH 08/13] fix(bridge) : ENG-289 timeout increase to 15s --- dev/config/base-config.yaml | 2 +- src/services/bridge/client.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index 6a3f5070a..88fb2ada0 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -19,7 +19,7 @@ bridge: apiKey: "sk-test-3bd6463c9cd77c3d8858c60b9997d0c6" baseUrl: "https://api.sandbox.bridge.xyz/v0" minWithdrawalAmount: 2 - timeoutMs: 10000 + timeoutMs: 15000 webhook: port: 4009 replaySecret: "also-not-so-secret" diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index 9e687e024..43d492183 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -67,15 +67,15 @@ export interface Customer { id: string type: "individual" | "business" status?: - | "active" - | "awaiting_questionnaire" - | "rejected" - | "paused" - | "under_review" - | "offboarded" - | "awaiting_ubo" - | "incomplete" - | "not_started" + | "active" + | "awaiting_questionnaire" + | "rejected" + | "paused" + | "under_review" + | "offboarded" + | "awaiting_ubo" + | "incomplete" + | "not_started" has_accepted_terms_of_service?: string created_at: string updated_at: string @@ -366,7 +366,7 @@ export class BridgeClient { } } - const timeoutMs = BridgeConfig.timeoutMs ?? 10_000 + const timeoutMs = BridgeConfig.timeoutMs ?? 15_000 const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeoutMs) From 27e226d1dd1bf59a66a83ed9460666c44648c8c3 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 21 May 2026 19:42:23 +0100 Subject: [PATCH 09/13] feat: adding full kyc status to reflect them stored as bridgeKycStatus in mongoDB instead of just pending state --- src/domain/accounts/index.types.d.ts | 4 ++-- src/graphql/public/schema.graphql | 1 + src/services/bridge/webhook-server/routes/kyc.ts | 12 ++++++------ src/services/mongoose/accounts.ts | 4 ++-- src/services/mongoose/schema.ts | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index aae1fe9e0..1b39d3c32 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -88,7 +88,7 @@ type Account = { erpParty?: string // Lookup key to Customer in ERPNext. Required for Account level > 1 // Bridge integration: bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "open" | "pending" | "approved" | "rejected" | "offboarded" + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string } @@ -179,7 +179,7 @@ interface IAccountsRepository { id: AccountId, fields: { bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "open" | "pending" | "approved" | "rejected" | "offboarded" + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string }, ): Promise diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 0ed531044..e2f607717 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -300,6 +300,7 @@ type BridgeWithdrawal { amount: String! createdAt: String! currency: String! + failureReason: String id: ID! status: String! } diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts index 9718f0453..d909fae49 100644 --- a/src/services/bridge/webhook-server/routes/kyc.ts +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -2,12 +2,12 @@ * Bridge KYC Webhook Handler * Handles all kyc.* events from Bridge.xyz * - * Bridge kyc_status → internal bridgeKycStatus mapping: - * not_started → "open" - * incomplete | awaiting_questionnaire | awaiting_ubo - * | under_review | paused → "pending" - * approved → "approved" - * rejected → "rejected" + * Bridge kyc_status → internal bridgeKycStatus mapping: + * not_started → "open" + * incomplete | awaiting_questionnaire | awaiting_ubo + * | under_review | paused → "pending" + * approved → "approved" + * rejected → "rejected" * offboarded → "offboarded" */ diff --git a/src/services/mongoose/accounts.ts b/src/services/mongoose/accounts.ts index 853a7a93b..4cc8fb43f 100644 --- a/src/services/mongoose/accounts.ts +++ b/src/services/mongoose/accounts.ts @@ -176,7 +176,7 @@ export const AccountsRepository = (): IAccountsRepository => { id: AccountId, fields: { bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string }, ): Promise => { @@ -291,6 +291,6 @@ const translateToAccount = (result: AccountRecord): Account => ({ kratosUserId: result.kratosUserId as UserId, displayCurrency: (result.displayCurrency || UsdDisplayCurrency) as DisplayCurrency, bridgeCustomerId: result.bridgeCustomerId as BridgeCustomerId | undefined, - bridgeKycStatus: result.bridgeKycStatus, + bridgeKycStatus: (result.bridgeKycStatus === "pending" ? "open" : result.bridgeKycStatus) as Account["bridgeKycStatus"], bridgeEthereumAddress: result.bridgeEthereumAddress, }) diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index be7165e52..ef7b0ebf9 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -339,7 +339,7 @@ const AccountSchema = new Schema( }, bridgeKycStatus: { type: String, - enum: ["open", "pending", "approved", "rejected", "offboarded"], + enum: ["open", "not_started", "incomplete", "awaiting_questionnaire", "awaiting_ubo", "under_review", "paused", "approved", "rejected", "offboarded"], required: false, }, bridgeEthereumAddress: { From be3ea3111793509035507f56f4f078b6f059339a Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 21 May 2026 19:46:38 +0100 Subject: [PATCH 10/13] feat: Kyc status call the kyc status directly from bridge and update it locally each time instead of just call from the cold storage (mongoDB) - when the KYC status is approved, check for the bridge Virtual Account id there is no one, create one - this flow comes to fix Kyc webhook issue --- src/services/bridge/index.ts | 134 +++++++++++++++++++++++++++++++---- 1 file changed, 120 insertions(+), 14 deletions(-) diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 24bb8f5e8..7fae0fe02 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -33,7 +33,7 @@ import { BridgeKycOffboardedError, BridgeCustomerNotFoundError, } from "./errors" -import BridgeApiClient from "./client" +import BridgeApiClient, { BridgeClient } from "./client" // ============ Types ============ @@ -71,7 +71,7 @@ type WithdrawalResult = { createdAt: string } -type KycStatusResult = "open" | "pending" | "approved" | "rejected" | "offboarded" | null +type KycStatusResult = "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" | null type VirtualAccountResult = { bridgeVirtualAccountId: string @@ -217,6 +217,14 @@ const initiateKyc = async ({ const bridgeError = error as { statusCode?: number; response?: { existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } } } if (bridgeError?.statusCode === 400 && bridgeError.response?.existing_kyc_link) { + + // store the customer id and the kyc status + const customerId = toBridgeCustomerId(bridgeError.response.existing_kyc_link.customer_id) + const updateResult = await AccountsRepository().updateBridgeFields(accountId, { + bridgeCustomerId: customerId, + bridgeKycStatus: "not_started", + }) + return { kycLink: bridgeError.response.existing_kyc_link.kyc_link, customerId: bridgeError.response.existing_kyc_link.customer_id, @@ -253,25 +261,53 @@ const createVirtualAccount = async ( const account = await checkAccountLevel(accountId) if (account instanceof Error) return account + + const PENDING_BRIDGE_STATUSES = new Set([ + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + ]) + try { - const customerId = account.bridgeCustomerId - if (!customerId) { + + if (!account.bridgeCustomerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + const customerId = toBridgeCustomerId(account.bridgeCustomerId) + if (!customerId && !account.bridgeKycStatus) { return new BridgeCustomerNotFoundError( "Account has no Bridge customer ID. Complete KYC first.", ) } + const customer = await BridgeApiClient.getCustomer(customerId); + + if (customer instanceof Error) { + baseLogger.error( + { accountId, error: customer }, + "Failed to retrieve Bridge customer status" + ) + return customer + } + + let kycStatus = customer.status + + // Check KYC status - if (account.bridgeKycStatus === "offboarded") { + if (kycStatus === "offboarded") { return new BridgeKycOffboardedError() } - if (account.bridgeKycStatus === "rejected") { + if (kycStatus === "rejected") { return new BridgeKycRejectedError() } - if (account.bridgeKycStatus === "pending" || account.bridgeKycStatus === "open") { + if (PENDING_BRIDGE_STATUSES.has(kycStatus!) || kycStatus as string === "open") { return new BridgeKycPendingError() } - if (account.bridgeKycStatus !== "approved") { + if (kycStatus !== "active" && kycStatus as string !== "approved") { return new BridgeKycPendingError("KYC not yet completed") } @@ -597,14 +633,84 @@ const getKycStatus = async (accountId: AccountId): Promise Date: Thu, 21 May 2026 19:48:09 +0100 Subject: [PATCH 11/13] fix: Duplicated webhook event message This commit check and fix the unicity of the event sent by bridge for Kyc --- .../bridge/webhook-server/routes/kyc.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts index d909fae49..965b38c1f 100644 --- a/src/services/bridge/webhook-server/routes/kyc.ts +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -2,12 +2,12 @@ * Bridge KYC Webhook Handler * Handles all kyc.* events from Bridge.xyz * - * Bridge kyc_status → internal bridgeKycStatus mapping: - * not_started → "open" - * incomplete | awaiting_questionnaire | awaiting_ubo - * | under_review | paused → "pending" - * approved → "approved" - * rejected → "rejected" + * Bridge kyc_status → internal bridgeKycStatus mapping: + * not_started → "open" + * incomplete | awaiting_questionnaire | awaiting_ubo + * | under_review | paused → "pending" + * approved → "approved" + * rejected → "rejected" * offboarded → "offboarded" */ @@ -26,14 +26,6 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid payload" }) } - // Idempotency check using customer_id + event as lock key - const lockKey = `bridge-kyc:${customer_id}:${event_object.kyc_status}:${event_object.id}` - const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) - if (lockResult instanceof Error) { - baseLogger.info({ customer_id, event_id }, "Duplicate Bridge KYC webhook") - return res.status(200).json({ status: "already_processed" }) - } - try { const bridgeCustomerId = toBridgeCustomerId(customer_id) const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId) @@ -45,6 +37,14 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(503).json({ error: "Account not ready" }) } + // Idempotency check — acquire lock after account is found so 503 retries are not blocked + const lockKey = `bridge-kyc:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ customer_id, event_id }, "Duplicate Bridge KYC webhook") + return res.status(200).json({ status: "already_processed" }) + } + const PENDING_BRIDGE_STATUSES = new Set([ "incomplete", "awaiting_questionnaire", @@ -56,7 +56,7 @@ export const kycHandler = async (req: Request, res: Response) => { // Update KYC status based on event if (kyc_status === "not_started") { const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: "open", + bridgeKycStatus: "not_started", }) if (result instanceof Error) { @@ -70,7 +70,7 @@ export const kycHandler = async (req: Request, res: Response) => { baseLogger.info({ accountId: account.id, customer_id }, "Bridge KYC not started") } else if (PENDING_BRIDGE_STATUSES.has(kyc_status)) { const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: "pending", + bridgeKycStatus: kyc_status, }) if (result instanceof Error) { From 7155e16de1bcd76d2a4ce3b95ffbd5608c0ea5a1 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 21 May 2026 19:48:56 +0100 Subject: [PATCH 12/13] fix: Duplicated webhook deposit event message This commit check and fix the unicity of the event sent by bridge for deposit --- .../bridge/webhook-server/routes/deposit.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/services/bridge/webhook-server/routes/deposit.ts b/src/services/bridge/webhook-server/routes/deposit.ts index 98256de20..c5d02b4a1 100644 --- a/src/services/bridge/webhook-server/routes/deposit.ts +++ b/src/services/bridge/webhook-server/routes/deposit.ts @@ -16,18 +16,10 @@ export const depositHandler = async (req: Request, res: Response) => { const { event_id, event_object } = req.body const { id, state, amount, currency, on_behalf_of, receipt } = event_object ?? {} - if (!id || !event_id) { + if (!id || !event_id || !amount || !on_behalf_of) { return res.status(400).json({ error: "Invalid payload" }) } - // Idempotency: lock on the transfer id + state so each state transition is processed once - const lockKey = `bridge-deposit:${id}:${state}` - const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) - if (lockResult instanceof Error) { - baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") - return res.status(200).json({ status: "already_processed" }) - } - try { baseLogger.info( { @@ -51,7 +43,7 @@ export const depositHandler = async (req: Request, res: Response) => { const depositLog = await createBridgeDeposit({ eventId: event_id, transferId: id, - customerId: on_behalf_of ?? "", + customerId: on_behalf_of, state, amount: String(amount), currency, @@ -78,6 +70,15 @@ export const depositHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to persist deposit log" }) } + // Idempotency: lock on event_id after successful DB write — aligns with the DB unique + // constraint on eventId and ensures Bridge retries are not blocked on transient failures + const lockKey = `bridge-deposit:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") + return res.status(200).json({ status: "already_processed" }) + } + if (state === "payment_processed" && receipt?.destination_tx_hash) { reconcileByTxHash({ txHash: receipt.destination_tx_hash }).catch((err) => baseLogger.error({ err, event_id, id }, "Real-time reconciliation failed"), From 3cde00fecab8f8bb656300906d22334a89ef0a61 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 21 May 2026 22:47:33 +0100 Subject: [PATCH 13/13] fix: Virtual Account Edge case from the kyc status if the user has approved kyc status, we check if the virtual account exist on bridge side and if it is activated, then we store it, otherwise we create him a new bridge virtual account --- src/services/bridge/client.ts | 24 +++++++++++- src/services/bridge/index.ts | 72 +++++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index 43d492183..d6d07db65 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -7,7 +7,7 @@ import crypto from "crypto" import { BridgeConfig } from "@config" -import { BridgeCustomerId, BridgeTransferId } from "@domain/primitives/bridge" +import { BridgeCustomerId, BridgeTransferId, BridgeVirtualAccountId } from "@domain/primitives/bridge" import { BridgeTimeoutError } from "./errors" // ============ Error Handling ============ @@ -440,6 +440,28 @@ export class BridgeClient { ) } + async getVirtualAccount( + customerId: BridgeCustomerId, virtualAccountId: BridgeVirtualAccountId, idempotencyKey?: string, + ): Promise { + return this.request( + "GET", + `/customers/${customerId}/virtual_accounts/${virtualAccountId}`, + undefined, + idempotencyKey, + ) + } + + + async getVirtualAccountByCustomerId(customerId: BridgeCustomerId): Promise { + const response = await this.request<{ data: VirtualAccount[] }>( + "GET", + `/customers/${customerId}/virtual_accounts`, + ) + + return response.data as VirtualAccount[] + + + } // ============ External Accounts ============ async createExternalAccount( diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 7fae0fe02..ba0fab74e 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -14,7 +14,7 @@ import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" import { RepositoryError } from "@domain/errors" -import { toBridgeCustomerId } from "@domain/primitives/bridge" +import { toBridgeCustomerId, toBridgeVirtualAccountId } from "@domain/primitives/bridge" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" import { USDTAmount, WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" @@ -34,6 +34,7 @@ import { BridgeCustomerNotFoundError, } from "./errors" import BridgeApiClient, { BridgeClient } from "./client" +import { BridgeVirtualAccount } from "@services/mongoose/schema" // ============ Types ============ @@ -350,12 +351,7 @@ const createVirtualAccount = async ( ethereumAddress = receiveInfo.data.address } - // Deterministic key so Bridge deduplicates on their side if two calls race past - // the check above before either has written to the repo. - const vaIdempotencyKey = crypto - .createHash("sha256") - .update(`va:${accountId}`) - .digest("hex") + const vaIdempotencyKey = `${accountId}:${crypto.randomUUID()}` // Create Bridge virtual account const virtualAccount = await BridgeApiClient.createVirtualAccount( @@ -677,10 +673,49 @@ const getKycStatus = async (accountId: AccountId): Promise va.destination.address === account.bridgeEthereumAddress) + + if (relatedVa?.status === "activated") { + + // if there's a related VA on Bridge side but it's not in our repo, create it in our repo to keep them in sync + if (existingVa instanceof RepositoryError) { + const repoResult = await BridgeAccountsRepo.createVirtualAccount({ + accountId: accountId as string, + bridgeVirtualAccountId: relatedVa.id, + bankName: relatedVa.source_deposit_instructions.bank_name || "", + routingNumber: relatedVa.source_deposit_instructions.bank_routing_number || "", + accountNumber: relatedVa.source_deposit_instructions.bank_account_number || "", + accountNumberLast4: relatedVa.source_deposit_instructions.bank_account_number?.slice(-4) || "", + }) + if (repoResult instanceof Error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error: repoResult }, + "Failed to create virtual account in repo after KYC approval", + ) + } else { + baseLogger.info( + { accountId, operation: "getKycStatus", virtualAccountId: relatedVa.id }, + "Proactively updated virtual account in repo after KYC approval", + ) + } + } + } else { + const vaResult = await createVirtualAccount(accountId) if (vaResult instanceof Error) { baseLogger.error( @@ -744,6 +779,27 @@ const getVirtualAccount = async ( return null } + // check if the virtual account still exists on Bridge side - if not, delete it from our repo and return null + const bridgeVa = await BridgeApiClient.getVirtualAccount(account.bridgeCustomerId!, toBridgeVirtualAccountId(virtualAccount.bridgeVirtualAccountId!)) + + if (bridgeVa instanceof Error) { + return new BridgeError(`Failed to retrieve virtual account from Bridge: ${bridgeVa.message}`) + } + + // check if the virtual account is still activated on Bridge side - if not, delete it from our repo and return null + if (bridgeVa.status !== "activated") { + + // delete the virtual account from our repo since it's no longer valid + + const deleteResult = await BridgeVirtualAccount.deleteOne({ + bridgeVirtualAccountId: virtualAccount.bridgeVirtualAccountId! as string + }) + + + return null + } + + const result: VirtualAccountResult = { bridgeVirtualAccountId: virtualAccount.bridgeVirtualAccountId!, bankName: virtualAccount.bankName,