Files
wanderbooks/crates/ledger-core/tests/spec_document_projects.rs
T
swaitsandClaude Opus 5.5 d7fa16e974
check / check (push) Canceled after 0s
feat(arap)!: documents read their figures from standing lines; control accounts
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
2026-10-24 09:17:42 -06:00

471 lines
16 KiB
Rust

//! An invoice's or bill's lines name their projects (rules.md B-37 item
//! 8): the posted entry's revenue or expense side is one line per project,
//! the untagged lines' as one; the receivable or payable line, and the
//! payments, never take one.
//!
//! ```text
//! GOAL: a client project's invoiced revenue and a contractor's billed
//! cost count toward the project, every other figure as before.
//! SOURCE: docs/spec/rules.md B-37 items 3 and 8 (job costing), B-14 (the
//! projects with the untagged lines partition the P&L), B-19 and
//! B-35 (1099 and party figures follow the money, not the tags).
//! ```
use chrono::NaiveDate;
use ledger_core::arap::{DocumentLine, document_parts};
use ledger_core::form1099::{PaymentAccounts, vendor_1099_report};
use ledger_core::parties::{MoneyAccounts, activity};
use ledger_core::reports::profit_and_loss_by_class;
use ledger_core::{
AccountType, ClassId, Currency, Ledger, LedgerError, ProjectFacts, ProjectStatus, Side,
project_entries, project_to_date,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
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, class: Option<ClassId>) -> DocumentLine {
DocumentLine {
description: what.into(),
amount: cents(c),
class_id: class,
}
}
/// Cash 1000 and two projects, Zeta (client) and Harbor.
struct Books {
l: Ledger,
cash: u64,
zeta: ClassId,
harbor: ClassId,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let zeta = l.add_class("Zeta").unwrap();
let harbor = l.add_class("Harbor").unwrap();
Books {
l,
cash,
zeta,
harbor,
}
}
/// Each line of `entry`: (code, side, amount, class).
fn lines_of(l: &Ledger, entry: u64) -> Vec<(String, Side, Decimal, Option<ClassId>)> {
l.get_entry(entry)
.unwrap()
.lines()
.iter()
.map(|x| {
(
l.chart().get(x.account).unwrap().code.clone(),
x.side,
x.amount.amount,
x.class_id,
)
})
.collect()
}
#[test]
fn an_invoices_revenue_is_one_line_per_project_and_the_receivable_none() {
let mut b = books();
let (z, h) = (b.zeta, b.harbor);
let c = b.l.add_customer("Cedar").unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(z)),
line("Hosting", 20_000, Some(h)),
line("Rush fee", 10_000, None),
line("Revisions", 50_000, Some(z)),
],
)
.unwrap();
let e = b.l.get_invoice(inv).unwrap().entry_id;
assert_eq!(
lines_of(&b.l, e),
vec![
("1200".into(), Side::Debit, cents(380_000), None),
("4000".into(), Side::Credit, cents(350_000), Some(z)),
("4000".into(), Side::Credit, cents(20_000), Some(h)),
("4000".into(), Side::Credit, cents(10_000), None),
]
);
// The invoice keeps its lines as given, projects and all.
let kept = &b.l.get_invoice(inv).unwrap().lines;
assert_eq!(kept[3].class_id, Some(z));
assert_eq!(
b.l.get_invoice(inv).unwrap().total().unwrap(),
cents(380_000)
);
// Its revenue counts toward each project, to the cent.
let zt = project_to_date(&b.l, z, d(2026, 12, 31)).unwrap();
assert_eq!((zt.revenue, zt.expenses), (cents(350_000), Decimal::ZERO));
assert_eq!(
project_to_date(&b.l, h, d(2026, 12, 31)).unwrap().revenue,
cents(20_000)
);
assert_eq!(project_entries(&b.l, z), vec![(e, cents(350_000))]);
// The payment is balance-sheet only: no project, no project figure.
let paid =
b.l.receive_payment(inv, d(2026, 3, 20), cents(380_000), b.cash)
.unwrap();
assert!(lines_of(&b.l, paid).iter().all(|l| l.3.is_none()));
assert_eq!(project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(), zt);
}
#[test]
fn a_bills_cost_is_one_line_per_project_and_the_payable_none() {
let mut b = books();
let (z, h) = (b.zeta, b.harbor);
let v = b.l.add_vendor("Dana Illustration").unwrap();
let bill =
b.l.create_bill(
v,
d(2026, 3, 9),
d(2026, 4, 8),
vec![
line("Office supplies", 5_000, None),
line("Illustration", 120_000, Some(z)),
line("Icons", 30_000, Some(h)),
],
)
.unwrap();
let e = b.l.get_bill(bill).unwrap().entry_id;
assert_eq!(
lines_of(&b.l, e),
vec![
("6999".into(), Side::Debit, cents(5_000), None),
("6999".into(), Side::Debit, cents(120_000), Some(z)),
("6999".into(), Side::Debit, cents(30_000), Some(h)),
("2000".into(), Side::Credit, cents(155_000), None),
]
);
let zt = project_to_date(&b.l, z, d(2026, 12, 31)).unwrap();
assert_eq!((zt.revenue, zt.expenses), (Decimal::ZERO, cents(120_000)));
let paid =
b.l.pay_bill(bill, d(2026, 3, 30), cents(155_000), b.cash)
.unwrap();
assert!(lines_of(&b.l, paid).iter().all(|l| l.3.is_none()));
assert_eq!(project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(), zt);
}
/// Reversing a tagged invoice's or bill's entry carries each line's
/// project to the reversal (B-37 item 3: each line on its normal side), so
/// every project nets back to zero; a partial payment, cash against the
/// receivable, moves no project figure.
#[test]
fn reversing_a_tagged_document_takes_its_projects_to_zero() {
let mut b = books();
let (z, h) = (b.zeta, b.harbor);
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(z)),
line("Hosting", 20_000, Some(h)),
line("Fee", 10_000, None),
],
)
.unwrap();
let bill =
b.l.create_bill(
v,
d(2026, 3, 9),
d(2026, 4, 8),
vec![line("Illustration", 120_000, Some(z))],
)
.unwrap();
let to_date = |l: &Ledger, p| {
let t = project_to_date(l, p, d(2026, 12, 31)).unwrap();
(t.revenue, t.expenses)
};
assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000)));
let pay =
b.l.receive_payment(inv, d(2026, 3, 20), cents(100_000), b.cash)
.unwrap();
assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000)));
assert_eq!(to_date(&b.l, h), (cents(20_000), Decimal::ZERO));
// The payment first: a void waits on the payments that stand (B-38
// scenario 3); its reversal moves no project figure either.
b.l.reverse_entry(pay, d(2026, 3, 30), "Bounced").unwrap();
assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000)));
for e in [
b.l.get_invoice(inv).unwrap().entry_id,
b.l.get_bill(bill).unwrap().entry_id,
] {
let r = b.l.reverse_entry(e, d(2026, 3, 31), "Reverse").unwrap();
let original: Vec<_> = lines_of(&b.l, e).into_iter().map(|l| l.3).collect();
let reversed: Vec<_> = lines_of(&b.l, r).into_iter().map(|l| l.3).collect();
assert_eq!(reversed, original, "entry {e}");
}
// Each line on its normal side: a reversed revenue line is a debit to
// revenue, so the project's revenue and expenses are both zero.
assert_eq!(to_date(&b.l, z), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(to_date(&b.l, h), (Decimal::ZERO, Decimal::ZERO));
let by_class = profit_and_loss_by_class(&b.l).unwrap();
assert!(!by_class.is_empty());
for r in &by_class {
assert_eq!(r.profit_and_loss.net_income(), Decimal::ZERO, "{r:?}");
}
}
/// A document naming no project posts exactly what every document posted
/// before projects: one revenue (or expense) line for its total.
#[test]
fn an_untagged_document_posts_as_before() {
let mut b = books();
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 1, 5),
d(2026, 2, 4),
vec![line("A", 10_000, None), line("B", 2_550, None)],
)
.unwrap();
let bill =
b.l.create_bill(v, d(2026, 1, 6), d(2026, 2, 5), vec![line("C", 999, None)])
.unwrap();
assert_eq!(
lines_of(&b.l, b.l.get_invoice(inv).unwrap().entry_id),
vec![
("1200".into(), Side::Debit, cents(12_550), None),
("4000".into(), Side::Credit, cents(12_550), None),
]
);
assert_eq!(
lines_of(&b.l, b.l.get_bill(bill).unwrap().entry_id),
vec![
("6999".into(), Side::Debit, cents(999), None),
("2000".into(), Side::Credit, cents(999), None),
]
);
}
#[test]
fn a_line_naming_an_unknown_project_is_refused_whole() {
let mut b = books();
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
let before = b.l.clone();
assert_eq!(
b.l.create_invoice(
c,
d(2026, 1, 5),
d(2026, 2, 4),
vec![line("A", 100, Some(b.zeta)), line("B", 100, Some(77))],
),
Err(LedgerError::UnknownClass(77))
);
assert_eq!(
b.l.create_bill(
v,
d(2026, 1, 5),
d(2026, 2, 4),
vec![line("A", 100, Some(78))]
),
Err(LedgerError::UnknownClass(78))
);
assert!(b.l.differs_from(&before).is_empty(), "nothing changed");
}
/// Finished and abandoned projects still take a document's lines (B-37
/// item 5: a late cost, a final invoice).
#[test]
fn a_finished_project_still_takes_an_invoice() {
let mut b = books();
b.l.set_project(
b.zeta,
"Zeta",
ProjectFacts {
status: ProjectStatus::Finished,
started_on: Some(d(2026, 1, 1)),
ended_on: Some(d(2026, 2, 28)),
..Default::default()
},
)
.unwrap();
let c = b.l.add_customer("Cedar").unwrap();
b.l.create_invoice(
c,
d(2026, 3, 15),
d(2026, 4, 14),
vec![line("Final", 40_000, Some(b.zeta))],
)
.unwrap();
assert_eq!(
project_to_date(&b.l, b.zeta, d(2026, 12, 31))
.unwrap()
.revenue,
cents(40_000)
);
}
/// The same books twice, one with its documents' lines tagged: every
/// figure that isn't a project's is the same (the P&L, the 1099
/// worksheet, a party's totals), and the tagged books' projects with the
/// untagged lines partition the P&L.
#[test]
fn tags_move_only_the_project_figures() {
let build = |tagged: bool| {
let mut b = books();
let tag = |c: ClassId| tagged.then_some(c);
let (z, h, cash) = (b.zeta, b.harbor, b.cash);
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
b.l.set_vendor_1099(
v,
ledger_core::form1099::Dated1099::from_beginning(
ledger_core::form1099::Decision1099::Files(ledger_core::form1099::Form1099::Nec),
None,
),
false,
)
.unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 2, 1),
d(2026, 3, 3),
vec![
line("Design", 500_000, tag(z)),
line("App", 80_000, tag(h)),
line("Fee", 1_000, None),
],
)
.unwrap();
b.l.receive_payment(inv, d(2026, 2, 20), cents(581_000), cash)
.unwrap();
let bill =
b.l.create_bill(
v,
d(2026, 2, 5),
d(2026, 3, 7),
vec![
line("Illustration", 180_000, tag(z)),
line("Misc", 2_500, None),
],
)
.unwrap();
b.l.pay_bill(bill, d(2026, 2, 25), cents(182_500), cash)
.unwrap();
(b, c, v)
};
let (plain, _, v) = build(false);
let (tagged, _, _) = build(true);
let year = (d(2026, 1, 1), d(2026, 12, 31));
let pnl = |l: &Ledger| ledger_core::profit_and_loss_for_period(l, year.0, year.1, None);
assert_eq!(
format!("{:?}", pnl(&plain.l).unwrap()),
format!("{:?}", pnl(&tagged.l).unwrap())
);
let sources = PaymentAccounts {
cash: vec![plain.cash],
financing: vec![],
};
let w = |l: &Ledger| {
format!(
"{:?}",
vendor_1099_report(l, 2026, None, &sources, &[], None)
)
};
assert_eq!(w(&plain.l), w(&tagged.l));
assert!(w(&tagged.l).contains("1825.00"), "{}", w(&tagged.l));
let money = MoneyAccounts {
cash: [plain.cash].into_iter().collect(),
liabilities: Default::default(),
};
let party = plain.l.party_of_vendor(v).unwrap();
let a = |l: &Ledger| format!("{:?}", activity(l, party, year, &money));
assert_eq!(a(&plain.l), a(&tagged.l));
// The tagged books: Zeta 5,000.00 - 1,800.00, Harbor 800.00, and the
// untagged lines 10.00 - 25.00; together the P&L's net.
let rows = profit_and_loss_by_class(&tagged.l).unwrap();
let net = |name: Option<&str>| {
let r = rows
.iter()
.find(|r| r.class.as_ref().map(|c| c.name.as_str()) == name)
.unwrap();
r.profit_and_loss.net_income()
};
assert_eq!(net(Some("Zeta")), cents(320_000));
assert_eq!(net(Some("Harbor")), cents(80_000));
assert_eq!(net(None), cents(-1_500));
let whole = pnl(&tagged.l).unwrap();
assert_eq!(cents(320_000 + 80_000 - 1_500), whole.net_income());
let z = project_to_date(&tagged.l, tagged.zeta, year.1).unwrap();
assert_eq!((z.revenue, z.expenses), (cents(500_000), cents(180_000)));
let none = project_to_date(&plain.l, plain.zeta, year.1).unwrap();
assert!(!none.any_tagged());
}
proptest! {
/// arap::document_parts INVARIANTS 1-3: the parts sum to the total,
/// each project once and in the order first named, the untagged lines
/// one part, and a document naming none one part.
#[test]
fn prop_document_parts_partition_the_total(
raw in prop::collection::vec((1i64..=10_000_000, prop::option::of(0u32..4)), 1..12)
) {
let lines: Vec<DocumentLine> = raw
.iter()
.map(|&(c, k)| line("x", c, k))
.collect();
let parts = document_parts(&lines, "doc").unwrap();
let total: Decimal = lines.iter().map(|l| l.amount).sum();
prop_assert_eq!(parts.iter().map(|p| p.1).sum::<Decimal>(), total);
let mut first: Vec<Option<ClassId>> = Vec::new();
for l in &lines {
if !first.contains(&l.class_id) {
first.push(l.class_id);
}
}
prop_assert_eq!(parts.iter().map(|p| p.0).collect::<Vec<_>>(), first);
for (k, amount) in &parts {
let sum: Decimal = lines.iter().filter(|l| l.class_id == *k).map(|l| l.amount).sum();
prop_assert_eq!(*amount, sum);
prop_assert!(*amount > Decimal::ZERO);
}
if lines.iter().all(|l| l.class_id.is_none()) {
prop_assert_eq!(parts, vec![(None, total)]);
}
}
}
/// document_parts INVARIANT 4: a sum past exact arithmetic is refused,
/// never a panic.
#[test]
fn document_parts_refuse_past_exact_arithmetic() {
let big = DocumentLine {
description: "x".into(),
amount: Decimal::MAX,
class_id: Some(1),
};
assert!(matches!(
document_parts(&[big.clone(), big], "doc"),
Err(LedgerError::TooLargeToCompute(_))
));
}