From afa3256c94c4657835403cac1be7eb26d5603c8a Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 28 May 2026 04:47:16 +0100 Subject: [PATCH 1/3] feat(externalAccounts): Webhook endpoint firing for external accounts feat: external accounts event triggering this commit initialize the webhook endpoint to register on Bridge to trigger external account link after plaid completion --- src/config/schema.ts | 3 +- src/config/schema.types.d.ts | 1 + .../mutation/bridge-add-external-account.ts | 4 +- .../object/bridge-external-account-link.ts | 11 +++ .../types/object/bridge-external-account.ts | 2 +- src/services/bridge/webhook-server/index.ts | 2 + .../middleware/verify-signature.ts | 2 +- .../webhook-server/routes/external-account.ts | 80 +++++++++++++++++++ 8 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 src/graphql/public/types/object/bridge-external-account-link.ts create mode 100644 src/services/bridge/webhook-server/routes/external-account.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index 72f08a6f8..9a62f7163 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -660,8 +660,9 @@ export const configSchema = { kyc: { type: "string" }, deposit: { type: "string" }, transfer: { type: "string" }, + external_account: { type: "string" }, }, - required: ["kyc", "deposit", "transfer"], + required: ["kyc", "deposit", "transfer", "external_account"], }, timestampSkewMs: { type: "integer" }, replaySecret: { type: "string" }, diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index dad0fb31c..bc1819d2d 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -33,6 +33,7 @@ type BridgeWebhookPublicKeys = { kyc: string deposit: string transfer: string + external_account: string } type BridgeWebhook = { diff --git a/src/graphql/public/root/mutation/bridge-add-external-account.ts b/src/graphql/public/root/mutation/bridge-add-external-account.ts index 0c4a2fd19..1c3ccfbe5 100644 --- a/src/graphql/public/root/mutation/bridge-add-external-account.ts +++ b/src/graphql/public/root/mutation/bridge-add-external-account.ts @@ -1,7 +1,7 @@ import { GT } from "@graphql/index" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import IError from "@graphql/shared/types/abstract/error" -import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" +import BridgeExternalAccountLink from "@graphql/public/types/object/bridge-external-account-link" import { BridgeConfig } from "@config" import BridgeService from "@services/bridge" import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" @@ -10,7 +10,7 @@ const BridgeAddExternalAccountPayload = GT.Object({ name: "BridgeAddExternalAccountPayload", fields: () => ({ errors: { type: GT.NonNullList(IError) }, - externalAccount: { type: BridgeExternalAccount }, + externalAccount: { type: BridgeExternalAccountLink }, }), }) diff --git a/src/graphql/public/types/object/bridge-external-account-link.ts b/src/graphql/public/types/object/bridge-external-account-link.ts new file mode 100644 index 000000000..5d51c664d --- /dev/null +++ b/src/graphql/public/types/object/bridge-external-account-link.ts @@ -0,0 +1,11 @@ +import { GT } from "@graphql/index" + +const BridgeExternalAccountLink = GT.Object({ + name: "BridgeExternalAccountLink", + fields: () => ({ + linkUrl: { type: GT.NonNull(GT.String) }, + expiresAt: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeExternalAccountLink diff --git a/src/graphql/public/types/object/bridge-external-account.ts b/src/graphql/public/types/object/bridge-external-account.ts index 890733978..92cae18ad 100644 --- a/src/graphql/public/types/object/bridge-external-account.ts +++ b/src/graphql/public/types/object/bridge-external-account.ts @@ -3,7 +3,7 @@ import { GT } from "@graphql/index" const BridgeExternalAccount = GT.Object({ name: "BridgeExternalAccount", fields: () => ({ - id: { type: GT.NonNullID }, + id: { type: GT.NonNullID, resolve: (obj) => obj.bridgeExternalAccountId }, bankName: { type: GT.NonNull(GT.String) }, accountNumberLast4: { type: GT.NonNull(GT.String) }, status: { type: GT.NonNull(GT.String) }, diff --git a/src/services/bridge/webhook-server/index.ts b/src/services/bridge/webhook-server/index.ts index 2cf97522a..e89060179 100644 --- a/src/services/bridge/webhook-server/index.ts +++ b/src/services/bridge/webhook-server/index.ts @@ -14,6 +14,7 @@ import { verifyBridgeSignature } from "./middleware/verify-signature" import { kycHandler } from "./routes/kyc" import { depositHandler } from "./routes/deposit" import { transferHandler } from "./routes/transfer" +import { externalAccountHandler } from "./routes/external-account" import { replayAuthMiddleware, replayHandler } from "./routes/replay" type RawBodyRequest = express.Request & { rawBody?: string } @@ -43,6 +44,7 @@ export const startBridgeWebhookServer = () => { app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler) app.post("/deposit", verifyBridgeSignature("deposit"), depositHandler) app.post("/transfer", verifyBridgeSignature("transfer"), transferHandler) + app.post("/external-account", verifyBridgeSignature("external_account"), externalAccountHandler) app.post("/internal/replay", replayAuthMiddleware, replayHandler) if (!BridgeConfig.webhook.replaySecret && !process.env.BRIDGE_WEBHOOK_REPLAY_SECRET) { diff --git a/src/services/bridge/webhook-server/middleware/verify-signature.ts b/src/services/bridge/webhook-server/middleware/verify-signature.ts index 03b75494f..447ff9aaf 100644 --- a/src/services/bridge/webhook-server/middleware/verify-signature.ts +++ b/src/services/bridge/webhook-server/middleware/verify-signature.ts @@ -14,7 +14,7 @@ import { baseLogger } from "@services/logger" type RawBodyRequest = Request & { rawBody?: string } -export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer") => { +export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer" | "external_account") => { return (req: Request, res: Response, next: NextFunction) => { const signature = req.headers["x-webhook-signature"] as string diff --git a/src/services/bridge/webhook-server/routes/external-account.ts b/src/services/bridge/webhook-server/routes/external-account.ts new file mode 100644 index 000000000..6618f5a31 --- /dev/null +++ b/src/services/bridge/webhook-server/routes/external-account.ts @@ -0,0 +1,80 @@ +/** + * Bridge External Account Webhook Handler + * Handles external_account.created and external_account.updated events from Bridge.xyz + * + * Fires after a user completes the Plaid bank-linking flow. + * Persists the linked account to MongoDB so it appears in bridgeExternalAccounts queries. + * + * Status mapping: + * active: true → "verified" + * active: false → "failed" + */ + +import { Request, Response } from "express" +import { AccountsRepository } from "@services/mongoose/accounts" +import { LockService } from "@services/lock" +import { baseLogger } from "@services/logger" +import { toBridgeCustomerId } from "@domain/primitives/bridge" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" + +const toStatus = (active: boolean | undefined): "pending" | "verified" | "failed" => { + if (active === true) return "verified" + if (active === false) return "failed" + return "pending" +} + +export const externalAccountHandler = async (req: Request, res: Response) => { + const { event_id, event_object } = req.body + const { id, customer_id, bank_name, last_4, active } = event_object ?? {} + + if (!id || !customer_id || !event_id) { + return res.status(400).json({ error: "Invalid payload" }) + } + + try { + const bridgeCustomerId = toBridgeCustomerId(customer_id) + const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId) + if (account instanceof Error) { + baseLogger.warn( + { customer_id, event_id }, + "Account not found for Bridge customer — may be a timing issue, Bridge will retry", + ) + return res.status(503).json({ error: "Account not ready" }) + } + + const lockKey = `bridge-external-account:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ customer_id, event_id, id }, "Duplicate Bridge external account webhook") + return res.status(200).json({ status: "already_processed" }) + } + + const status = toStatus(active) + + const result = await BridgeAccountsRepo.createExternalAccount({ + accountId: String(account.id), + bridgeExternalAccountId: id, + bankName: bank_name ?? "Unknown", + accountNumberLast4: last_4 ?? "0000", + status, + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, event_id, id, error: result }, + "Failed to persist Bridge external account", + ) + return res.status(500).json({ error: "Failed to persist external account" }) + } + + baseLogger.info( + { accountId: account.id, bridgeExternalAccountId: id, status }, + "Bridge external account persisted", + ) + + return res.status(200).json({ status: "success" }) + } catch (error) { + baseLogger.error({ error, customer_id, event_id }, "Error processing Bridge external account webhook") + return res.status(500).json({ error: "Internal server error" }) + } +} From 430b7d48f19f779781eb65da0bd9956ac17c5c9f Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 28 May 2026 04:58:09 +0100 Subject: [PATCH 2/3] fix(ENG-350) : reset pending withdraw state on transfer failure. this commit mark the transfer as failed in our bridge withdrawals collection when something went wrong during the trasfer flow, the user is notified with a popup and on return state, as well for transfer successfully completed --- src/services/bridge/webhook-server/routes/replay.ts | 6 +++++- src/services/bridge/webhook-server/routes/transfer.ts | 8 +++----- src/services/mongoose/bridge-accounts.ts | 7 ++++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 33b499bd9..009414c28 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -14,14 +14,16 @@ import { } from "../transfer-direction" import { depositHandler } from "./deposit" +import { externalAccountHandler } from "./external-account" import { kycHandler } from "./kyc" import { transferHandler } from "./transfer" -type RouteKey = "kyc" | "deposit" | "transfer" +type RouteKey = "kyc" | "deposit" | "transfer" | "external_account" const HANDLERS: Record Promise> = { kyc: kycHandler, deposit: depositHandler, transfer: transferHandler, + external_account: externalAccountHandler, } const DEPOSIT_EVENT_TYPES = new Set([ @@ -39,6 +41,7 @@ const DEPOSIT_EVENT_TYPES = new Set([ const toRouteKey = (bridgeEventType: string): RouteKey | null => { if (bridgeEventType.startsWith("kyc")) return "kyc" if (bridgeEventType.startsWith("transfer")) return "transfer" + if (bridgeEventType.startsWith("external_account")) return "external_account" if (DEPOSIT_EVENT_TYPES.has(bridgeEventType)) return "deposit" return null } @@ -114,6 +117,7 @@ const toHandlerBody = ({ event_id: eventId, event_object: eventObject, } + } export const replayAuthMiddleware = (req: Request, res: Response, next: () => void) => { diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index a24a32fdc..6330ad94a 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -1,6 +1,6 @@ /** * Bridge Transfer Webhook Handler - * Handles transfer.completed and transfer.failed events from Bridge.xyz + * Handles transfer webhook events from Bridge.xyz (transfer.completed, transfer.updated.status_transitioned) */ import { Request, Response } from "express" @@ -65,7 +65,7 @@ export const transferHandler = async (req: Request, res: Response) => { event === "transfer.payment_processed" || state === "payment_processed" - const isFailure = event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state) + const isFailure = TERMINAL_FAILURE_STATES.has(state) if (!isCompletion && !isFailure) { baseLogger.info({ transfer_id, state, event }, "Bridge transfer event not handled") @@ -118,9 +118,7 @@ export const transferHandler = async (req: Request, res: Response) => { const failureReason = state === "refund_failed" ? (return_reason as string | undefined) - : event === "transfer.failed" - ? (reason as string | undefined) - : ((reason as string | undefined) ?? (return_reason as string | undefined)) + : ((reason as string | undefined) ?? (return_reason as string | undefined)) const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index c045295be..4afd2c974 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -61,7 +61,12 @@ export const createExternalAccount = async (data: { status?: "pending" | "verified" | "failed" }) => { try { - const record = await BridgeExternalAccount.create(data) + const { bridgeExternalAccountId, accountId, status, ...immutable } = data + const record = await BridgeExternalAccount.findOneAndUpdate( + { bridgeExternalAccountId, accountId }, + { $setOnInsert: { bridgeExternalAccountId, accountId, ...immutable }, $set: { status: status ?? "pending" } }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) return record } catch (error) { return new RepositoryError(String(error)) From ebc4ea2f0b99e01790a3f4889de29e035c7e9964 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Sun, 31 May 2026 07:26:43 +0100 Subject: [PATCH 3/3] fix(bridge): normalize currency display in withdrawal notification Map "usdt" to "USD" and uppercase other currency codes so the push notification shows a human-readable label instead of a raw enum value. --- src/app/bridge/send-withdrawal-notification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/bridge/send-withdrawal-notification.ts b/src/app/bridge/send-withdrawal-notification.ts index 8adf90f30..cc5c7ae29 100644 --- a/src/app/bridge/send-withdrawal-notification.ts +++ b/src/app/bridge/send-withdrawal-notification.ts @@ -70,7 +70,7 @@ export const sendBridgeWithdrawalNotification = async ({ data: { type: `bridge_withdrawal_${outcome}`, amount, - currency, + currency: currency == "usdt" ? "USD" : currency.toUpperCase(), ...(failureReason ? { failureReason } : {}), }, })