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.
226 lines
10 KiB
Markdown
226 lines
10 KiB
Markdown
# 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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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.**
|