open-standards/openpay/crates/op-settlement/tests/end_to_end.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

159 lines
5.7 KiB
Rust

//! End-to-end settlement test:
//!
//! 1. Post three transactions into the ledger.
//! 2. Open a batch, add all three entries.
//! 3. Tick a daily cutoff past 07:00 UTC — batch closes.
//! 4. Compute holdback (50 bps).
//! 5. Render NACHA file.
//! 6. Submit-for-payout, mark settled.
//!
//! No mocks for the financial primitives; we walk the same surfaces
//! a production operator would.
use op_core::{Currency, Money};
use op_ledger::{
Account, AccountClass, Entry, InMemoryLedgerStore, Ledger, LedgerStore, Transaction,
};
use op_settlement::nacha::{NachaCredit, NachaProfile, SecCode};
use op_settlement::{
Cutoff, HoldbackPolicy, InMemorySettlementStore, PayoutRail, SettlementEngine, SettlementStore,
nacha_file,
};
#[test]
fn ledger_to_nacha_full_pipeline() {
// ---- Ledger setup ----
let ledger_store = InMemoryLedgerStore::new();
let l = Ledger::new("Acme").unwrap();
let lid = l.id;
ledger_store.create_ledger(l).unwrap();
let cash = Account::new(lid, "cash", AccountClass::Asset, Currency::USD);
let rev = Account::new(lid, "rev", AccountClass::Revenue, Currency::USD);
let cid = cash.id;
let rid = rev.id;
ledger_store.create_account(cash).unwrap();
ledger_store.create_account(rev).unwrap();
let amounts = [750_000_i64, 250_000, 100_000];
let mut tx_ids = Vec::new();
for (i, amt) in amounts.iter().enumerate() {
let tx = Transaction::new_posted(
lid,
1_700_000_000 + i as u64, // monotonic effective time
vec![
Entry::debit(cid, Money::from_minor(*amt, Currency::USD)),
Entry::credit(rid, Money::from_minor(*amt, Currency::USD)),
],
)
.unwrap();
let tx_id = ledger_store.post_transaction(tx).unwrap();
tx_ids.push(tx_id);
}
// ---- Settlement engine ----
let settlement = InMemorySettlementStore::new();
let engine = SettlementEngine::new(
Currency::USD,
PayoutRail::AchNacha,
Cutoff::daily(7).unwrap(),
HoldbackPolicy::flat(50), // 50 bp
);
// Open a batch at Nov 14 22:13:20 UTC.
let opened_at = 1_700_000_000_u64;
let batch_id = engine.open_batch(&settlement, opened_at).unwrap();
// Pull each posted tx into the batch.
for (i, tx_id) in tx_ids.iter().enumerate() {
engine
.add_entry(
&settlement,
batch_id,
*tx_id,
Money::from_minor(amounts[i], Currency::USD),
Some(format!("ord-{i}")),
)
.unwrap();
}
// Tick past the Nov 15 07:00 UTC cutoff — should close.
let next_day_after_cutoff = 1_700_032_000_u64;
let closed = engine
.tick(&settlement, opened_at, next_day_after_cutoff)
.unwrap();
assert_eq!(closed, Some(batch_id));
let batch_after_close = settlement.get_batch(batch_id).unwrap();
assert_eq!(batch_after_close.status.code(), "closed");
let hb = batch_after_close.holdback.expect("holdback set");
// Gross = $11,000.00 = 1_100_000 cents. 50bp = 5_500 cents.
assert_eq!(hb.gross, Money::from_minor(1_100_000, Currency::USD));
assert_eq!(hb.reserve, Money::from_minor(5_500, Currency::USD));
// ---- NACHA payout ----
let profile = NachaProfile {
odfi_routing: "121000248".into(),
immediate_origin: "1234567890".into(),
immediate_destination: "0210000211".into(),
company_name: "OPENPAY VENDOR INC".into(),
company_id: "9876543210".into(),
company_entry_description: "SETTLEMNT".into(),
effective_entry_date: "261122".into(),
};
let credits: Vec<NachaCredit> = amounts
.iter()
.enumerate()
.map(|(i, amt)| NachaCredit {
rdfi_routing: "021000021".into(),
account_number: format!("11111111{i:02}"),
receiver_name: format!("RECEIVER {i}"),
amount_cents: u64::try_from(*amt).unwrap(),
individual_id: format!("RCV-{i}"),
sec: SecCode::Ccd,
})
.collect();
let nacha = nacha_file(&batch_after_close, &profile, &credits).unwrap();
// 1 file_header + 1 batch_header + 3 details + 1 batch_ctl +
// 1 file_ctl = 7 records, padded to 10.
assert_eq!(nacha.lines().count(), 10);
for line in nacha.lines() {
assert_eq!(line.len(), 94);
}
// ---- Submit and settle ----
engine
.submit_for_payout(&settlement, batch_id, "trace-abc-123", 1_700_032_500)
.unwrap();
let final_batch = engine
.mark_settled(&settlement, batch_id, 1_700_032_600)
.unwrap();
assert!(final_batch.status.is_terminal());
assert_eq!(final_batch.status.code(), "paid");
}
#[test]
fn empty_batch_cannot_be_paid() {
let settlement = InMemorySettlementStore::new();
let engine = SettlementEngine::new(
Currency::USD,
PayoutRail::AchNacha,
Cutoff::Manual,
HoldbackPolicy::none(),
);
let bid = engine.open_batch(&settlement, 1_000).unwrap();
// Close with no entries — legal (zero-amount batch).
let b = engine.close_batch(&settlement, bid, 0, 2_000).unwrap();
assert_eq!(b.status.code(), "closed");
// But NACHA generation rejects empty credit lists.
let profile = NachaProfile {
odfi_routing: "121000248".into(),
immediate_origin: "1234567890".into(),
immediate_destination: "0210000211".into(),
company_name: "OPENPAY VENDOR INC".into(),
company_id: "9876543210".into(),
company_entry_description: "SETTLEMNT".into(),
effective_entry_date: "261122".into(),
};
let err = nacha_file(&b, &profile, &[]).unwrap_err();
assert!(matches!(err, op_settlement::Error::EmptyBatch));
}