Skip to content

feat(bridge): promote topup deposit audit row to Completed on crypto receive#457

Merged
islandbitcoin merged 3 commits into
mainfrom
feat/promote-topup-on-crypto-receive
Jul 19, 2026
Merged

feat(bridge): promote topup deposit audit row to Completed on crypto receive#457
islandbitcoin merged 3 commits into
mainfrom
feat/promote-topup-on-crypto-receive

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Problem

Topup rows in the ERPNext Bridge Transfer Request audit trail were stuck at Fiat Received forever. The Bridge deposit webhook only ever wrote Fiat Received, the IBEX crypto receive wrote a separate ibex:<txHash> row at Settled, and nothing correlated the two — Completed was only reachable on the Cashout path. An operator couldn't tell a healthy finished topup from one stuck mid-conversion without manually hunting for the sibling ibex: row.

Design

Join the two sides of a topup on the IBEX destination tx hash, covering both webhook orderings:

1. Crypto receive arrives after the deposit webhooks (common case)
After writing the ibex:<txHash> settle row, the crypto-receive handler calls promoteBridgeDepositForCryptoReceive, which finds the deposit-side row (ibex_tx_hash = txHash, transaction_type = Topup, request_id not like "ibex:%") and promotes it to Completed, stamping the credited account_id/wallet_id and appending ibex_crypto_receive to source_systems_seen. Best-effort: a promotion failure logs + alerts (erpnext_promote_failed) but never fails the webhook — funds are already credited and the settle row exists. not_found is normal when Bridge's payment_processed event (which carries receipt.destination_tx_hash) hasn't stamped the deposit row yet; the reverse path below converges it.

2. Crypto receive arrives before the deposit's payment_processed webhook
writeBridgeDepositRequest now checks for an existing ibex:<txHash> settle row whenever the event carries receipt.destination_tx_hash, and writes the row as Completed directly. A failed check degrades to Fiat Received (never blocks the audit write).

Guard rails in upsertBridgeTransferRequest (load-bearing for the above):

  • Topup statuses are monotonic (Pending < Fiat Received < Settled < Completed < Failed): Bridge webhook retries of earlier deposit events can no longer stomp a promoted row back to Fiat Received. Cashout rows keep their existing last-write-wins semantics.
  • source_systems_seen merges as a set union on update instead of overwriting, so a promoted row keeps recording both bridge_deposit and ibex_crypto_receive.

No ERPNext-side changes needed: the doctype's status options and the transfer-requests admin page already handle Completed for topups.

Known limitation

If the payment_processed deposit event and the crypto receive race within the same sub-second window, both matching checks can miss (deposit row not yet stamped with the hash when promotion looks; ibex: row not yet written when the deposit write checks). The row self-heals on any subsequent Bridge webhook retry. The read-modify-write on promotion has no compare-and-swap, same as the existing upsert.

Tests

33 unit tests across the three touched specs (all passing), including:

  • promotion: filter shape, account/wallet attribution, not_found, idempotent already_completed, write-failure error path
  • deposit writer: Completed when settle row exists, no lookup without a destination hash, degrade-to-Fiat Received on lookup failure
  • upsert guards: no Topup downgrade, forward moves allowed, cashout last-write-wins preserved, source_systems_seen union
  • route: promotion called with credited account/wallet, webhook still succeeds on not_found and on promotion failure (with alert)

yarn tsc-check and eslint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7

bobodread876 and others added 3 commits July 18, 2026 14:31
…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:<txHash> 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:<txHash> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
…n 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
@islandbitcoin
islandbitcoin merged commit 0d3b062 into main Jul 19, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants