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.
11 KiB
Phase 21 — SOTA sweep: refunds, disputes, observability, audit report
Status: Draft v0.21 Date: 2026-05-22
Why
OpenPay's mission is to bring margin back to vendors by removing the 3–5% tax stack. To do that, the reference stack has to cover the full payment-acceptance surface — not just the happy path. After Phase 20 the books, the rails, the reconciler, and the routing brain were all SOTA, but three operator-facing surfaces were still missing: refund workflows, dispute / chargeback workflows, and the trace + audit-report plumbing that auditors and SREs ask for. Phase 21 closes those gaps.
No new external dependencies. Every new crate sits on the same typed-store / graph-substrate / pluggable-backend pattern the rest of the workspace already establishes.
What shipped
| # | Item | Where |
|---|---|---|
| 1 | op-refund crate — full/partial refunds, state machine, idempotency on external_id, pluggable RefundStore + in-memory ref impl |
crates/op-refund/ |
| 2 | op-dispute crate — chargeback / inquiry / representment workflow, normalized cross-network reason taxonomy, evidence refs, pluggable DisputeStore |
crates/op-dispute/ |
| 3 | OpenTelemetry-compatible trace propagation — tracing workspace dep, #[tracing::instrument] on the hot paths in orchestrator / ledger / webhook / reconciliation |
Cargo.toml, op-{orchestrator,ledger,webhook,reconciliation} |
| 4 | History compaction — GraphHandle::compact() surfacing Minigraf's checkpoint API |
op-graph/src/graph.rs |
| 5 | Audit report builder — AuditReport::for_window(handle, start, end, now) joining ledger_tx × rail_attempt × reconciliation_task in one graph pass |
op-graph/src/audit.rs, tests/audit_report.rs |
Workspace at the end of Phase 21:
| Check | Result |
|---|---|
cargo build --workspace --all-targets |
0 errors, 0 warnings |
cargo test --workspace |
861 passing, 0 failing (+37 vs Phase 20) |
cargo clippy --workspace --all-targets |
0 warnings |
Test-count delta breakdown: op-refund +17, op-dispute +16, op-graph audit_report integration suite +4.
op-refund
A refund is its own lifecycle, not just a "negative transaction." Operators need to track intent (the customer or merchant asked for the money back), submit to the PSP, wait for approval, and then post the ledger reversal when settlement is confirmed — sometimes days later. Folding all of that into the ledger as raw debits/credits loses the workflow.
Domain model
Refund carries: a deterministic UUIDv7 RefundId, the
original_tx_id it reverses, the Money amount, a
RefundReason, optional operator external_id for idempotency,
optional psp_refund_id once submitted, an ordered metadata
list, request/decision timestamps, and a Status.
pub enum Status {
Requested,
Submitted { psp_refund_id: String },
Approved,
Settled { settled_at_unix_secs: u64 },
Declined { code: String, message: String },
Failed { code: String, message: String },
}
State transitions live as methods on Refund that enforce the
graph:
submit(psp_refund_id)—Requested → Submittedapprove()—Submitted → Approvedsettle(at)—Approved → Settleddecline(code, msg)—Requested|Submitted → Declinedfail_after_approval(code, msg)—Approved → Failed
Any other transition returns Error::InvalidTransition. Amounts
larger than the original tx (operators are supposed to enforce
this with list_for_tx + sum) return Error::AmountExceeded
when the caller opts in to validation.
Idempotency
RefundStore::create_refund is idempotent on external_id. The
in-memory ref impl uses a body-equivalence helper
(bodies_equivalent) that does an order-insensitive comparison
of the metadata bag — operators retrying with the same logical
payload but a re-ordered metadata HashMap iteration get the
existing id back, not a mismatch. A genuine body change (amount,
reason, tx id, external id, metadata content) returns
Error::IdempotencyMismatch(external_id).
Storage trait
RefundStore mirrors LedgerStore / WebhookStore: a small
sync trait with explicit atomicity contract on the closure-driven
update. The closure receives &mut Refund, runs the state
transition, and the store commits only if the closure returns
Ok(()). A partial mutation must never be persisted. The
in-memory impl clones-stage-commit; a future Postgres impl would
wrap the closure in a transaction.
op-dispute
Disputes are a different beast from refunds. A refund is operator-
or customer-initiated; a dispute is issuer- or *bank-*initiated
and carries evidence, deadlines, and a possible representment
loop. The cross-network taxonomy varies wildly — Visa Reason Codes
vs Mastercard vs Faster Payments "Confirmation of Payee" — so
the crate normalizes to a curated class while letting operators
keep the raw network_reason_code on the Dispute.
Domain model
Dispute carries id, tx_id, DisputeReason, optional
network_reason_code, a Vec<EvidenceRef> (each carrying
kind, url, optional note, and attached_at_unix_secs),
optional external_id, deadlines, timestamps, and a Status:
pub enum Status {
Inquiry, // pre-chargeback; issuer asking for info
Chargeback, // money already pulled
Representment, // we're contesting the chargeback
Won, // representment succeeded
Lost, // representment failed
Accepted, // we accepted the chargeback (didn't represent)
}
Transitions:
attach_evidence(EvidenceRef)— at any pre-resolution statusescalate_to_chargeback()—Inquiry → Chargebackrepresent()—Chargeback → Representmentresolve_won()/resolve_lost()— fromRepresentmentaccept()—Inquiry|Chargeback → Accepted
Storage trait
DisputeStore is the same shape as RefundStore (deliberate
parallelism — operators implementing the Postgres backend wire
both at once). Idempotency on external_id, closure-driven
update with atomicity contract, list_for_tx for fan-out.
OpenTelemetry trace propagation
tracing is now a workspace dep, and the four crates an operator
typically wants to see in a flamegraph carry
#[tracing::instrument] on their hot path:
| Span | Where |
|---|---|
orchestrator.run |
Orchestrator::run — outer-most boundary; carries intent_key, amount_minor, currency |
ledger.post_transaction |
InMemoryLedgerStore::post_transaction — tx_id, entry_count |
webhook.dispatch |
Dispatcher::dispatch — event_id, endpoint_count |
reconciliation.reconcile |
Reconciler::reconcile — window_start, window_end, tx_count |
These are just span boundaries — no opinionated exporter, no
forced OTLP wiring. Operators install whatever
tracing-subscriber layer they prefer (Jaeger, Honeycomb,
stdout, tracing-opentelemetry). The crates themselves stay
exporter-agnostic.
History compaction
Minigraf's Database::checkpoint collapses the bi-temporal log
once retracted assertions stop mattering. Phase 17 wired the
retract-then-assert pattern in but never surfaced compaction.
GraphHandle::compact() is the operator-facing thin wrapper:
handle.compact()?; // calls minigraf::Database::checkpoint internally
For the in-memory backend it returns Ok(()) (no-op). For a
file-backed Minigraf store it does what you'd expect — fold the
journal, free the dead records. Test
compact_returns_ok_on_in_memory_handle in
tests/audit_report.rs exercises the no-op path.
Audit report
Auditors and finance teams ask the same question every quarter: "for every ledger transaction in this window, show me the rail that processed it, the reconciliation tasks that touched it, and the status." That join was already implicit across the typed stores — Phase 21 surfaces it as a structured report on the shared graph substrate.
let report = AuditReport::for_window(
&graph_handle,
start_tx_count,
end_tx_count,
generated_at_unix_secs, // operator-supplied for determinism
)?;
for entry in &report.entries {
println!(
"{} status={} rail={:?} driver={:?} tasks={:?}",
entry.tx_id, entry.status,
entry.rail, entry.driver,
entry.reconciliation_task_ids,
);
}
Each AuditEntry carries:
tx_idandexternal_id(the idempotency-key join axis)status(lifecycle: pending / posted / archived)posted_at_tx_countandeffective_at_unix_secssettled_amount_minor+currency_code(debit sum, single- currency only; multi-currency txs surface asNone)rail+driver(joined viarail_attempt.external_id_hint)reconciliation_task_ids(inboundtask_aboutedges)
The report sorts ascending by posted_at_tx_count so it reads
like an audit log. generated_at_unix_secs is operator-supplied
so replays / test runs are byte-identical.
Implementation note
for_window is a single-pass walk: build the
external_id → (rail, driver) index up front from all
rail_attempt vertices, then iterate ledger_tx vertices,
filter by posted_at_tx_count window, join. One graph, one
report, no cross-store mutex coordination.
Honest concerns (carry-forward)
- Refund → ledger reversal posting is the operator's job. The
refund crate models the workflow but doesn't auto-post a
reversal entry when
Status::Settledlands. That coupling belongs in operator glue (which ledger? which reversal account?). A future phase could ship aRefundLedgerLinkhelper for the common single-ledger case. - Dispute → ledger reservation is also operator-side. Many networks debit the merchant the moment the chargeback opens. We track the workflow; we don't auto-post the hold.
- Audit report is read-only. No "rebuild from journal"
capability yet — operators with a Minigraf file store get
bi-temporal time-travel via the existing
LedgerHistoryAPIs, but the audit report itself is a snapshot. - Trace context propagation between crates is not enforced. The instrumented spans nest correctly when callers come from the orchestrator, but an operator wiring a standalone reconciler against a wholly different orchestrator will see detached span trees unless they set up an external context propagator.
Test totals
op-refund 17 tests (state machine 11, idempotency 3, store CRUD 3)
op-dispute 16 tests (state machine 10, evidence 2, store CRUD 4)
op-graph 4 tests (audit_report.rs — end-to-end join, empty,
inverted window, compact no-op)
----
+37 net
cargo test --workspace: 861 passing, 0 failing.
cargo build --workspace --all-targets: 0 warnings.
cargo clippy --workspace --all-targets: 0 warnings.