From 03d12839bfe99416b07096267bf36037586b5983 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 23 Jun 2026 20:39:17 +0100 Subject: [PATCH 1/6] feat: support deposit notification outcoumes (funds received, processing, completed) Add an outcome parameter to deposit push notifications so each lifecycle stage can use its own title and body. --- src/app/bridge/send-deposit-notification.ts | 10 +++-- src/config/locales/en.json | 49 ++++++++++++++++++++- src/config/locales/es.json | 49 ++++++++++++++++++++- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/app/bridge/send-deposit-notification.ts b/src/app/bridge/send-deposit-notification.ts index 9845f676a..bda4f2e95 100644 --- a/src/app/bridge/send-deposit-notification.ts +++ b/src/app/bridge/send-deposit-notification.ts @@ -20,14 +20,18 @@ const i18n = getI18nInstance() const formatDepositAmount = (amount: string, currency: string): string => `${amount} ${currency.toUpperCase()}` +export type BridgeDepositNotificationOutcome = "received" | "processing" | "completed" + export const sendBridgeDepositNotification = async ({ accountId: accountIdRaw, amount, currency, + outcome = "completed", }: { accountId: string amount: string currency: string + outcome?: BridgeDepositNotificationOutcome }): Promise => { const accountId = checkedToAccountId(accountIdRaw) if (accountId instanceof Error) return accountId @@ -40,7 +44,7 @@ export const sendBridgeDepositNotification = async ({ const locale = getLanguageOrDefault(user.language) const formattedAmount = formatDepositAmount(amount, currency) - const phraseBase = "notification.bridgeDeposit" + const phraseBase = `notification.bridgeDeposit.${outcome}` const title = i18n.__({ phrase: `${phraseBase}.title`, locale }) const body = i18n.__( @@ -55,7 +59,7 @@ export const sendBridgeDepositNotification = async ({ notificationCategory: FlashNotificationCategories.Payments, notificationSettings: account.notificationSettings, data: { - type: "bridge_deposit_completed", + type: `bridge_deposit_${outcome}`, amount, currency: currency == "usdt" ? "USD" : currency.toUpperCase(), }, @@ -91,7 +95,7 @@ export const sendBridgeDepositNotificationBestEffort = async ( if (result instanceof Error) { baseLogger.warn( - { accountId: args.accountId, error: result }, + { accountId: args.accountId, outcome: args.outcome ?? "completed", error: result }, "Failed to send Bridge deposit push notification", ) } diff --git a/src/config/locales/en.json b/src/config/locales/en.json index 4fe922f16..c61b61efc 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -42,8 +42,18 @@ "title": "Cashout" }, "bridgeDeposit": { - "body": "Your deposit of {{amount}} has been added to your account.", - "title": "Deposit received" + "completed": { + "body": "Your deposit of {{amount}} has been added to your account.", + "title": "Deposit received" + }, + "processing": { + "body": "Your deposit of {{amount}} is being sent to your wallet.", + "title": "Deposit processing" + }, + "received": { + "body": "We received your deposit of {{amount}}.", + "title": "Deposit received" + } }, "bridgeWithdrawal": { "flashFeeNotice": "Shown fees and amounts are estimates. Final fees may differ.", @@ -59,6 +69,41 @@ "body": "Your withdrawal of {{amount}} could not be completed.", "bodyWithReason": "Your withdrawal of {{amount}} could not be completed: {{reason}}.", "title": "Withdrawal failed" + }, + "processing": { + "body": "Your withdrawal of {{amount}} is being processed.", + "title": "Withdrawal in progress" + }, + "submitted": { + "body": "Your withdrawal of {{amount}} has been submitted.", + "title": "Withdrawal submitted" + }, + "usdt_sent": { + "body": "Your withdrawal of {{amount}} is on its way to your bank.", + "title": "Withdrawal processing" + } + }, + "bridgeKyc": { + "approved": { + "body": "Your identity verification has been approved.", + "title": "Identity verified" + }, + "in_review": { + "body": "Your identity verification is under review. We'll notify you when it's complete.", + "title": "Verification in progress" + }, + "incomplete": { + "body": "Your identity verification is not finished yet. Please complete it to continue.", + "title": "Complete your verification" + }, + "offboarded": { + "body": "Your identity verification has been closed.", + "title": "Verification closed" + }, + "rejected": { + "body": "Your identity verification was not approved.", + "bodyWithReason": "Your identity verification was not approved: {{reason}}.", + "title": "Verification unsuccessful" } } } diff --git a/src/config/locales/es.json b/src/config/locales/es.json index 0fc877e9d..ddcef88b9 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -38,8 +38,18 @@ } }, "bridgeDeposit": { - "body": "Su depósito de {{amount}} se agregó a su cuenta.", - "title": "Depósito recibido" + "completed": { + "body": "Su depósito de {{amount}} se agregó a su cuenta.", + "title": "Depósito recibido" + }, + "processing": { + "body": "Su depósito de {{amount}} se está enviando a su billetera.", + "title": "Depósito en proceso" + }, + "received": { + "body": "Recibimos su depósito de {{amount}}.", + "title": "Depósito recibido" + } }, "bridgeWithdrawal": { "flashFeeNotice": "Las comisiones y montos mostrados son estimados. Las comisiones finales pueden variar.", @@ -55,6 +65,41 @@ "body": "No se pudo completar su retiro de {{amount}}.", "bodyWithReason": "No se pudo completar su retiro de {{amount}}: {{reason}}.", "title": "Retiro fallido" + }, + "processing": { + "body": "Su retiro de {{amount}} está en proceso.", + "title": "Retiro en progreso" + }, + "submitted": { + "body": "Su retiro de {{amount}} ha sido enviado.", + "title": "Retiro enviado" + }, + "usdt_sent": { + "body": "Su retiro de {{amount}} está en camino a su banco.", + "title": "Retiro en proceso" + } + }, + "bridgeKyc": { + "approved": { + "body": "Su verificación de identidad ha sido aprobada.", + "title": "Identidad verificada" + }, + "in_review": { + "body": "Su verificación de identidad está en revisión. Le avisaremos cuando esté completa.", + "title": "Verificación en progreso" + }, + "incomplete": { + "body": "Su verificación de identidad aún no está completa. Complétela para continuar.", + "title": "Complete su verificación" + }, + "offboarded": { + "body": "Su verificación de identidad ha sido cerrada.", + "title": "Verificación cerrada" + }, + "rejected": { + "body": "Su verificación de identidad no fue aprobada.", + "bodyWithReason": "Su verificación de identidad no fue aprobada: {{reason}}.", + "title": "Verificación no exitosa" } } } From 39b87cdc3313efb34fed973c506c7f943813255d Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 23 Jun 2026 20:52:54 +0100 Subject: [PATCH 2/6] feat: send withdrawal push notifications on submit and USDT send Notify users when a withdrawal is submitted to Bridge and when USDT is sent, and centralize send-failure handling with push alerts. --- src/app/bridge/send-withdrawal-notification.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/bridge/send-withdrawal-notification.ts b/src/app/bridge/send-withdrawal-notification.ts index 07003a18f..dc780583a 100644 --- a/src/app/bridge/send-withdrawal-notification.ts +++ b/src/app/bridge/send-withdrawal-notification.ts @@ -20,7 +20,13 @@ const i18n = getI18nInstance() const formatWithdrawalAmount = (amount: string, currency: string): string => `${amount} ${currency.toUpperCase()}` -export type BridgeWithdrawalNotificationOutcome = "completed" | "failed" | "cancelled" +export type BridgeWithdrawalNotificationOutcome = + | "submitted" + | "usdt_sent" + | "processing" + | "completed" + | "failed" + | "cancelled" export const sendBridgeWithdrawalNotification = async ({ accountId: accountIdRaw, From 2561195237c48671ab7e41c6ce6ecf6c84059659 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 23 Jun 2026 20:56:11 +0100 Subject: [PATCH 3/6] feat: add kyc push notification module with imcomplete deep-link support send localized kyc status notifications, skip pushes before initiation, use a dedicated incomplete message, and attach latest KYC links for incomplete notifications. --- src/app/bridge/send-kyc-notification.ts | 180 ++++++++++++++ .../app/bridge/send-kyc-notification.spec.ts | 231 ++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 src/app/bridge/send-kyc-notification.ts create mode 100644 test/flash/unit/app/bridge/send-kyc-notification.spec.ts diff --git a/src/app/bridge/send-kyc-notification.ts b/src/app/bridge/send-kyc-notification.ts new file mode 100644 index 000000000..80f4bc6db --- /dev/null +++ b/src/app/bridge/send-kyc-notification.ts @@ -0,0 +1,180 @@ +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 { toBridgeCustomerId } from "@domain/primitives/bridge" +import BridgeApiClient from "@services/bridge/client" +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 PENDING_KYC_STATUSES = new Set>([ + "open", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", +]) + +export type BridgeKycNotificationOutcome = + | "approved" + | "rejected" + | "in_review" + | "incomplete" + | "offboarded" + +export const isBridgeKycInitiated = ( + status: Account["bridgeKycStatus"], +): status is NonNullable => + status !== undefined && status !== null + +export const toBridgeKycNotificationOutcome = ( + status: Account["bridgeKycStatus"], +): BridgeKycNotificationOutcome | null => { + if (!status || status === "not_started") return null + if (status === "approved") return "approved" + if (status === "rejected") return "rejected" + if (status === "offboarded") return "offboarded" + if (status === "incomplete") return "incomplete" + if (PENDING_KYC_STATUSES.has(status)) return "in_review" + return null +} + +const formatRejectionReason = (rejectionReasons: unknown[]): string | undefined => { + const reasons = rejectionReasons + .map((reason) => { + if (typeof reason === "string") return reason + if (reason && typeof reason === "object" && "reason" in reason) { + return String((reason as { reason: unknown }).reason) + } + return null + }) + .filter((reason): reason is string => Boolean(reason)) + + return reasons.length > 0 ? reasons.join(", ") : undefined +} + +const fetchIncompleteKycLinks = async ( + account: Account, +): Promise<{ kycLink?: string; tosLink?: string }> => { + if (!account.bridgeCustomerId) { + baseLogger.warn( + { accountId: account.id }, + "Cannot attach KYC links to incomplete notification: no Bridge customer ID", + ) + return {} + } + + try { + const customerId = toBridgeCustomerId(account.bridgeCustomerId) + const kycLinkResult = await BridgeApiClient.getKycLatestLink(customerId) + return { + kycLink: kycLinkResult.kyc_link, + tosLink: kycLinkResult.tos_link, + } + } catch (error) { + baseLogger.warn( + { accountId: account.id, error }, + "Failed to fetch KYC links for incomplete notification", + ) + return {} + } +} + +export const sendBridgeKycNotification = async ({ + accountId: accountIdRaw, + outcome, + kycStatus, + rejectionReasons = [], +}: { + accountId: string + outcome: BridgeKycNotificationOutcome + kycStatus: NonNullable + rejectionReasons?: unknown[] +}): 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 phraseBase = `notification.bridgeKyc.${outcome}` + const rejectionReason = formatRejectionReason(rejectionReasons) + + const title = i18n.__({ phrase: `${phraseBase}.title`, locale }) + const bodyPhrase = + outcome === "rejected" && rejectionReason + ? `${phraseBase}.bodyWithReason` + : `${phraseBase}.body` + const body = i18n.__( + { phrase: bodyPhrase, locale }, + { reason: rejectionReason ?? "" }, + ) + + const incompleteKycLinks = + outcome === "incomplete" ? await fetchIncompleteKycLinks(account) : {} + + const result = await PushNotificationsService().sendFilteredNotification({ + deviceTokens: user.deviceTokens, + title, + body, + notificationCategory: FlashNotificationCategories.Payments, + notificationSettings: account.notificationSettings, + data: { + type: `bridge_kyc_${outcome}`, + status: kycStatus, + ...(rejectionReason ? { rejectionReason } : {}), + ...incompleteKycLinks, + }, + }) + + if (result instanceof NotificationsServiceError) return result + + if (result.status === SendFilteredPushNotificationStatus.Filtered) { + return true + } + + return true +} + +export const sendBridgeKycNotificationBestEffort = async ( + args: Parameters[0], +): Promise => { + const result = await sendBridgeKycNotification(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 KYC push notification", + ) + } +} diff --git a/test/flash/unit/app/bridge/send-kyc-notification.spec.ts b/test/flash/unit/app/bridge/send-kyc-notification.spec.ts new file mode 100644 index 000000000..b958ee815 --- /dev/null +++ b/test/flash/unit/app/bridge/send-kyc-notification.spec.ts @@ -0,0 +1,231 @@ +import { + isBridgeKycInitiated, + sendBridgeKycNotification, + toBridgeKycNotificationOutcome, +} from "@app/bridge/send-kyc-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" +import BridgeApiClient from "@services/bridge/client" + +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("@services/bridge/client", () => ({ + __esModule: true, + default: { + getKycLatestLink: jest.fn(), + }, +})) + +jest.mock("@config", () => { + const mockI18n = { + __: jest.fn().mockImplementation(({ phrase }, options) => `${phrase} ${JSON.stringify(options)}`), + } + return { + getI18nInstance: jest.fn(() => mockI18n), + } +}) + +describe("isBridgeKycInitiated", () => { + it.each([ + [undefined, false], + ["open", true], + ["not_started", true], + ["approved", true], + ])("returns %s for %s", (status, expected) => { + expect(isBridgeKycInitiated(status)).toBe(expected) + }) +}) + +describe("toBridgeKycNotificationOutcome", () => { + it.each([ + ["approved", "approved"], + ["rejected", "rejected"], + ["offboarded", "offboarded"], + ["under_review", "in_review"], + ["incomplete", "incomplete"], + ["open", "in_review"], + ["not_started", null], + [undefined, null], + ])("maps %s to %s", (status, expected) => { + expect(toBridgeKycNotificationOutcome(status)).toBe(expected) + }) +}) + +describe("sendBridgeKycNotification", () => { + const accountId = "507f1f77bcf86cd799439011" + const bridgeCustomerId = "cus_test_123" + const mockAccount = { + id: accountId, + kratosUserId: "user-id", + bridgeCustomerId, + 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() + ;(BridgeApiClient.getKycLatestLink as jest.Mock).mockResolvedValue({ + kyc_link: "https://bridge.xyz/kyc/abc", + tos_link: "https://bridge.xyz/tos/abc", + customer_id: bridgeCustomerId, + }) + ;(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 an approved KYC notification with Payments category", async () => { + const result = await sendBridgeKycNotification({ + accountId, + outcome: "approved", + kycStatus: "approved", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + deviceTokens: mockUser.deviceTokens, + notificationCategory: "Payments", + data: expect.objectContaining({ type: "bridge_kyc_approved", status: "approved" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ phrase: "notification.bridgeKyc.approved.title" }), + ) + }) + + it("uses bodyWithReason when rejection reasons are provided", async () => { + await sendBridgeKycNotification({ + accountId, + outcome: "rejected", + kycStatus: "rejected", + rejectionReasons: [{ reason: "Document unreadable" }], + }) + + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeKyc.rejected.bodyWithReason", + }), + expect.objectContaining({ reason: "Document unreadable" }), + ) + }) + + it("includes KYC links in incomplete notification data", async () => { + await sendBridgeKycNotification({ + accountId, + outcome: "incomplete", + kycStatus: "incomplete", + }) + + expect(BridgeApiClient.getKycLatestLink).toHaveBeenCalledWith(bridgeCustomerId) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + type: "bridge_kyc_incomplete", + status: "incomplete", + kycLink: "https://bridge.xyz/kyc/abc", + tosLink: "https://bridge.xyz/tos/abc", + }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ phrase: "notification.bridgeKyc.incomplete.title" }), + ) + }) + + it("still sends incomplete notification when KYC link fetch fails", async () => { + ;(BridgeApiClient.getKycLatestLink as jest.Mock).mockRejectedValue(new Error("Bridge unavailable")) + + const result = await sendBridgeKycNotification({ + accountId, + outcome: "incomplete", + kycStatus: "incomplete", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + type: "bridge_kyc_incomplete", + status: "incomplete", + }, + }), + ) + }) + + it("does not fetch KYC links for non-incomplete notifications", async () => { + await sendBridgeKycNotification({ + accountId, + outcome: "approved", + kycStatus: "approved", + }) + + expect(BridgeApiClient.getKycLatestLink).not.toHaveBeenCalled() + }) + + it("sends an in-review KYC notification", async () => { + await sendBridgeKycNotification({ + accountId, + outcome: "in_review", + kycStatus: "under_review", + }) + + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: "bridge_kyc_in_review", status: "under_review" }), + }), + ) + }) + + it("returns true when notification is filtered by user settings", async () => { + sendFilteredNotification.mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Filtered, + }) + + const result = await sendBridgeKycNotification({ + accountId, + outcome: "approved", + kycStatus: "approved", + }) + + expect(result).toBe(true) + }) +}) From 70f32b5d1082fc7baff6ae214bae952d809c59fc Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 23 Jun 2026 20:59:06 +0100 Subject: [PATCH 4/6] feat: wire kyc webhook status changes to push notifications Notify users on KYC status transitions from bridge webhook handler, with guards for unchanged status and pre-initiation states. --- .../bridge/webhook-server/routes/kyc.ts | 73 +++++- .../bridge/webhook-server/kyc.spec.ts | 217 ++++++++++++++++++ 2 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 test/flash/unit/services/bridge/webhook-server/kyc.spec.ts diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts index 290951527..9c7f1900c 100644 --- a/src/services/bridge/webhook-server/routes/kyc.ts +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -18,12 +18,42 @@ */ import { Request, Response } from "express" +import { + isBridgeKycInitiated, + sendBridgeKycNotificationBestEffort, + toBridgeKycNotificationOutcome, +} from "@app/bridge/send-kyc-notification" import { AccountsRepository } from "@services/mongoose/accounts" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" import { toBridgeCustomerId } from "@domain/primitives/bridge" import BridgeService from "@services/bridge" +const notifyKycStatusChange = async ({ + account, + previousStatus, + nextStatus, + rejectionReasons, +}: { + account: Account + previousStatus: Account["bridgeKycStatus"] + nextStatus: NonNullable + rejectionReasons: unknown[] +}) => { + if (previousStatus === nextStatus) return + if (!isBridgeKycInitiated(previousStatus)) return + + const outcome = toBridgeKycNotificationOutcome(nextStatus) + if (!outcome) return + + await sendBridgeKycNotificationBestEffort({ + accountId: account.id, + outcome, + kycStatus: nextStatus, + rejectionReasons, + }) +} + export const kycHandler = async (req: Request, res: Response) => { const { event_id, event_object, event_type } = req.body @@ -69,8 +99,9 @@ export const kycHandler = async (req: Request, res: Response) => { // Map Bridge customer status fields to our internal kyc status // Bridge customer.status values: not_started, active (approved), rejected, offboarded if (status === "not_started") { + const nextStatus = "not_started" as const const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: "not_started", + bridgeKycStatus: nextStatus, }) if (result instanceof Error) { @@ -83,8 +114,9 @@ export const kycHandler = async (req: Request, res: Response) => { baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC not started") } else if (PENDING_BRIDGE_STATUSES.has(status)) { + const nextStatus = status as NonNullable const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: status as Account["bridgeKycStatus"], + bridgeKycStatus: nextStatus, }) if (result instanceof Error) { @@ -95,13 +127,21 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + await notifyKycStatusChange({ + account, + previousStatus: account.bridgeKycStatus, + nextStatus, + rejectionReasons, + }) + baseLogger.info( { accountId: account.id, customerId, status }, "Bridge KYC moved to pending", ) } else if (status === "active" || status === "approved") { + const nextStatus = "approved" as const const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: "approved", + bridgeKycStatus: nextStatus, }) if (result instanceof Error) { @@ -112,6 +152,13 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + await notifyKycStatusChange({ + account, + previousStatus: account.bridgeKycStatus, + nextStatus, + rejectionReasons, + }) + baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC approved") const vaResult = await BridgeService.createVirtualAccount(account.id) @@ -127,8 +174,9 @@ export const kycHandler = async (req: Request, res: Response) => { ) } } else if (status === "rejected") { + const nextStatus = "rejected" as const const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: "rejected", + bridgeKycStatus: nextStatus, }) if (result instanceof Error) { @@ -139,6 +187,13 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + await notifyKycStatusChange({ + account, + previousStatus: account.bridgeKycStatus, + nextStatus, + rejectionReasons, + }) + baseLogger.warn( { accountId: account.id, @@ -148,8 +203,9 @@ export const kycHandler = async (req: Request, res: Response) => { "Bridge KYC rejected", ) } else if (status === "offboarded") { + const nextStatus = "offboarded" as const const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: "offboarded", + bridgeKycStatus: nextStatus, }) if (result instanceof Error) { @@ -160,6 +216,13 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + await notifyKycStatusChange({ + account, + previousStatus: account.bridgeKycStatus, + nextStatus, + rejectionReasons, + }) + baseLogger.warn({ accountId: account.id, customerId }, "Bridge KYC offboarded") } else { baseLogger.info( diff --git a/test/flash/unit/services/bridge/webhook-server/kyc.spec.ts b/test/flash/unit/services/bridge/webhook-server/kyc.spec.ts new file mode 100644 index 000000000..162333228 --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/kyc.spec.ts @@ -0,0 +1,217 @@ +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/lock", () => ({ + LockService: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/bridge", () => ({ + __esModule: true, + default: { + createVirtualAccount: jest.fn().mockResolvedValue({ virtualAccountId: "va-1" }), + }, +})) + +jest.mock("@app/bridge/send-kyc-notification", () => ({ + isBridgeKycInitiated: (status: Account["bridgeKycStatus"]) => + status !== undefined && status !== null, + sendBridgeKycNotificationBestEffort: jest.fn().mockResolvedValue(undefined), + toBridgeKycNotificationOutcome: jest.fn((status) => { + if (status === "approved") return "approved" + if (status === "rejected") return "rejected" + if (status === "offboarded") return "offboarded" + if (status === "incomplete") return "incomplete" + if ( + status === "under_review" || + status === "open" || + status === "awaiting_questionnaire" || + status === "awaiting_ubo" || + status === "paused" + ) { + return "in_review" + } + return null + }), +})) + +import { Request, Response } from "express" +import { kycHandler } from "@services/bridge/webhook-server/routes/kyc" +import { AccountsRepository } from "@services/mongoose/accounts" +import { LockService } from "@services/lock" +import { sendBridgeKycNotificationBestEffort } from "@app/bridge/send-kyc-notification" + +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 + +describe("kycHandler", () => { + const accountId = "507f1f77bcf86cd799439011" + const customerId = "cus_test_123" + const mockAccount = { + id: accountId, + bridgeKycStatus: "incomplete", + } + + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByBridgeCustomerId: jest.fn().mockResolvedValue(mockAccount), + updateBridgeFields: jest.fn().mockResolvedValue(mockAccount), + }) + ;(LockService as jest.Mock).mockReturnValue({ + lockIdempotencyKey: jest.fn().mockResolvedValue(true), + }) + }) + + it("sends a push notification when KYC is approved", async () => { + const req = makeReq({ + event_id: "evt-approved", + event_type: "customer.updated.status_transitioned", + event_object: { + id: customerId, + status: "active", + }, + }) + const res = makeRes() + + await kycHandler(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(sendBridgeKycNotificationBestEffort).toHaveBeenCalledWith({ + accountId, + outcome: "approved", + kycStatus: "approved", + rejectionReasons: [], + }) + }) + + it("sends a push notification when KYC moves to under review", async () => { + const req = makeReq({ + event_id: "evt-review", + event_type: "customer.updated.status_transitioned", + event_object: { + id: customerId, + status: "under_review", + }, + }) + const res = makeRes() + + await kycHandler(req, res) + + expect(sendBridgeKycNotificationBestEffort).toHaveBeenCalledWith({ + accountId, + outcome: "in_review", + kycStatus: "under_review", + rejectionReasons: [], + }) + }) + + it("sends a push notification when KYC is incomplete", async () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByBridgeCustomerId: jest.fn().mockResolvedValue({ + ...mockAccount, + bridgeKycStatus: "open", + }), + updateBridgeFields: jest.fn().mockResolvedValue(mockAccount), + }) + + const req = makeReq({ + event_id: "evt-incomplete", + event_type: "customer.updated.status_transitioned", + event_object: { + id: customerId, + status: "incomplete", + }, + }) + const res = makeRes() + + await kycHandler(req, res) + + expect(sendBridgeKycNotificationBestEffort).toHaveBeenCalledWith({ + accountId, + outcome: "incomplete", + kycStatus: "incomplete", + rejectionReasons: [], + }) + }) + + it("does not send a push notification before KYC initiation", async () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByBridgeCustomerId: jest.fn().mockResolvedValue({ + ...mockAccount, + bridgeKycStatus: undefined, + }), + updateBridgeFields: jest.fn().mockResolvedValue(mockAccount), + }) + + const req = makeReq({ + event_id: "evt-pre-init", + event_type: "customer.updated.status_transitioned", + event_object: { + id: customerId, + status: "under_review", + }, + }) + const res = makeRes() + + await kycHandler(req, res) + + expect(sendBridgeKycNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("does not send a push notification when the status is unchanged", async () => { + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByBridgeCustomerId: jest.fn().mockResolvedValue({ + ...mockAccount, + bridgeKycStatus: "approved", + }), + updateBridgeFields: jest.fn().mockResolvedValue(mockAccount), + }) + + const req = makeReq({ + event_id: "evt-approved-dup", + event_type: "customer.updated.status_transitioned", + event_object: { + id: customerId, + status: "active", + }, + }) + const res = makeRes() + + await kycHandler(req, res) + + expect(sendBridgeKycNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("sends a rejected notification with rejection reasons", async () => { + const req = makeReq({ + event_id: "evt-rejected", + event_type: "customer.updated.status_transitioned", + event_object: { + id: customerId, + status: "rejected", + rejection_reasons: [{ reason: "Document expired" }], + }, + }) + const res = makeRes() + + await kycHandler(req, res) + + expect(sendBridgeKycNotificationBestEffort).toHaveBeenCalledWith({ + accountId, + outcome: "rejected", + kycStatus: "rejected", + rejectionReasons: [{ reason: "Document expired" }], + }) + }) +}) From 114d06659f51045523a43ccb82d6612605e7e307 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 23 Jun 2026 21:39:09 +0100 Subject: [PATCH 5/6] feat(bridge): wire withdrawal submit and USDT-sent push notifications Notify users when a withdrawal is submitted to Bridge and when USDT is sent. Centralize send-failure handling with failed push alerts and add unit tests for the new notification outcomes. --- src/services/bridge/index.ts | 111 +++++++++++++----- .../send-withdrawal-notification.spec.ts | 42 +++++++ 2 files changed, 125 insertions(+), 28 deletions(-) diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 7c8e0ed38..31cdde303 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -839,6 +839,43 @@ const requestWithdrawal = async ( } } +const markWithdrawalSendFailed = async ({ + accountId, + withdrawalId, + transferId, + amount, + currency, + bridgeDepositAddress, + failureReason, +}: { + accountId: string + withdrawalId: string + transferId: string + amount: string + currency: string + bridgeDepositAddress: string + failureReason: string +}) => { + const updated = await BridgeAccountsRepo.updateWithdrawalSendFailed( + withdrawalId, + transferId, + amount, + currency, + bridgeDepositAddress, + failureReason, + ) + if (!(updated instanceof Error)) { + await sendBridgeWithdrawalNotificationBestEffort({ + accountId, + amount, + currency, + outcome: "failed", + failureReason, + }) + } + return updated +} + /** * Initiates a previously requested withdrawal — fetches the pending record by ID, * re-checks balance, then submits the transfer to Bridge. @@ -961,19 +998,27 @@ const initiateWithdrawal = async ( ) if (submitted instanceof Error) return submitted + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: accountId as string, + amount: transfer.amount, + currency: transfer.currency, + outcome: "submitted", + }) + const sendRequirements = await IbexClient.getCryptoSendRequirements({ network: "ethereum", currencyId: USDTAmount.currencyId, }) if (sendRequirements instanceof Error) { - await BridgeAccountsRepo.updateWithdrawalSendFailed( - pendingWithdrawal.id, - transfer.id, - transfer.amount, - transfer.currency, + await markWithdrawalSendFailed({ + accountId: accountId as string, + withdrawalId: pendingWithdrawal.id, + transferId: transfer.id, + amount: transfer.amount, + currency: transfer.currency, bridgeDepositAddress, - sendRequirements.message, - ) + failureReason: sendRequirements.message, + }) return sendRequirements } @@ -983,26 +1028,28 @@ const initiateWithdrawal = async ( data: { address: bridgeDepositAddress }, }) if (cryptoSendInfo instanceof Error) { - await BridgeAccountsRepo.updateWithdrawalSendFailed( - pendingWithdrawal.id, - transfer.id, - transfer.amount, - transfer.currency, + await markWithdrawalSendFailed({ + accountId: accountId as string, + withdrawalId: pendingWithdrawal.id, + transferId: transfer.id, + amount: transfer.amount, + currency: transfer.currency, bridgeDepositAddress, - cryptoSendInfo.message, - ) + failureReason: cryptoSendInfo.message, + }) return cryptoSendInfo } if (!cryptoSendInfo.id) { const error = new Error("IBEX crypto send info did not return id") - await BridgeAccountsRepo.updateWithdrawalSendFailed( - pendingWithdrawal.id, - transfer.id, - transfer.amount, - transfer.currency, + await markWithdrawalSendFailed({ + accountId: accountId as string, + withdrawalId: pendingWithdrawal.id, + transferId: transfer.id, + amount: transfer.amount, + currency: transfer.currency, bridgeDepositAddress, - error.message, - ) + failureReason: error.message, + }) return error } @@ -1012,14 +1059,15 @@ const initiateWithdrawal = async ( amount: sendAmount.toIbex(), }) if (sendResult instanceof Error) { - await BridgeAccountsRepo.updateWithdrawalSendFailed( - pendingWithdrawal.id, - transfer.id, - transfer.amount, - transfer.currency, + await markWithdrawalSendFailed({ + accountId: accountId as string, + withdrawalId: pendingWithdrawal.id, + transferId: transfer.id, + amount: transfer.amount, + currency: transfer.currency, bridgeDepositAddress, - sendResult.message, - ) + failureReason: sendResult.message, + }) return sendResult } @@ -1043,6 +1091,13 @@ const initiateWithdrawal = async ( ) if (updated instanceof Error) return updated + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: accountId as string, + amount: updated.amount, + currency: updated.currency, + outcome: "usdt_sent", + }) + const auditResult = await writeBridgeCashoutPending({ transferId: transfer.id, amount: transfer.amount, diff --git a/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts index 30f3bb9c0..267eae470 100644 --- a/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts +++ b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts @@ -120,6 +120,48 @@ describe("sendBridgeWithdrawalNotification", () => { expect(result).toBe(true) }) + it("sends a submitted withdrawal notification with the correct phrase key and data type", async () => { + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "75.00", + currency: "usdt", + outcome: "submitted", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: "bridge_withdrawal_submitted" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.submitted.title", + }), + ) + }) + + it("sends a usdt_sent withdrawal notification with the correct phrase key and data type", async () => { + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "75.00", + currency: "usdt", + outcome: "usdt_sent", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: "bridge_withdrawal_usdt_sent" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.usdt_sent.title", + }), + ) + }) + it("sends a cancelled withdrawal notification with the correct phrase key and data type", async () => { const result = await sendBridgeWithdrawalNotification({ accountId, From b0e3635cda6479e13ec7f71dc3d765bf07a826a3 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Tue, 23 Jun 2026 22:22:31 +0100 Subject: [PATCH 6/6] fix: send developer_fee on Bridge withdrawal transfers bridge only applies developer_fee_percent when flexible_amount is enabled, fixed amount offramps (implemented in flash) must use developer_fee (USD value), so pass the stored flashFee from the pending withdrawal instead --- src/services/bridge/index.ts | 3 ++- test/flash/unit/services/bridge/index.spec.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 31cdde303..2fc8b4b60 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -970,7 +970,8 @@ const initiateWithdrawal = async ( payment_rail: "ethereum", currency: "usdt", }, - developer_fee_percent: String(BridgeConfig.developerFeePercent), + // Fixed-amount offramps require developer_fee (USD), not developer_fee_percent. + developer_fee: pendingWithdrawal.flashFee, destination: { payment_rail: "ach", currency: "usd", diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 754917ca3..b5755bc73 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -955,6 +955,7 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { CUSTOMER_ID, expect.objectContaining({ amount: AMOUNT, + developer_fee: "1.00", source: { payment_rail: "ethereum", currency: "usdt", @@ -967,6 +968,14 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { ) }) + it("sends a fixed developer_fee instead of developer_fee_percent for offramps", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const transferPayload = (BridgeClient.createTransfer as jest.Mock).mock.calls[0][1] + expect(transferPayload.developer_fee).toBe("1.00") + expect(transferPayload).not.toHaveProperty("developer_fee_percent") + }) + it("revalidates the pending withdrawal external account against Bridge before creating a transfer", async () => { ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ data: [{ ...mockBridgeExternalAccount, id: "bridge-current-account-002" }],