diff --git a/src/services/frappe/BridgeTransferRequestWriter.ts b/src/services/frappe/BridgeTransferRequestWriter.ts index abe2e1dfe..432d40089 100644 --- a/src/services/frappe/BridgeTransferRequestWriter.ts +++ b/src/services/frappe/BridgeTransferRequestWriter.ts @@ -51,6 +51,29 @@ const upsert = async ( return ErpNext.upsertBridgeTransferRequest(request) } +// 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: doc }, + "Failed to check IBEX settle row for Bridge deposit; keeping Fiat Received", + ) + return undefined + } + if (!doc || doc.status !== BridgeTransferRequestStatus.Settled) return undefined + return { accountId: doc.account_id, walletId: doc.wallet_id } +} + export const writeBridgeDepositRequest = async ({ eventId, eventObject, @@ -87,11 +110,20 @@ export const writeBridgeDepositRequest = async ({ return true } + const destinationTxHash = receipt?.destination_tx_hash + const settledReceive = destinationTxHash + ? await findSettledIbexReceive(destinationTxHash) + : undefined + return upsert( new BridgeTransferRequest({ requestId: stableRequestId, transactionType: BridgeTransferRequestTransactionType.Topup, - status: BridgeTransferRequestStatus.FiatReceived, + status: settledReceive + ? BridgeTransferRequestStatus.Completed + : BridgeTransferRequestStatus.FiatReceived, + accountId: settledReceive?.accountId, + walletId: settledReceive?.walletId, amount: String(eventObject.amount), currency: String(currency), developerFee: @@ -106,12 +138,34 @@ export const writeBridgeDepositRequest = async ({ ibexTxHash: receipt?.destination_tx_hash, sourceEventId: eventId, sourceEventType: `deposit.${state}`, - sourceSystemsSeen: ["bridge_deposit"], + sourceSystemsSeen: settledReceive + ? ["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..19a76453d 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,40 @@ 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 +} + +export type BridgeTransferRequestDoc = { + name: string + status?: string + source_systems_seen?: string + account_id?: string + wallet_id?: string +} + export class ErpNext { url: string headers: Record @@ -464,13 +503,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.findBridgeTransferRequest(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 +525,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.findBridgeTransferRequest(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 +550,116 @@ export class ErpNext { } } - private async findBridgeTransferRequestName( + // 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: BridgeTransferRequestDoc | 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: BridgeTransferRequestDoc, + ): 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 + } + + async findBridgeTransferRequest( requestId: string, - ): Promise { + ): Promise { try { const filters = JSON.stringify([ [BridgeTransferRequest.doctype, "request_id", "=", requestId], ]) - const fields = JSON.stringify(["name"]) + 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)}`, { @@ -527,7 +668,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..63880f462 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(), + findBridgeTransferRequest: 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 findRow = ErpNext.findBridgeTransferRequest 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) + findRow.mockResolvedValue(undefined) }) it("writes Bridge deposit events as topup audit requests", async () => { @@ -55,6 +61,155 @@ describe("BridgeTransferRequestWriter", () => { ibexTxHash: "tx_123", }), ) + expect(findRow).toHaveBeenCalledWith("ibex:tx_123") + }) + + 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", + 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(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", + eventObject: { + id: "tr_123", + state: "funds_received", + amount: "10.00", + currency: "usd", + on_behalf_of: "cust_123", + }, + rawPayload: { event_id: "wh_123" }, + }) + + expect(findRow).not.toHaveBeenCalled() + expect(lastRequestInput()).toEqual( + expect.objectContaining({ status: BridgeTransferRequestStatus.FiatReceived }), + ) + }) + + 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")) + + 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..dd4a4e1f7 100644 --- a/test/flash/unit/services/frappe/ErpNext.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.spec.ts @@ -78,4 +78,314 @@ 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("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: { + 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.findBridgeTransferRequest", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + 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") + + 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 undefined when no row exists", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + 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.findBridgeTransferRequest("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..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 @@ -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,107 @@ 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("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"), + ) + 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"),