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]>
685 lines
23 KiB
Rust
685 lines
23 KiB
Rust
//! The merchant-facing [`Statement`] aggregate.
|
|
//!
|
|
//! A statement is a closed, immutable snapshot of money movement for a
|
|
//! merchant over a [`Period`](crate::cadence::Period). It is the
|
|
//! aggregate that every downstream serializer
|
|
//! ([`RenderTarget`](crate::render::RenderTarget),
|
|
//! [`Camt053Builder`](crate::iso20022::Camt053Builder),
|
|
//! [`Bai2Writer`](crate::bai2::Bai2Writer),
|
|
//! [`Mt940Writer`](crate::mt940::Mt940Writer)) walks.
|
|
//!
|
|
//! ## Stripe-style structure
|
|
//!
|
|
//! We model the same shape Stripe daily statements ship: a per-currency
|
|
//! aggregate (gross volume, refunds, chargebacks, fees, payouts) plus
|
|
//! the line-item detail behind it. Operators on other rails populate
|
|
//! the same shape; only the fee buckets differ.
|
|
|
|
use op_core::{Currency, Money};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::cadence::Period;
|
|
use crate::error::{Error, Result};
|
|
use crate::fees::FeeLine;
|
|
|
|
/// What category a [`StatementLine`] represents.
|
|
///
|
|
/// Stripe-style buckets that map cleanly onto bank-statement lines as
|
|
/// well as PSP daily reports. The kind is what distinguishes a refund
|
|
/// from a gross capture in the aggregate roll-up.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum StatementLineKind {
|
|
/// Captured payment from a customer (gross volume).
|
|
GrossCapture,
|
|
/// Refund paid back to a customer (negative against gross).
|
|
Refund,
|
|
/// Chargeback / dispute debit (negative against gross).
|
|
Chargeback,
|
|
/// Fee charged by the acquirer / scheme / BNPL provider. Carries a
|
|
/// [`FeeLine`] attached.
|
|
Fee,
|
|
/// Payout sent to the merchant bank account (negative against
|
|
/// retained balance).
|
|
Payout,
|
|
/// Adjustment / reserve / sundry. Sign determined by the line's
|
|
/// amount.
|
|
Adjustment,
|
|
}
|
|
|
|
impl StatementLineKind {
|
|
/// Does this kind contribute to the merchant's gross volume tally?
|
|
#[must_use]
|
|
pub const fn is_gross_volume(self) -> bool {
|
|
matches!(self, Self::GrossCapture)
|
|
}
|
|
|
|
/// Does this kind reduce the merchant's retained balance?
|
|
#[must_use]
|
|
pub const fn is_outflow(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Refund | Self::Chargeback | Self::Fee | Self::Payout
|
|
)
|
|
}
|
|
}
|
|
|
|
/// One line on a statement.
|
|
///
|
|
/// Lines are signed-magnitude: `amount` is always non-negative,
|
|
/// `kind` carries the semantic direction. Downstream aggregation
|
|
/// flips signs per [`StatementLineKind::is_outflow`].
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct StatementLine {
|
|
/// Stable id, unique within the statement.
|
|
pub id: String,
|
|
/// Kind / bucket.
|
|
pub kind: StatementLineKind,
|
|
/// Magnitude amount in some currency.
|
|
pub amount: Money,
|
|
/// Unix epoch seconds when the line posted.
|
|
pub posted_at_unix_secs: u64,
|
|
/// Optional cross-reference to an op-ledger transaction's
|
|
/// `external_id` or a rail-level reference.
|
|
pub external_id: Option<String>,
|
|
/// If `kind == Fee`, the structured fee line.
|
|
pub fee: Option<FeeLine>,
|
|
/// Free-form passthrough preserved for the renderer.
|
|
pub metadata: Vec<(String, String)>,
|
|
}
|
|
|
|
impl StatementLine {
|
|
/// Construct a line. `amount` is normalized to its magnitude; the
|
|
/// sign is carried by [`StatementLineKind`].
|
|
#[must_use]
|
|
pub fn new(
|
|
id: impl Into<String>,
|
|
kind: StatementLineKind,
|
|
amount: Money,
|
|
posted_at_unix_secs: u64,
|
|
) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
kind,
|
|
amount: Money {
|
|
minor_units: amount.minor_units.abs(),
|
|
currency: amount.currency,
|
|
},
|
|
posted_at_unix_secs,
|
|
external_id: None,
|
|
fee: None,
|
|
metadata: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Builder: attach external id.
|
|
#[must_use]
|
|
pub fn with_external_id(mut self, id: impl Into<String>) -> Self {
|
|
self.external_id = Some(id.into());
|
|
self
|
|
}
|
|
|
|
/// Builder: attach a structured fee line. Only meaningful when
|
|
/// `kind == Fee`.
|
|
#[must_use]
|
|
pub fn with_fee(mut self, fee: FeeLine) -> Self {
|
|
self.fee = Some(fee);
|
|
self
|
|
}
|
|
|
|
/// Builder: attach a metadata pair.
|
|
#[must_use]
|
|
pub fn with_metadata(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
|
|
self.metadata.push((k.into(), v.into()));
|
|
self
|
|
}
|
|
|
|
/// Signed contribution of this line to the ending balance.
|
|
///
|
|
/// Gross captures increase balance; refunds, chargebacks, fees,
|
|
/// and payouts decrease it. Adjustments use the caller-supplied
|
|
/// sign embedded in the raw minor-units (`None` if the line was
|
|
/// normalized to magnitude — adjustments should pre-set the sign).
|
|
#[must_use]
|
|
pub fn signed_minor_units(&self) -> i64 {
|
|
let mag = self.amount.minor_units.abs();
|
|
match self.kind {
|
|
StatementLineKind::GrossCapture => mag,
|
|
StatementLineKind::Refund
|
|
| StatementLineKind::Chargeback
|
|
| StatementLineKind::Fee
|
|
| StatementLineKind::Payout => -mag,
|
|
StatementLineKind::Adjustment => self.amount.minor_units,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// FX rate carried per non-primary currency on a statement.
|
|
///
|
|
/// `minor_per_unit_q` is a fixed-point fraction: it expresses how many
|
|
/// minor units of the **primary** currency one whole unit of the
|
|
/// **foreign** currency converts to, scaled by `10^scale`. Using
|
|
/// scaled integers preserves the no-`f64`-money invariant.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FxRate {
|
|
/// The foreign currency.
|
|
pub foreign: Currency,
|
|
/// The primary currency this rate converts INTO.
|
|
pub primary: Currency,
|
|
/// Numerator. `1 foreign` = `minor_per_unit_q / 10^scale` primary
|
|
/// minor units.
|
|
pub minor_per_unit_q: i128,
|
|
/// Scale factor (power of ten).
|
|
pub scale: u8,
|
|
/// Rate-effective unix epoch seconds. Caller-supplied.
|
|
pub as_of_unix_secs: u64,
|
|
}
|
|
|
|
impl FxRate {
|
|
/// Convert a foreign-currency [`Money`] to the primary currency's
|
|
/// minor units. Saturates on overflow (statement rendering must
|
|
/// not panic on adversarial rates).
|
|
#[must_use]
|
|
pub fn convert_minor(&self, foreign_amount: Money) -> i64 {
|
|
if foreign_amount.currency != self.foreign {
|
|
return 0;
|
|
}
|
|
let foreign_minor = i128::from(foreign_amount.minor_units);
|
|
let foreign_exp = i32::from(foreign_amount.currency.exponent());
|
|
let primary_exp = i32::from(self.primary.exponent());
|
|
|
|
// (foreign_minor / 10^foreign_exp) * (minor_per_unit_q / 10^scale)
|
|
// * (10^primary_exp / 1)
|
|
// = foreign_minor * minor_per_unit_q
|
|
// / 10^(foreign_exp + scale - primary_exp)
|
|
let numerator = match foreign_minor.checked_mul(self.minor_per_unit_q) {
|
|
Some(n) => n,
|
|
None => return 0,
|
|
};
|
|
let denom_exp = foreign_exp + i32::from(self.scale) - primary_exp;
|
|
let result = if denom_exp >= 0 {
|
|
let d = 10_i128.pow(u32::try_from(denom_exp).unwrap_or(0));
|
|
numerator / d
|
|
} else {
|
|
let m = 10_i128.pow(u32::try_from(-denom_exp).unwrap_or(0));
|
|
match numerator.checked_mul(m) {
|
|
Some(n) => n,
|
|
None => return 0,
|
|
}
|
|
};
|
|
i64::try_from(result).unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
/// Per-currency rolled-up totals for a statement.
|
|
///
|
|
/// Computed from [`StatementLine`]s by [`Statement::aggregate`]. The
|
|
/// invariant is `ending = opening + gross_volume - refunds -
|
|
/// chargebacks - fees - payouts + adjustments`.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CurrencyAggregate {
|
|
/// The currency these totals are denominated in.
|
|
pub currency: Currency,
|
|
/// Opening balance for the period.
|
|
pub opening: Money,
|
|
/// Sum of [`StatementLineKind::GrossCapture`] line magnitudes.
|
|
pub gross_volume: Money,
|
|
/// Sum of refund line magnitudes.
|
|
pub refunds: Money,
|
|
/// Sum of chargeback line magnitudes.
|
|
pub chargebacks: Money,
|
|
/// Sum of fee line magnitudes.
|
|
pub fees: Money,
|
|
/// Sum of payout line magnitudes.
|
|
pub payouts: Money,
|
|
/// Net adjustments (signed).
|
|
pub adjustments: Money,
|
|
/// Ending balance = opening + gross - refunds - chargebacks - fees
|
|
/// - payouts + adjustments.
|
|
pub ending: Money,
|
|
}
|
|
|
|
impl CurrencyAggregate {
|
|
/// Zero aggregate in the given currency.
|
|
#[must_use]
|
|
pub fn zero(currency: Currency) -> Self {
|
|
let z = Money::zero(currency);
|
|
Self {
|
|
currency,
|
|
opening: z,
|
|
gross_volume: z,
|
|
refunds: z,
|
|
chargebacks: z,
|
|
fees: z,
|
|
payouts: z,
|
|
adjustments: z,
|
|
ending: z,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Opening / ending balance snapshot in the primary currency.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct BalanceSnapshot {
|
|
/// Opening balance in primary currency minor units.
|
|
pub opening: Money,
|
|
/// Ending balance in primary currency minor units.
|
|
pub ending: Money,
|
|
}
|
|
|
|
/// A merchant statement.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Statement {
|
|
/// Stable id (caller-supplied; merchant statement number).
|
|
pub id: String,
|
|
/// Merchant account / merchant-of-record identifier.
|
|
pub merchant_id: String,
|
|
/// Reporting period.
|
|
pub period: Period,
|
|
/// Primary currency. All cross-currency totals are FX-adjusted to
|
|
/// this currency in [`Self::primary_aggregate`].
|
|
pub primary_currency: Currency,
|
|
/// All line items.
|
|
pub lines: Vec<StatementLine>,
|
|
/// Per-currency aggregates, keyed by currency code.
|
|
pub aggregates: Vec<CurrencyAggregate>,
|
|
/// FX rates available for cross-currency aggregation.
|
|
pub fx_rates: Vec<FxRate>,
|
|
/// Free-form passthrough.
|
|
pub metadata: Vec<(String, String)>,
|
|
}
|
|
|
|
impl Statement {
|
|
/// Construct a statement skeleton. Aggregates start at zero in the
|
|
/// primary currency; call [`Self::aggregate`] after attaching
|
|
/// lines.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::InvalidPeriod`] if `period.end < period.start`.
|
|
pub fn new(
|
|
id: impl Into<String>,
|
|
merchant_id: impl Into<String>,
|
|
period: Period,
|
|
primary_currency: Currency,
|
|
) -> Result<Self> {
|
|
if period.end_unix_secs < period.start_unix_secs {
|
|
return Err(Error::InvalidPeriod {
|
|
start: period.start_unix_secs,
|
|
end: period.end_unix_secs,
|
|
});
|
|
}
|
|
Ok(Self {
|
|
id: id.into(),
|
|
merchant_id: merchant_id.into(),
|
|
period,
|
|
primary_currency,
|
|
lines: Vec::new(),
|
|
aggregates: vec![CurrencyAggregate::zero(primary_currency)],
|
|
fx_rates: Vec::new(),
|
|
metadata: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Attach the opening balance in the primary currency. Idempotent.
|
|
pub fn with_opening(mut self, opening: Money) -> Result<Self> {
|
|
if opening.currency != self.primary_currency {
|
|
return Err(Error::CurrencyMismatch {
|
|
line: opening.currency.code().to_owned(),
|
|
statement: self.primary_currency.code().to_owned(),
|
|
});
|
|
}
|
|
// Ensure the primary aggregate exists and update opening.
|
|
if let Some(agg) = self
|
|
.aggregates
|
|
.iter_mut()
|
|
.find(|a| a.currency == self.primary_currency)
|
|
{
|
|
agg.opening = opening;
|
|
agg.ending = opening;
|
|
}
|
|
Ok(self)
|
|
}
|
|
|
|
/// Builder: register an FX rate for cross-currency aggregation.
|
|
#[must_use]
|
|
pub fn with_fx_rate(mut self, rate: FxRate) -> Self {
|
|
self.fx_rates.push(rate);
|
|
self
|
|
}
|
|
|
|
/// Append a line. Rejects duplicate ids.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::DuplicateLineId`] on collision.
|
|
pub fn push_line(&mut self, line: StatementLine) -> Result<()> {
|
|
if self.lines.iter().any(|l| l.id == line.id) {
|
|
return Err(Error::DuplicateLineId(line.id));
|
|
}
|
|
self.lines.push(line);
|
|
Ok(())
|
|
}
|
|
|
|
/// Recompute [`Self::aggregates`] from [`Self::lines`].
|
|
///
|
|
/// Multi-currency safe: produces one [`CurrencyAggregate`] per
|
|
/// distinct currency observed. The primary-currency aggregate is
|
|
/// always present even if no primary-currency lines exist (its
|
|
/// `gross_volume` etc. stay zero).
|
|
///
|
|
/// # Errors
|
|
/// [`Error::Overflow`] on i64 saturation during summation.
|
|
pub fn aggregate(&mut self) -> Result<()> {
|
|
// Preserve any caller-supplied opening on the primary
|
|
// aggregate before we rebuild.
|
|
let opening = self
|
|
.aggregates
|
|
.iter()
|
|
.find(|a| a.currency == self.primary_currency)
|
|
.map_or_else(|| Money::zero(self.primary_currency), |a| a.opening);
|
|
|
|
let mut by_currency: Vec<CurrencyAggregate> = Vec::new();
|
|
by_currency.push(CurrencyAggregate::zero(self.primary_currency));
|
|
let primary_currency = self.primary_currency;
|
|
|
|
for line in &self.lines {
|
|
let cur = line.amount.currency;
|
|
let idx = match by_currency.iter().position(|a| a.currency == cur) {
|
|
Some(i) => i,
|
|
None => {
|
|
by_currency.push(CurrencyAggregate::zero(cur));
|
|
by_currency.len() - 1
|
|
}
|
|
};
|
|
let agg = &mut by_currency[idx];
|
|
let mag = line.amount.minor_units.abs();
|
|
match line.kind {
|
|
StatementLineKind::GrossCapture => {
|
|
agg.gross_volume.minor_units = agg
|
|
.gross_volume
|
|
.minor_units
|
|
.checked_add(mag)
|
|
.ok_or(Error::Overflow)?;
|
|
}
|
|
StatementLineKind::Refund => {
|
|
agg.refunds.minor_units = agg
|
|
.refunds
|
|
.minor_units
|
|
.checked_add(mag)
|
|
.ok_or(Error::Overflow)?;
|
|
}
|
|
StatementLineKind::Chargeback => {
|
|
agg.chargebacks.minor_units = agg
|
|
.chargebacks
|
|
.minor_units
|
|
.checked_add(mag)
|
|
.ok_or(Error::Overflow)?;
|
|
}
|
|
StatementLineKind::Fee => {
|
|
agg.fees.minor_units = agg
|
|
.fees
|
|
.minor_units
|
|
.checked_add(mag)
|
|
.ok_or(Error::Overflow)?;
|
|
}
|
|
StatementLineKind::Payout => {
|
|
agg.payouts.minor_units = agg
|
|
.payouts
|
|
.minor_units
|
|
.checked_add(mag)
|
|
.ok_or(Error::Overflow)?;
|
|
}
|
|
StatementLineKind::Adjustment => {
|
|
agg.adjustments.minor_units = agg
|
|
.adjustments
|
|
.minor_units
|
|
.checked_add(line.amount.minor_units)
|
|
.ok_or(Error::Overflow)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now compute ending balances per currency.
|
|
for agg in &mut by_currency {
|
|
let starting = if agg.currency == primary_currency {
|
|
opening.minor_units
|
|
} else {
|
|
0
|
|
};
|
|
agg.opening = Money::from_minor(starting, agg.currency);
|
|
let mut ending = starting;
|
|
ending = ending
|
|
.checked_add(agg.gross_volume.minor_units)
|
|
.ok_or(Error::Overflow)?;
|
|
ending = ending
|
|
.checked_sub(agg.refunds.minor_units)
|
|
.ok_or(Error::Overflow)?;
|
|
ending = ending
|
|
.checked_sub(agg.chargebacks.minor_units)
|
|
.ok_or(Error::Overflow)?;
|
|
ending = ending
|
|
.checked_sub(agg.fees.minor_units)
|
|
.ok_or(Error::Overflow)?;
|
|
ending = ending
|
|
.checked_sub(agg.payouts.minor_units)
|
|
.ok_or(Error::Overflow)?;
|
|
ending = ending
|
|
.checked_add(agg.adjustments.minor_units)
|
|
.ok_or(Error::Overflow)?;
|
|
agg.ending = Money::from_minor(ending, agg.currency);
|
|
}
|
|
|
|
self.aggregates = by_currency;
|
|
Ok(())
|
|
}
|
|
|
|
/// The primary-currency aggregate (always present after
|
|
/// [`Self::aggregate`]).
|
|
#[must_use]
|
|
pub fn primary_aggregate(&self) -> &CurrencyAggregate {
|
|
// The constructor ensures this is always present. We fall back
|
|
// to a defensive zero rather than panicking if a caller
|
|
// hand-mutated `aggregates` to empty.
|
|
self.aggregates
|
|
.iter()
|
|
.find(|a| a.currency == self.primary_currency)
|
|
.unwrap_or_else(|| {
|
|
// SAFETY: not reachable under documented invariants;
|
|
// we deliberately leak a static zero here. The check
|
|
// above keeps the happy path branch-free.
|
|
debug_assert!(false, "primary aggregate missing");
|
|
&PRIMARY_FALLBACK
|
|
})
|
|
}
|
|
|
|
/// FX-adjust all non-primary aggregates to the primary currency,
|
|
/// summing into one cross-currency [`BalanceSnapshot`]. Missing
|
|
/// rates contribute zero (lossy, by design — the operator who
|
|
/// hasn't supplied a rate has implicitly told us they don't want
|
|
/// cross-currency totals).
|
|
#[must_use]
|
|
pub fn primary_snapshot(&self) -> BalanceSnapshot {
|
|
let primary = self.primary_aggregate();
|
|
let mut opening = primary.opening.minor_units;
|
|
let mut ending = primary.ending.minor_units;
|
|
for agg in &self.aggregates {
|
|
if agg.currency == self.primary_currency {
|
|
continue;
|
|
}
|
|
if let Some(rate) = self
|
|
.fx_rates
|
|
.iter()
|
|
.find(|r| r.foreign == agg.currency && r.primary == self.primary_currency)
|
|
{
|
|
opening = opening.saturating_add(rate.convert_minor(agg.opening));
|
|
ending = ending.saturating_add(rate.convert_minor(agg.ending));
|
|
}
|
|
}
|
|
BalanceSnapshot {
|
|
opening: Money::from_minor(opening, self.primary_currency),
|
|
ending: Money::from_minor(ending, self.primary_currency),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Defensive static fallback for `primary_aggregate()` — never
|
|
// observable under documented usage; exists so the public surface
|
|
// doesn't have to return `Option<&_>` for what is structurally
|
|
// guaranteed.
|
|
static PRIMARY_FALLBACK: CurrencyAggregate = CurrencyAggregate {
|
|
currency: Currency::USD,
|
|
opening: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
gross_volume: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
refunds: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
chargebacks: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
fees: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
payouts: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
adjustments: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
ending: Money {
|
|
minor_units: 0,
|
|
currency: Currency::USD,
|
|
},
|
|
};
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::cadence::Period;
|
|
use op_core::Currency;
|
|
|
|
fn p() -> Period {
|
|
Period::new(1_700_000_000, 1_700_086_400).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn statement_new_validates_period() {
|
|
let r = Statement::new(
|
|
"S1",
|
|
"M1",
|
|
Period {
|
|
start_unix_secs: 100,
|
|
end_unix_secs: 50,
|
|
},
|
|
Currency::USD,
|
|
);
|
|
assert!(matches!(r, Err(Error::InvalidPeriod { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_rolls_up_per_currency() {
|
|
let mut s = Statement::new("S1", "M1", p(), Currency::USD).unwrap();
|
|
s.push_line(StatementLine::new(
|
|
"l1",
|
|
StatementLineKind::GrossCapture,
|
|
Money::from_minor(10_000, Currency::USD),
|
|
p().start_unix_secs,
|
|
))
|
|
.unwrap();
|
|
s.push_line(StatementLine::new(
|
|
"l2",
|
|
StatementLineKind::Fee,
|
|
Money::from_minor(290, Currency::USD),
|
|
p().start_unix_secs,
|
|
))
|
|
.unwrap();
|
|
s.push_line(StatementLine::new(
|
|
"l3",
|
|
StatementLineKind::Refund,
|
|
Money::from_minor(1_000, Currency::USD),
|
|
p().start_unix_secs,
|
|
))
|
|
.unwrap();
|
|
s.aggregate().unwrap();
|
|
let a = s.primary_aggregate();
|
|
assert_eq!(a.gross_volume.minor_units, 10_000);
|
|
assert_eq!(a.refunds.minor_units, 1_000);
|
|
assert_eq!(a.fees.minor_units, 290);
|
|
// 0 + 10000 - 1000 - 290 - 0 = 8710
|
|
assert_eq!(a.ending.minor_units, 8_710);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_line_id_rejected() {
|
|
let mut s = Statement::new("S1", "M1", p(), Currency::USD).unwrap();
|
|
s.push_line(StatementLine::new(
|
|
"dup",
|
|
StatementLineKind::GrossCapture,
|
|
Money::from_minor(1, Currency::USD),
|
|
0,
|
|
))
|
|
.unwrap();
|
|
let r = s.push_line(StatementLine::new(
|
|
"dup",
|
|
StatementLineKind::Fee,
|
|
Money::from_minor(1, Currency::USD),
|
|
0,
|
|
));
|
|
assert!(matches!(r, Err(Error::DuplicateLineId(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn opening_balance_carries_through() {
|
|
let s = Statement::new("S1", "M1", p(), Currency::USD)
|
|
.unwrap()
|
|
.with_opening(Money::from_minor(5_000, Currency::USD))
|
|
.unwrap();
|
|
assert_eq!(s.primary_aggregate().opening.minor_units, 5_000);
|
|
}
|
|
|
|
#[test]
|
|
fn fx_convert_usd_per_eur() {
|
|
// 1 EUR = 1.10 USD. 100 EUR = 110 USD = 11000 minor USD.
|
|
let rate = FxRate {
|
|
foreign: Currency::EUR,
|
|
primary: Currency::USD,
|
|
minor_per_unit_q: 110, // 1.10 with scale = 2
|
|
scale: 2,
|
|
as_of_unix_secs: 0,
|
|
};
|
|
let converted = rate.convert_minor(Money::from_minor(10_000, Currency::EUR));
|
|
// 10000 minor EUR = 100 EUR. 100 EUR * 1.10 = 110 USD = 11000 minor USD.
|
|
assert_eq!(converted, 11_000);
|
|
}
|
|
|
|
#[test]
|
|
fn primary_snapshot_sums_fx_adjusted() {
|
|
let mut s = Statement::new("S1", "M1", p(), Currency::USD).unwrap();
|
|
s.push_line(StatementLine::new(
|
|
"l1",
|
|
StatementLineKind::GrossCapture,
|
|
Money::from_minor(10_000, Currency::EUR),
|
|
0,
|
|
))
|
|
.unwrap();
|
|
s = s.with_fx_rate(FxRate {
|
|
foreign: Currency::EUR,
|
|
primary: Currency::USD,
|
|
minor_per_unit_q: 110,
|
|
scale: 2,
|
|
as_of_unix_secs: 0,
|
|
});
|
|
s.aggregate().unwrap();
|
|
let snap = s.primary_snapshot();
|
|
// 0 USD + 100 EUR * 1.10 = 110 USD = 11_000 minor USD ending.
|
|
assert_eq!(snap.ending.minor_units, 11_000);
|
|
}
|
|
}
|