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.
267 lines
11 KiB
Markdown
267 lines
11 KiB
Markdown
# 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`.
|
||
|
||
```rust
|
||
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 → Submitted`
|
||
- `approve()` — `Submitted → Approved`
|
||
- `settle(at)` — `Approved → Settled`
|
||
- `decline(code, msg)` — `Requested|Submitted → Declined`
|
||
- `fail_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`:
|
||
|
||
```rust
|
||
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 status
|
||
- `escalate_to_chargeback()` — `Inquiry → Chargeback`
|
||
- `represent()` — `Chargeback → Representment`
|
||
- `resolve_won()`/`resolve_lost()` — from `Representment`
|
||
- `accept()` — `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:
|
||
|
||
```rust
|
||
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.
|
||
|
||
```rust
|
||
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_id` and `external_id` (the idempotency-key join axis)
|
||
- `status` (lifecycle: pending / posted / archived)
|
||
- `posted_at_tx_count` and `effective_at_unix_secs`
|
||
- `settled_amount_minor` + `currency_code` (debit sum, single-
|
||
currency only; multi-currency txs surface as `None`)
|
||
- `rail` + `driver` (joined via `rail_attempt.external_id_hint`)
|
||
- `reconciliation_task_ids` (inbound `task_about` edges)
|
||
|
||
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::Settled` lands. That coupling
|
||
belongs in operator glue (which ledger? which reversal
|
||
account?). A future phase could ship a `RefundLedgerLink`
|
||
helper 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 `LedgerHistory`
|
||
APIs, 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**.
|