feat(transactions): dry_run preview for create and refund - #348
Open
Shivam8584 wants to merge 3 commits into
Open
feat(transactions): dry_run preview for create and refund#348Shivam8584 wants to merge 3 commits into
Shivam8584 wants to merge 3 commits into
Conversation
The balance-applying path mutates in place: UpdateBalances writes PreciseAmount onto the transaction, and addDebit/addCredit/computeBalance mutate the balances via big.Int's Add and Sub, which write into their receiver. That makes a plain struct copy unsafe as a snapshot — it would share the underlying integers, so applying a transaction to the copy would rewrite the original too. Clone copies every *big.Int by value, preserving nil so a clone behaves identically under InitializeBalanceFields, and duplicates the MetaData map and distribution slices. Groundwork for dry-run transaction previews (blnkfinance#330), which hold a "before" snapshot alongside the balances the projection is applied to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PreviewTransaction answers "if I post this, would it succeed and what would
the balances become" without touching ledger state.
The projection runs the same arithmetic a real post runs — processBalances,
and through it UpdateBalances and canProcessTransaction — against deep copies
of the balances. Sharing the math rather than reimplementing it is what keeps
a preview from drifting away from enforcement as the posting path changes.
Two things the real path does that a preview must not:
- QueueTransaction persists a QUEUED row and applies balances later in a
worker, so the projection drives the apply seam directly instead.
- Resolving an @Indicator creates the balance when it is missing, so the
projection resolves through read-only lookups and reports an unknown
indicator as a virtual balance projected against zero.
Locks are taken for the read so both balances are seen as of one moment, but
acquired directly rather than through executeWithLock: a preview should not
record hot-pair contention and steer hot-lane routing for real traffic.
Where the ledger is permissive, the projection reports rather than invents a
rule — a currency mismatch and an in-use reference are surfaced as notes,
because the apply path allows both and a preview that were stricter than
enforcement would be misleading.
Refs blnkfinance#330.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dry run answers what a transaction would do and stops there, so the handlers branch before anything is queued. The response is 200 rather than 201 because nothing was created, and a projected rejection is a 200 too: the request succeeded and the answer is "no". Reserving 4xx for requests that are actually malformed means no new error codes were needed. The rejection carries the same code a real post would have returned, resolved through the classifier the other endpoints already use, so existing client-side handling for TXN_INSUFFICIENT_FUNDS and friends works against a preview without change. dry_run stays out of model.Transaction. It describes what to do with the request rather than anything about the movement itself, and keeping it at the transport boundary means a transaction marked for preview cannot reach the writer by accident. On the refund endpoint the flag rides on the existing optional body, so a bodiless POST keeps meaning "refund normally". Closes blnkfinance#330 for single transactions and refunds; bulk and inflight follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 9, 2026
Collaborator
|
Hi @Shivam8584 taking a look |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
dry_runtoPOST /transactionsandPOST /refund-transaction/:id, answering "if I post this, would it succeed and what would the balances become" without writing anything.Part one of two, per #330 — bulk and inflight follow in a second PR, where the cross-leg accumulator and multi-leg parent expansion carry most of the complexity. Corrections to the original issue text are in this comment.
POST /transactions { "dry_run": true, "amount": 120, ... } → 200 { "dry_run": true, "would_apply": true, "precise_amount": "12000", "balances": [ { "role": "source", "current_balance": "50000", "resulting_balance": "38000", ... }, { "role": "destination", "current_balance": "0", "resulting_balance": "12000", ... } ] }How it works
The projection runs the same arithmetic a real post runs —
processBalances, and through itUpdateBalancesandcanProcessTransaction— against deep copies of the balances. Sharing the math rather than reimplementing it is the whole point: a preview that computed availability its own way would drift from enforcement the first time overdraft rules changed, and a preview that disagrees with reality is worse than none.Three things the code made necessary that aren't obvious:
POST /transactionsdoesn't reachRecordTransaction. It goes throughQueueTransaction, which persists aQUEUEDrow and applies balances later in a worker. The projection drives the apply seam directly instead — threading a flag through the queue would still write a row.@indicatorwrites.getOrCreateBalanceByIndicatorcreates the balance when it's missing, so resolution goes through read-only lookups and an unknown indicator is projected against a zeroed virtual balance rather than created.UpdateBalanceswritesPreciseAmountonto the transaction, andbig.Int'sAdd/Submutate their receiver — so a plain struct copy would share the underlying integers and a "before" snapshot would silently show "after" values. HenceClone()in the first commit.Locks are taken so both balances are read as of one moment, but acquired directly rather than through
executeWithLock: a preview shouldn't record hot-pair contention and steer hot-lane routing for real traffic.Deliberate choices
No new error codes. A projected rejection is a
200withwould_apply: false— the request succeeded, the answer is "no". The rejection carries the same code a real post returns, resolved through the existingclassifyMessage, so client-side handling forTXN_INSUFFICIENT_FUNDSworks against a preview unchanged.codes.gois untouched.dry_runstays out ofmodel.Transaction. It describes what to do with the request, not the movement, so it lives at the transport boundary — which also makes it structurally impossible for a preview transaction to reach the writer by accident.Where the ledger is permissive, the preview reports rather than invents a rule. The apply path performs no currency check, so a mismatched currency is projected with a note instead of rejected. A preview stricter than enforcement would be misleading.
dry_runwins overskip_queue, and always answers synchronously with200.Scope of the guarantee
A dry run changes no ledger state: no transaction row, no balance mutation, no queue entry, no webhook, no hook, and the reference is not consumed.
It is not byte-for-byte write-free at the HTTP layer — auth middleware still updates
api_keys.last_used_at, and OTel spans are still emitted. Both are deliberate; a preview you can't trace is worse to operate.One known wart: because permissions are gated by HTTP method rather than by effect, a preview on
POST /transactionsstill requires write scope — awkward for the pre-flight UI check that motivates the feature. Happy to follow up separately if you'd like previews to be read-scope-accessible; it seemed like a permission-model change that didn't belong in a feature PR.Tests
26 new tests. The ones that matter:
version(the optimistic-lock counter moves on any persisted write, so it's the sharpest tripwire); no transaction row; reference still usable by a real post afterwards; unknown@indicatornot created. The service-level tests run againstsqlmock, so an unexpectedINSERTfails the test by itself rather than needing an assertion.Clonedoesn't alias — every*big.Intchecked withNotSame, plus mutate-the-clone-assert-the-original.UpdateBalances, so it errors rather than being a silent no-op).POST /refund-transaction/:idwith an empty body keeps meaning "refund normally".Verified end to end against a running server: previewed a transaction, then really posted it — the projection matched the resulting balances field for field, and
versionwas unchanged across the preview.go test ./...passes apart frominternal/search, which needs Typesense and fails identically onmain.