Skip to content

feat(transactions): dry_run preview for bulk and inflight - #349

Open
Shivam8584 wants to merge 4 commits into
blnkfinance:mainfrom
Shivam8584:feat/dry-run-bulk-inflight
Open

feat(transactions): dry_run preview for bulk and inflight#349
Shivam8584 wants to merge 4 commits into
blnkfinance:mainfrom
Shivam8584:feat/dry-run-bulk-inflight

Conversation

@Shivam8584

Copy link
Copy Markdown
Contributor

Completes #330 by adding dry_run to POST /transactions/bulk and PUT /transactions/inflight/:txID.

Stacked on #348 — please merge that first; this branch contains its three commits, so the diff here is just the final commit. Rebases to main cleanly once #348 lands.

Bulk: the projection mode has to match how the batch really runs

This is the part the original issue got wrong, and it's the main reason this is a separate PR.

I'd claimed later items see earlier items' balance changes. That's only true with skip_queue: true. processBulkTransactions assigns the batch-level flag to every item (transaction_bulk.go:36), so on the default path each item goes to processTransactionAsync's detached goroutine and the loop moves on — the items race.

So the mode follows the flag, and the response says which one you got:

// skip_queue: true — 500.00 available, items of 450.00 then 100.00
{ "cumulative": true, "would_apply": false,
  "results": [ { "would_apply": true },
               { "would_apply": false, "rejection": { "code": "TXN_INSUFFICIENT_FUNDS" } } ] }

// skip_queue: false — same input, both fit on their own
{ "cumulative": false, "would_apply": true,
  "results": [ { "would_apply": true }, { "would_apply": true } ] }

Identical input, opposite verdict, because the execution semantics genuinely differ. Projecting cumulatively on the default path would assert an ordering the ledger doesn't provide — an authoritative-looking wrong answer, which is worse than no preview.

Batch-level balances are reported only in cumulative mode: without a guaranteed order there's no single combined outcome to state.

No all_or_nothing field, for the same honesty reason. rollbackBatchTransactions compensates by voiding or refunding already-applied items after a failure, and that compensation can itself fail. The response carries a note saying so rather than implying isolation.

Balances are fetched once per distinct balance and reused across items — needed for correctness in cumulative mode anyway, and it keeps a 10k-item batch from doing 20k round trips.

Inflight: reuses the existing expansion rather than reimplementing it

preValidateInflightAction already handles the case where an id names the parent of a split rather than a single hold, so the projection resolves legs the same way and projects every leg — a per-transaction preview on a parent would otherwise answer for one representative leg, or fail outright.

Void has no partial form: finalizeVoidTransaction always uses the full remaining hold. An amount sent with a void is reported as ignored rather than silently dropped:

PUT /transactions/inflight/:id  { "dry_run": true, "status": "void", "precise_amount": 4000 }

{ "operation": "void", "precise_amount": "10000",
  "notes": ["amount is ignored when voiding; a void always releases the full remaining hold"] }

Both projections run on clones — validateAndUpdateAmount writes the settled amount onto the transaction it's given. The dry-run branch also sits ahead of ApplyPrecisionWithDBLookup, which caches into package-level state.

Tests

9 new tests covering: cumulative mode catching an intra-batch shortfall; independent mode not assuming order; batch and inflight previews writing nothing (balances and version unchanged, hold still INFLIGHT, no settlement rows); void ignoring its amount; and unsupported actions still being rejected as malformed.

Verified against a running server — the cumulative case correctly rejected the second item at TXN_INSUFFICIENT_FUNDS where independent mode passed both, the commit projection showed the hold releasing and the money moving while void showed the hold releasing with the balance unchanged, and afterwards the hold was still INFLIGHT with zero settlement rows written.

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

Shivam8584 and others added 4 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>
Completes blnkfinance#330 by projecting the two remaining endpoints.

Bulk projection depends on how the batch would really run. With skip_queue
the items are applied inline one after another, so a later item sees what
earlier ones did and the projection carries balances forward — which is what
catches a batch that spends within itself more than it has. Without it, each
item is handed to its own goroutine and the loop moves on, so the items race;
projecting them cumulatively would assert an ordering the ledger does not
provide, and they are projected independently instead. The response reports
which mode was used.

For the same reason there is no all_or_nothing field: atomic batches
compensate on failure by voiding or refunding already-applied items rather
than rolling back, and that compensation can itself fail, so the response
says that rather than implying isolation.

Inflight settlement reuses the existing read-only validation, including the
parent expansion the real commit and void paths use, so previewing an action
against the parent of a split projects every leg instead of one
representative. Void has no partial form — it always releases the whole
remaining hold — so an amount sent with one is reported as ignored rather
than silently dropped.

Both projections run on clones: validateAndUpdateAmount writes the settled
amount onto the transaction it is given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant