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]>
245 lines
8.9 KiB
Rust
245 lines
8.9 KiB
Rust
//! SWIFT MT940 customer statement writer (and MT942 intra-day report).
|
|
//!
|
|
//! MT940 is the canonical end-of-day statement in SWIFT FIN format,
|
|
//! still the workhorse for European correspondent banking and many
|
|
//! ERP integrations. MT942 is the same shape, intra-day. Both share
|
|
//! field semantics; the only difference relevant to us is the
|
|
//! statement-type indicator on field `:25:` / `:28C:`.
|
|
//!
|
|
//! ### Fields emitted
|
|
//!
|
|
//! - `:20:` Transaction reference number — `statement.id`.
|
|
//! - `:25:` Account identification — `merchant_id`.
|
|
//! - `:28C:` Statement / sequence number — `1/1`.
|
|
//! - `:60F:` Opening balance — type `C` or `D`, date YYMMDD, currency, amount.
|
|
//! - `:61:` Statement line — one per [`StatementLine`].
|
|
//! - `:86:` Information to account owner — free text (kind, ext id).
|
|
//! - `:62F:` Closing balance.
|
|
|
|
use crate::error::Result;
|
|
use crate::statement::{Statement, StatementLineKind};
|
|
|
|
/// MT940 (and MT942) writer.
|
|
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct Mt940Writer {
|
|
/// If true, emits as an MT942 intra-day report; otherwise MT940.
|
|
/// MT942 omits the `:60F:` opening balance and uses `:90D:`/`:90C:`
|
|
/// summary counters; in our minimal implementation the only
|
|
/// observable change is the field label set we use.
|
|
pub intra_day: bool,
|
|
}
|
|
|
|
impl Mt940Writer {
|
|
/// Serialize a [`Statement`] as MT940/MT942 plaintext.
|
|
///
|
|
/// # Errors
|
|
/// Reserved.
|
|
pub fn build(&self, statement: &Statement) -> Result<String> {
|
|
let primary = statement.primary_aggregate();
|
|
let mut out = String::new();
|
|
out.push_str(&format!(":20:{}\n", truncate(&statement.id, 16)));
|
|
out.push_str(&format!(
|
|
":25:{}\n",
|
|
truncate(&statement.merchant_id, 35)
|
|
));
|
|
out.push_str(":28C:1/1\n");
|
|
|
|
let yymmdd_end = unix_to_yymmdd(statement.period.end_unix_secs);
|
|
let yymmdd_start = unix_to_yymmdd(statement.period.start_unix_secs);
|
|
|
|
if !self.intra_day {
|
|
// :60F: Opening balance
|
|
let (sign, amt) = signed_for_swift(primary.opening.minor_units);
|
|
out.push_str(&format!(
|
|
":60F:{sign}{yymmdd_start}{cur}{amt}\n",
|
|
cur = primary.opening.currency,
|
|
amt = format_swift_amount(amt, primary.opening.currency.exponent())
|
|
));
|
|
}
|
|
|
|
// :61: + :86: per line.
|
|
for line in &statement.lines {
|
|
let (sign, amt) = signed_for_swift_line(line.kind, line.amount.minor_units);
|
|
let entry_date = unix_to_yymmdd(line.posted_at_unix_secs);
|
|
let value_date = &entry_date[2..]; // MMDD on :61:
|
|
let n_code = mt940_transaction_type(line.kind);
|
|
let ref_field = line
|
|
.external_id
|
|
.as_deref()
|
|
.map_or_else(|| line.id.clone(), str::to_owned);
|
|
out.push_str(&format!(
|
|
":61:{entry_date}{value_date}{sign}{amt}{n_code}{ref_field}//{src}\n",
|
|
amt = format_swift_amount(amt, line.amount.currency.exponent()),
|
|
src = truncate(&line.id, 16)
|
|
));
|
|
out.push_str(&format!(
|
|
":86:{kind:?} ext={ext}\n",
|
|
kind = line.kind,
|
|
ext = line.external_id.as_deref().unwrap_or("")
|
|
));
|
|
}
|
|
|
|
if !self.intra_day {
|
|
// :62F: Closing balance
|
|
let (sign, amt) = signed_for_swift(primary.ending.minor_units);
|
|
out.push_str(&format!(
|
|
":62F:{sign}{yymmdd_end}{cur}{amt}\n",
|
|
cur = primary.ending.currency,
|
|
amt = format_swift_amount(amt, primary.ending.currency.exponent())
|
|
));
|
|
} else {
|
|
// :90D:/:90C: summary counters for MT942.
|
|
let mut credits: i64 = 0;
|
|
let mut debits: i64 = 0;
|
|
for line in &statement.lines {
|
|
if line.kind.is_outflow()
|
|
|| matches!(line.kind, StatementLineKind::Adjustment)
|
|
&& line.amount.minor_units < 0
|
|
{
|
|
debits = debits.saturating_add(line.amount.minor_units.abs());
|
|
} else {
|
|
credits = credits.saturating_add(line.amount.minor_units.abs());
|
|
}
|
|
}
|
|
let cur = statement.primary_currency;
|
|
let exp = cur.exponent();
|
|
out.push_str(&format!(
|
|
":90D:{n}{cur}{a}\n",
|
|
n = statement.lines.iter().filter(|l| l.kind.is_outflow()).count(),
|
|
a = format_swift_amount(debits, exp)
|
|
));
|
|
out.push_str(&format!(
|
|
":90C:{n}{cur}{a}\n",
|
|
n = statement
|
|
.lines
|
|
.iter()
|
|
.filter(|l| matches!(l.kind, StatementLineKind::GrossCapture))
|
|
.count(),
|
|
a = format_swift_amount(credits, exp)
|
|
));
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
fn signed_for_swift(minor: i64) -> (&'static str, i64) {
|
|
if minor < 0 { ("D", minor.abs()) } else { ("C", minor) }
|
|
}
|
|
|
|
fn signed_for_swift_line(kind: StatementLineKind, minor: i64) -> (&'static str, i64) {
|
|
let mag = minor.abs();
|
|
match kind {
|
|
StatementLineKind::GrossCapture => ("C", mag),
|
|
StatementLineKind::Refund
|
|
| StatementLineKind::Chargeback
|
|
| StatementLineKind::Fee
|
|
| StatementLineKind::Payout => ("D", mag),
|
|
StatementLineKind::Adjustment => signed_for_swift(minor),
|
|
}
|
|
}
|
|
|
|
const fn mt940_transaction_type(kind: StatementLineKind) -> &'static str {
|
|
// SWIFT N-code prefixes per MT940 spec.
|
|
match kind {
|
|
StatementLineKind::GrossCapture => "NTRF", // Transfer
|
|
StatementLineKind::Refund => "NRTI", // Reversal
|
|
StatementLineKind::Chargeback => "NCHG", // Charge
|
|
StatementLineKind::Fee => "NCOM", // Commission
|
|
StatementLineKind::Payout => "NTRF", // Transfer
|
|
StatementLineKind::Adjustment => "NMSC", // Miscellaneous
|
|
}
|
|
}
|
|
|
|
fn format_swift_amount(minor: i64, exponent: u8) -> String {
|
|
let exp = u32::from(exponent);
|
|
if exp == 0 {
|
|
return format!("{minor},");
|
|
}
|
|
let divisor = 10_i64.pow(exp);
|
|
let whole = minor / divisor;
|
|
let frac = (minor % divisor).abs();
|
|
// SWIFT decimal separator is ','
|
|
format!("{whole},{frac:0width$}", width = exp as usize)
|
|
}
|
|
|
|
fn unix_to_yymmdd(unix_secs: u64) -> String {
|
|
let iso = crate::iso20022::unix_to_iso8601(unix_secs);
|
|
let bytes = iso.as_bytes();
|
|
if bytes.len() < 10 {
|
|
return "000000".to_owned();
|
|
}
|
|
let yy = core::str::from_utf8(&bytes[2..4]).unwrap_or("00");
|
|
let mm = core::str::from_utf8(&bytes[5..7]).unwrap_or("00");
|
|
let dd = core::str::from_utf8(&bytes[8..10]).unwrap_or("00");
|
|
format!("{yy}{mm}{dd}")
|
|
}
|
|
|
|
fn truncate(s: &str, n: usize) -> String {
|
|
if s.len() <= n { s.to_owned() } else { s[..n].to_owned() }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::cadence::Period;
|
|
use crate::statement::{Statement, StatementLine};
|
|
use op_core::{Currency, Money};
|
|
|
|
#[test]
|
|
fn mt940_emits_canonical_fields() {
|
|
let mut s = Statement::new(
|
|
"STMT-1",
|
|
"MRCH-1",
|
|
Period::new(0, 86_399).unwrap(),
|
|
Currency::USD,
|
|
)
|
|
.unwrap()
|
|
.with_opening(Money::from_minor(1_000, Currency::USD))
|
|
.unwrap();
|
|
s.push_line(StatementLine::new(
|
|
"l1",
|
|
StatementLineKind::GrossCapture,
|
|
Money::from_minor(10_000, Currency::USD),
|
|
42,
|
|
))
|
|
.unwrap();
|
|
s.aggregate().unwrap();
|
|
let mt = Mt940Writer::default().build(&s).unwrap();
|
|
assert!(mt.contains(":20:STMT-1"));
|
|
assert!(mt.contains(":25:MRCH-1"));
|
|
assert!(mt.contains(":28C:1/1"));
|
|
assert!(mt.contains(":60F:C700101USD"));
|
|
assert!(mt.contains(":61:"));
|
|
assert!(mt.contains(":62F:"));
|
|
// The :61: amount carries no currency (it lives on :25:/:60F:).
|
|
// For the $100.00 capture we expect a credit amount of "100,00"
|
|
// and the N-code suffix NTRF for a gross capture.
|
|
assert!(mt.contains("C100,00NTRF"));
|
|
// The opening balance of $10.00 is "10,00" in MT940 decimal form.
|
|
assert!(mt.contains(":60F:C700101USD10,00"));
|
|
}
|
|
|
|
#[test]
|
|
fn mt942_uses_summary_counters_not_balance() {
|
|
let mut s = Statement::new(
|
|
"STMT-2",
|
|
"MRCH-1",
|
|
Period::new(0, 1).unwrap(),
|
|
Currency::USD,
|
|
)
|
|
.unwrap();
|
|
s.push_line(StatementLine::new(
|
|
"l1",
|
|
StatementLineKind::GrossCapture,
|
|
Money::from_minor(500, Currency::USD),
|
|
0,
|
|
))
|
|
.unwrap();
|
|
s.aggregate().unwrap();
|
|
let mt = Mt940Writer { intra_day: true }.build(&s).unwrap();
|
|
assert!(!mt.contains(":60F:"));
|
|
assert!(!mt.contains(":62F:"));
|
|
assert!(mt.contains(":90C:"));
|
|
assert!(mt.contains(":90D:"));
|
|
}
|
|
}
|