From 73eb418b061ed5aadc2845bede94d8973799c914 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 18 Jul 2026 14:31:58 -0700 Subject: [PATCH 1/3] feat(bridge): promote topup deposit audit row to Completed on crypto receive The ERPNext Bridge Transfer Request audit trail left topup deposit rows stuck at "Fiat Received" forever: the IBEX crypto receive wrote a separate ibex: Settled row, and nothing ever correlated the two. Operators could not tell a healthy completed topup from one stuck mid-conversion. Join the two sides on the IBEX destination tx hash, handling both webhook orderings: - Crypto receive after deposit: the crypto-receive handler now promotes the matching deposit row (ibex_tx_hash = txHash, excluding ibex:% rows) to Completed, stamping the credited account_id/wallet_id on it. Best-effort: a promotion failure alerts but never fails the webhook. - Deposit after crypto receive: writeBridgeDepositRequest checks for an existing ibex: settle row when the event carries receipt.destination_tx_hash, and writes Completed directly. Guard rails in upsertBridgeTransferRequest: - Topup row statuses are now monotonic (Pending < Fiat Received < Settled < Completed < Failed) so Bridge webhook retries of earlier deposit events can no longer downgrade a promoted row. Cashout rows keep last-write-wins semantics. - source_systems_seen is merged as a set union on update instead of overwritten, so promoted rows keep recording both source systems. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7 --- .../frappe/BridgeTransferRequestWriter.ts | 51 +++- src/services/frappe/ErpNext.ts | 174 ++++++++++++-- .../frappe/models/BridgeTransferRequest.ts | 2 +- .../webhook-server/routes/crypto-receive.ts | 33 ++- .../BridgeTransferRequestWriter.spec.ts | 93 ++++++++ .../unit/services/frappe/ErpNext.spec.ts | 217 ++++++++++++++++++ .../routes/crypto-receive.spec.ts | 84 ++++++- 7 files changed, 634 insertions(+), 20 deletions(-) diff --git a/src/services/frappe/BridgeTransferRequestWriter.ts b/src/services/frappe/BridgeTransferRequestWriter.ts index abe2e1dfe..90affbdcc 100644 --- a/src/services/frappe/BridgeTransferRequestWriter.ts +++ b/src/services/frappe/BridgeTransferRequestWriter.ts @@ -51,6 +51,24 @@ const upsert = async ( return ErpNext.upsertBridgeTransferRequest(request) } +// True when the IBEX crypto receive settle row (`ibex:`) already +// exists — i.e. the crypto side of this topup landed before this deposit +// event. A lookup failure degrades to false so the deposit audit write never +// fails on the enrichment; the row just stays at Fiat Received until the +// crypto-receive handler (or a Bridge retry) promotes it. +const hasSettledIbexReceive = async (txHash: string): Promise => { + if (!ErpNext?.hasBridgeTransferRequest) return false + const result = await ErpNext.hasBridgeTransferRequest(`ibex:${txHash}`) + if (result instanceof Error) { + baseLogger.warn( + { txHash, error: result }, + "Failed to check IBEX settle row for Bridge deposit; keeping Fiat Received", + ) + return false + } + return result +} + export const writeBridgeDepositRequest = async ({ eventId, eventObject, @@ -87,11 +105,18 @@ export const writeBridgeDepositRequest = async ({ return true } + const destinationTxHash = receipt?.destination_tx_hash + const cryptoSettled = destinationTxHash + ? await hasSettledIbexReceive(destinationTxHash) + : false + return upsert( new BridgeTransferRequest({ requestId: stableRequestId, transactionType: BridgeTransferRequestTransactionType.Topup, - status: BridgeTransferRequestStatus.FiatReceived, + status: cryptoSettled + ? BridgeTransferRequestStatus.Completed + : BridgeTransferRequestStatus.FiatReceived, amount: String(eventObject.amount), currency: String(currency), developerFee: @@ -106,12 +131,34 @@ export const writeBridgeDepositRequest = async ({ ibexTxHash: receipt?.destination_tx_hash, sourceEventId: eventId, sourceEventType: `deposit.${state}`, - sourceSystemsSeen: ["bridge_deposit"], + sourceSystemsSeen: cryptoSettled + ? ["bridge_deposit", "ibex_crypto_receive"] + : ["bridge_deposit"], rawPayload, }), ) } +// Called by the IBEX crypto-receive handler after it writes the settle row: +// promotes the matching deposit-side Topup row (joined on ibex_tx_hash) to +// Completed and stamps the credited account/wallet on it. +export const promoteBridgeDepositForCryptoReceive = async ({ + txHash, + accountId, + walletId, +}: { + txHash: string + accountId: AccountId + walletId: WalletId +}): Promise< + "completed" | "already_completed" | "not_found" | BridgeTransferRequestUpsertError +> => { + if (!ErpNext?.completeBridgeTopupByTxHash) { + return new BridgeTransferRequestUpsertError("ERPNext client is not configured") + } + return ErpNext.completeBridgeTopupByTxHash({ txHash, accountId, walletId }) +} + export const writeIbexCryptoReceiveRequest = async ({ txHash, address, diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index c0f9303f4..b3b6a6c79 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -26,7 +26,12 @@ import { BankAccountUpdateRequest, ErpNextBankAccountUpdateRequestData, } from "./models/BankAccountUpdateRequest" -import { BridgeTransferRequest } from "./models/BridgeTransferRequest" +import { + BridgeTransferRequest, + BridgeTransferRequestStatus, + BridgeTransferRequestTransactionType, + toFrappeDatetime, +} from "./models/BridgeTransferRequest" import { Filter } from "./SearchFilters" export type AccountUpgradeRequestFilters = { username?: Filter; status?: Filter } @@ -48,6 +53,38 @@ export const toJson = (filters: AccountUpgradeRequestFilters): string => { export type CashoutId = string & { readonly brand: unique symbol } +// Topup rows are written by two independent webhook streams (Bridge deposit +// events and the IBEX crypto receive). Statuses must only ever move forward — +// a Bridge retry of an early deposit event must not stomp a row that has +// already been promoted past it. +const BRIDGE_TRANSFER_STATUS_RANK: Record = { + [BridgeTransferRequestStatus.Pending]: 0, + [BridgeTransferRequestStatus.FiatReceived]: 1, + [BridgeTransferRequestStatus.Settled]: 2, + [BridgeTransferRequestStatus.Completed]: 3, + [BridgeTransferRequestStatus.Failed]: 4, +} + +const mergeSourceSystemsSeen = ( + existing?: string, + incoming?: string, +): string | undefined => { + const merged = [ + ...new Set( + [...(existing?.split(",") ?? []), ...(incoming?.split(",") ?? [])] + .map((system) => system.trim()) + .filter(Boolean), + ), + ] + return merged.length ? merged.join(",") : undefined +} + +type ExistingBridgeTransferRequestDoc = { + name: string + status?: string + source_systems_seen?: string +} + export class ErpNext { url: string headers: Record @@ -464,13 +501,13 @@ export class ErpNext { const requestId = payload.request_id try { - const existingName = await this.findBridgeTransferRequestName(requestId) - if (existingName instanceof BridgeTransferRequestUpsertError) return existingName + const existing = await this.findBridgeTransferRequestDoc(requestId) + if (existing instanceof BridgeTransferRequestUpsertError) return existing - if (existingName) { + if (existing) { await axios.put( - `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}/${encodeURIComponent(existingName)}`, - payload, + `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}/${encodeURIComponent(existing.name)}`, + this.applyUpdateGuards(payload, existing), { headers: this.headers }, ) return true @@ -486,13 +523,13 @@ export class ErpNext { } catch (err) { if (!this.isDuplicateRequestError(err)) throw err - const racedName = await this.findBridgeTransferRequestName(requestId) - if (racedName instanceof BridgeTransferRequestUpsertError) return racedName - if (!racedName) throw err + const raced = await this.findBridgeTransferRequestDoc(requestId) + if (raced instanceof BridgeTransferRequestUpsertError) return raced + if (!raced) throw err await axios.put( - `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}/${encodeURIComponent(racedName)}`, - payload, + `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}/${encodeURIComponent(raced.name)}`, + this.applyUpdateGuards(payload, raced), { headers: this.headers }, ) return true @@ -511,14 +548,120 @@ export class ErpNext { } } - private async findBridgeTransferRequestName( + async hasBridgeTransferRequest( + requestId: string, + ): Promise { + const existing = await this.findBridgeTransferRequestDoc(requestId) + if (existing instanceof BridgeTransferRequestUpsertError) return existing + return Boolean(existing) + } + + // Promote the deposit-side Topup audit row (keyed by Bridge deposit id, not + // the `ibex:` settle row) to Completed once the IBEX crypto receive + // has been observed for its destination tx hash. "not_found" is normal when + // the crypto receive lands before Bridge's payment_processed webhook has + // stamped the deposit row with the tx hash — the deposit writer covers that + // ordering by checking for the settle row itself. + async completeBridgeTopupByTxHash({ + txHash, + accountId, + walletId, + }: { + txHash: string + accountId?: string + walletId?: string + }): Promise< + "completed" | "already_completed" | "not_found" | BridgeTransferRequestUpsertError + > { + try { + const filters = JSON.stringify([ + [BridgeTransferRequest.doctype, "ibex_tx_hash", "=", txHash], + [ + BridgeTransferRequest.doctype, + "transaction_type", + "=", + BridgeTransferRequestTransactionType.Topup, + ], + [BridgeTransferRequest.doctype, "request_id", "not like", "ibex:%"], + ]) + const fields = JSON.stringify(["name", "status", "source_systems_seen"]) + const resp = await axios.get( + `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}`, + { + params: { filters, fields, limit_page_length: 1 }, + headers: this.headers, + }, + ) + + const doc: ExistingBridgeTransferRequestDoc | undefined = resp.data?.data?.[0] + if (!doc?.name) return "not_found" + if (doc.status === BridgeTransferRequestStatus.Completed) { + return "already_completed" + } + + await axios.put( + `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}/${encodeURIComponent(doc.name)}`, + { + status: BridgeTransferRequestStatus.Completed, + account_id: accountId, + wallet_id: walletId, + source_systems_seen: mergeSourceSystemsSeen( + doc.source_systems_seen, + "ibex_crypto_receive", + ), + last_seen_at: toFrappeDatetime(), + }, + { headers: this.headers }, + ) + return "completed" + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData, txHash }, + "Error promoting Bridge Transfer Request to Completed in ERPNext", + ) + recordExceptionInCurrentSpan({ + error: err, + attributes: { "erpnext.exception": responseData?.exception }, + }) + return new BridgeTransferRequestUpsertError(err) + } + } + + private applyUpdateGuards( + payload: ReturnType, + existing: ExistingBridgeTransferRequestDoc, + ): ReturnType { + const guarded = { + ...payload, + source_systems_seen: mergeSourceSystemsSeen( + existing.source_systems_seen, + payload.source_systems_seen, + ), + } + + if ( + payload.transaction_type === BridgeTransferRequestTransactionType.Topup && + existing.status && + (BRIDGE_TRANSFER_STATUS_RANK[existing.status] ?? -1) > + (BRIDGE_TRANSFER_STATUS_RANK[payload.status] ?? -1) + ) { + guarded.status = existing.status as BridgeTransferRequestStatus + } + + return guarded + } + + private async findBridgeTransferRequestDoc( requestId: string, - ): Promise { + ): Promise< + ExistingBridgeTransferRequestDoc | undefined | BridgeTransferRequestUpsertError + > { try { const filters = JSON.stringify([ [BridgeTransferRequest.doctype, "request_id", "=", requestId], ]) - const fields = JSON.stringify(["name"]) + const fields = JSON.stringify(["name", "status", "source_systems_seen"]) const resp = await axios.get( `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}`, { @@ -527,7 +670,8 @@ export class ErpNext { }, ) - return resp.data?.data?.[0]?.name + const doc = resp.data?.data?.[0] + return doc?.name ? doc : undefined } catch (err) { const responseData = isAxiosError(err) ? err.response?.data : undefined baseLogger.error( diff --git a/src/services/frappe/models/BridgeTransferRequest.ts b/src/services/frappe/models/BridgeTransferRequest.ts index 1f33861d2..32dcc675f 100644 --- a/src/services/frappe/models/BridgeTransferRequest.ts +++ b/src/services/frappe/models/BridgeTransferRequest.ts @@ -39,7 +39,7 @@ export type BridgeTransferRequestInput = { failureReason?: string } -const toFrappeDatetime = (value?: string): string => { +export const toFrappeDatetime = (value?: string): string => { const date = value ? new Date(value) : new Date() if (Number.isNaN(date.getTime())) return value ?? "" diff --git a/src/services/ibex/webhook-server/routes/crypto-receive.ts b/src/services/ibex/webhook-server/routes/crypto-receive.ts index 2efdd7b24..1755543ef 100644 --- a/src/services/ibex/webhook-server/routes/crypto-receive.ts +++ b/src/services/ibex/webhook-server/routes/crypto-receive.ts @@ -12,7 +12,10 @@ import { alertIbexCryptoReceiveFailure, alertIbexReconciliationFailed, } from "@services/alerts/ibex-bridge-movement" -import { writeIbexCryptoReceiveRequest } from "@services/frappe/BridgeTransferRequestWriter" +import { + promoteBridgeDepositForCryptoReceive, + writeIbexCryptoReceiveRequest, +} from "@services/frappe/BridgeTransferRequestWriter" import { ibexWebhookPaths } from "@services/ibex/webhook-config" import { authenticate, logRequest, validateIbexIp } from "../middleware" @@ -192,6 +195,34 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { return { status: "error", code: "erpnext_audit_failed" } as CryptoReceiveResult } + // Best-effort: the funds are already credited and the settle row is + // written, so a promotion failure must not fail the webhook. The + // deposit writer's reverse check (and Bridge webhook retries) will + // converge the row on a later event. + const promoteResult = await promoteBridgeDepositForCryptoReceive({ + txHash: String(tx_hash), + accountId: account.id, + walletId: usdtWallet.id, + }) + if (promoteResult instanceof Error) { + baseLogger.error( + { error: promoteResult, tx_hash, accountId: account.id }, + "Failed to promote Bridge deposit audit row to Completed", + ) + alertIbexCryptoReceiveFailure({ + txHash: String(tx_hash), + code: "erpnext_promote_failed", + title: "Bridge deposit audit row promotion failed", + detail: promoteResult.message, + context: { accountId: account.id, walletId: usdtWallet.id, address }, + }) + } else { + baseLogger.info( + { tx_hash, promoteResult }, + "Bridge deposit audit row promotion result", + ) + } + await sendBridgeDepositNotificationBestEffort({ accountId: account.id, amount: String(usdtAmount.asNumber()), diff --git a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts index 71fd7d1de..4ab013dbc 100644 --- a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts +++ b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts @@ -1,5 +1,7 @@ jest.mock("@services/frappe/ErpNext", () => ({ upsertBridgeTransferRequest: jest.fn(), + hasBridgeTransferRequest: jest.fn(), + completeBridgeTopupByTxHash: jest.fn(), })) jest.mock("@services/logger", () => ({ @@ -9,6 +11,7 @@ jest.mock("@services/logger", () => ({ import ErpNext from "@services/frappe/ErpNext" import { baseLogger } from "@services/logger" import { + promoteBridgeDepositForCryptoReceive, writeBridgeCashoutCompleted, writeBridgeCashoutFailed, writeBridgeCashoutPending, @@ -18,12 +21,15 @@ import { import { BridgeTransferRequestStatus } from "@services/frappe/models/BridgeTransferRequest" const upsert = ErpNext.upsertBridgeTransferRequest as jest.Mock +const hasRow = ErpNext.hasBridgeTransferRequest as jest.Mock +const completeByTxHash = ErpNext.completeBridgeTopupByTxHash as jest.Mock const lastRequestInput = () => upsert.mock.calls[0][0].input describe("BridgeTransferRequestWriter", () => { beforeEach(() => { jest.clearAllMocks() upsert.mockResolvedValue(true) + hasRow.mockResolvedValue(false) }) it("writes Bridge deposit events as topup audit requests", async () => { @@ -55,6 +61,93 @@ describe("BridgeTransferRequestWriter", () => { ibexTxHash: "tx_123", }), ) + expect(hasRow).toHaveBeenCalledWith("ibex:tx_123") + }) + + it("writes the deposit as Completed when the IBEX settle row already exists", async () => { + hasRow.mockResolvedValue(true) + + await writeBridgeDepositRequest({ + eventId: "wh_123", + eventObject: { + id: "tr_123", + state: "payment_processed", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + receipt: { destination_tx_hash: "tx_123" }, + }, + rawPayload: { event_id: "wh_123" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "tr_123", + status: BridgeTransferRequestStatus.Completed, + sourceSystemsSeen: ["bridge_deposit", "ibex_crypto_receive"], + }), + ) + }) + + it("does not check for a settle row when the deposit has no destination tx hash", async () => { + await writeBridgeDepositRequest({ + eventId: "wh_123", + eventObject: { + id: "tr_123", + state: "funds_received", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + }, + rawPayload: { event_id: "wh_123" }, + }) + + expect(hasRow).not.toHaveBeenCalled() + expect(lastRequestInput()).toEqual( + expect.objectContaining({ status: BridgeTransferRequestStatus.FiatReceived }), + ) + }) + + it("falls back to Fiat Received when the settle-row check fails", async () => { + hasRow.mockResolvedValue(new Error("erpnext down")) + + await writeBridgeDepositRequest({ + eventId: "wh_123", + eventObject: { + id: "tr_123", + state: "payment_processed", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + receipt: { destination_tx_hash: "tx_123" }, + }, + rawPayload: { event_id: "wh_123" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ status: BridgeTransferRequestStatus.FiatReceived }), + ) + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ txHash: "tx_123" }), + "Failed to check IBEX settle row for Bridge deposit; keeping Fiat Received", + ) + }) + + it("promotes the deposit row via ErpNext on crypto receive", async () => { + completeByTxHash.mockResolvedValue("completed") + + const result = await promoteBridgeDepositForCryptoReceive({ + txHash: "tx_123", + accountId: "acct_123" as AccountId, + walletId: "wallet_123" as WalletId, + }) + + expect(result).toBe("completed") + expect(completeByTxHash).toHaveBeenCalledWith({ + txHash: "tx_123", + accountId: "acct_123", + walletId: "wallet_123", + }) }) it("skips virtual account activity until Bridge provides a stable deposit id", async () => { diff --git a/test/flash/unit/services/frappe/ErpNext.spec.ts b/test/flash/unit/services/frappe/ErpNext.spec.ts index d473bae9b..1ab51d36b 100644 --- a/test/flash/unit/services/frappe/ErpNext.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.spec.ts @@ -78,4 +78,221 @@ describe("ErpNext.upsertBridgeTransferRequest", () => { expect.any(Object), ) }) + + it("never downgrades a promoted Topup row's status", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BTR-1", + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }, + ], + }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + const result = await client.upsertBridgeTransferRequest(request) + + expect(result).toBe(true) + expect(mockedAxios.put).toHaveBeenCalledWith( + "https://erp.example/api/resource/Bridge%20Transfer%20Request/BTR-1", + expect.objectContaining({ + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }), + expect.any(Object), + ) + }) + + it("allows a Topup row's status to move forward", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BTR-1", + status: BridgeTransferRequestStatus.FiatReceived, + source_systems_seen: "bridge_deposit", + }, + ], + }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + const completedRequest = new BridgeTransferRequest({ + requestId: "tr_123", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.Completed, + amount: "10.00", + currency: "usd", + sourceSystemsSeen: ["bridge_deposit", "ibex_crypto_receive"], + }) + const result = await client.upsertBridgeTransferRequest(completedRequest) + + expect(result).toBe(true) + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }), + expect.any(Object), + ) + }) + + it("merges source_systems_seen instead of overwriting on update", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BTR-1", + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }, + ], + }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + await client.upsertBridgeTransferRequest(request) + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }), + expect.any(Object), + ) + }) + + it("keeps last-write-wins semantics for Cashout rows", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [{ name: "BTR-2", status: BridgeTransferRequestStatus.Completed }], + }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-2" } } }) + + const failedCashout = new BridgeTransferRequest({ + requestId: "tr_cashout", + transactionType: BridgeTransferRequestTransactionType.Cashout, + status: BridgeTransferRequestStatus.Failed, + amount: "5.00", + currency: "usdt", + }) + await client.upsertBridgeTransferRequest(failedCashout) + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: BridgeTransferRequestStatus.Failed }), + expect.any(Object), + ) + }) +}) + +describe("ErpNext.hasBridgeTransferRequest", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns true when a row with the request_id exists", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [{ name: "BTR-1" }] } }) + + await expect(client.hasBridgeTransferRequest("ibex:tx_123")).resolves.toBe(true) + }) + + it("returns false when no row exists", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + await expect(client.hasBridgeTransferRequest("ibex:tx_123")).resolves.toBe(false) + }) + + it("returns an error when the lookup fails", async () => { + mockedAxios.get.mockRejectedValue(new Error("network down")) + + const result = await client.hasBridgeTransferRequest("ibex:tx_123") + expect(result).toBeInstanceOf(Error) + }) +}) + +describe("ErpNext.completeBridgeTopupByTxHash", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("promotes the matching deposit row to Completed with account attribution", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BTR-1", + status: BridgeTransferRequestStatus.FiatReceived, + source_systems_seen: "bridge_deposit", + }, + ], + }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + const result = await client.completeBridgeTopupByTxHash({ + txHash: "tx_123", + accountId: "acct_123", + walletId: "wallet_123", + }) + + expect(result).toBe("completed") + const getParams = mockedAxios.get.mock.calls[0][1].params + expect(JSON.parse(getParams.filters)).toEqual([ + ["Bridge Transfer Request", "ibex_tx_hash", "=", "tx_123"], + ["Bridge Transfer Request", "transaction_type", "=", "Topup"], + ["Bridge Transfer Request", "request_id", "not like", "ibex:%"], + ]) + expect(mockedAxios.put).toHaveBeenCalledWith( + "https://erp.example/api/resource/Bridge%20Transfer%20Request/BTR-1", + expect.objectContaining({ + status: BridgeTransferRequestStatus.Completed, + account_id: "acct_123", + wallet_id: "wallet_123", + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + last_seen_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/), + }), + expect.any(Object), + ) + }) + + it("returns not_found when no deposit row carries the tx hash", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + const result = await client.completeBridgeTopupByTxHash({ txHash: "tx_123" }) + + expect(result).toBe("not_found") + expect(mockedAxios.put).not.toHaveBeenCalled() + }) + + it("is idempotent when the deposit row is already Completed", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [{ name: "BTR-1", status: BridgeTransferRequestStatus.Completed }], + }, + }) + + const result = await client.completeBridgeTopupByTxHash({ txHash: "tx_123" }) + + expect(result).toBe("already_completed") + expect(mockedAxios.put).not.toHaveBeenCalled() + }) + + it("returns an error when the promotion write fails", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [{ name: "BTR-1", status: BridgeTransferRequestStatus.FiatReceived }], + }, + }) + mockedAxios.put.mockRejectedValue(new Error("erpnext down")) + + const result = await client.completeBridgeTopupByTxHash({ txHash: "tx_123" }) + + expect(result).toBeInstanceOf(Error) + }) }) diff --git a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts index bc781bcd3..7e04a173a 100644 --- a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts +++ b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts @@ -34,6 +34,7 @@ jest.mock("@app/bridge/send-deposit-notification", () => ({ jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ writeIbexCryptoReceiveRequest: jest.fn(), + promoteBridgeDepositForCryptoReceive: jest.fn(), })) jest.mock("@services/alerts/ibex-bridge-movement", () => ({ @@ -56,7 +57,10 @@ import { createIbexCryptoReceive } from "@services/mongoose/ibex-crypto-receive- import { listWalletsByAccountId } from "@app/wallets" import { LockService } from "@services/lock" import { WalletCurrency } from "@domain/shared" -import { writeIbexCryptoReceiveRequest } from "@services/frappe/BridgeTransferRequestWriter" +import { + promoteBridgeDepositForCryptoReceive, + writeIbexCryptoReceiveRequest, +} from "@services/frappe/BridgeTransferRequestWriter" import { alertIbexCryptoReceiveFailure } from "@services/alerts/ibex-bridge-movement" const ACCOUNT_ID = "account-001" as AccountId @@ -86,6 +90,7 @@ describe("cryptoReceiveHandler", () => { { id: WALLET_ID, currency: WalletCurrency.Usdt }, ]) ;(writeIbexCryptoReceiveRequest as jest.Mock).mockResolvedValue(true) + ;(promoteBridgeDepositForCryptoReceive as jest.Mock).mockResolvedValue("completed") }) it("protects the route with rate limit, IP allowlist, auth, and request logging", () => { @@ -175,6 +180,83 @@ describe("cryptoReceiveHandler", () => { expect(res.status).toHaveBeenCalledWith(200) }) + it("promotes the matching Bridge deposit row after writing the settle row", async () => { + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + }, + } as never, + res as never, + ) + + expect(promoteBridgeDepositForCryptoReceive).toHaveBeenCalledWith({ + txHash: TX_HASH, + accountId: ACCOUNT_ID, + walletId: WALLET_ID, + }) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ status: "success" }) + }) + + it("still succeeds when no deposit row matches the tx hash yet", async () => { + ;(promoteBridgeDepositForCryptoReceive as jest.Mock).mockResolvedValue("not_found") + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + }, + } as never, + res as never, + ) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ status: "success" }) + expect(alertIbexCryptoReceiveFailure).not.toHaveBeenCalled() + }) + + it("alerts but still succeeds when the deposit-row promotion fails", async () => { + ;(promoteBridgeDepositForCryptoReceive as jest.Mock).mockResolvedValue( + new Error("erpnext down"), + ) + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + }, + } as never, + res as never, + ) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ status: "success" }) + expect(alertIbexCryptoReceiveFailure).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: TX_HASH, + code: "erpnext_promote_failed", + title: "Bridge deposit audit row promotion failed", + }), + ) + }) + it("returns 500 when the ERPNext audit write fails", async () => { ;(writeIbexCryptoReceiveRequest as jest.Mock).mockResolvedValue( new Error("erpnext timeout"), From a7a690a8d9816b47003446e53eb512fd83bcec9b Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 18 Jul 2026 15:46:55 -0700 Subject: [PATCH 2/3] fix(bridge): carry settle-row attribution into the deposit row; harden tests Review follow-ups on the topup promotion flow: - The crypto-first ordering now stamps account_id/wallet_id on the deposit row too: the settle-row lookup returns the full doc instead of a boolean (hasBridgeTransferRequest -> findBridgeTransferRequest), and the deposit writer lifts the attribution from it. Both orderings now produce the same enriched row. - The lookup checks the settle row is actually at Settled instead of treating bare existence as settled. - New tests: the raced-duplicate upsert path (POST 409 -> re-find -> PUT) preserves a promoted status; attribution asymmetry; not-Settled settle row ignored; missing existing status; source_systems_seen whitespace and dedupe normalization; already_completed promotion result on the crypto-receive route. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7 --- .../frappe/BridgeTransferRequestWriter.ts | 41 ++++--- src/services/frappe/ErpNext.ts | 34 +++--- .../BridgeTransferRequestWriter.spec.ts | 52 +++++++-- .../unit/services/frappe/ErpNext.spec.ts | 107 ++++++++++++++++-- .../routes/crypto-receive.spec.ts | 24 ++++ 5 files changed, 208 insertions(+), 50 deletions(-) diff --git a/src/services/frappe/BridgeTransferRequestWriter.ts b/src/services/frappe/BridgeTransferRequestWriter.ts index 90affbdcc..432d40089 100644 --- a/src/services/frappe/BridgeTransferRequestWriter.ts +++ b/src/services/frappe/BridgeTransferRequestWriter.ts @@ -51,22 +51,27 @@ const upsert = async ( return ErpNext.upsertBridgeTransferRequest(request) } -// True when the IBEX crypto receive settle row (`ibex:`) already -// exists — i.e. the crypto side of this topup landed before this deposit -// event. A lookup failure degrades to false so the deposit audit write never -// fails on the enrichment; the row just stays at Fiat Received until the -// crypto-receive handler (or a Bridge retry) promotes it. -const hasSettledIbexReceive = async (txHash: string): Promise => { - if (!ErpNext?.hasBridgeTransferRequest) return false - const result = await ErpNext.hasBridgeTransferRequest(`ibex:${txHash}`) - if (result instanceof Error) { +// The IBEX crypto receive settle row (`ibex:`), when it already +// exists at Settled — i.e. the crypto side of this topup landed before this +// deposit event. Carries the credited account/wallet so the deposit row gets +// the same attribution the promotion path stamps. A lookup failure degrades +// to undefined so the deposit audit write never fails on the enrichment; the +// row just stays at Fiat Received until the crypto-receive handler (or a +// Bridge retry) promotes it. +const findSettledIbexReceive = async ( + txHash: string, +): Promise<{ accountId?: string; walletId?: string } | undefined> => { + if (!ErpNext?.findBridgeTransferRequest) return undefined + const doc = await ErpNext.findBridgeTransferRequest(`ibex:${txHash}`) + if (doc instanceof Error) { baseLogger.warn( - { txHash, error: result }, + { txHash, error: doc }, "Failed to check IBEX settle row for Bridge deposit; keeping Fiat Received", ) - return false + return undefined } - return result + if (!doc || doc.status !== BridgeTransferRequestStatus.Settled) return undefined + return { accountId: doc.account_id, walletId: doc.wallet_id } } export const writeBridgeDepositRequest = async ({ @@ -106,17 +111,19 @@ export const writeBridgeDepositRequest = async ({ } const destinationTxHash = receipt?.destination_tx_hash - const cryptoSettled = destinationTxHash - ? await hasSettledIbexReceive(destinationTxHash) - : false + const settledReceive = destinationTxHash + ? await findSettledIbexReceive(destinationTxHash) + : undefined return upsert( new BridgeTransferRequest({ requestId: stableRequestId, transactionType: BridgeTransferRequestTransactionType.Topup, - status: cryptoSettled + status: settledReceive ? BridgeTransferRequestStatus.Completed : BridgeTransferRequestStatus.FiatReceived, + accountId: settledReceive?.accountId, + walletId: settledReceive?.walletId, amount: String(eventObject.amount), currency: String(currency), developerFee: @@ -131,7 +138,7 @@ export const writeBridgeDepositRequest = async ({ ibexTxHash: receipt?.destination_tx_hash, sourceEventId: eventId, sourceEventType: `deposit.${state}`, - sourceSystemsSeen: cryptoSettled + sourceSystemsSeen: settledReceive ? ["bridge_deposit", "ibex_crypto_receive"] : ["bridge_deposit"], rawPayload, diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index b3b6a6c79..19a76453d 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -79,10 +79,12 @@ const mergeSourceSystemsSeen = ( return merged.length ? merged.join(",") : undefined } -type ExistingBridgeTransferRequestDoc = { +export type BridgeTransferRequestDoc = { name: string status?: string source_systems_seen?: string + account_id?: string + wallet_id?: string } export class ErpNext { @@ -501,7 +503,7 @@ export class ErpNext { const requestId = payload.request_id try { - const existing = await this.findBridgeTransferRequestDoc(requestId) + const existing = await this.findBridgeTransferRequest(requestId) if (existing instanceof BridgeTransferRequestUpsertError) return existing if (existing) { @@ -523,7 +525,7 @@ export class ErpNext { } catch (err) { if (!this.isDuplicateRequestError(err)) throw err - const raced = await this.findBridgeTransferRequestDoc(requestId) + const raced = await this.findBridgeTransferRequest(requestId) if (raced instanceof BridgeTransferRequestUpsertError) return raced if (!raced) throw err @@ -548,14 +550,6 @@ export class ErpNext { } } - async hasBridgeTransferRequest( - requestId: string, - ): Promise { - const existing = await this.findBridgeTransferRequestDoc(requestId) - if (existing instanceof BridgeTransferRequestUpsertError) return existing - return Boolean(existing) - } - // Promote the deposit-side Topup audit row (keyed by Bridge deposit id, not // the `ibex:` settle row) to Completed once the IBEX crypto receive // has been observed for its destination tx hash. "not_found" is normal when @@ -593,7 +587,7 @@ export class ErpNext { }, ) - const doc: ExistingBridgeTransferRequestDoc | undefined = resp.data?.data?.[0] + const doc: BridgeTransferRequestDoc | undefined = resp.data?.data?.[0] if (!doc?.name) return "not_found" if (doc.status === BridgeTransferRequestStatus.Completed) { return "already_completed" @@ -630,7 +624,7 @@ export class ErpNext { private applyUpdateGuards( payload: ReturnType, - existing: ExistingBridgeTransferRequestDoc, + existing: BridgeTransferRequestDoc, ): ReturnType { const guarded = { ...payload, @@ -652,16 +646,20 @@ export class ErpNext { return guarded } - private async findBridgeTransferRequestDoc( + async findBridgeTransferRequest( requestId: string, - ): Promise< - ExistingBridgeTransferRequestDoc | undefined | BridgeTransferRequestUpsertError - > { + ): Promise { try { const filters = JSON.stringify([ [BridgeTransferRequest.doctype, "request_id", "=", requestId], ]) - const fields = JSON.stringify(["name", "status", "source_systems_seen"]) + const fields = JSON.stringify([ + "name", + "status", + "source_systems_seen", + "account_id", + "wallet_id", + ]) const resp = await axios.get( `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}`, { diff --git a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts index 4ab013dbc..55fcaba2f 100644 --- a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts +++ b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts @@ -1,6 +1,6 @@ jest.mock("@services/frappe/ErpNext", () => ({ upsertBridgeTransferRequest: jest.fn(), - hasBridgeTransferRequest: jest.fn(), + findBridgeTransferRequest: jest.fn(), completeBridgeTopupByTxHash: jest.fn(), })) @@ -21,7 +21,7 @@ import { import { BridgeTransferRequestStatus } from "@services/frappe/models/BridgeTransferRequest" const upsert = ErpNext.upsertBridgeTransferRequest as jest.Mock -const hasRow = ErpNext.hasBridgeTransferRequest as jest.Mock +const findRow = ErpNext.findBridgeTransferRequest as jest.Mock const completeByTxHash = ErpNext.completeBridgeTopupByTxHash as jest.Mock const lastRequestInput = () => upsert.mock.calls[0][0].input @@ -29,7 +29,7 @@ describe("BridgeTransferRequestWriter", () => { beforeEach(() => { jest.clearAllMocks() upsert.mockResolvedValue(true) - hasRow.mockResolvedValue(false) + findRow.mockResolvedValue(undefined) }) it("writes Bridge deposit events as topup audit requests", async () => { @@ -61,11 +61,16 @@ describe("BridgeTransferRequestWriter", () => { ibexTxHash: "tx_123", }), ) - expect(hasRow).toHaveBeenCalledWith("ibex:tx_123") + expect(findRow).toHaveBeenCalledWith("ibex:tx_123") }) - it("writes the deposit as Completed when the IBEX settle row already exists", async () => { - hasRow.mockResolvedValue(true) + it("writes the deposit as Completed with account attribution when the IBEX settle row already exists", async () => { + findRow.mockResolvedValue({ + name: "BTR-9", + status: BridgeTransferRequestStatus.Settled, + account_id: "acct_123", + wallet_id: "wallet_123", + }) await writeBridgeDepositRequest({ eventId: "wh_123", @@ -80,15 +85,46 @@ describe("BridgeTransferRequestWriter", () => { rawPayload: { event_id: "wh_123" }, }) + expect(findRow).toHaveBeenCalledWith("ibex:tx_123") expect(lastRequestInput()).toEqual( expect.objectContaining({ requestId: "tr_123", status: BridgeTransferRequestStatus.Completed, + accountId: "acct_123", + walletId: "wallet_123", sourceSystemsSeen: ["bridge_deposit", "ibex_crypto_receive"], }), ) }) + it("ignores a settle-row match that is not at Settled status", async () => { + findRow.mockResolvedValue({ + name: "BTR-9", + status: BridgeTransferRequestStatus.Pending, + }) + + await writeBridgeDepositRequest({ + eventId: "wh_123", + eventObject: { + id: "tr_123", + state: "payment_processed", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + receipt: { destination_tx_hash: "tx_123" }, + }, + rawPayload: { event_id: "wh_123" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + status: BridgeTransferRequestStatus.FiatReceived, + accountId: undefined, + walletId: undefined, + }), + ) + }) + it("does not check for a settle row when the deposit has no destination tx hash", async () => { await writeBridgeDepositRequest({ eventId: "wh_123", @@ -102,14 +138,14 @@ describe("BridgeTransferRequestWriter", () => { rawPayload: { event_id: "wh_123" }, }) - expect(hasRow).not.toHaveBeenCalled() + expect(findRow).not.toHaveBeenCalled() expect(lastRequestInput()).toEqual( expect.objectContaining({ status: BridgeTransferRequestStatus.FiatReceived }), ) }) it("falls back to Fiat Received when the settle-row check fails", async () => { - hasRow.mockResolvedValue(new Error("erpnext down")) + findRow.mockResolvedValue(new Error("erpnext down")) await writeBridgeDepositRequest({ eventId: "wh_123", diff --git a/test/flash/unit/services/frappe/ErpNext.spec.ts b/test/flash/unit/services/frappe/ErpNext.spec.ts index 1ab51d36b..dd4a4e1f7 100644 --- a/test/flash/unit/services/frappe/ErpNext.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.spec.ts @@ -166,6 +166,75 @@ describe("ErpNext.upsertBridgeTransferRequest", () => { ) }) + it("preserves a promoted status when losing the create race", async () => { + mockedAxios.get.mockResolvedValueOnce({ data: { data: [] } }).mockResolvedValueOnce({ + data: { + data: [ + { + name: "BTR-1", + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }, + ], + }, + }) + mockedAxios.post.mockRejectedValue({ + isAxiosError: true, + response: { status: 409, data: { exception: "DuplicateEntryError" } }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + const result = await client.upsertBridgeTransferRequest(request) + + expect(result).toBe(true) + expect(mockedAxios.put).toHaveBeenCalledWith( + "https://erp.example/api/resource/Bridge%20Transfer%20Request/BTR-1", + expect.objectContaining({ + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }), + expect.any(Object), + ) + }) + + it("writes the incoming status when the existing row has none", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [{ name: "BTR-1" }] } }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + await client.upsertBridgeTransferRequest(request) + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: BridgeTransferRequestStatus.FiatReceived }), + expect.any(Object), + ) + }) + + it("normalizes whitespace and duplicates when merging source_systems_seen", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BTR-1", + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: " bridge_deposit , ibex_crypto_receive ,bridge_deposit", + }, + ], + }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-1" } } }) + + await client.upsertBridgeTransferRequest(request) + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + source_systems_seen: "bridge_deposit,ibex_crypto_receive", + }), + expect.any(Object), + ) + }) + it("keeps last-write-wins semantics for Cashout rows", async () => { mockedAxios.get.mockResolvedValue({ data: { @@ -191,27 +260,51 @@ describe("ErpNext.upsertBridgeTransferRequest", () => { }) }) -describe("ErpNext.hasBridgeTransferRequest", () => { +describe("ErpNext.findBridgeTransferRequest", () => { beforeEach(() => { jest.clearAllMocks() }) - it("returns true when a row with the request_id exists", async () => { - mockedAxios.get.mockResolvedValue({ data: { data: [{ name: "BTR-1" }] } }) + it("returns the doc with status and account attribution when the row exists", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BTR-1", + status: BridgeTransferRequestStatus.Settled, + account_id: "acct_123", + wallet_id: "wallet_123", + }, + ], + }, + }) + + const result = await client.findBridgeTransferRequest("ibex:tx_123") - await expect(client.hasBridgeTransferRequest("ibex:tx_123")).resolves.toBe(true) + expect(result).toEqual( + expect.objectContaining({ + name: "BTR-1", + status: BridgeTransferRequestStatus.Settled, + account_id: "acct_123", + wallet_id: "wallet_123", + }), + ) + const getParams = mockedAxios.get.mock.calls[0][1].params + expect(JSON.parse(getParams.fields)).toEqual( + expect.arrayContaining(["name", "status", "account_id", "wallet_id"]), + ) }) - it("returns false when no row exists", async () => { + it("returns undefined when no row exists", async () => { mockedAxios.get.mockResolvedValue({ data: { data: [] } }) - await expect(client.hasBridgeTransferRequest("ibex:tx_123")).resolves.toBe(false) + await expect(client.findBridgeTransferRequest("ibex:tx_123")).resolves.toBeUndefined() }) it("returns an error when the lookup fails", async () => { mockedAxios.get.mockRejectedValue(new Error("network down")) - const result = await client.hasBridgeTransferRequest("ibex:tx_123") + const result = await client.findBridgeTransferRequest("ibex:tx_123") expect(result).toBeInstanceOf(Error) }) }) diff --git a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts index 7e04a173a..cbd168606 100644 --- a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts +++ b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts @@ -227,6 +227,30 @@ describe("cryptoReceiveHandler", () => { expect(alertIbexCryptoReceiveFailure).not.toHaveBeenCalled() }) + it("treats an already-completed deposit row as success without alerting", async () => { + ;(promoteBridgeDepositForCryptoReceive as jest.Mock).mockResolvedValue( + "already_completed", + ) + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + }, + } as never, + res as never, + ) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ status: "success" }) + expect(alertIbexCryptoReceiveFailure).not.toHaveBeenCalled() + }) + it("alerts but still succeeds when the deposit-row promotion fails", async () => { ;(promoteBridgeDepositForCryptoReceive as jest.Mock).mockResolvedValue( new Error("erpnext down"), From 93257560581b3d0d2e6a77acdfc307c8e563a4f4 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 18 Jul 2026 16:17:48 -0700 Subject: [PATCH 3/3] test(bridge): pin the wire contract for deposit-row account attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hash-less deposit write (or Bridge retry) must omit account_id/wallet_id from the upsert payload entirely — an explicit null would wipe the attribution a prior promotion stamped on the row, and the status guard does not cover those fields. Assert both the writer input and the JSON-serialized toErpnext() output, which is what axios actually sends. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7 --- .../BridgeTransferRequestWriter.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts index 55fcaba2f..63880f462 100644 --- a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts +++ b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts @@ -144,6 +144,32 @@ describe("BridgeTransferRequestWriter", () => { ) }) + it("omits account attribution from the wire payload when the deposit is not settled", async () => { + // A hash-less deposit (or retry) must not carry account_id/wallet_id at + // all — an explicit null in the PUT body would wipe the attribution a + // prior promotion stamped on the row, and the status guard doesn't cover + // those fields. The wire contract is JSON: undefined-valued keys are + // dropped, null-valued keys are sent. + await writeBridgeDepositRequest({ + eventId: "wh_123", + eventObject: { + id: "tr_123", + state: "funds_received", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + }, + rawPayload: { event_id: "wh_123" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ accountId: undefined, walletId: undefined }), + ) + const wirePayload = JSON.parse(JSON.stringify(upsert.mock.calls[0][0].toErpnext())) + expect(wirePayload).not.toHaveProperty("account_id") + expect(wirePayload).not.toHaveProperty("wallet_id") + }) + it("falls back to Fiat Received when the settle-row check fails", async () => { findRow.mockResolvedValue(new Error("erpnext down"))