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

458 lines
16 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.

//! Integration tests for `op-ledger`.
//!
//! Plain ledger flows are unit-tested per-module. These tests
//! exercise composition against `op-orchestrator` — proving that a
//! merchant deployment can route the orchestrator's
//! `OrchestrationOutcome` directly into a ledger transaction.
//!
//! Strict bookkeeping flow:
//!
//! 1. Orchestrator returns `Approved` from a card auth.
//! 2. Caller posts a **pending** ledger transaction: debit
//! `merchant_receivable_card` (asset, debit-normal); credit
//! `revenue` (revenue, credit-normal).
//! 3. Later, settlement notification arrives; caller marks the
//! transaction **posted**. (Or rail signals failure → archived.)
//! 4. PSP fees are recorded separately: debit `psp_fees` (expense,
//! debit-normal); credit `cash` (asset, debit-normal).
//!
//! Critical invariant exercised here: **idempotency at both layers**.
//! The orchestrator's idempotency key flows through to the ledger
//! transaction's `external_id`. A duplicate orchestration → a
//! duplicate ledger post → returns the existing transaction.
//! Balance is updated exactly once.
use op_core::{Currency, Money};
use op_ledger::{
Account, AccountClass, Entry, InMemoryLedgerStore, Ledger, LedgerStore, Status, Transaction,
};
const COFFEE_USD_MINOR: i64 = 525; // $5.25
/// Set up a tiny chart of accounts for an Acme Coffee LLC merchant.
fn merchant_books() -> (
InMemoryLedgerStore,
op_ledger::LedgerId,
op_ledger::AccountId, // merchant_receivable_card
op_ledger::AccountId, // revenue
op_ledger::AccountId, // psp_fees
op_ledger::AccountId, // cash
) {
let store = InMemoryLedgerStore::new();
let l = Ledger::new("Acme Coffee FY2026").unwrap();
let lid = l.id;
store.create_ledger(l).unwrap();
let receivable = Account::new(
lid,
"merchant_receivable_card",
AccountClass::Asset,
Currency::USD,
);
let revenue = Account::new(lid, "revenue", AccountClass::Revenue, Currency::USD);
let psp_fees = Account::new(lid, "psp_fees", AccountClass::Expense, Currency::USD);
let cash = Account::new(lid, "cash", AccountClass::Asset, Currency::USD);
let rid = receivable.id;
let rev_id = revenue.id;
let fees_id = psp_fees.id;
let cash_id = cash.id;
store.create_account(receivable).unwrap();
store.create_account(revenue).unwrap();
store.create_account(psp_fees).unwrap();
store.create_account(cash).unwrap();
(store, lid, rid, rev_id, fees_id, cash_id)
}
// ============================================================
// Test 1: Approved card auth → pending ledger transaction
// ============================================================
#[test]
fn approved_auth_creates_pending_transaction() {
let (store, lid, receivable, revenue, _fees, _cash) = merchant_books();
// Orchestrator just returned Approved for ord-1. Post the
// pending bookkeeping.
let auth = Transaction::new_pending(
lid,
1_700_000_000, // effective_at, unix seconds
vec![
Entry::debit(
receivable,
Money::from_minor(COFFEE_USD_MINOR, Currency::USD),
),
Entry::credit(revenue, Money::from_minor(COFFEE_USD_MINOR, Currency::USD)),
],
)
.unwrap()
.with_external_id("ord-1")
.with_description("Card auth ord-1 (pending settlement)");
let tid = store.post_transaction(auth).unwrap();
// Pending balance reflects the auth; posted balance does not.
let r = store.balance(receivable).unwrap();
assert_eq!(r.posted.minor_units, 0);
assert_eq!(r.pending.minor_units, COFFEE_USD_MINOR);
let rev = store.balance(revenue).unwrap();
assert_eq!(rev.posted.minor_units, 0);
assert_eq!(rev.pending.minor_units, COFFEE_USD_MINOR);
// Transaction is retrievable by external id.
let recovered = store.find_by_external_id("ord-1").unwrap().unwrap();
assert_eq!(recovered.id, tid);
assert_eq!(recovered.status, Status::Pending);
}
// ============================================================
// Test 2: Settlement → mark posted; balances move pending → posted
// ============================================================
#[test]
fn settlement_marks_posted_and_balances_settle() {
let (store, lid, receivable, revenue, _fees, _cash) = merchant_books();
let auth = Transaction::new_pending(
lid,
1_700_000_000,
vec![
Entry::debit(
receivable,
Money::from_minor(COFFEE_USD_MINOR, Currency::USD),
),
Entry::credit(revenue, Money::from_minor(COFFEE_USD_MINOR, Currency::USD)),
],
)
.unwrap()
.with_external_id("ord-2");
let tid = store.post_transaction(auth).unwrap();
// Settlement notification arrives.
store.mark_posted(tid).unwrap();
let r = store.balance(receivable).unwrap();
assert_eq!(r.posted.minor_units, COFFEE_USD_MINOR);
assert_eq!(r.pending.minor_units, COFFEE_USD_MINOR);
let t = store.get_transaction(tid).unwrap();
assert_eq!(t.status, Status::Posted);
}
// ============================================================
// Test 3: PSP fee captured as separate transaction
// ============================================================
#[test]
fn psp_fee_recorded_separately() {
let (store, lid, _receivable, _revenue, fees, cash) = merchant_books();
// PSP charged $0.15 on the $5.25 sale.
let fee_tx = Transaction::new_posted(
lid,
1_700_000_000,
vec![
Entry::debit(fees, Money::from_minor(15, Currency::USD)),
Entry::credit(cash, Money::from_minor(15, Currency::USD)),
],
)
.unwrap()
.with_external_id("psp-fee-ord-1");
store.post_transaction(fee_tx).unwrap();
let fees_bal = store.balance(fees).unwrap();
assert_eq!(fees_bal.posted.minor_units, 15);
// Cash, debit-normal, decreased by 15 → balance = -15.
let cash_bal = store.balance(cash).unwrap();
assert_eq!(cash_bal.posted.minor_units, -15);
}
// ============================================================
// Test 4: Idempotency replay returns same transaction id; balance
// updated only once.
// ============================================================
#[test]
fn replay_with_same_external_id_does_not_double_count() {
let (store, lid, receivable, revenue, _fees, _cash) = merchant_books();
let entries = vec![
Entry::debit(receivable, Money::from_minor(1000, Currency::USD)),
Entry::credit(revenue, Money::from_minor(1000, Currency::USD)),
];
let auth1 = Transaction::new_posted(lid, 100, entries.clone())
.unwrap()
.with_external_id("ord-replay");
let auth2 = Transaction::new_posted(lid, 100, entries)
.unwrap()
.with_external_id("ord-replay");
let id1 = store.post_transaction(auth1).unwrap();
let id2 = store.post_transaction(auth2).unwrap();
assert_eq!(id1, id2);
// Balance reflects ONE transaction, $10.00.
let r = store.balance(receivable).unwrap();
assert_eq!(r.posted.minor_units, 1000);
}
// ============================================================
// Test 5: Refund modeled as reversal of original posted transaction
// ============================================================
#[test]
fn refund_reverses_balance_to_zero() {
let (store, lid, receivable, revenue, _fees, _cash) = merchant_books();
let original = Transaction::new_posted(
lid,
100,
vec![
Entry::debit(receivable, Money::from_minor(2500, Currency::USD)),
Entry::credit(revenue, Money::from_minor(2500, Currency::USD)),
],
)
.unwrap()
.with_external_id("ord-refund");
store.post_transaction(original.clone()).unwrap();
assert_eq!(store.balance(receivable).unwrap().posted.minor_units, 2500);
// Customer wants a refund. Reverse the original.
let refund = Transaction::reversal_of(&original, 200).unwrap();
let rid = store.post_transaction(refund).unwrap();
store.mark_posted(rid).unwrap();
let r = store.balance(receivable).unwrap();
assert_eq!(r.posted.minor_units, 0);
let rev = store.balance(revenue).unwrap();
assert_eq!(rev.posted.minor_units, 0);
}
// ============================================================
// Test 6: Multiple orders accumulate
// ============================================================
#[test]
fn many_small_orders_sum_correctly() {
let (store, lid, receivable, revenue, _fees, _cash) = merchant_books();
for i in 0..10 {
let amount = 100 * (i + 1); // $1.00, $2.00, ..., $10.00
let t = Transaction::new_posted(
lid,
i as u64,
vec![
Entry::debit(receivable, Money::from_minor(amount, Currency::USD)),
Entry::credit(revenue, Money::from_minor(amount, Currency::USD)),
],
)
.unwrap()
.with_external_id(format!("ord-batch-{i}"));
store.post_transaction(t).unwrap();
}
// Sum of 100 to 1000 stepping 100 = 5500.
let r = store.balance(receivable).unwrap();
assert_eq!(r.posted.minor_units, 5500);
}
// ============================================================
// Test 7: Archived transactions don't affect balance
// ============================================================
#[test]
fn archived_transactions_are_invisible_to_balance() {
let (store, lid, receivable, revenue, _fees, _cash) = merchant_books();
// Two transactions: ord-7 posted, ord-8 archived.
let posted = Transaction::new_posted(
lid,
100,
vec![
Entry::debit(receivable, Money::from_minor(500, Currency::USD)),
Entry::credit(revenue, Money::from_minor(500, Currency::USD)),
],
)
.unwrap()
.with_external_id("ord-7");
let archived_pending = Transaction::new_pending(
lid,
100,
vec![
Entry::debit(receivable, Money::from_minor(9999, Currency::USD)),
Entry::credit(revenue, Money::from_minor(9999, Currency::USD)),
],
)
.unwrap()
.with_external_id("ord-8");
store.post_transaction(posted).unwrap();
let archived_id = store.post_transaction(archived_pending).unwrap();
store.mark_archived(archived_id).unwrap();
let r = store.balance(receivable).unwrap();
// Only the posted $5.00 counts.
assert_eq!(r.posted.minor_units, 500);
assert_eq!(r.pending.minor_units, 500);
}
// ============================================================
// Test 8: Multi-currency ledger handles each currency independently
// ============================================================
#[test]
fn multi_currency_ledger_supports_per_currency_accounts() {
let store = InMemoryLedgerStore::new();
let ledger = Ledger::new("MultiCcy").unwrap();
let lid = ledger.id;
store.create_ledger(ledger).unwrap();
// USD chart.
let usd_recv = Account::new(lid, "recv_usd", AccountClass::Asset, Currency::USD);
let usd_rev = Account::new(lid, "rev_usd", AccountClass::Revenue, Currency::USD);
let usd_recv_id = usd_recv.id;
let usd_rev_id = usd_rev.id;
store.create_account(usd_recv).unwrap();
store.create_account(usd_rev).unwrap();
// EUR chart.
let eur_recv = Account::new(lid, "recv_eur", AccountClass::Asset, Currency::EUR);
let eur_rev = Account::new(lid, "rev_eur", AccountClass::Revenue, Currency::EUR);
let eur_recv_id = eur_recv.id;
let eur_rev_id = eur_rev.id;
store.create_account(eur_recv).unwrap();
store.create_account(eur_rev).unwrap();
// USD sale.
let usd_tx = Transaction::new_posted(
lid,
0,
vec![
Entry::debit(usd_recv_id, Money::from_minor(1000, Currency::USD)),
Entry::credit(usd_rev_id, Money::from_minor(1000, Currency::USD)),
],
)
.unwrap();
store.post_transaction(usd_tx).unwrap();
// EUR sale.
let eur_tx = Transaction::new_posted(
lid,
0,
vec![
Entry::debit(eur_recv_id, Money::from_minor(2000, Currency::EUR)),
Entry::credit(eur_rev_id, Money::from_minor(2000, Currency::EUR)),
],
)
.unwrap();
store.post_transaction(eur_tx).unwrap();
// Each balance carries its own currency; they don't leak.
let usd_b = store.balance(usd_recv_id).unwrap();
assert_eq!(usd_b.currency, Currency::USD);
assert_eq!(usd_b.posted.minor_units, 1000);
let eur_b = store.balance(eur_recv_id).unwrap();
assert_eq!(eur_b.currency, Currency::EUR);
assert_eq!(eur_b.posted.minor_units, 2000);
}
// ============================================================
// Test 9: Full kiosk-day simulation — many orders, fees, one refund
// ============================================================
#[test]
fn kiosk_day_simulation_balances_remain_consistent() {
let (store, lid, receivable, revenue, fees, cash) = merchant_books();
// 5 orders of $10 each, with $0.30 PSP fee each.
let mut posted_orders: Vec<op_ledger::TransactionId> = Vec::new();
for i in 0..5 {
let ord = Transaction::new_posted(
lid,
i,
vec![
Entry::debit(receivable, Money::from_minor(1000, Currency::USD)),
Entry::credit(revenue, Money::from_minor(1000, Currency::USD)),
],
)
.unwrap()
.with_external_id(format!("ord-{i}"));
posted_orders.push(store.post_transaction(ord).unwrap());
let fee = Transaction::new_posted(
lid,
i,
vec![
Entry::debit(fees, Money::from_minor(30, Currency::USD)),
Entry::credit(cash, Money::from_minor(30, Currency::USD)),
],
)
.unwrap()
.with_external_id(format!("fee-ord-{i}"));
store.post_transaction(fee).unwrap();
}
// Refund order 2.
let order2 = store.get_transaction(posted_orders[2]).unwrap();
let refund = Transaction::reversal_of(&order2, 1000).unwrap();
let refund_id = store.post_transaction(refund).unwrap();
store.mark_posted(refund_id).unwrap();
// Expected balances:
// receivable: 4 orders × $10 = $40 (one refunded)
// revenue: same: $40
// fees: 5 fees × $0.30 = $1.50 (PSP doesn't refund the fee)
// cash: -5 × $0.30 = -$1.50
assert_eq!(store.balance(receivable).unwrap().posted.minor_units, 4000);
assert_eq!(store.balance(revenue).unwrap().posted.minor_units, 4000);
assert_eq!(store.balance(fees).unwrap().posted.minor_units, 150);
assert_eq!(store.balance(cash).unwrap().posted.minor_units, -150);
}
// ============================================================
// Test 10: Transactions across ledgers are isolated
// ============================================================
#[test]
fn ledgers_are_isolated_balance_views() {
let store = InMemoryLedgerStore::new();
let l1 = Ledger::new("Merchant A").unwrap();
let l2 = Ledger::new("Merchant B").unwrap();
let l1_id = l1.id;
let l2_id = l2.id;
store.create_ledger(l1).unwrap();
store.create_ledger(l2).unwrap();
// Each ledger has its own "revenue" account.
let rev_a = Account::new(l1_id, "revenue", AccountClass::Revenue, Currency::USD);
let cash_a = Account::new(l1_id, "cash", AccountClass::Asset, Currency::USD);
let rev_b = Account::new(l2_id, "revenue", AccountClass::Revenue, Currency::USD);
let cash_b = Account::new(l2_id, "cash", AccountClass::Asset, Currency::USD);
let rev_a_id = rev_a.id;
let cash_a_id = cash_a.id;
let rev_b_id = rev_b.id;
store.create_account(rev_a).unwrap();
store.create_account(cash_a).unwrap();
store.create_account(rev_b).unwrap();
store.create_account(cash_b).unwrap();
// Sale in ledger A only.
let tx_a = Transaction::new_posted(
l1_id,
0,
vec![
Entry::debit(cash_a_id, Money::from_minor(1234, Currency::USD)),
Entry::credit(rev_a_id, Money::from_minor(1234, Currency::USD)),
],
)
.unwrap();
store.post_transaction(tx_a).unwrap();
// Ledger B is untouched.
assert_eq!(store.balance(rev_a_id).unwrap().posted.minor_units, 1234);
assert_eq!(store.balance(rev_b_id).unwrap().posted.minor_units, 0);
}