Files
wanderbooks/crates/ledger-core/tests/spec_document_projects.rs
T
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
2026-10-27 00:02:51 -06:00

482 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::{
AccountId, 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,
account: None,
quantity: None,
unit_price: None,
}
}
/// 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<AccountId>, Option<ClassId>)> = Vec::new();
for l in &lines {
let key = (l.account, l.class_id);
if !first.contains(&key) {
first.push(key);
}
}
prop_assert_eq!(parts.iter().map(|p| p.0).collect::<Vec<_>>(), first);
for ((account, class), amount) in &parts {
let sum: Decimal = lines
.iter()
.filter(|l| l.account == *account && l.class_id == *class)
.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() && l.account.is_none()) {
prop_assert_eq!(parts, vec![((None, 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),
account: None,
quantity: None,
unit_price: None,
};
assert!(matches!(
document_parts(&[big.clone(), big], "doc"),
Err(LedgerError::TooLargeToCompute(_))
));
}