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.
208 lines
7 KiB
Rust
208 lines
7 KiB
Rust
//! Bi-temporal time-travel tests for `LedgerHistory` on
|
|
//! `GraphLedgerStore`.
|
|
//!
|
|
//! These exercise Phase 17's core promise: the graph backend keeps
|
|
//! every prior fact, so an operator (or auditor) can ask "what did
|
|
//! the books look like at point X?" and get an answer.
|
|
|
|
use op_core::{Currency, Money};
|
|
use op_graph::{GraphHandle, GraphLedgerStore};
|
|
use op_ledger::{
|
|
Account, AccountClass, Entry, Ledger, LedgerHistory, LedgerStore, Status, Transaction,
|
|
};
|
|
|
|
fn fixture() -> (
|
|
GraphLedgerStore,
|
|
GraphHandle,
|
|
op_ledger::LedgerId,
|
|
op_ledger::AccountId,
|
|
op_ledger::AccountId,
|
|
) {
|
|
let handle = GraphHandle::new_in_memory();
|
|
let store = GraphLedgerStore::with_handle(handle.clone());
|
|
let l = Ledger::new("Acme").unwrap();
|
|
let lid = l.id;
|
|
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;
|
|
store.create_account(cash).unwrap();
|
|
store.create_account(rev).unwrap();
|
|
(store, handle, lid, cid, rid)
|
|
}
|
|
|
|
#[test]
|
|
fn balance_as_of_before_reversal_shows_pre_reversal_state() {
|
|
let (store, handle, lid, cid, rid) = fixture();
|
|
|
|
// Post an initial $5.00 transaction.
|
|
let tx = Transaction::new_posted(
|
|
lid,
|
|
1_000,
|
|
vec![
|
|
Entry::debit(cid, Money::from_minor(500, Currency::USD)),
|
|
Entry::credit(rid, Money::from_minor(500, Currency::USD)),
|
|
],
|
|
)
|
|
.unwrap()
|
|
.with_external_id("ORD-1");
|
|
store.post_transaction(tx).unwrap();
|
|
|
|
// Snapshot AFTER the initial post but BEFORE the reversal.
|
|
let snap = handle.tx_count();
|
|
|
|
// Now post a reversing entry of $5.00 (credit cash, debit rev).
|
|
let reversal = Transaction::new_posted(
|
|
lid,
|
|
2_000,
|
|
vec![
|
|
Entry::credit(cid, Money::from_minor(500, Currency::USD)),
|
|
Entry::debit(rid, Money::from_minor(500, Currency::USD)),
|
|
],
|
|
)
|
|
.unwrap()
|
|
.with_external_id("ORD-1-REVERSE");
|
|
store.post_transaction(reversal).unwrap();
|
|
|
|
// Present-time balance on cash is zero (the reversal cancelled it).
|
|
let now = store.balance(cid).unwrap();
|
|
assert_eq!(now.posted.minor_units, 0);
|
|
|
|
// Time-travel: at the snapshot, cash was up by $5 (debit-normal,
|
|
// posted only the first tx's debit).
|
|
let then = store.balance_as_of(cid, snap).unwrap();
|
|
assert_eq!(then.posted.minor_units, 500);
|
|
}
|
|
|
|
#[test]
|
|
fn transaction_as_of_returns_pending_status_before_mark_posted() {
|
|
let (store, handle, lid, cid, rid) = fixture();
|
|
|
|
let tx = Transaction::new_pending(
|
|
lid,
|
|
1_000,
|
|
vec![
|
|
Entry::debit(cid, Money::from_minor(525, Currency::USD)),
|
|
Entry::credit(rid, Money::from_minor(525, Currency::USD)),
|
|
],
|
|
)
|
|
.unwrap()
|
|
.with_external_id("ORD-PENDING");
|
|
let tid = store.post_transaction(tx).unwrap();
|
|
|
|
// Snapshot while the tx is still pending.
|
|
let snap = handle.tx_count();
|
|
|
|
// Then settle it.
|
|
store.mark_posted(tid).unwrap();
|
|
|
|
// Present: posted.
|
|
let now = store.get_transaction(tid).unwrap();
|
|
assert_eq!(now.status, Status::Posted);
|
|
|
|
// Then: pending — the historical view at `snap` predates the
|
|
// `mark_posted`.
|
|
let then = store.transaction_as_of(tid, snap).unwrap();
|
|
assert_eq!(then.status, Status::Pending);
|
|
assert_eq!(then.external_id.as_deref(), Some("ORD-PENDING"));
|
|
assert_eq!(then.entries.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn balance_as_of_through_pending_then_posted_transition() {
|
|
// Pending tx contributes to `pending` balance but not `posted`.
|
|
// After `mark_posted` it contributes to both. Snapshot in
|
|
// between and verify that.
|
|
let (store, handle, lid, cid, rid) = fixture();
|
|
let tx = Transaction::new_pending(
|
|
lid,
|
|
1_000,
|
|
vec![
|
|
Entry::debit(cid, Money::from_minor(1_000, Currency::USD)),
|
|
Entry::credit(rid, Money::from_minor(1_000, Currency::USD)),
|
|
],
|
|
)
|
|
.unwrap()
|
|
.with_external_id("ORD-FLOW");
|
|
let tid = store.post_transaction(tx).unwrap();
|
|
let snap_pending = handle.tx_count();
|
|
store.mark_posted(tid).unwrap();
|
|
let snap_posted = handle.tx_count();
|
|
|
|
let b_pending = store.balance_as_of(cid, snap_pending).unwrap();
|
|
assert_eq!(b_pending.posted.minor_units, 0);
|
|
assert_eq!(b_pending.pending.minor_units, 1_000);
|
|
|
|
let b_posted = store.balance_as_of(cid, snap_posted).unwrap();
|
|
assert_eq!(b_posted.posted.minor_units, 1_000);
|
|
assert_eq!(b_posted.pending.minor_units, 1_000);
|
|
}
|
|
|
|
#[test]
|
|
fn transaction_as_of_unknown_returns_not_found() {
|
|
let (store, handle, _, _, _) = fixture();
|
|
let snap = handle.tx_count();
|
|
let fake = op_ledger::TransactionId::from_uuid(uuid::Uuid::new_v4());
|
|
let err = store.transaction_as_of(fake, snap).unwrap_err();
|
|
assert!(matches!(err, op_ledger::Error::TransactionNotFound(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn time_travel_survives_persistence_round_trip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("history.graph");
|
|
|
|
let (cid, snap) = {
|
|
let handle = GraphHandle::new_persistent(&path).unwrap();
|
|
let store = GraphLedgerStore::with_handle(handle.clone());
|
|
let l = Ledger::new("Acme").unwrap();
|
|
let lid = l.id;
|
|
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;
|
|
store.create_account(cash).unwrap();
|
|
store.create_account(rev).unwrap();
|
|
|
|
store
|
|
.post_transaction(
|
|
Transaction::new_posted(
|
|
lid,
|
|
1_000,
|
|
vec![
|
|
Entry::debit(cid, Money::from_minor(400, Currency::USD)),
|
|
Entry::credit(rid, Money::from_minor(400, Currency::USD)),
|
|
],
|
|
)
|
|
.unwrap()
|
|
.with_external_id("FIRST"),
|
|
)
|
|
.unwrap();
|
|
let snap = handle.tx_count();
|
|
store
|
|
.post_transaction(
|
|
Transaction::new_posted(
|
|
lid,
|
|
2_000,
|
|
vec![
|
|
Entry::debit(cid, Money::from_minor(300, Currency::USD)),
|
|
Entry::credit(rid, Money::from_minor(300, Currency::USD)),
|
|
],
|
|
)
|
|
.unwrap()
|
|
.with_external_id("SECOND"),
|
|
)
|
|
.unwrap();
|
|
(cid, snap)
|
|
};
|
|
|
|
// Reopen and time-travel: the saved snapshot still points at
|
|
// the same moment in the persisted history.
|
|
let store2 = GraphLedgerStore::with_handle(GraphHandle::new_persistent(&path).unwrap());
|
|
let now = store2.balance(cid).unwrap();
|
|
assert_eq!(now.posted.minor_units, 700);
|
|
let then = store2.balance_as_of(cid, snap).unwrap();
|
|
assert_eq!(then.posted.minor_units, 400);
|
|
}
|