Skip to content

feat(transactions): dry_run preview for create and refund - #348

Open
Shivam8584 wants to merge 3 commits into
blnkfinance:mainfrom
Shivam8584:feat/dry-run-preview
Open

feat(transactions): dry_run preview for create and refund#348
Shivam8584 wants to merge 3 commits into
blnkfinance:mainfrom
Shivam8584:feat/dry-run-preview

Conversation

@Shivam8584

Copy link
Copy Markdown
Contributor

Adds dry_run to POST /transactions and POST /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 runsprocessBalances, and through it UpdateBalances and canProcessTransaction — 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 /transactions doesn't reach RecordTransaction. It goes through QueueTransaction, which persists a QUEUED row 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.
  • Resolving an @indicator writes. getOrCreateBalanceByIndicator creates 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.
  • The apply path mutates in place. UpdateBalances writes PreciseAmount onto the transaction, and big.Int's Add/Sub mutate their receiver — so a plain struct copy would share the underlying integers and a "before" snapshot would silently show "after" values. Hence Clone() 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 200 with would_apply: false — the request succeeded, the answer is "no". The rejection carries the same code a real post returns, resolved through the existing classifyMessage, so client-side handling for TXN_INSUFFICIENT_FUNDS works against a preview unchanged. codes.go is untouched.

dry_run stays out of model.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_run wins over skip_queue, and always answers synchronously with 200.

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 /transactions still 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:

  • Changes nothing — balances byte-identical including 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 @indicator not created. The service-level tests run against sqlmock, so an unexpected INSERT fails the test by itself rather than needing an assertion.
  • Clone doesn't alias — every *big.Int checked with NotSame, plus mutate-the-clone-assert-the-original.
  • Projection matches enforcement — insufficient funds, inflight-adjusted availability, zero amount rejected (validate runs inside UpdateBalances, so it errors rather than being a silent no-op).
  • Bodiless refund still works — the new field is optional and POST /refund-transaction/:id with 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 version was unchanged across the preview.

go test ./... passes apart from internal/search, which needs Typesense and fails identically on main.

Shivam8584 and others added 3 commits August 9, 2026 09:15
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>
@jerry-enebeli

Copy link
Copy Markdown
Collaborator

Hi @Shivam8584 taking a look

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