open-standards/openpay/docs/22-settlement-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
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.
2026-05-23 07:58:09 -04:00

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
  • Open accepts new posted-tx entries (Batch::add_entry).
  • Closed is frozen — entry list and holdback set in stone.
  • Paying is the in-flight state, carrying the rail's external reference (NACHA trace, pacs.008 msgId, wire ref).
  • Paid and Failed are 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 helper BatchLedgerLink::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.008 envelope 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_held filters via list_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 Failed batch 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 vs pacs.002 status 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.