open-standards/openpay/crates/op-statements/src/fees.rs
Transaction Science fdc9cf6ab0 ingest wave C: 3 crates · op-statements + smart-byte-activitypub + eoc-observability
OpenPay (1 crate, ~2940 LOC, 40 tests)
  op-statements         Statement generation + reconciliation + fee accounting.
                        Render targets: PDF (template), CSV, JSON, 94-char
                        NACHA fixed-width. ISO 20022 camt.053 with full
                        BkToCstmrStmt envelope (GrpHdr/Stmt/Bal/Ntry). BAI2
                        with 01/02/03/16/49/98/99 records. MT940/MT942
                        SWIFT with :20:/:25:/:28C:/:60F:/:61:/:86:/:62F:
                        + :90C:/:90D: counters. FeeBucket (interchange/scheme/
                        acquirer/fx/settlement-network/bnpl) + FeeSchedule
                        with bps + flat rules. Cadence (daily/weekly/monthly/
                        custom). Two-tier Reconciler (external_id → window
                        heuristic).

Smart Byte (1 crate, 3032 LOC, 39 tests)
  smart-byte-activitypub ActivityPub W3C Rec (2018). AS2 vocab + Public
                         addressing; Actor (Person/Application/Group/
                         Organization/Service) with publicKey + shared-inbox;
                         Object (Note/Article/Image/Video/Tombstone) with
                         Mastodon extensions (sensitive, CW summary,
                         conversation, blurhash); 12 Activity types
                         (Create/Update/Delete/Follow/Accept/Reject/Announce/
                         Like/Undo/Move/Block/Flag) with side-effect rules;
                         OrderedCollection + Page; Webfinger (RFC 7033);
                         HTTP Signatures (cavage draft-12 + RFC 9421);
                         SHA-256 Digest; Ed25519 sign/verify.

EOC (1 crate, 2759 LOC, 20 tests)
  eoc-observability     OpenTelemetry-compatible tracing + metrics + logs.
                        W3C tracecontext + baggage. Span / SpanKind / Status.
                        Samplers: AlwaysOn/Off/RatioBased/ParentBased. Sync
                        BatchSpanProcessor + Exporter trait. Counter / Gauge /
                        Histogram. Prometheus text exposition (HELP/TYPE/
                        _bucket/_sum/_count/+Inf). StatsD / DogStatsD lines.
                        GenAI semconv (gen_ai.{operation,system,request.*,
                        response.*,usage.*}) + EOC extensions (eoc.joules.*,
                        eoc.co2e_grams, eoc.cost.usd). USD + gCO2e attribution
                        with PUE. LangSmith + LangFuse export adapters.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:51:09 -04:00

287 lines
9.5 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Typed fee accounting.
//!
//! Real-world payment fees fall into a small number of named buckets,
//! each governed by a different party with a different settlement
//! cadence. Conflating them ("just a fee") makes statement reconciliation
//! impossible; the merchant sees one PSP fee, the PSP owes 4 different
//! parties a piece of it, the regulator wants to see interchange broken
//! out separately, and the audit needs every bucket on its own line.
use op_core::{Money, Currency};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// Canonical fee buckets.
///
/// Aligned with the way modern PSP statements (Stripe, Adyen,
/// Checkout.com) break out fees, plus the buckets specific to BNPL
/// providers (Klarna, Affirm) and FX intermediaries.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FeeBucket {
/// Interchange paid to the issuer (Visa / Mastercard rate sheets;
/// EFTPOS equivalents). Largest component of card-payment fees.
Interchange,
/// Scheme fee paid to the card network (Visa, Mastercard, Amex,
/// Discover) — assessment, network usage, cross-border.
Scheme,
/// Acquirer / processor markup — the PSP's own margin.
Acquirer,
/// FX spread / conversion fee on cross-currency transactions.
Fx,
/// Settlement-network fee (FedNow, RTP, SEPA Instant operator
/// fees; ACH or wire ticket charges).
SettlementNetwork,
/// BNPL provider commission (Klarna, Affirm, Afterpay).
Bnpl,
/// Other / catch-all for rail-specific fees that don't slot
/// elsewhere. Always provide a meaningful `code` when using.
Other,
}
impl FeeBucket {
/// Human-readable label for renderers.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Interchange => "Interchange",
Self::Scheme => "Scheme",
Self::Acquirer => "Acquirer",
Self::Fx => "FX",
Self::SettlementNetwork => "Settlement Network",
Self::Bnpl => "BNPL",
Self::Other => "Other",
}
}
}
/// One fee line.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeeLine {
/// Which bucket.
pub bucket: FeeBucket,
/// A rail-specific code (e.g. Visa "CPS / Retail" interchange
/// program code, or a PSP fee code).
pub code: Option<String>,
/// Magnitude amount in the line's currency.
pub amount: Money,
/// The underlying transaction id this fee accrued against
/// (op-ledger external_id or rail reference).
pub against_external_id: Option<String>,
}
impl FeeLine {
/// Construct.
#[must_use]
pub fn new(bucket: FeeBucket, amount: Money) -> Self {
Self {
bucket,
code: None,
amount: Money {
minor_units: amount.minor_units.abs(),
currency: amount.currency,
},
against_external_id: None,
}
}
/// Builder: rail-specific code.
#[must_use]
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
/// Builder: cross-reference to the underlying transaction.
#[must_use]
pub fn with_against(mut self, external_id: impl Into<String>) -> Self {
self.against_external_id = Some(external_id.into());
self
}
}
/// A fee schedule: declarative rate sheet that turns a captured
/// transaction into a [`FeeLine`].
///
/// Each rule is `bucket × percent_bps + flat`. A captured transaction
/// runs through every rule whose `applies_when` predicate returns
/// `true`, accruing a [`FeeLine`] per matching rule.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeeSchedule {
/// Currency this schedule's flat amounts are denominated in.
pub currency: Currency,
/// Rules in evaluation order.
pub rules: Vec<FeeRule>,
}
/// One rule in a [`FeeSchedule`].
///
/// `percent_bps` is in basis points (1 bps = 0.01%). The fee on a
/// `gross` capture is `gross * percent_bps / 10_000 + flat`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeeRule {
/// Which bucket this rule produces.
pub bucket: FeeBucket,
/// Rail-specific code (echoed onto the resulting [`FeeLine`]).
pub code: Option<String>,
/// Variable component in basis points (1/100 of a percent).
pub percent_bps: u32,
/// Flat component in the schedule's currency (minor units).
pub flat_minor: i64,
}
impl FeeRule {
/// Construct.
#[must_use]
pub const fn new(bucket: FeeBucket, percent_bps: u32, flat_minor: i64) -> Self {
Self {
bucket,
code: None,
percent_bps,
flat_minor,
}
}
/// Builder: attach a rail-specific code.
#[must_use]
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
/// Compute this rule's fee against a gross amount in
/// `schedule_currency`. Returns the magnitude.
fn compute(&self, gross_minor: i64) -> Result<i64> {
let pct_part = i128::from(gross_minor.abs())
.checked_mul(i128::from(self.percent_bps))
.ok_or(Error::Overflow)?
/ 10_000;
let total = pct_part
.checked_add(i128::from(self.flat_minor))
.ok_or(Error::Overflow)?;
i64::try_from(total).map_err(|_| Error::Overflow)
}
}
impl FeeSchedule {
/// Empty schedule in the given currency.
#[must_use]
pub fn new(currency: Currency) -> Self {
Self {
currency,
rules: Vec::new(),
}
}
/// Builder: append a rule.
#[must_use]
pub fn with_rule(mut self, rule: FeeRule) -> Self {
self.rules.push(rule);
self
}
/// Apply this schedule to a captured gross amount, producing one
/// [`FeeLine`] per rule. The gross amount's currency must match
/// the schedule's currency.
///
/// # Errors
/// - [`Error::CurrencyMismatch`] on currency mismatch.
/// - [`Error::Overflow`] on i64 saturation.
pub fn accrue(
&self,
gross: Money,
against_external_id: Option<&str>,
) -> Result<Vec<FeeLine>> {
if gross.currency != self.currency {
return Err(Error::CurrencyMismatch {
line: gross.currency.code().to_owned(),
statement: self.currency.code().to_owned(),
});
}
let mut out = Vec::with_capacity(self.rules.len());
for rule in &self.rules {
let amount_minor = rule.compute(gross.minor_units)?;
if amount_minor == 0 {
continue;
}
let mut line = FeeLine::new(rule.bucket, Money::from_minor(amount_minor, self.currency));
if let Some(code) = rule.code.clone() {
line.code = Some(code);
}
if let Some(ext) = against_external_id {
line.against_external_id = Some(ext.to_owned());
}
out.push(line);
}
Ok(out)
}
/// Sum the magnitude across a slice of fee lines, grouped by
/// bucket. The returned vec is in declaration order of buckets the
/// caller passed.
#[must_use]
pub fn group_by_bucket(lines: &[FeeLine]) -> Vec<(FeeBucket, i64)> {
let mut out: Vec<(FeeBucket, i64)> = Vec::new();
for line in lines {
if let Some(slot) = out.iter_mut().find(|(b, _)| *b == line.bucket) {
slot.1 = slot.1.saturating_add(line.amount.minor_units);
} else {
out.push((line.bucket, line.amount.minor_units));
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interchange_2_9_pct_plus_30_cents() {
// Stripe-classic: 2.9% + 30c on a $100 capture = $3.20.
let sched = FeeSchedule::new(Currency::USD).with_rule(FeeRule::new(
FeeBucket::Acquirer,
290, // 2.90%
30, // 30 cents
));
let fees = sched
.accrue(Money::from_minor(10_000, Currency::USD), Some("ord-1"))
.unwrap();
assert_eq!(fees.len(), 1);
assert_eq!(fees[0].amount.minor_units, 320);
assert_eq!(fees[0].bucket, FeeBucket::Acquirer);
}
#[test]
fn currency_mismatch_rejected() {
let sched = FeeSchedule::new(Currency::USD).with_rule(FeeRule::new(FeeBucket::Scheme, 5, 0));
let r = sched.accrue(Money::from_minor(100, Currency::EUR), None);
assert!(matches!(r, Err(Error::CurrencyMismatch { .. })));
}
#[test]
fn zero_amount_rule_is_skipped() {
let sched = FeeSchedule::new(Currency::USD)
.with_rule(FeeRule::new(FeeBucket::Scheme, 0, 0))
.with_rule(FeeRule::new(FeeBucket::Acquirer, 100, 0)); // 1%
let fees = sched
.accrue(Money::from_minor(10_000, Currency::USD), None)
.unwrap();
assert_eq!(fees.len(), 1);
assert_eq!(fees[0].bucket, FeeBucket::Acquirer);
}
#[test]
fn group_by_bucket_sums() {
let lines = vec![
FeeLine::new(FeeBucket::Interchange, Money::from_minor(100, Currency::USD)),
FeeLine::new(FeeBucket::Interchange, Money::from_minor(50, Currency::USD)),
FeeLine::new(FeeBucket::Scheme, Money::from_minor(10, Currency::USD)),
];
let g = FeeSchedule::group_by_bucket(&lines);
assert_eq!(g.len(), 2);
assert_eq!(g[0], (FeeBucket::Interchange, 150));
assert_eq!(g[1], (FeeBucket::Scheme, 10));
}
}