diff --git a/src/services/bridge/webhook-server/routes/deposit.ts b/src/services/bridge/webhook-server/routes/deposit.ts index 33e163147..10d5f6108 100644 --- a/src/services/bridge/webhook-server/routes/deposit.ts +++ b/src/services/bridge/webhook-server/routes/deposit.ts @@ -1,8 +1,12 @@ /** * Bridge Deposit Webhook Handler - * Handles transfer state-transition events (deposit flow) from Bridge.xyz + * Handles incoming-funds events from Bridge.xyz via the /deposit route. * - * NOTE: This handler only logs the deposit event. + * Two event categories land here: + * - virtual_account.activity — fiat payments hitting a virtual account + * - bridge_wallet.activity — on-chain/off-chain bridge wallet movements + * + * Both represent money arriving that needs to be logged for reconciliation. * The actual balance crediting happens when IBEX sends its crypto.received webhook. */ @@ -15,76 +19,153 @@ import { writeBridgeDepositRequest } from "@services/frappe/BridgeTransferReques import { alertBridge, generateDedupKey } from "@services/alerts" import { alertIbexReconciliationFailed } from "@services/alerts/ibex-bridge-movement" +type DepositEventObject = { + id: string + amount: string + currency?: string + // Transfer event shape + state?: string + on_behalf_of?: string + developer_fee?: string + receipt?: { + initial_amount?: string + subtotal_amount?: string + final_amount?: string + developer_fee?: string + destination_tx_hash?: string + } + // Virtual account activity shape + type?: string + customer_id?: string + virtual_account_id?: string + deposit_id?: string + subtotal_amount?: string + developer_fee_amount?: string + exchange_fee_amount?: string + destination_payment_rail?: string + // Bridge wallet activity shape + bridge_wallet_id?: string + available_balance?: string + destination?: { + tx_hash?: string + } + payment_route?: { + type?: string + customer_id?: string + transfer_id?: string + deposit_id?: string + virtual_account_id?: string + } +} + 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 ?? {} + const { event_id, event_category, event_object } = req.body + const obj = (event_object ?? {}) as DepositEventObject - if (!id || !event_id || !amount || !on_behalf_of) { - return res.status(400).json({ error: "Invalid payload" }) + // Normalise from either payload shape. + // Transfer events use on_behalf_of; virtual_account / bridge_wallet use customer_id. + const customerId = obj.on_behalf_of ?? obj.customer_id ?? obj.payment_route?.customer_id + // "state" for transfers, "type" (funds_received / deposit / etc.) for others + const state = obj.state ?? obj.type + const currency = obj.currency ?? "usd" + + if (!obj.id || !event_id) { + baseLogger.warn( + { event_id, event_category, event_object_id: obj.id }, + "Bridge deposit webhook rejected: missing required fields", + ) + return res.status(400).json({ + error: "Invalid payload", + detail: + "Missing one or more required fields: id, event_id", + }) + } + + if (!obj.amount || !customerId) { + baseLogger.warn( + { + event_id, + event_category, + event_object_id: obj.id, + has_amount: Boolean(obj.amount), + has_customer_identifier: Boolean(customerId), + }, + "Bridge deposit webhook acknowledged without deposit row: missing crediting fields", + ) + return res.status(200).json({ + status: "skipped", + reason: "missing_crediting_fields", + }) } try { - const lockKey = `bridge-deposit:${id}:${state}` + const lockKey = `bridge-deposit:${obj.id}:${state ?? "unknown"}` const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) if (lockResult instanceof Error) { - baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") + baseLogger.info({ event_id, id: obj.id, state }, "Duplicate Bridge deposit webhook") return res.status(200).json({ status: "already_processed" }) } + const rxReceipt = obj.receipt + + const developerFee = + asOptionalString(rxReceipt?.developer_fee) ?? + asOptionalString(obj.developer_fee_amount) ?? + asOptionalString(obj.developer_fee) ?? + "0.0" + baseLogger.info( { - id, + event_id, + event_category, + id: obj.id, state, - amount, + amount: obj.amount, currency, - on_behalf_of, - receipt: { - initial_amount: receipt?.initial_amount, - subtotal_amount: receipt?.subtotal_amount, - final_amount: receipt?.final_amount, - developer_fee: receipt?.developer_fee, - destination_tx_hash: receipt?.destination_tx_hash, - }, - event_id, + customerId, + developerFee, + subtotalAmount: rxReceipt?.subtotal_amount ?? obj.subtotal_amount, + destinationTxHash: rxReceipt?.destination_tx_hash ?? obj.destination?.tx_hash, }, "Bridge deposit event", ) const depositLog = await createBridgeDeposit({ eventId: event_id, - transferId: id, - customerId: on_behalf_of, - state, - amount: String(amount), + transferId: obj.id, + customerId, + state: state ?? "unknown", + amount: String(obj.amount), currency, - developerFee: - receipt?.developer_fee != null - ? String(receipt.developer_fee) - : event_object?.developer_fee != null - ? String(event_object.developer_fee) - : "0.0", + developerFee, subtotalAmount: - receipt?.subtotal_amount != null ? String(receipt.subtotal_amount) : undefined, - initialAmount: - receipt?.initial_amount != null ? String(receipt.initial_amount) : undefined, - finalAmount: - receipt?.final_amount != null ? String(receipt.final_amount) : undefined, - destinationTxHash: receipt?.destination_tx_hash, + asOptionalString(rxReceipt?.subtotal_amount) ?? + asOptionalString(obj.subtotal_amount), + initialAmount: asOptionalString(rxReceipt?.initial_amount), + finalAmount: asOptionalString(rxReceipt?.final_amount), + destinationTxHash: rxReceipt?.destination_tx_hash ?? obj.destination?.tx_hash, }) if (depositLog instanceof Error) { baseLogger.error( - { error: depositLog, event_id, id }, + { error: depositLog, event_id, id: obj.id }, "Failed to persist bridge deposit log", ) return res.status(500).json({ error: "Failed to persist deposit log" }) } - 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") + // Real-time reconciliation: only trigger for transfer events that have + // reached payment_processed with an on-chain tx hash. + if ( + event_category === "transfer" && + state === "payment_processed" && + rxReceipt?.destination_tx_hash + ) { + const txHash = rxReceipt.destination_tx_hash + reconcileByTxHash({ txHash }).catch((err) => { + baseLogger.error({ err, event_id, id: obj.id }, "Real-time reconciliation failed") alertIbexReconciliationFailed({ - txHash: receipt.destination_tx_hash, + txHash, detail: err instanceof Error ? err.message : String(err), }) }) @@ -97,16 +178,16 @@ export const depositHandler = async (req: Request, res: Response) => { }) if (auditResult instanceof Error) { baseLogger.error( - { error: auditResult, event_id, id }, + { error: auditResult, event_id, id: obj.id }, "Failed to persist Bridge deposit ERPNext audit row", ) alertBridge({ - dedupKey: generateDedupKey.erpnextDepositAudit(id), + dedupKey: generateDedupKey.erpnextDepositAudit(obj.id), source: "erpnext-audit", severity: "critical", title: "Bridge deposit ERPNext audit write failed", detail: auditResult.message, - context: { event_id, transfer_id: id }, + context: { event_id, transfer_id: obj.id }, }) return res.status(500).json({ error: "Failed to persist ERPNext audit row" }) } @@ -118,21 +199,29 @@ export const depositHandler = async (req: Request, res: Response) => { auditLockKey as IdempotencyKey, ) if (auditLockResult instanceof Error) { - baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") + baseLogger.info({ event_id, id: obj.id, state }, "Duplicate Bridge deposit webhook") return res.status(200).json({ status: "already_processed" }) } return res.status(200).json({ status: "success" }) } catch (error) { - baseLogger.error({ error, id, event_id }, "Error processing Bridge deposit webhook") + baseLogger.error( + { error, id: obj.id, event_id }, + "Error processing Bridge deposit webhook", + ) alertBridge({ dedupKey: generateDedupKey.bridgeWebhookDeposit(event_id), source: "bridge-webhook", severity: "critical", title: "Bridge deposit webhook processing error", detail: error instanceof Error ? error.message : String(error), - context: { event_id, transfer_id: id }, + context: { event_id, transfer_id: obj.id }, }) return res.status(500).json({ error: "Internal server error" }) } } + +const asOptionalString = (value: unknown): string | undefined => { + if (value === undefined || value === null) return undefined + return String(value) +} diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts index 965b38c1f..290951527 100644 --- a/src/services/bridge/webhook-server/routes/kyc.ts +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -1,12 +1,18 @@ /** * Bridge KYC Webhook Handler - * Handles all kyc.* events from Bridge.xyz + * Handles customer status and kyc.* events from Bridge.xyz * - * Bridge kyc_status → internal bridgeKycStatus mapping: - * not_started → "open" + * Bridge sends events to this endpoint for: + * customer.created → uses event_object.id / event_object.status + * customer.updated.* → uses event_object.id / event_object.status + * external_account.created → uses event_object.customer_id + * (future kyc.* events handled similarly) + * + * Bridge status → internal bridgeKycStatus mapping: + * not_started → "not_started" + * active (approved) → "approved" * incomplete | awaiting_questionnaire | awaiting_ubo * | under_review | paused → "pending" - * approved → "approved" * rejected → "rejected" * offboarded → "offboarded" */ @@ -19,19 +25,26 @@ import { toBridgeCustomerId } from "@domain/primitives/bridge" import BridgeService from "@services/bridge" export const kycHandler = async (req: Request, res: Response) => { - const { event_id, event_object } = req.body - const { customer_id, kyc_status, rejection_reasons } = event_object + const { event_id, event_object, event_type } = req.body + + // Bridge uses different field names depending on event type: + // - customer.* events: event_object.id, event_object.status + // - external_account.* events: event_object.customer_id + // - (future kyc.* events: event_object.customer_id, event_object.kyc_status) + const customerId = event_object.customer_id || event_object.id + const status = event_object.kyc_status || event_object.status + const rejectionReasons = event_object.rejection_reasons || [] - if (!customer_id || !event_id) { + if (!customerId || !event_id) { return res.status(400).json({ error: "Invalid payload" }) } try { - const bridgeCustomerId = toBridgeCustomerId(customer_id) + const bridgeCustomerId = toBridgeCustomerId(customerId) const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId) if (account instanceof Error) { baseLogger.warn( - { customer_id }, + { customerId, event_type, 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" }) @@ -41,7 +54,7 @@ export const kycHandler = async (req: Request, res: Response) => { 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") + baseLogger.info({ customerId, event_id }, "Duplicate Bridge KYC webhook") return res.status(200).json({ status: "already_processed" }) } @@ -53,8 +66,9 @@ export const kycHandler = async (req: Request, res: Response) => { "paused", ]) - // Update KYC status based on event - if (kyc_status === "not_started") { + // 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 result = await AccountsRepository().updateBridgeFields(account.id, { bridgeKycStatus: "not_started", }) @@ -67,10 +81,10 @@ export const kycHandler = async (req: Request, res: Response) => { 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)) { + baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC not started") + } else if (PENDING_BRIDGE_STATUSES.has(status)) { const result = await AccountsRepository().updateBridgeFields(account.id, { - bridgeKycStatus: kyc_status, + bridgeKycStatus: status as Account["bridgeKycStatus"], }) if (result instanceof Error) { @@ -82,10 +96,10 @@ export const kycHandler = async (req: Request, res: Response) => { } baseLogger.info( - { accountId: account.id, customer_id, kyc_status }, + { accountId: account.id, customerId, status }, "Bridge KYC moved to pending", ) - } else if (kyc_status === "approved") { + } else if (status === "active" || status === "approved") { const result = await AccountsRepository().updateBridgeFields(account.id, { bridgeKycStatus: "approved", }) @@ -98,7 +112,7 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } - baseLogger.info({ accountId: account.id, customer_id }, "Bridge KYC approved") + baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC approved") const vaResult = await BridgeService.createVirtualAccount(account.id) if (vaResult instanceof Error) { @@ -112,7 +126,7 @@ export const kycHandler = async (req: Request, res: Response) => { "Virtual account auto-created after KYC approval", ) } - } else if (kyc_status === "rejected") { + } else if (status === "rejected") { const result = await AccountsRepository().updateBridgeFields(account.id, { bridgeKycStatus: "rejected", }) @@ -128,12 +142,12 @@ export const kycHandler = async (req: Request, res: Response) => { baseLogger.warn( { accountId: account.id, - customer_id, - rejection_reasons, + customerId, + rejectionReasons, }, "Bridge KYC rejected", ) - } else if (kyc_status === "offboarded") { + } else if (status === "offboarded") { const result = await AccountsRepository().updateBridgeFields(account.id, { bridgeKycStatus: "offboarded", }) @@ -146,15 +160,17 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } - baseLogger.warn( - { accountId: account.id, customer_id }, - "Bridge KYC offboarded", + baseLogger.warn({ accountId: account.id, customerId }, "Bridge KYC offboarded") + } else { + baseLogger.info( + { accountId: account.id, customerId, status, event_type }, + "Unhandled Bridge customer status — no action taken", ) } return res.status(200).json({ status: "success" }) } catch (error) { - baseLogger.error({ error, customer_id }, "Error processing Bridge KYC webhook") + baseLogger.error({ error, customerId }, "Error processing Bridge KYC webhook") return res.status(500).json({ error: "Internal server error" }) } } diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index fd6410e87..d5ea2402b 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -47,17 +47,34 @@ const markProcessed = async ( } export const transferHandler = async (req: Request, res: Response) => { - const { event, event_id, data } = req.body - const { transfer_id, state, amount, currency, reason, return_reason } = data + // Bridge webhook payload: { event_id, event_category, event_type, event_object: { id, state, ... } } + const { event_type, event_id, event_object } = req.body + const obj = (event_object ?? {}) as Record + + // Normalise from Bridge's webhook envelope + const event = event_type ?? req.body.event + const transfer_id = (obj.id ?? obj.transfer_id) as string | undefined + const state = (obj.state ?? obj.status) as string | undefined + const amount = obj.amount as string | undefined + const currency = obj.currency as string | undefined + // Bridge transfer events nest failure reasons in source.details or destination + const source = obj.source as Record | undefined + const destination = obj.destination as Record | undefined + const reason = source?.failure_reason as string | undefined + const return_reason = destination?.return_reason as string | undefined if (!transfer_id || !event) { + baseLogger.warn( + { event_id, event_category: req.body.event_category, event_type }, + "Bridge transfer webhook rejected: missing transfer_id or event_type", + ) return res.status(400).json({ error: "Invalid payload" }) } try { - const bridgeTransferId = toBridgeTransferId(transfer_id) + const bridgeTransferId = toBridgeTransferId(transfer_id!) - if (TRANSIENT_STATES.has(state)) { + if (state && TRANSIENT_STATES.has(state)) { baseLogger.info( { transfer_id, state, event }, "Bridge transfer in transient state — awaiting terminal event", @@ -70,10 +87,13 @@ export const transferHandler = async (req: Request, res: Response) => { event === "transfer.payment_processed" || state === "payment_processed" - const isFailure = TERMINAL_FAILURE_STATES.has(state) + const isFailure = state != null && TERMINAL_FAILURE_STATES.has(state) if (!isCompletion && !isFailure) { - baseLogger.info({ transfer_id, state, event }, "Bridge transfer event not handled") + baseLogger.info( + { transfer_id, state: state ?? "unknown", event }, + "Bridge transfer event not handled", + ) return res.status(200).json({ status: "ignored" }) } diff --git a/src/services/frappe/BridgeTransferRequestWriter.ts b/src/services/frappe/BridgeTransferRequestWriter.ts index a61a6af2f..3185974a9 100644 --- a/src/services/frappe/BridgeTransferRequestWriter.ts +++ b/src/services/frappe/BridgeTransferRequestWriter.ts @@ -1,4 +1,5 @@ import ErpNext from "@services/frappe/ErpNext" +import { baseLogger } from "@services/logger" import { BridgeTransferRequestUpsertError } from "@services/frappe/errors" import { @@ -13,6 +14,19 @@ type BridgeDepositEventObject = { amount: string currency: string on_behalf_of: string + deposit_id?: string + virtual_account_id?: string + product_type?: string + // Virtual-account/bridge-wallet activity fields + type?: string + customer_id?: string + payment_route?: { + customer_id?: string + type?: string + deposit_id?: string + transfer_id?: string + } + destination_payment_rail?: string receipt?: { developer_fee?: unknown initial_amount?: unknown @@ -48,13 +62,38 @@ export const writeBridgeDepositRequest = async ({ }): Promise => { const receipt = eventObject.receipt + // Normalise: virtual_account / bridge_wallet events use different field names + const customerId = + eventObject.on_behalf_of ?? + eventObject.customer_id ?? + eventObject.payment_route?.customer_id + const state = eventObject.state ?? eventObject.type ?? "unknown" + const currency = eventObject.currency ?? "usd" + const isVirtualAccountActivity = + !!eventObject.type || + !!eventObject.virtual_account_id || + eventObject.product_type === "virtual_account" + const stableRequestId = + eventObject.deposit_id ?? + eventObject.payment_route?.deposit_id ?? + eventObject.payment_route?.transfer_id ?? + (isVirtualAccountActivity ? undefined : eventObject.id) + + if (!stableRequestId) { + baseLogger.warn( + { eventId, bridgeEventObjectId: eventObject.id, state }, + "Skipping Bridge deposit ERPNext audit row without stable request id", + ) + return true + } + return upsert( new BridgeTransferRequest({ - requestId: eventObject.id, + requestId: stableRequestId, transactionType: BridgeTransferRequestTransactionType.Topup, status: BridgeTransferRequestStatus.FiatReceived, amount: String(eventObject.amount), - currency: String(eventObject.currency), + currency: String(currency), developerFee: asOptionalString(receipt?.developer_fee) ?? asOptionalString(eventObject.developer_fee) ?? @@ -62,11 +101,11 @@ export const writeBridgeDepositRequest = async ({ initialAmount: asOptionalString(receipt?.initial_amount), subtotalAmount: asOptionalString(receipt?.subtotal_amount), finalAmount: asOptionalString(receipt?.final_amount), - bridgeCustomerId: eventObject.on_behalf_of, - bridgeTransferId: eventObject.id, + bridgeCustomerId: customerId ?? "unknown", + bridgeTransferId: stableRequestId, ibexTxHash: receipt?.destination_tx_hash, sourceEventId: eventId, - sourceEventType: `deposit.${eventObject.state ?? "unknown"}`, + sourceEventType: `deposit.${state}`, sourceSystemsSeen: ["bridge_deposit"], rawPayload, }), diff --git a/test/flash/unit/services/bridge/webhook-server/deposit.spec.ts b/test/flash/unit/services/bridge/webhook-server/deposit.spec.ts index 54094f81d..8f3ed3afd 100644 --- a/test/flash/unit/services/bridge/webhook-server/deposit.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/deposit.spec.ts @@ -109,6 +109,32 @@ describe("depositHandler — invalid payload", () => { expect(res.status as jest.Mock).toHaveBeenCalledWith(400) expect(DepositLog.createBridgeDeposit).not.toHaveBeenCalled() }) + + it("acknowledges Bridge wallet activity without amount or customer identifiers", async () => { + const res = makeRes() + await depositHandler( + makeReq({ + ...VALID_BODY, + event_category: "bridge_wallet.activity", + event_object: { + id: "activity_wallet_balance", + type: "balance_changed", + bridge_wallet_id: "wallet_123", + available_balance: "100.00", + currency: "usdb", + }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect(res.json as jest.Mock).toHaveBeenCalledWith({ + status: "skipped", + reason: "missing_crediting_fields", + }) + expect(DepositLog.createBridgeDeposit).not.toHaveBeenCalled() + expect(writeBridgeDepositRequest).not.toHaveBeenCalled() + }) }) // ── Idempotency ─────────────────────────────────────────────────────────────── @@ -185,6 +211,26 @@ describe("depositHandler — fee persistence (AC3)", () => { ) }) + it("does not use destination payment rail as a currency fallback", async () => { + ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ + id: "log-currency-001", + }) + + const eventObject = { + ...VALID_EVENT_OBJECT, + currency: undefined, + destination_payment_rail: "ach", + } + + await depositHandler(makeReq({ ...VALID_BODY, event_object: eventObject }), makeRes()) + + expect(DepositLog.createBridgeDeposit).toHaveBeenCalledWith( + expect.objectContaining({ + currency: "usd", + }), + ) + }) + it("returns 200 success after persisting the deposit log", async () => { ;(DepositLog.createBridgeDeposit as jest.Mock).mockResolvedValue({ id: "log-fee-003", diff --git a/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts index 602e0d467..0c6aff7c2 100644 --- a/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts @@ -61,8 +61,12 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.failed", - data: { transfer_id: "tr-early", state: "canceled", reason: "rejected" }, + event_type: "transfer.failed", + event_object: { + id: "tr-early", + state: "canceled", + source: { failure_reason: "rejected" }, + }, }), res, ) @@ -75,10 +79,10 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.completed", + event_type: "transfer.completed", event_id: "wh-transfer-1", - data: { - transfer_id: "tr-abc", + event_object: { + id: "tr-abc", state: "payment_processed", amount: "25.00", currency: "usdt", @@ -110,8 +114,8 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.failed", - data: { transfer_id: "tr-abc", state: "canceled" }, + event_type: "transfer.failed", + event_object: { id: "tr-abc", state: "canceled" }, }), res, ) @@ -124,8 +128,8 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.failed", - data: { transfer_id: "tr-abc", state: "refund_in_flight" }, + event_type: "transfer.failed", + event_object: { id: "tr-abc", state: "refund_in_flight" }, }), res, ) @@ -144,14 +148,16 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.completed", - data: { transfer_id: "tr-abc", state: "payment_processed" }, + event_type: "transfer.completed", + event_object: { 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" }) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ + status: "already_processed", + }) }) it("sends a push notification after a successful completion", async () => { @@ -166,8 +172,8 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.completed", - data: { transfer_id: "tr-abc", state: "payment_processed" }, + event_type: "transfer.completed", + event_object: { id: "tr-abc", state: "payment_processed" }, }), res, ) @@ -186,8 +192,8 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.completed", - data: { transfer_id: "tr-abc", state: "payment_processed" }, + event_type: "transfer.completed", + event_object: { id: "tr-abc", state: "payment_processed" }, }), res, ) @@ -203,9 +209,9 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.completed", - data: { - transfer_id: "tr-abc", + event_type: "transfer.completed", + event_object: { + id: "tr-abc", state: "payment_processed", amount: "25.00", currency: "usdt", @@ -235,14 +241,14 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.failed", + event_type: "transfer.failed", event_id: "wh-transfer-2", - data: { - transfer_id: "tr-abc", + event_object: { + id: "tr-abc", state: "returned", - reason: "ACH return", amount: "25.00", currency: "usdt", + source: { failure_reason: "ACH return" }, }, }), res, @@ -271,14 +277,20 @@ describe("transferHandler", () => { const res = makeRes() await transferHandler( makeReq({ - event: "transfer.failed", - data: { transfer_id: "tr-abc", state: "returned", reason: "ACH return" }, + event_type: "transfer.failed", + event_object: { + id: "tr-abc", + state: "returned", + source: { failure_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((res.json as jest.Mock).mock.calls[0][0]).toEqual({ + status: "already_terminal", + }) expect(lockFn).not.toHaveBeenCalled() }) }) diff --git a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts index 173ecc03c..736631830 100644 --- a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts +++ b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts @@ -2,7 +2,12 @@ jest.mock("@services/frappe/ErpNext", () => ({ upsertBridgeTransferRequest: jest.fn(), })) +jest.mock("@services/logger", () => ({ + baseLogger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, +})) + import ErpNext from "@services/frappe/ErpNext" +import { baseLogger } from "@services/logger" import { writeBridgeCashoutCompleted, writeBridgeCashoutFailed, @@ -51,6 +56,81 @@ describe("BridgeTransferRequestWriter", () => { ) }) + it("skips virtual account activity until Bridge provides a stable deposit id", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_scheduled", + eventObject: { + id: "activity_123", + type: "funds_scheduled", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + customer_id: "cust_123", + virtual_account_id: "va_123", + product_type: "virtual_account", + }, + rawPayload: { event_id: "wh_scheduled" }, + }) + + expect(upsert).not.toHaveBeenCalled() + expect(baseLogger.warn).toHaveBeenCalledWith( + { + eventId: "wh_scheduled", + bridgeEventObjectId: "activity_123", + state: "funds_scheduled", + }, + "Skipping Bridge deposit ERPNext audit row without stable request id", + ) + }) + + it("does not use destination payment rail as a deposit currency fallback", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_rail", + eventObject: { + id: "tr_rail", + state: "funds_received", + amount: "10.00", + currency: undefined as unknown as string, + destination_payment_rail: "wire", + on_behalf_of: "cust_123", + }, + rawPayload: { event_id: "wh_rail" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + currency: "usd", + }), + ) + }) + + it("keys virtual account deposits by Bridge deposit id", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_received", + eventObject: { + id: "activity_456", + type: "funds_received", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + customer_id: "cust_123", + deposit_id: "deposit_123", + virtual_account_id: "va_123", + product_type: "virtual_account", + }, + rawPayload: { event_id: "wh_received" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "deposit_123", + bridgeTransferId: "deposit_123", + sourceEventId: "wh_received", + sourceEventType: "deposit.funds_received", + }), + ) + }) + it("writes IBEX crypto receives as settled topup audit requests", async () => { await writeIbexCryptoReceiveRequest({ txHash: "tx_123",