feat(arap)!: documents read their figures from standing lines; control accounts
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
refuse hand entries (P4b S1, part A) Plan P4b (D-45), slice S1, the engine half: rules.md B-38. - ledger-core: every receivables or payables line carries its document (Ledger.line_docs: DocRef, Movement, DocTag), written by the document commands in the step that posts it, derived on replay from the existing document events (the role's account as it stood then; payments by the ordinal rule), copied onto a reversal's mirror. Ledger::stands_on; doc_state / invoice_state / bill_state (total, paid, outstanding, status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day; ar_aging / ap_aging read them, so reversed payments and voids are right. The control guard (add_line, add_line_fx, post_entry, register_recurring) refuses untagged lines on receivables/payables, exempting a reversal's mirror of a legacy line; replay never refuses. Bounds (kernel::running_bounds, bounds_kept) on payments and reversals; a document reversal never dated before its entry; deposit/payer accounts must be cash-like and hold no role. New API: receive_payment_with / pay_bill_with (PaymentDetails: instrument, reference), void_document / check_void_document, control_check, payments_recorded, standing_payments, replay_reverse_entry. Invoice.amount_paid/status remain only as derived caches for the MCP layer until part B. cashflow bill_splits reads payments from the tags. - ledger-store: journal feature document_standing (D-39 gated) for a payment accepted only because an earlier one was reversed; receive_payment_with / pay_bill_with (instrument journaled as entry.instrument_set, reference in the description), void_document; replay uses replay_reverse_entry; schema 15 drops the projection's amount_paid/status. Fixture p4-arap-before + golden figures written by the version before S1, adjudicated in arap_standing.rs. - ledger-gui: party card, cash "coming up" and company owed totals read doc_state as of today (late from the derived state). - Lean Documents.lean (control_agrees and friends); Kani bounds_check_matches_definition, bounds_kept_is_the_definition_on_books_in_bounds; rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand lines; tests that hand-post to 1200/2000 post as replay or renumbered. Review fixes (both reviews of part A): - control_role_of guards only an account the role is assigned to or one holding its documents' lines (a chart's own 1200 Equipment or card at 2000 is free until a document posts there); payment accounts check other roles by holds_role (type-checked). The MCP tests that failed on the guard (oracle_mcp, spec_projects) pass unchanged. - standing_payments leaves out reversals (sorted, deduped); replayed tags match the movement's side; payments_recorded counts every payment event (tagged or not, Ledger.payment_events) and refuses overflow. - The record caches settled (paid + discounted + written off) and owed (Invoice/Bill.owed, never serialized): a void never reads as paid. - assign_role: receivables/payables refuse moving onto an account with untagged lines (RoleAccountHasHandLines) and, on their control account holding a balance, say DocumentRoleHoldsBalance (no hand entry named); B-23 reworded. The reversal-mirror exemption only takes a hand line off (even chain depth); putting it back is refused. - DocStatus Overpaid and Unattached; aging leaves out credit balances; ControlCheck.attention lists out-of-bounds and unattached documents. - stands_on counts a chain in order; indexes (doc_index, doc_accounts, order_pos, reversed_by) keep the derived figures linear. - Viewer reads a post-dated document as of its own date; one state per row. bounds_kept's side-crossing documented as intended. - Tests: ten new spec_documents scenarios, stronger proptests, store proptest arap_props (live = replay = snapshot at every date, closed months), fuzz ops void/payments with details, a GUI post-dated test; Lean Step.assign / Step.release with unattached_zero kept; model limits stated in docs/testing.md and B-38. Review fixes (CPA and engineering lenses): only an assigned role's account or one holding document lines is a control account (a chart's own 1200 asset stays free); a role can't move onto untagged lines or off document lines (Lean Step.assign); a void's cache never reads as paid; no Decimal sum panics; per-document indexes keep figures linear; a replayed tag matches its side; the feature gate counts payment events; a payment's reversal is never listed as standing; an overpaid document has its own status; the viewer keeps post-dated documents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
54 files changed
+5997
-649
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)]
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Documents through the store, at random (rules.md B-38 INVARIANTS 1, 2,
|
||||
//! 4 and 6; plan P4b.15): histories of invoices, bills, payments (with a
|
||||
//! payment method and a reference, or none), voids, reversals of any
|
||||
//! entry, closed months and refused hand lines, run through `Store`, then
|
||||
//! replayed (the books reopened) and read by a `Snapshot`.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a document's state is a function of the journal and the day:
|
||||
//! the live books, their replay and a viewer's snapshot agree on
|
||||
//! every figure, at every date, and the control accounts equal
|
||||
//! their documents.
|
||||
//! SOURCE: docs/spec/rules.md B-28 (replay), B-38 INVARIANTS 1, 2, 4, 6.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, DocRef, Ledger, LedgerError, Money, PaymentDetails, Side,
|
||||
};
|
||||
use ledger_store::{Store, StoreError, snapshot::Snapshot};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn day(n: u8) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n))
|
||||
}
|
||||
|
||||
fn cents(c: i64) -> Decimal {
|
||||
Decimal::new(c, 2)
|
||||
}
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir() -> TestDir {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wanderbooks-arap-props-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// One step of a random history.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Op {
|
||||
/// An invoice (true) or a bill, on day `n`, for `c` cents.
|
||||
Create(bool, u8, i64),
|
||||
/// A payment on document `k` (mod the documents) on day `n`, for `c`
|
||||
/// cents, by card or not, with a reference or not.
|
||||
Pay(usize, u8, i64, bool, bool),
|
||||
/// Reverse entry `k` (mod the posted entries) on day `n`.
|
||||
Reverse(usize, u8),
|
||||
/// Void document `k` on day `n`.
|
||||
Void(usize, u8),
|
||||
/// Close month `m` (January to March), in order.
|
||||
Close(u8),
|
||||
/// A hand line on receivables (true) or payables: always refused.
|
||||
Hand(bool, i64),
|
||||
}
|
||||
|
||||
fn arb_op() -> impl Strategy<Value = Op> {
|
||||
prop_oneof![
|
||||
3 => (any::<bool>(), 0u8..80, 1i64..50_000).prop_map(|(i, n, c)| Op::Create(i, n, c)),
|
||||
4 => (0usize..8, 0u8..90, 1i64..60_000, any::<bool>(), any::<bool>())
|
||||
.prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)),
|
||||
3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)),
|
||||
1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)),
|
||||
1 => (1u8..4).prop_map(Op::Close),
|
||||
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
|
||||
]
|
||||
}
|
||||
|
||||
/// The books' fixed accounts: bank, card (and its payment method).
|
||||
struct Setup {
|
||||
bank: u64,
|
||||
card: u64,
|
||||
visa: u64,
|
||||
customer: u64,
|
||||
vendor: u64,
|
||||
}
|
||||
|
||||
fn setup(dir: &Path) -> (Store, Setup) {
|
||||
let mut s = Store::create(dir, Currency::USD).unwrap();
|
||||
let bank = s
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = s
|
||||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
s.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let visa = s
|
||||
.add_instrument(
|
||||
ledger_core::InstrumentKind::BusinessCard,
|
||||
"Fernhill Visa",
|
||||
Some("4242"),
|
||||
None,
|
||||
card,
|
||||
)
|
||||
.unwrap();
|
||||
let customer = s.add_customer("Fernhill Labs").unwrap();
|
||||
let vendor = s.add_vendor("Tobin Harlow Supply").unwrap();
|
||||
(
|
||||
s,
|
||||
Setup {
|
||||
bank,
|
||||
card,
|
||||
visa,
|
||||
customer,
|
||||
vendor,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `ops` on the store; a refusal must journal nothing.
|
||||
fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec<DocRef>) -> Result<(), TestCaseError> {
|
||||
for op in ops {
|
||||
let head = s.head();
|
||||
let line = |c: i64| {
|
||||
vec![DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: cents(c),
|
||||
class_id: None,
|
||||
}]
|
||||
};
|
||||
let result: Result<(), StoreError> = match *op {
|
||||
Op::Create(true, n, c) => s
|
||||
.create_invoice(k.customer, day(n), day(n) + chrono::Days::new(30), line(c))
|
||||
.map(|i| docs.push(DocRef::Invoice(i))),
|
||||
Op::Create(false, n, c) => s
|
||||
.create_bill(k.vendor, day(n), day(n) + chrono::Days::new(30), line(c))
|
||||
.map(|i| docs.push(DocRef::Bill(i))),
|
||||
Op::Pay(..) if docs.is_empty() => Ok(()),
|
||||
Op::Pay(i, n, c, by_card, reference) => {
|
||||
let details = PaymentDetails {
|
||||
instrument: by_card.then_some(k.visa),
|
||||
reference: reference.then(|| format!("trace {i}{n}")),
|
||||
};
|
||||
match docs[i % docs.len()] {
|
||||
DocRef::Invoice(id) => s
|
||||
.receive_payment_with(id, day(n), cents(c), k.bank, &details)
|
||||
.map(drop),
|
||||
DocRef::Bill(id) => {
|
||||
let from = if by_card { k.card } else { k.bank };
|
||||
s.pay_bill_with(id, day(n), cents(c), from, &details)
|
||||
.map(drop)
|
||||
}
|
||||
}
|
||||
}
|
||||
Op::Reverse(i, n) => {
|
||||
let posted: Vec<u64> = s.ledger().posted_entries().map(|e| e.id()).collect();
|
||||
match posted.get(i % posted.len().max(1)) {
|
||||
Some(&e) => s.reverse_entry(e, day(n), "undo").map(drop),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
Op::Void(..) if docs.is_empty() => Ok(()),
|
||||
Op::Void(i, n) => s
|
||||
.void_document(docs[i % docs.len()], day(n), "duplicate")
|
||||
.map(drop),
|
||||
Op::Close(m) => {
|
||||
let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap();
|
||||
let end = start + chrono::Months::new(1) - chrono::Days::new(1);
|
||||
s.create_period(&format!("2026-{m:02}"), start, end)
|
||||
.and_then(|p| {
|
||||
let mid = s.head();
|
||||
s.close_period(p).map(drop).inspect_err(|_| {
|
||||
// The period itself was journaled: only the
|
||||
// close must leave nothing behind.
|
||||
assert_eq!(s.head(), mid);
|
||||
})
|
||||
})
|
||||
.map(drop)
|
||||
}
|
||||
Op::Hand(receivables, c) => {
|
||||
let code = if receivables { "1200" } else { "2000" };
|
||||
match s.ledger().chart().find_by_code(code).map(|a| a.id) {
|
||||
Some(a) if s.ledger().control_role_of(a).is_some() => {
|
||||
// After every month the history can close.
|
||||
let on = NaiveDate::from_ymd_opt(2026, 12, 1).unwrap();
|
||||
let e = s.create_entry(on, "hand").unwrap();
|
||||
let mid = s.head();
|
||||
let m = Money::new(cents(c), Currency::USD);
|
||||
let r = s.add_line(e, a, Side::Debit, m);
|
||||
prop_assert!(
|
||||
matches!(
|
||||
r,
|
||||
Err(StoreError::Ledger(LedgerError::UntaggedControlLine { .. }))
|
||||
),
|
||||
"{r:?}"
|
||||
);
|
||||
prop_assert_eq!(s.head(), mid);
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
};
|
||||
if result.is_err() && !matches!(op, Op::Close(_)) {
|
||||
prop_assert_eq!(s.head(), head, "{:?} refused, yet journaled", op);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every day a figure could change on, and the end.
|
||||
fn days(l: &Ledger) -> Vec<NaiveDate> {
|
||||
let mut v: Vec<NaiveDate> = l.posted_entries().map(|e| e.date()).collect();
|
||||
v.push(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap());
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// What the books say about their documents on day `on`: each state, the
|
||||
/// control check and both agings, as one comparable value.
|
||||
fn figures(l: &Ledger, docs: &[DocRef], on: NaiveDate) -> serde_json::Value {
|
||||
let states: Vec<_> = docs.iter().map(|d| l.doc_state(*d, on).unwrap()).collect();
|
||||
serde_json::json!({
|
||||
"states": states,
|
||||
"control": l.control_check(on).unwrap(),
|
||||
"ar": l.ar_aging(on).unwrap(),
|
||||
"ap": l.ap_aging(on).unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The live books, their replay and a snapshot agree; the control
|
||||
/// accounts equal their documents, each within bounds, at every date.
|
||||
fn agree(live: &Ledger, dir: &Path, docs: &[DocRef]) -> Result<(), TestCaseError> {
|
||||
let replayed = Store::open(dir).unwrap();
|
||||
let snap = Snapshot::load(dir).unwrap();
|
||||
prop_assert!(
|
||||
live.differs_from(replayed.ledger()).is_empty(),
|
||||
"replay differs: {:?}",
|
||||
live.differs_from(replayed.ledger())
|
||||
);
|
||||
prop_assert!(
|
||||
live.differs_from(snap.ledger()).is_empty(),
|
||||
"snapshot differs: {:?}",
|
||||
live.differs_from(snap.ledger())
|
||||
);
|
||||
for on in days(live) {
|
||||
let f = figures(live, docs, on);
|
||||
prop_assert_eq!(
|
||||
&f,
|
||||
&figures(replayed.ledger(), docs, on),
|
||||
"replay on {}",
|
||||
on
|
||||
);
|
||||
prop_assert_eq!(&f, &figures(snap.ledger(), docs, on), "snapshot on {}", on);
|
||||
for c in live.control_check(on).unwrap() {
|
||||
prop_assert_eq!(c.difference, Decimal::ZERO);
|
||||
prop_assert!(c.unattached.is_empty() && c.attention.is_empty(), "{:?}", c);
|
||||
}
|
||||
for d in docs {
|
||||
if let Some(st) = live.doc_state(*d, on).unwrap() {
|
||||
prop_assert!(!st.out_of_bounds(), "{d} on {on}: {st:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
replayed.verify().unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(32))]
|
||||
|
||||
/// B-38 INVARIANT 4 (and 1, 2, 6 through the store): a document's
|
||||
/// state is a function of the journal and the day. Half the history,
|
||||
/// then the books reopened and compared; the rest on the reopened
|
||||
/// books, compared again.
|
||||
#[test]
|
||||
fn status_is_a_function_of_journal_and_date(
|
||||
first in prop::collection::vec(arb_op(), 0..18),
|
||||
then in prop::collection::vec(arb_op(), 0..18),
|
||||
) {
|
||||
let dir = test_dir();
|
||||
let (mut s, k) = setup(&dir.0);
|
||||
let mut docs = Vec::new();
|
||||
run(&mut s, &k, &first, &mut docs)?;
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
agree(&live, &dir.0, &docs)?;
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
run(&mut s, &k, &then, &mut docs)?;
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
agree(&live, &dir.0, &docs)?;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
//! Documents read their figures from the ledger lines tagged with them
|
||||
//! (plan P4b S1, D-45; rules.md B-38): the books an earlier version wrote,
|
||||
//! replayed, and the golden figures that version showed for them.
|
||||
//!
|
||||
//! The fixture `tests/fixtures/p4-arap-before` is a books dir written by
|
||||
//! the version before S1 (main at 0df7f52a), and `figures.json` beside it
|
||||
//! is what that version reported for it. This version opens the same
|
||||
//! journal; every figure that moves is adjudicated here, cent by cent
|
||||
//! (P4b.14 item 2): only documents whose entry or payment was reversed
|
||||
//! change, and the general ledger doesn't move at all.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::{JOURNAL_FILE, Store};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn cents(c: i64) -> Decimal {
|
||||
Decimal::new(c, 2)
|
||||
}
|
||||
|
||||
fn line(what: &str, c: i64) -> DocumentLine {
|
||||
DocumentLine {
|
||||
description: what.into(),
|
||||
amount: cents(c),
|
||||
class_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dates the golden figures are taken at: between a payment and its
|
||||
/// reversal, a month end, and the end of the books.
|
||||
const GOLDEN_DATES: [(i32, u32, u32); 3] = [(2026, 1, 22), (2026, 1, 31), (2026, 3, 31)];
|
||||
|
||||
/// Write the fixture's books into `dir`, through the Store's commands as
|
||||
/// the version before S1 had them: two customers' invoices (one paid then
|
||||
/// the payment reversed, one reversed outright, one part paid), a vendor's
|
||||
/// bills (one paid then the payment reversed), and a hand entry on
|
||||
/// receivables. Only a version before S1 can (S1 refuses the hand entry).
|
||||
fn write_books(dir: &Path) {
|
||||
let mut s = Store::create(dir, Currency::USD).unwrap();
|
||||
let bank = s
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let seed = s.create_entry(d(2026, 1, 1), "Opening capital").unwrap();
|
||||
let m = Money::new(Decimal::from(1000), Currency::USD);
|
||||
s.add_line(seed, bank, Side::Debit, m).unwrap();
|
||||
s.add_line(seed, capital, Side::Credit, m).unwrap();
|
||||
s.post_entry(seed).unwrap();
|
||||
let fernhill = s.add_customer("Fernhill Labs").unwrap();
|
||||
let morgan = s.add_customer("Morgan Ellis Studio").unwrap();
|
||||
let vendor = s.add_vendor("Tobin Harlow Supply").unwrap();
|
||||
// Invoice 1: paid in full, then the payment reversed (a bounced check).
|
||||
let i1 = s
|
||||
.create_invoice(
|
||||
fernhill,
|
||||
d(2026, 1, 10),
|
||||
d(2026, 2, 9),
|
||||
vec![line("Design", 10_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let p1 = s
|
||||
.receive_payment(i1, d(2026, 1, 20), cents(10_000), bank)
|
||||
.unwrap();
|
||||
s.reverse_entry(p1, d(2026, 1, 25), "Check bounced")
|
||||
.unwrap();
|
||||
// Invoice 2: issued to the wrong customer and reversed (a void).
|
||||
let i2 = s
|
||||
.create_invoice(
|
||||
morgan,
|
||||
d(2026, 2, 1),
|
||||
d(2026, 3, 3),
|
||||
vec![line("Wrong customer", 20_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let e2 = s.ledger().get_invoice(i2).unwrap().entry_id;
|
||||
s.reverse_entry(e2, d(2026, 2, 5), "Void: wrong customer")
|
||||
.unwrap();
|
||||
// Invoice 3: part paid, nothing reversed.
|
||||
let i3 = s
|
||||
.create_invoice(
|
||||
morgan,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line("Workshop", 30_000)],
|
||||
)
|
||||
.unwrap();
|
||||
s.receive_payment(i3, d(2026, 3, 10), cents(12_000), bank)
|
||||
.unwrap();
|
||||
// Bill 1: paid, then the payment reversed (sent to the wrong vendor).
|
||||
let b1 = s
|
||||
.create_bill(
|
||||
vendor,
|
||||
d(2026, 1, 15),
|
||||
d(2026, 2, 14),
|
||||
vec![line("Paper", 8_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let q1 = s.pay_bill(b1, d(2026, 1, 20), cents(8_000), bank).unwrap();
|
||||
s.reverse_entry(q1, d(2026, 1, 28), "Paid in error")
|
||||
.unwrap();
|
||||
// Bill 2: open.
|
||||
s.create_bill(
|
||||
vendor,
|
||||
d(2026, 2, 10),
|
||||
d(2026, 3, 12),
|
||||
vec![line("Ink", 4_000)],
|
||||
)
|
||||
.unwrap();
|
||||
// A hand entry on receivables, no document behind it.
|
||||
let ar = s.ledger().chart().find_by_code("1200").unwrap().id;
|
||||
let revenue = s.ledger().chart().find_by_code("4000").unwrap().id;
|
||||
let hand = s.create_entry(d(2026, 2, 15), "Hand-booked sale").unwrap();
|
||||
let m = Money::new(cents(2_500), Currency::USD);
|
||||
s.add_line(hand, ar, Side::Debit, m).unwrap();
|
||||
s.add_line(hand, revenue, Side::Credit, m).unwrap();
|
||||
s.post_entry(hand).unwrap();
|
||||
}
|
||||
|
||||
/// An aging report as JSON: each row's name and buckets, as strings.
|
||||
fn aging_json(rows: &[ledger_core::AgingRow]) -> Value {
|
||||
Value::Array(
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"name": r.name,
|
||||
"current": r.current.normalize().to_string(),
|
||||
"days_30": r.days_30.normalize().to_string(),
|
||||
"days_60": r.days_60.normalize().to_string(),
|
||||
"days_90": r.days_90.normalize().to_string(),
|
||||
"days_90_plus": r.days_90_plus.normalize().to_string(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The trial balance as JSON: each account's code and signed balance.
|
||||
fn trial_balance_json(s: &Store) -> Value {
|
||||
let tb = ledger_core::reports::trial_balance(s.ledger());
|
||||
Value::Array(
|
||||
tb.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
json!({
|
||||
"code": l.code,
|
||||
"debit": l.debit.normalize().to_string(),
|
||||
"credit": l.credit.normalize().to_string(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// How tests/fixtures/p4-arap-before was written, and its golden figures
|
||||
/// taken: by the version before S1. Run with WB_WRITE_P4_ARAP_FIXTURE=DIR
|
||||
/// on that version (S1 refuses the fixture's hand entry on receivables).
|
||||
#[test]
|
||||
fn write_the_p4_arap_fixture() {
|
||||
let Ok(out) = std::env::var("WB_WRITE_P4_ARAP_FIXTURE") else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
!ledger_store::format::KNOWN_FEATURES.contains(&"document_standing"),
|
||||
"only a version before S1 writes it"
|
||||
);
|
||||
let out = PathBuf::from(out);
|
||||
write_books(&out);
|
||||
let s = Store::open(&out).unwrap();
|
||||
let mut at = serde_json::Map::new();
|
||||
for (y, m, day) in GOLDEN_DATES {
|
||||
let on = d(y, m, day);
|
||||
at.insert(
|
||||
on.to_string(),
|
||||
json!({
|
||||
"ar_aging": aging_json(&s.ledger().ar_aging(on).unwrap()),
|
||||
"ap_aging": aging_json(&s.ledger().ap_aging(on).unwrap()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let invoices: Vec<Value> = s
|
||||
.ledger()
|
||||
.invoices()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
json!({"id": i.id, "balance": i.balance().unwrap().normalize().to_string(),
|
||||
"status": format!("{:?}", i.status)})
|
||||
})
|
||||
.collect();
|
||||
let bills: Vec<Value> = s
|
||||
.ledger()
|
||||
.bills()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
json!({"id": b.id, "balance": b.balance().unwrap().normalize().to_string(),
|
||||
"status": format!("{:?}", b.status)})
|
||||
})
|
||||
.collect();
|
||||
let figures = json!({
|
||||
"written_by": "wanderbooks main 0df7f52a, before P4b S1",
|
||||
"at": at,
|
||||
"invoices": invoices,
|
||||
"bills": bills,
|
||||
"trial_balance": trial_balance_json(&s),
|
||||
});
|
||||
drop(s);
|
||||
std::fs::write(
|
||||
out.join("figures.json"),
|
||||
serde_json::to_string_pretty(&figures).unwrap() + "\n",
|
||||
)
|
||||
.unwrap();
|
||||
for f in [ledger_store::LOCK_FILE, &ledger_store::db_file()] {
|
||||
let _ = std::fs::remove_file(out.join(f));
|
||||
}
|
||||
let _ = std::fs::remove_dir(out.join("blobs"));
|
||||
assert!(out.join(JOURNAL_FILE).exists());
|
||||
}
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wanderbooks-arap-standing-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// A copy of the fixture's journal (its projection is built on open).
|
||||
fn fixture(name: &str) -> TestDir {
|
||||
let dir = test_dir(name);
|
||||
let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p4-arap-before");
|
||||
std::fs::copy(from.join(JOURNAL_FILE), dir.0.join(JOURNAL_FILE)).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn golden() -> Value {
|
||||
serde_json::from_str(include_str!("fixtures/p4-arap-before/figures.json")).unwrap()
|
||||
}
|
||||
|
||||
fn journal_len(dir: &Path) -> usize {
|
||||
std::fs::read_to_string(dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The lines journaled from `from` on, as (type, payload).
|
||||
fn since(dir: &Path, from: usize) -> Vec<(String, Value)> {
|
||||
std::fs::read_to_string(dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.skip(from)
|
||||
.map(|l| {
|
||||
let v: Value = serde_json::from_str(l).unwrap();
|
||||
(
|
||||
v["type"].as_str().unwrap().to_string(),
|
||||
v["payload"].clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The fixture an earlier version wrote opens, verifies, and shows each
|
||||
/// figure that version got wrong, right (P4b.14 item 2, adjudicated cent
|
||||
/// by cent against figures.json):
|
||||
///
|
||||
/// - The general ledger is the golden's, line for line: the GL already
|
||||
/// held every reversal.
|
||||
/// - Jan 22, between the payments and their reversals: as the golden
|
||||
/// (both documents paid then).
|
||||
/// - Jan 31: the golden showed nothing owed; Fernhill's invoice 1 owes
|
||||
/// 100.00 again (its payment reversed Jan 25) and Tobin's bill 1 80.00
|
||||
/// (its payment reversed Jan 28), both current.
|
||||
/// - Mar 31: the golden aged Morgan's voided invoice 2 (200.00, 1-30)
|
||||
/// and left Fernhill out; now Morgan owes only invoice 3's 180.00
|
||||
/// (current), Fernhill 100.00 at 50 days past due (31-60), and Tobin
|
||||
/// 80.00 at 45 days (31-60) beside bill 2's 40.00 (1-30).
|
||||
/// - Receivables 305.00 = the invoices' 280.00 + the hand line's 25.00,
|
||||
/// unattached.
|
||||
#[test]
|
||||
fn legacy_reversal_now_counts() {
|
||||
let dir = fixture("golden");
|
||||
let s = Store::open(&dir.0).unwrap();
|
||||
s.verify().unwrap();
|
||||
let g = golden();
|
||||
assert_eq!(
|
||||
trial_balance_json(&s),
|
||||
g["trial_balance"],
|
||||
"the GL doesn't move"
|
||||
);
|
||||
let on = |y, m, day| d(y, m, day).to_string();
|
||||
let at = |date: String| g["at"][&date].clone();
|
||||
let now = |date: NaiveDate| {
|
||||
json!({
|
||||
"ar_aging": aging_json(&s.ledger().ar_aging(date).unwrap()),
|
||||
"ap_aging": aging_json(&s.ledger().ap_aging(date).unwrap()),
|
||||
})
|
||||
};
|
||||
assert_eq!(now(d(2026, 1, 22)), at(on(2026, 1, 22)), "unchanged");
|
||||
let row = |name: &str, current: &str, d30: &str, d60: &str| {
|
||||
json!({"name": name, "current": current, "days_30": d30, "days_60": d60,
|
||||
"days_90": "0", "days_90_plus": "0"})
|
||||
};
|
||||
assert_eq!(at(on(2026, 1, 31)), json!({"ar_aging": [], "ap_aging": []}));
|
||||
assert_eq!(
|
||||
now(d(2026, 1, 31)),
|
||||
json!({
|
||||
"ar_aging": [row("Fernhill Labs", "100", "0", "0")],
|
||||
"ap_aging": [row("Tobin Harlow Supply", "80", "0", "0")],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
at(on(2026, 3, 31))["ar_aging"],
|
||||
json!([row("Morgan Ellis Studio", "180", "200", "0")])
|
||||
);
|
||||
assert_eq!(
|
||||
now(d(2026, 3, 31)),
|
||||
json!({
|
||||
"ar_aging": [
|
||||
row("Fernhill Labs", "0", "0", "100"),
|
||||
row("Morgan Ellis Studio", "180", "0", "0"),
|
||||
],
|
||||
"ap_aging": [row("Tobin Harlow Supply", "0", "40", "80")],
|
||||
})
|
||||
);
|
||||
let state = |doc| s.ledger().doc_state(doc, d(2026, 12, 31)).unwrap().unwrap();
|
||||
use ledger_core::{DocRef, DocStatus};
|
||||
for (doc, status, owed) in [
|
||||
(DocRef::Invoice(1), DocStatus::Open, 10_000),
|
||||
(DocRef::Invoice(2), DocStatus::Void, 0),
|
||||
(DocRef::Invoice(3), DocStatus::Partial, 18_000),
|
||||
(DocRef::Bill(1), DocStatus::Open, 8_000),
|
||||
(DocRef::Bill(2), DocStatus::Open, 4_000),
|
||||
] {
|
||||
let st = state(doc);
|
||||
assert_eq!((st.status, st.outstanding), (status, cents(owed)), "{doc}");
|
||||
}
|
||||
let checks = s.ledger().control_check(d(2026, 12, 31)).unwrap();
|
||||
let ar = checks.iter().find(|c| c.role == "receivables").unwrap();
|
||||
assert_eq!(
|
||||
(ar.balance, ar.documents_total, ar.difference),
|
||||
(cents(30_500), cents(28_000), Decimal::ZERO)
|
||||
);
|
||||
assert_eq!(ar.unattached.len(), 1);
|
||||
assert_eq!(ar.unattached[0].amount, cents(2_500));
|
||||
let ap = checks.iter().find(|c| c.role == "payables").unwrap();
|
||||
assert_eq!(
|
||||
(ap.balance, ap.documents_total, ap.unattached.len()),
|
||||
(cents(12_000), cents(12_000), 0)
|
||||
);
|
||||
// Opening wrote nothing.
|
||||
drop(s);
|
||||
let again = Store::open(&dir.0).unwrap();
|
||||
assert_eq!(trial_balance_json(&again), g["trial_balance"]);
|
||||
}
|
||||
|
||||
/// B-38 scenario 5 and item 7 in books an earlier version keeps: the
|
||||
/// right payment after the wrong one was reversed waits for the journal
|
||||
/// feature document_standing (an older version would count both),
|
||||
/// refused and journaling nothing until the owner's upgrade; a payment
|
||||
/// any version reads alike is taken at once; after the upgrade the
|
||||
/// re-payment is taken, and the books replay to what was live.
|
||||
#[test]
|
||||
fn repaying_after_a_reversal_waits_for_document_standing() {
|
||||
use ledger_store::StoreError;
|
||||
let dir = fixture("standing");
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
assert!(
|
||||
!s.journal_format()
|
||||
.has_feature(ledger_store::format::DOCUMENT_STANDING)
|
||||
);
|
||||
let bank = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
let head = s.head();
|
||||
match s.receive_payment(1, d(2026, 2, 1), cents(10_000), bank) {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature document_standing")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
s.pay_bill(1, d(2026, 2, 1), cents(8_000), bank),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
assert_eq!(s.head(), head, "nothing journaled");
|
||||
// Invoice 3: nothing reversed on it, so any version reads it alike.
|
||||
s.receive_payment(3, d(2026, 3, 20), cents(18_000), bank)
|
||||
.unwrap();
|
||||
assert!(s.upgrade_format().unwrap().is_some());
|
||||
s.receive_payment(1, d(2026, 2, 1), cents(10_000), bank)
|
||||
.unwrap();
|
||||
s.pay_bill(1, d(2026, 2, 1), cents(8_000), bank).unwrap();
|
||||
s.verify().unwrap();
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
let reopened = Store::open(&dir.0).unwrap();
|
||||
assert!(
|
||||
live.differs_from(reopened.ledger()).is_empty(),
|
||||
"{:?}",
|
||||
live.differs_from(reopened.ledger())
|
||||
);
|
||||
let st = reopened
|
||||
.ledger()
|
||||
.invoice_state(1, d(2026, 12, 31))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(st.status, ledger_core::DocStatus::Paid);
|
||||
}
|
||||
|
||||
/// The control guard through the store, and a void under a payment:
|
||||
/// refused, journaling nothing (B-38 scenarios 3 and 6).
|
||||
#[test]
|
||||
fn the_store_refuses_hand_lines_and_a_void_under_a_payment() {
|
||||
let dir = fixture("guard");
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
let ar = s.ledger().chart().find_by_code("1200").unwrap().id;
|
||||
let e = s.create_entry(d(2026, 3, 1), "by hand").unwrap();
|
||||
let head = s.head();
|
||||
let m = Money::new(cents(100), Currency::USD);
|
||||
assert!(matches!(
|
||||
s.add_line(e, ar, Side::Debit, m),
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::UntaggedControlLine { .. }
|
||||
))
|
||||
));
|
||||
assert!(
|
||||
s.void_document(ledger_core::DocRef::Invoice(3), d(2026, 3, 31), "dup")
|
||||
.is_err()
|
||||
);
|
||||
let inv3 = s.ledger().get_invoice(3).unwrap().entry_id;
|
||||
assert!(s.reverse_entry(inv3, d(2026, 3, 31), "Void").is_err());
|
||||
assert_eq!(s.head(), head, "nothing journaled");
|
||||
// The remedy: reverse the payment, then the void is taken.
|
||||
let pay = s
|
||||
.ledger()
|
||||
.standing_payments(ledger_core::DocRef::Invoice(3));
|
||||
assert_eq!(pay.len(), 1);
|
||||
s.reverse_entry(pay[0], d(2026, 3, 30), "Refunded").unwrap();
|
||||
s.void_document(ledger_core::DocRef::Invoice(3), d(2026, 3, 31), "duplicate")
|
||||
.unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A void writes no event of its own: it is the entry.reversed of the
|
||||
/// document's entry, described "Void invoice N: reason" (P4b.7), and
|
||||
/// replays to the same books.
|
||||
#[test]
|
||||
fn a_void_is_journaled_as_its_reversal() {
|
||||
let dir = test_dir("void");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let c = s.add_customer("Fernhill Labs").unwrap();
|
||||
let inv = s
|
||||
.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 5_000)])
|
||||
.unwrap();
|
||||
let origin = s.ledger().get_invoice(inv).unwrap().entry_id;
|
||||
let from = journal_len(&dir.0);
|
||||
let r = s
|
||||
.void_document(
|
||||
ledger_core::DocRef::Invoice(inv),
|
||||
d(2026, 4, 2),
|
||||
"duplicate",
|
||||
)
|
||||
.unwrap();
|
||||
let got = since(&dir.0, from);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].0, "entry.reversed");
|
||||
assert_eq!(got[0].1["id"], json!(r));
|
||||
assert_eq!(got[0].1["reverses"], json!(origin));
|
||||
assert_eq!(
|
||||
got[0].1["description"],
|
||||
json!(format!("Void invoice {inv}: duplicate"))
|
||||
);
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
let s = Store::open(&dir.0).unwrap();
|
||||
assert!(live.differs_from(s.ledger()).is_empty());
|
||||
let st = s
|
||||
.ledger()
|
||||
.invoice_state(inv, d(2026, 4, 30))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(st.status, ledger_core::DocStatus::Void);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A payment with its method and reference: the reference in its
|
||||
/// entry's description, the method as entry.instrument_set right after
|
||||
/// the payment event (feature payment_instruments); a payment with
|
||||
/// neither is journaled exactly as before, field for field.
|
||||
#[test]
|
||||
fn a_payment_journals_its_method_and_reference() {
|
||||
let dir = test_dir("method");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let card = s
|
||||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let bank = s
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let v = s.add_vendor("Tobin Harlow Supply").unwrap();
|
||||
let c = s.add_customer("Fernhill Labs").unwrap();
|
||||
let visa = s
|
||||
.add_instrument(
|
||||
ledger_core::InstrumentKind::BusinessCard,
|
||||
"Visa",
|
||||
Some("4242"),
|
||||
None,
|
||||
card,
|
||||
)
|
||||
.unwrap();
|
||||
let bill = s
|
||||
.create_bill(v, d(2026, 4, 1), d(2026, 5, 1), vec![line("Paper", 2_000)])
|
||||
.unwrap();
|
||||
let inv = s
|
||||
.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 3_000)])
|
||||
.unwrap();
|
||||
let from = journal_len(&dir.0);
|
||||
let details = ledger_core::PaymentDetails {
|
||||
instrument: Some(visa),
|
||||
reference: Some("ch_3Nx".into()),
|
||||
};
|
||||
let e = s
|
||||
.pay_bill_with(bill, d(2026, 4, 3), cents(2_000), card, &details)
|
||||
.unwrap();
|
||||
let got = since(&dir.0, from);
|
||||
let types: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
[
|
||||
"entry.created",
|
||||
"entry.line_added",
|
||||
"entry.line_added",
|
||||
"entry.posted",
|
||||
"bill.paid",
|
||||
"entry.instrument_set"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].1["description"],
|
||||
json!(format!("Payment on bill {bill} (ref ch_3Nx)"))
|
||||
);
|
||||
assert_eq!(s.ledger().entry_instrument(e), Some(visa));
|
||||
let from = journal_len(&dir.0);
|
||||
s.receive_payment(inv, d(2026, 4, 4), cents(3_000), bank)
|
||||
.unwrap();
|
||||
let got = since(&dir.0, from);
|
||||
assert_eq!(got.len(), 5, "{got:?}");
|
||||
let keys: Vec<&String> = got[4].1.as_object().unwrap().keys().collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
["amount", "cash_account", "date", "entry_id", "invoice_id"]
|
||||
);
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
let s = Store::open(&dir.0).unwrap();
|
||||
assert!(live.differs_from(s.ledger()).is_empty());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// Tags are derived on replay, nothing journaled for them: the fixture's
|
||||
/// invoice 1 carries its receivable line (Origin) and its payment's, the
|
||||
/// payment's reversal its mirror; the hand line none.
|
||||
#[test]
|
||||
fn legacy_documents_replay_with_tags() {
|
||||
use ledger_core::{DocRef, Movement};
|
||||
let dir = fixture("tags");
|
||||
let s = Store::open(&dir.0).unwrap();
|
||||
let l = s.ledger();
|
||||
let e = l.get_invoice(1).unwrap().entry_id;
|
||||
let tag = l.line_doc(e, 0).unwrap();
|
||||
assert_eq!(
|
||||
(tag.doc, tag.movement),
|
||||
(DocRef::Invoice(1), Movement::Origin)
|
||||
);
|
||||
let lines = l.doc_lines(DocRef::Invoice(1));
|
||||
assert_eq!(
|
||||
lines
|
||||
.iter()
|
||||
.map(|x| (x.movement, x.reversal))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(Movement::Origin, false),
|
||||
(Movement::Payment, false),
|
||||
(Movement::Payment, true)
|
||||
]
|
||||
);
|
||||
let bill = l.get_bill(1).unwrap().entry_id;
|
||||
let last = l.get_entry(bill).unwrap().lines().len() - 1;
|
||||
assert_eq!(l.line_doc(bill, last).unwrap().doc, DocRef::Bill(1));
|
||||
let hand = l
|
||||
.posted_entries()
|
||||
.find(|e| e.description() == "Hand-booked sale")
|
||||
.unwrap()
|
||||
.id();
|
||||
assert!((0..2).all(|i| l.line_doc(hand, i).is_none()));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"at": {
|
||||
"2026-01-22": {
|
||||
"ap_aging": [],
|
||||
"ar_aging": []
|
||||
},
|
||||
"2026-01-31": {
|
||||
"ap_aging": [],
|
||||
"ar_aging": []
|
||||
},
|
||||
"2026-03-31": {
|
||||
"ap_aging": [
|
||||
{
|
||||
"current": "0",
|
||||
"days_30": "40",
|
||||
"days_60": "0",
|
||||
"days_90": "0",
|
||||
"days_90_plus": "0",
|
||||
"name": "Tobin Harlow Supply"
|
||||
}
|
||||
],
|
||||
"ar_aging": [
|
||||
{
|
||||
"current": "180",
|
||||
"days_30": "200",
|
||||
"days_60": "0",
|
||||
"days_90": "0",
|
||||
"days_90_plus": "0",
|
||||
"name": "Morgan Ellis Studio"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bills": [
|
||||
{
|
||||
"balance": "0",
|
||||
"id": 1,
|
||||
"status": "Paid"
|
||||
},
|
||||
{
|
||||
"balance": "40",
|
||||
"id": 2,
|
||||
"status": "Open"
|
||||
}
|
||||
],
|
||||
"invoices": [
|
||||
{
|
||||
"balance": "0",
|
||||
"id": 1,
|
||||
"status": "Paid"
|
||||
},
|
||||
{
|
||||
"balance": "200",
|
||||
"id": 2,
|
||||
"status": "Open"
|
||||
},
|
||||
{
|
||||
"balance": "180",
|
||||
"id": 3,
|
||||
"status": "Partial"
|
||||
}
|
||||
],
|
||||
"trial_balance": [
|
||||
{
|
||||
"code": "1000",
|
||||
"credit": "0",
|
||||
"debit": "1120"
|
||||
},
|
||||
{
|
||||
"code": "1200",
|
||||
"credit": "0",
|
||||
"debit": "305"
|
||||
},
|
||||
{
|
||||
"code": "2000",
|
||||
"credit": "120",
|
||||
"debit": "0"
|
||||
},
|
||||
{
|
||||
"code": "3000",
|
||||
"credit": "1000",
|
||||
"debit": "0"
|
||||
},
|
||||
{
|
||||
"code": "4000",
|
||||
"credit": "425",
|
||||
"debit": "0"
|
||||
},
|
||||
{
|
||||
"code": "6999",
|
||||
"credit": "0",
|
||||
"debit": "120"
|
||||
}
|
||||
],
|
||||
"written_by": "wanderbooks main 0df7f52a, before P4b S1"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-23T17:49:14Z","type":"store.created"}
|
||||
{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"eb6e78d8d00bed1be9a59e3c07c1423083ddbaf9beee4ff9da1106628faef362","seq":2,"ts":"2026-10-23T17:49:14Z","type":"journal.format_set"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"9f985787d31957f133c73041a0a8a284653cdd863923dde12976a6d5d69513ea","seq":3,"ts":"2026-10-23T17:49:14Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"b08a6f2026e9f34347f553e40ec59e3123c7f1a62646ef8c3c80a41df2579a22","seq":4,"ts":"2026-10-23T17:49:14Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"dea25bc2fdbac79fc3b058bcf7b62459b10a7b582d2295b8c932c692e557cacb","seq":5,"ts":"2026-10-23T17:49:14Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"1000","currency":"USD","id":1,"side":"debit"},"prev":"fc8968c9ef6778af950e7d47295803ebc762fd2146732c38f1891b550e276b69","seq":6,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"}
|
||||
{"payload":{"account":2,"amount":"1000","currency":"USD","id":1,"side":"credit"},"prev":"7c8eab52ac9ab71ff98665d96f3c706fa4ac114a6fecb452e8006020c2a2daf7","seq":7,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000","currency":"USD","side":"debit"},{"account":2,"amount":"1000","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8179f4c64b537508e3297aeef4acbec685cf0d56c172db52cb16f253e87e80db","seq":8,"ts":"2026-10-23T17:49:14Z","type":"entry.posted"}
|
||||
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"ec1c5635e2bfa8a9683e3c06f4e68bb2e86abf47558da91e7977847bd9b9d18e","seq":9,"ts":"2026-10-23T17:49:14Z","type":"customer.added"}
|
||||
{"payload":{"id":2,"name":"Morgan Ellis Studio"},"prev":"5db484ed2a315872e20f67497fef853bec9829d4854f4fe911196982b3822930","seq":10,"ts":"2026-10-23T17:49:14Z","type":"customer.added"}
|
||||
{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"3bd354891c4a3da6f70fe93b6da6e199dde6e6b32c09598d815129b7905cc414","seq":11,"ts":"2026-10-23T17:49:14Z","type":"vendor.added"}
|
||||
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"6ed860b583ebfcb6d190be0e40662bc4e91a66873d0cb554fbeee3255374e556","seq":12,"ts":"2026-10-23T17:49:14Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"12e4262f9e11bd6e20a91109dd5e5e29c98628a1fff26b4bc76cfce809611849","seq":13,"ts":"2026-10-23T17:49:14Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":2},"prev":"4caf31b7426bacb7ff48b6c7c2ecb82f0d932fb2ad43ed0d2508eeb1661e3d6a","seq":14,"ts":"2026-10-23T17:49:14Z","type":"entry.created"}
|
||||
{"payload":{"account":3,"amount":"100.00","currency":"USD","id":2,"side":"debit"},"prev":"43933d9c0657e03fe38b658a6ac8b7ed55de7f57303434ec54895ef8342eeafc","seq":15,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"100.00","currency":"USD","id":2,"side":"credit"},"prev":"725d36996f9e49adef4d8591ce59fbcb56c6815badfd4857b483b96350984a27","seq":16,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"100.00","currency":"USD","side":"debit"},{"account":4,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"43521bd0b41d487b95e85582082664e982626fef4f41d18f5617669eb6c19e9a","seq":17,"ts":"2026-10-23T17:49:14Z","type":"entry.posted"}
|
||||
{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":2,"id":1,"lines":[{"amount":"100.00","description":"Design"}]},"prev":"896e5a852057640d244350dd6c762bc0765bea135a01a845f753046f395a81ff","seq":18,"ts":"2026-10-23T17:49:14Z","type":"invoice.created"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":3},"prev":"18202fcc839f243d5091d15a63190b2731d40f04a527585f19365dc68c9ea884","seq":19,"ts":"2026-10-23T17:49:14Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"100.00","currency":"USD","id":3,"side":"debit"},"prev":"aa81e39741d2389c28d5f514493b0b409c5cb2197735ce63c47413e9da5a3a30","seq":20,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"}
|
||||
{"payload":{"account":3,"amount":"100.00","currency":"USD","id":3,"side":"credit"},"prev":"cc0461c19c921b59a1f1f07664fcc9ce331b96fe8cdd17fadf162ee0473ed63d","seq":21,"ts":"2026-10-23T17:49:14Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"debit"},{"account":3,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c5f3ab8a361fb24b70998c38e13822c26f9693c7002841f954434bf32b220bc2","seq":22,"ts":"2026-10-23T17:49:14Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"100.00","cash_account":1,"date":"2026-01-20","entry_id":3,"invoice_id":1},"prev":"8b407977fa9b6787b07b4a943f7714fd6357deddf17ad0344130cb6a3cd294e2","seq":23,"ts":"2026-10-23T17:49:14Z","type":"invoice.payment_received"}
|
||||
{"payload":{"date":"2026-01-25","description":"Check bounced","id":4,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"credit"},{"account":3,"amount":"100.00","currency":"USD","side":"debit"}],"reverses":3},"prev":"de7a16daea36b5b580c8863d3aed63eeebf4ab410011f1484a31fd495bb2a5c5","seq":24,"ts":"2026-10-23T17:49:14Z","type":"entry.reversed"}
|
||||
{"payload":{"date":"2026-02-01","description":"Invoice 2","id":5},"prev":"fef4279c55719dccb847fd6ec2bf8cf0443f828939cb60b7d5e33d9bebbdfb02","seq":25,"ts":"2026-10-23T17:49:14Z","type":"entry.created"}
|
||||
{"payload":{"account":3,"amount":"200.00","currency":"USD","id":5,"side":"debit"},"prev":"e385985b3ed74ced6eab48ac7b93f46a5bf76a11fcdd4c2b70449ebbffa68b13","seq":26,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"200.00","currency":"USD","id":5,"side":"credit"},"prev":"4940c3c4b730cf67b51aed3aaf98942660ceeade217cc4d1e62bef65096421b8","seq":27,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-02-01","description":"Invoice 2","id":5,"lines":[{"account":3,"amount":"200.00","currency":"USD","side":"debit"},{"account":4,"amount":"200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8ea332be289c7733a31aa2b1c50a725fbd86bd72ec06f04227149e75a4c7e48a","seq":28,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"}
|
||||
{"payload":{"customer_id":2,"date":"2026-02-01","due_date":"2026-03-03","entry_id":5,"id":2,"lines":[{"amount":"200.00","description":"Wrong customer"}]},"prev":"6d557342119cf5defa10fe1c99714b191b78c6881ea4b7b9221e0753327f0c78","seq":29,"ts":"2026-10-23T17:49:15Z","type":"invoice.created"}
|
||||
{"payload":{"date":"2026-02-05","description":"Void: wrong customer","id":6,"lines":[{"account":3,"amount":"200.00","currency":"USD","side":"credit"},{"account":4,"amount":"200.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"a28ac5a9be20a1e5e1660628a1cc2098f06bb671365e2c14599eb805d34d6df5","seq":30,"ts":"2026-10-23T17:49:15Z","type":"entry.reversed"}
|
||||
{"payload":{"date":"2026-03-01","description":"Invoice 3","id":7},"prev":"817f1ab3784a95a37c66f0b6c08b6bf7c6181199a7b6cd1f2826312c7dafa3aa","seq":31,"ts":"2026-10-23T17:49:15Z","type":"entry.created"}
|
||||
{"payload":{"account":3,"amount":"300.00","currency":"USD","id":7,"side":"debit"},"prev":"a6efe165c18aa3e98c41d9379aca2c5bce495e96eedb542265aa68df1b84f45f","seq":32,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"300.00","currency":"USD","id":7,"side":"credit"},"prev":"cfe0f607c0fc65cd6a084022ffd860c5484e7497f1284edff4b8fcd364456e86","seq":33,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-03-01","description":"Invoice 3","id":7,"lines":[{"account":3,"amount":"300.00","currency":"USD","side":"debit"},{"account":4,"amount":"300.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5760d58c9ad06005d9b9872ab79f32fe49d6d0863bd48ae0d791a224738362f9","seq":34,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"}
|
||||
{"payload":{"customer_id":2,"date":"2026-03-01","due_date":"2026-03-31","entry_id":7,"id":3,"lines":[{"amount":"300.00","description":"Workshop"}]},"prev":"6cbb0e41c1c5d6070bc543cd3164cfc16a0d673e1d0f196b28077fae08c3d24d","seq":35,"ts":"2026-10-23T17:49:15Z","type":"invoice.created"}
|
||||
{"payload":{"date":"2026-03-10","description":"Payment on invoice 3","id":8},"prev":"d9d6986ff6735a1d54dabac27c9e32194870cec5a485f3809a9d9dab05949fe2","seq":36,"ts":"2026-10-23T17:49:15Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"120.00","currency":"USD","id":8,"side":"debit"},"prev":"b02a85dcdf99109a1ad6dde3297381ed0cd0d0fe22f8320b4d5cd0b395453233","seq":37,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"account":3,"amount":"120.00","currency":"USD","id":8,"side":"credit"},"prev":"6df3a9fdb02a4fd1299fda857469f7de9eae54a9cb2dafba0b3cea824e065f8b","seq":38,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-03-10","description":"Payment on invoice 3","id":8,"lines":[{"account":1,"amount":"120.00","currency":"USD","side":"debit"},{"account":3,"amount":"120.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"cf15a58d2636aa9c9aab41500a03b490f604e83baed37b90cbe4561b00e64956","seq":39,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"120.00","cash_account":1,"date":"2026-03-10","entry_id":8,"invoice_id":3},"prev":"84f0e3f69aa2e4333a231f63160b588e835b7aa82d4e429244fbe6caae210b60","seq":40,"ts":"2026-10-23T17:49:15Z","type":"invoice.payment_received"}
|
||||
{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"d312dbf6968c2f90f15b79f3d90483e293ab12c6f13f23509e54994d70e5459a","seq":41,"ts":"2026-10-23T17:49:15Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"68d3ece17cb34265b91b7860c3d195c7e4dfe356ae28d6f9bb6868c8776e7d0e","seq":42,"ts":"2026-10-23T17:49:15Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-15","description":"Bill 1","id":9},"prev":"105043103a6a765ff38955e62e1239fa361aae9f3e911445da856de579ce3e6a","seq":43,"ts":"2026-10-23T17:49:15Z","type":"entry.created"}
|
||||
{"payload":{"account":5,"amount":"80.00","currency":"USD","id":9,"side":"debit"},"prev":"0946afac7673374597c5172e6feeaf6ac339726b105faec8dea8e2e015b4bcf2","seq":44,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"account":6,"amount":"80.00","currency":"USD","id":9,"side":"credit"},"prev":"1d514a116ab96f78f6deede2255d9a2ac89df843721443ca42daa5749afc1d8a","seq":45,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-15","description":"Bill 1","id":9,"lines":[{"account":5,"amount":"80.00","currency":"USD","side":"debit"},{"account":6,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f4ef92842aa02735fa0e6c63c7b1e9fb3d5bc131d114b5fdfa7315ea5f8a6477","seq":46,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-01-15","due_date":"2026-02-14","entry_id":9,"id":1,"lines":[{"amount":"80.00","description":"Paper"}],"vendor_id":1},"prev":"b36e0445d8c6794d62c8a7c743030142d1e0393f04e3972d2bc7544bf789a7db","seq":47,"ts":"2026-10-23T17:49:15Z","type":"bill.created"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on bill 1","id":10},"prev":"cc86b398d183f7630fabe1fc0e91cc1bd286d28e2cecfd6d063ad36915c99dcb","seq":48,"ts":"2026-10-23T17:49:15Z","type":"entry.created"}
|
||||
{"payload":{"account":6,"amount":"80.00","currency":"USD","id":10,"side":"debit"},"prev":"b91a8933d4c93ca33e0386b5b3b0a2eeffa66ab643951c96d909325724c74e70","seq":49,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"80.00","currency":"USD","id":10,"side":"credit"},"prev":"b55247fffbfd0c6c229930630360357b132d41ec78bc6e435fd0aa47b8761303","seq":50,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on bill 1","id":10,"lines":[{"account":6,"amount":"80.00","currency":"USD","side":"debit"},{"account":1,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"3495cc1736311c81149fccc5008c8ba80c8770433b65f6c8f365cf1fbbb7c54b","seq":51,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"80.00","bill_id":1,"cash_account":1,"date":"2026-01-20","entry_id":10},"prev":"1fafcefc65716cad231f193bf9fc50abd11b2a119d9b333483f974956e0fae3d","seq":52,"ts":"2026-10-23T17:49:15Z","type":"bill.paid"}
|
||||
{"payload":{"date":"2026-01-28","description":"Paid in error","id":11,"lines":[{"account":6,"amount":"80.00","currency":"USD","side":"credit"},{"account":1,"amount":"80.00","currency":"USD","side":"debit"}],"reverses":10},"prev":"e894365a6916297a100c55124ebb5940aea4385b0d20e7adec8665065937bc45","seq":53,"ts":"2026-10-23T17:49:15Z","type":"entry.reversed"}
|
||||
{"payload":{"date":"2026-02-10","description":"Bill 2","id":12},"prev":"0d6fd6e40ad12a680e5f95664974c7a0ab578f8ecdfecd820a6ee1dbb906381e","seq":54,"ts":"2026-10-23T17:49:15Z","type":"entry.created"}
|
||||
{"payload":{"account":5,"amount":"40.00","currency":"USD","id":12,"side":"debit"},"prev":"c5e519bd4e997d5582c24365a479be56a5be92929de378496d3583e8fc75a8e2","seq":55,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"account":6,"amount":"40.00","currency":"USD","id":12,"side":"credit"},"prev":"07cfcbe13cea029e232d70c24a26d75ddc72ba10c3131b8a049f0a295031de0d","seq":56,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-02-10","description":"Bill 2","id":12,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":6,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4c24978be29aed0e01852d5e9f787c53e6c6ba06113da2295417381b448a68cb","seq":57,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-02-10","due_date":"2026-03-12","entry_id":12,"id":2,"lines":[{"amount":"40.00","description":"Ink"}],"vendor_id":1},"prev":"d42003f062e9233ce4282a5efac1b31ea6cb643ea3d4bf8e30e3bda6cfe780a7","seq":58,"ts":"2026-10-23T17:49:15Z","type":"bill.created"}
|
||||
{"payload":{"date":"2026-02-15","description":"Hand-booked sale","id":13},"prev":"20811aeed7514d05640dac5a339a765500419a9dc46a7abdc9ef90194823f55c","seq":59,"ts":"2026-10-23T17:49:15Z","type":"entry.created"}
|
||||
{"payload":{"account":3,"amount":"25.00","currency":"USD","id":13,"side":"debit"},"prev":"9359cb76e35884f79412ccc894cd2f11874e1a70a37296da8e58cffe29089836","seq":60,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"25.00","currency":"USD","id":13,"side":"credit"},"prev":"9c667cd07a65b9161d2de4e4bd2fa00f50875255ba6e14d39ecb48ffb3f1296a","seq":61,"ts":"2026-10-23T17:49:15Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-02-15","description":"Hand-booked sale","id":13,"lines":[{"account":3,"amount":"25.00","currency":"USD","side":"debit"},{"account":4,"amount":"25.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7cb35848235c502669a89c80b9db1ca9942f2c10b9cee4fee5dd3502afde0a77","seq":62,"ts":"2026-10-23T17:49:15Z","type":"entry.posted"}
|
||||
@@ -1006,6 +1006,12 @@ mod refusals {
|
||||
Bill(u64, u32, u32, i64),
|
||||
ReceivePayment(u64, u32, i64, u64),
|
||||
PayBill(u64, u32, i64, u64),
|
||||
/// A payment with its method and reference (plan P4b S1): an
|
||||
/// invoice's (true) or a bill's, id, day, cents, account, payment
|
||||
/// method (maybe unknown), reference (none, a good one, two lines).
|
||||
PayWith(bool, u64, u32, i64, u64, Option<u64>, u8),
|
||||
/// Void an invoice (true) or a bill: id, day, reason (good, blank).
|
||||
Void(bool, u64, u32, bool),
|
||||
AddClass(u8),
|
||||
SetLineClass(u64, usize, Option<u32>),
|
||||
SetEntryVendor(u64, Option<u64>),
|
||||
@@ -1101,6 +1107,18 @@ mod refusals {
|
||||
.prop_map(|(i, d, a, c)| Op::ReceivePayment(i, d, a, c)),
|
||||
(0u64..4, 0u32..120, arb_cents(), 0u64..10)
|
||||
.prop_map(|(b, d, a, c)| Op::PayBill(b, d, a, c)),
|
||||
(
|
||||
any::<bool>(),
|
||||
0u64..4,
|
||||
0u32..120,
|
||||
arb_cents(),
|
||||
0u64..10,
|
||||
prop::option::of(0u64..4),
|
||||
0u8..3
|
||||
)
|
||||
.prop_map(|(k, i, d, a, c, m, r)| Op::PayWith(k, i, d, a, c, m, r)),
|
||||
(any::<bool>(), 0u64..4, 0u32..120, any::<bool>())
|
||||
.prop_map(|(k, i, d, r)| Op::Void(k, i, d, r)),
|
||||
(0u8..3).prop_map(Op::AddClass),
|
||||
(id.clone(), 0usize..4, prop::option::of(0u32..4))
|
||||
.prop_map(|(e, i, c)| Op::SetLineClass(e, i, c)),
|
||||
@@ -1286,6 +1304,32 @@ mod refusals {
|
||||
.receive_payment(i, day(d), Decimal::new(a, 2), c)
|
||||
.map(drop),
|
||||
Op::PayBill(b, d, a, c) => store.pay_bill(b, day(d), Decimal::new(a, 2), c).map(drop),
|
||||
Op::PayWith(invoice, i, d, a, c, m, r) => {
|
||||
let details = ledger_core::PaymentDetails {
|
||||
instrument: m,
|
||||
reference: [None, Some("ch_3Nx 0042"), Some("two\nlines")][r as usize]
|
||||
.map(str::to_string),
|
||||
};
|
||||
if invoice {
|
||||
store
|
||||
.receive_payment_with(i, day(d), Decimal::new(a, 2), c, &details)
|
||||
.map(drop)
|
||||
} else {
|
||||
store
|
||||
.pay_bill_with(i, day(d), Decimal::new(a, 2), c, &details)
|
||||
.map(drop)
|
||||
}
|
||||
}
|
||||
Op::Void(invoice, i, d, reason) => {
|
||||
let doc = if invoice {
|
||||
ledger_core::DocRef::Invoice(i)
|
||||
} else {
|
||||
ledger_core::DocRef::Bill(i)
|
||||
};
|
||||
store
|
||||
.void_document(doc, day(d), if reason { "duplicate" } else { " " })
|
||||
.map(drop)
|
||||
}
|
||||
Op::AddClass(n) => store.add_class(["A", "B", ""][n as usize]).map(drop),
|
||||
Op::SetLineClass(e, i, c) => store.set_line_class(e, i, c),
|
||||
Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v),
|
||||
@@ -2762,8 +2806,10 @@ fn searching_entries_filters_and_pages() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The database's invoice row follows its payments: amount paid and
|
||||
/// open / partial / paid, matching the ledger.
|
||||
/// The database's invoice row keeps no figure of its own (schema 15,
|
||||
/// rules.md B-38): what it has been paid is the ledger's, read from the
|
||||
/// lines tagged with it, so a reversed payment no longer counts; the row
|
||||
/// is the same before and after each payment, and verify agrees.
|
||||
#[test]
|
||||
fn the_database_tracks_what_an_invoice_has_been_paid() {
|
||||
use ledger_core::arap::DocumentLine;
|
||||
@@ -2784,24 +2830,37 @@ fn the_database_tracks_what_an_invoice_has_been_paid() {
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let row = |dir: &std::path::Path| -> (String, String) {
|
||||
let row = |dir: &std::path::Path| -> String {
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
|
||||
conn.query_row(
|
||||
"SELECT amount_paid, status FROM invoices WHERE id = ?",
|
||||
[inv as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
conn.query_row("SELECT * FROM invoices WHERE id = ?", [inv as i64], |r| {
|
||||
Ok(format!(
|
||||
"{:?}",
|
||||
(0..6)
|
||||
.map(|i| r.get_ref(i).map(|v| format!("{v:?}")))
|
||||
.collect::<Vec<_>>()
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(row(&dir).1, "open");
|
||||
store
|
||||
let before = row(&dir);
|
||||
let paid = |s: &Store| {
|
||||
s.ledger()
|
||||
.invoice_state(inv, day(31))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.paid
|
||||
};
|
||||
let p = store
|
||||
.receive_payment(inv, day(10), Decimal::from(40), cash)
|
||||
.unwrap();
|
||||
assert_eq!(row(&dir), ("40".into(), "partial".into()));
|
||||
assert_eq!(paid(&store), Decimal::from(40));
|
||||
store.reverse_entry(p, day(12), "Bounced").unwrap();
|
||||
assert_eq!(paid(&store), Decimal::ZERO);
|
||||
store
|
||||
.receive_payment(inv, day(20), Decimal::from(60), cash)
|
||||
.unwrap();
|
||||
assert_eq!(row(&dir), ("100".into(), "paid".into()));
|
||||
assert_eq!(paid(&store), Decimal::from(60));
|
||||
assert_eq!(row(&dir), before, "the row holds no figure");
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user