Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
10 KiB
Phase 22 — Settlement & payout: when does the vendor actually see the money?
Status: Draft v0.22 Date: 2026-05-22
Why
Posted ledger transactions are an accounting fact, not a bank deposit. A "payment stack" that accepts money but doesn't tell the vendor when funds land in their bank account isn't actually delivering the SOTA payment-acceptance experience that brings margin back to vendors — it's just a half of one.
Phase 22 closes that loop. op-settlement is the new layer
between posted-ledger and the payout rail: it groups transactions
into batches, applies a holdback policy (operator reserve
- dispute risk adjustment), and produces the payout file the rail expects.
This is the first of three sequenced phases (22 → 23 → 24) closing out the operator-facing surface. Next up: HTTP API server (Phase 23), then driver SDK + reference integrations (Phase 24).
What shipped
| # | Item | Where |
|---|---|---|
| 1 | op-settlement crate root + domain modules (error, batch, cutoff, holdback, payout, store, engine, nacha) |
crates/op-settlement/src/ |
| 2 | Batch type — id (UUIDv7), currency, rail, entries, holdback, lifecycle state machine |
batch.rs |
| 3 | Cutoff schedule — Daily{hour_utc}, MultiDaily{hours_utc}, Manual — with should_close(last, now) semantics |
cutoff.rs |
| 4 | HoldbackPolicy — flat-rate basis points + dispute adjustment + ceiling; clamps to gross |
holdback.rs |
| 5 | PayoutRail — AchNacha / SepaCt / FedNow / Rtp / Wire / InternalBookTransfer; helpers is_nacha, is_iso20022_pacs008 |
payout.rs |
| 6 | Pacs008EntryContext — per-entry struct mapping a batch line to the data points an [op_iso20022::CreditTransferBuilder] needs |
payout.rs |
| 7 | SettlementStore trait + InMemorySettlementStore ref impl with idempotency-on-external_id and closure-driven update |
store.rs |
| 8 | SettlementEngine — opens / closes / submits / settles / fails batches; cutoff-driven tick(last, now) |
engine.rs |
| 9 | nacha_file(batch, profile, credits) — full NACHA file generator: file header / batch header / entry detail / batch control / file control + 9-filler block padding |
nacha.rs |
| 10 | End-to-end integration test: ledger → batch → cutoff → close → NACHA → submit → settled | tests/end_to_end.rs |
| 11 | Tracing instrumentation: settlement.open_batch, settlement.add_entry, settlement.close_batch |
engine.rs |
Workspace at the end of Phase 22:
| Check | Result |
|---|---|
cargo build --workspace --all-targets |
0 errors, 0 warnings |
cargo test --workspace |
897 passing, 0 failing (+36 vs Phase 21) |
cargo clippy --workspace --all-targets |
0 warnings |
Test-count delta: op-settlement +36 (cutoff 7, holdback 5, batch 6, store 4, engine 6, nacha 4, end-to-end 2, +2 nacha tests).
Architectural placement
op-ledger ──posted tx──► op-settlement ──batch──► op-iso20022
(pacs.008)
│
└─► nacha_file(...) → NACHA writer
op-settlement is downstream of op-ledger (it consumes posted
transactions) and upstream of rail payout (NACHA / pacs.008).
It deliberately does NOT talk to banks — that's the operator's
payout adapter. The output is a file (or in the pacs.008 case,
a structured per-entry context the operator feeds to the existing
CreditTransferBuilder). The "last mile" — SFTP to the ODFI,
POST to the FedNow service — sits outside this crate.
Batch lifecycle
Open ──close(holdback)──► Closed ──pay(reference)──► Paying
│
┌──settled(at)
│
▼
Paid
│
└──fail(code, msg)──► Failed
Openaccepts new posted-tx entries (Batch::add_entry).Closedis frozen — entry list and holdback set in stone.Payingis the in-flight state, carrying the rail's external reference (NACHA trace, pacs.008 msgId, wire ref).PaidandFailedare terminal. Reaching them is the operator's responsibility — we don't poll the rail.
Each transition is enforced by Batch::* methods that return
Error::InvalidTransition on illegal moves.
Cutoff math
Cutoff::daily(7) // 07:00 UTC daily (US nightly ~02:00 ET)
Cutoff::multi_daily(vec![7, 19])// 07:00 + 19:00 UTC
Cutoff::Manual // operator drives close
should_close(last_tick, now) returns true iff a scheduled
cutoff hour falls in (last_tick, now]. The implementation walks
day-aligned UTC midnight + hour:00:00 candidates — bounded
iteration, no calendar library needed.
The engine's tick(store, last, now) uses this to close exactly
one open batch matching the engine's (currency, rail) filter.
Multiple open batches return Error::Invalid — operators
disambiguate before retrying (typically a misconfigured engine).
Holdback policy
HoldbackPolicy::flat(50) // 0.50% reserve
HoldbackPolicy::flat(100).with_ceiling(2_000) // 1% flat, capped at 20%
HoldbackPolicy::none() // pass-through
compute(gross, dispute_adjustment_bps) returns a Holdback
carrying the gross, reserve, and the two basis-point inputs.
Combined-reserve clamps at max_total_bps, and reserve clamps at
the gross — we never withhold more than the operator earned.
The dispute adjustment is operator-supplied — op-settlement
deliberately doesn't read the dispute store. Operators compute the
adjustment from their own risk model (number of open disputes /
recent chargeback rate / etc.) and pass it in. Keeps the crate
boundaries clean.
NACHA generator
A full NACHA credit file (PPD/CCD) at nacha::nacha_file. Five
record types, fixed-width 94-char ASCII, block-aligned to 10
records with 9 filler. Reference: NACHA 2024 Operating Rules,
Appendix Three.
let file_str: String = nacha_file(&batch, &profile, &credits)?;
// 7 functional records padded to 10, all exactly 94 chars.
Scope: this is a reference generator covering the common credit case. Returns, NOC, addenda records, IAT/CTX/web-debit flavors are explicitly out of scope — operators wanting full NACHA support extend the generator or wire their own.
pacs.008 path
The crate doesn't auto-generate pacs.008 files because
op-iso20022::CreditTransferBuilder already builds one
credit-transfer message per entry and the bundling format varies
by rail (FedNow takes them one-at-a-time, SEPA Direct File bundles
many in a pain.001/pacs.008 envelope).
Pacs008EntryContext is the bridging struct: operators iterate
batch.entries, look up debtor/creditor party identifications
from their merchant directory, build a Pacs008EntryContext, and
hand it to the builder.
Engine API surface
let engine = SettlementEngine::new(
Currency::USD,
PayoutRail::AchNacha,
Cutoff::daily(7)?,
HoldbackPolicy::flat(50),
);
let batch_id = engine.open_batch(&store, now_unix_secs)?;
engine.add_entry(&store, batch_id, tx_id, amount, Some("o-1".into()))?;
// ... time passes ...
engine.tick(&store, last_tick, now)?; // closes if cutoff fired
engine.submit_for_payout(&store, batch_id, "trace-1", now)?;
engine.mark_settled(&store, batch_id, now)?;
The engine takes &impl SettlementStore (rather than
&dyn SettlementStore) because the trait carries a generic
update<F> method for closure-driven atomicity, which would
make the trait non-dyn-compatible. Operators with multiple
store backends just monomorphize the engine per backend.
Honest concerns (carry-forward)
- No ledger linkage on payout-confirmation. When a batch
transitions to
Paid, we don't auto-post the settlement entry (debit the cash-in-transit account, credit the operator's bank). That coupling is operator-side and ledger-shape-specific. A helperBatchLedgerLink::post_settlement(batch, &ledger_store)would close this in a follow-up. - Single-batch-per-file NACHA. Real-world NACHA files often pack multiple batches per file (different SEC codes, different effective dates). The current generator is one-batch-per-file — operators bundle externally if needed.
pacs.008envelope generation is operator-driven. SEPA Direct Debit File / FedNow Service Bus envelope formats vary by rail provider. We surface the per-entry context; the envelope is outside scope.- Total-reserve query is
O(open batches)on the ref store.total_reserve_heldfilters vialist_open, which excludes closed/paid batches. For real-money reserves operators want a proper index on the store backend — surfaceable but not added in this phase since it's a backend-specific concern. - No payout retry logic. A
Failedbatch is terminal. The pattern is to open a fresh batch and re-enrol the transactions. Auto-retry would couple this layer to rail-specific quirks (NACHA returns codes vspacs.002status reason codes); the operator's payout adapter is better positioned.
Test totals
op-settlement 36 tests
cutoff 7 (validation, fire-when-crossed, idempotent windows)
holdback 5 (none, flat, dispute add, ceiling, clamp)
batch 6 (open / lifecycle / mismatch / transitions)
store 4 (CRUD, idempotency, list_open)
engine 6 (open, add, close, submit-settle, tick variants)
nacha 4 (rail check, empty, block alignment, validation)
end_to_end 2 (full ledger→NACHA pipeline, empty-batch path)
+ 2 nacha record-content assertions
----
+36 net
cargo test --workspace: 897 passing, 0 failing.
cargo build --workspace --all-targets: 0 warnings.
cargo clippy --workspace --all-targets: 0 warnings.