open-standards/openpay/crates/op-reconciliation/src/discrepancy.rs
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

183 lines
7 KiB
Rust

//! Discrepancies and the report that aggregates them.
use op_core::Money;
use op_ledger::{Status, TransactionId};
use crate::statement::StatementLine;
/// One way the ledger and a statement disagree.
///
/// Every variant carries enough context for an operator to act
/// without re-deriving anything: the offending statement line and/or
/// the ledger transaction id, plus what specifically differed.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Discrepancy {
/// The statement says money moved, but no ledger transaction
/// matched it. Classic missed webhook / unbooked settlement.
UnmatchedStatement {
/// The orphan bank/PSP line.
line: StatementLine,
},
/// The ledger has a transaction in the window that no statement
/// line accounts for. Either the rail hasn't reported yet, or we
/// booked something the bank never saw.
UnmatchedLedger {
/// The orphan ledger transaction.
tx_id: TransactionId,
/// Its `external_id`, if any, to speed operator lookup.
external_id: Option<String>,
},
/// A line and a transaction matched on reference, but the amounts
/// differ. Often a fee netted out, or a partial settlement.
AmountMismatch {
/// The statement line.
line: StatementLine,
/// The matched ledger transaction.
tx_id: TransactionId,
/// The ledger transaction's settled magnitude.
ledger_amount: Money,
},
/// Matched on reference and amount, but the ledger's lifecycle
/// state contradicts the statement (e.g. the bank settled it but
/// our tx is still `Pending`, or it's `Archived`/voided yet the
/// bank moved money anyway).
StatusMismatch {
/// The statement line.
line: StatementLine,
/// The matched ledger transaction.
tx_id: TransactionId,
/// The ledger transaction's current status.
ledger_status: Status,
},
}
/// A flat, storage-friendly description of one discrepancy.
///
/// The `task_id` is **deterministic** — derived only from the
/// discrepancy's identifying fields, never from a clock or random
/// source — so recording the same unresolved discrepancy twice is
/// idempotent (a store keys on it). This is what lets a nightly
/// reconciliation job run repeatedly without piling up duplicate
/// operator tasks.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaskDescriptor {
/// Stable id: `"{kind}:{source_id|tx_id}"`.
pub task_id: String,
/// Discrepancy class.
pub kind: &'static str,
/// One-line operator-facing summary.
pub detail: String,
/// Related ledger transaction, if any.
pub ledger_tx_id: Option<TransactionId>,
/// Related statement line source id, if any.
pub statement_source_id: Option<String>,
}
impl Discrepancy {
/// Project to a deterministic [`TaskDescriptor`] for persistence.
#[must_use]
pub fn task_descriptor(&self) -> TaskDescriptor {
match self {
Self::UnmatchedStatement { line } => TaskDescriptor {
task_id: format!("unmatched_statement:{}", line.source_id),
kind: "unmatched_statement",
detail: format!(
"statement line {} ({} {}) has no matching ledger tx",
line.source_id,
line.amount.minor_units,
line.amount.currency.code()
),
ledger_tx_id: None,
statement_source_id: Some(line.source_id.clone()),
},
Self::UnmatchedLedger { tx_id, external_id } => TaskDescriptor {
task_id: format!("unmatched_ledger:{}", tx_id.0),
kind: "unmatched_ledger",
detail: format!(
"ledger tx {} (external_id {:?}) has no matching statement line",
tx_id.0, external_id
),
ledger_tx_id: Some(*tx_id),
statement_source_id: None,
},
Self::AmountMismatch {
line,
tx_id,
ledger_amount,
} => TaskDescriptor {
task_id: format!("amount_mismatch:{}", line.source_id),
kind: "amount_mismatch",
detail: format!(
"line {} says {} {} but ledger tx {} settled {} {}",
line.source_id,
line.amount.minor_units,
line.amount.currency.code(),
tx_id.0,
ledger_amount.minor_units,
ledger_amount.currency.code()
),
ledger_tx_id: Some(*tx_id),
statement_source_id: Some(line.source_id.clone()),
},
Self::StatusMismatch {
line,
tx_id,
ledger_status,
} => TaskDescriptor {
task_id: format!("status_mismatch:{}", line.source_id),
kind: "status_mismatch",
detail: format!(
"line {} settled at the bank but ledger tx {} is {:?}",
line.source_id, tx_id.0, ledger_status
),
ledger_tx_id: Some(*tx_id),
statement_source_id: Some(line.source_id.clone()),
},
}
}
}
/// The outcome of one reconciliation run.
///
/// Serializable so an operator can pipe it straight into whatever
/// ticketing / alerting they run. The graph-materialization path in
/// `op-graph` consumes the same `discrepancies` list.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ReconciliationReport {
/// The `[start, end]` unix-second window the caller reconciled.
pub window: (u64, u64),
/// Lines that matched a ledger tx exactly (reference + amount +
/// consistent status).
pub matched: usize,
/// Lines matched only by the amount/window heuristic (no shared
/// reference). Counted as reconciled but flagged because the join
/// is weaker — an operator may want to spot-check these.
pub fuzzy_matched: usize,
/// Everything that didn't cleanly reconcile.
pub discrepancies: Vec<Discrepancy>,
/// The full set of `(statement source_id → ledger tx)` matches,
/// both exact and fuzzy. Used by graph-backed stores to emit
/// `statement_line --reconciles--> ledger_tx` edges so an
/// operator can traverse "which bank line settled this tx."
/// Empty in older serialized reports — the field is
/// `serde(default)` for backwards compatibility.
#[serde(default)]
pub matched_pairs: Vec<crate::matcher::MatchedPair>,
}
impl ReconciliationReport {
/// True when nothing needs an operator's attention.
#[must_use]
pub fn is_clean(&self) -> bool {
self.discrepancies.is_empty()
}
/// Total lines + transactions that reconciled (exact + fuzzy).
#[must_use]
pub fn reconciled(&self) -> usize {
self.matched + self.fuzzy_matched
}
}