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.
This commit is contained in:
swaits committed 2026-10-27 00:02:51 -06:00
1 parent 58724bb007
commit 13282cf80e
44 files changed
+1986 -67

No files matched your search

+6
View File
@@ -982,6 +982,9 @@ impl<'a> Books<'a> {
description: description.to_string(),
amount: positive(a),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
(None, [_, ..]) => lines
.iter()
@@ -993,6 +996,9 @@ impl<'a> Books<'a> {
panic!("{what}: project {p:?} not in setup.projects")
})
}),
account: None,
quantity: None,
unit_price: None,
})
.collect(),
_ => panic!("{what}: a document has an amount or lines, exactly one"),
+3
View File
@@ -161,6 +161,9 @@ fn books() -> Ledger {
description: "Design work".into(),
amount: dec("1500"),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
+3
View File
@@ -990,6 +990,9 @@ fn doc(amount: u64) -> Vec<ledger_core::DocumentLine> {
description: "work".into(),
amount: Decimal::from(amount),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
}
+49
View File
@@ -484,4 +484,53 @@ proptest! {
prop_assert_eq!(i.built_in_gain_704c.map(dec), d.built_in_gain_704c);
}
}
/// kernel::discount_max at i64 (what Kani's discount_max_is_bounded
/// proved) and at Decimal agree where both are exact: the bound is
/// the same money, never above the total, and result x 10,000 =
/// total x basis points on both.
#[test]
fn discount_max_agrees(
total in 0..=MAX_CENTS,
percent_bp in 1u32..=9_999,
) {
let narrow = kernel::discount_max(total, percent_bp);
let wide = kernel::discount_max(dec(total), percent_bp)
.expect("a decimal division by 10,000 is exact");
if let Some(n) = narrow {
prop_assert_eq!(wide, dec(n));
}
prop_assert!(wide >= Decimal::ZERO);
prop_assert!(wide <= dec(total));
prop_assert_eq!(
wide * Decimal::from(10_000u32),
dec(total) * Decimal::from(percent_bp)
);
}
/// The discount bound Kani proves at i32 (small totals) is the same
/// bound at i64, which the property above carries to Decimal.
#[test]
fn discount_max_i32_agrees_with_i64(
total in 0i32..=200_000,
percent_bp in 1u32..=9_999,
) {
prop_assert_eq!(
kernel::discount_max(total, percent_bp).map(i64::from),
kernel::discount_max(i64::from(total), percent_bp)
);
}
/// kernel::line_amount at i64 (what Kani's line_amount_is_exact
/// proved) and at Decimal agree: cents x cents at i64 is the same
/// product as dollars x dollars at Decimal, to four places.
#[test]
fn line_amount_agrees(
quantity in 0..=1_000_000i64,
unit_price in 0..=1_000_000i64,
) {
let narrow = kernel::line_amount(quantity, unit_price);
let wide = kernel::line_amount(dec(quantity), dec(unit_price));
prop_assert_eq!(narrow.map(|n| Decimal::new(n, 4)), wide);
}
}
+25 -1
View File
@@ -37,6 +37,9 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
description: desc.to_string(),
amount: dec(amount),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}
}
@@ -187,6 +190,13 @@ fn invoice_total_is_sum_of_lines_and_balance_is_what_is_owed() {
owed: dec("90.00"),
status: DocumentStatus::Partial,
entry_id: 1,
number: None,
terms: None,
bill_to: None,
seller: None,
customer_ref: None,
memo: None,
sends: Vec::new(),
};
assert_eq!(inv.total().unwrap(), dec("150.00"));
assert_eq!(inv.balance().unwrap(), dec("90.00"));
@@ -205,6 +215,10 @@ fn bill_total_is_sum_of_lines_and_balance_is_what_is_owed() {
owed: Decimal::ZERO,
status: DocumentStatus::Paid,
entry_id: 9,
vendor_ref: None,
received_on: None,
terms: None,
memo: None,
};
assert_eq!(bill.total().unwrap(), dec("20.00"));
assert_eq!(bill.balance().unwrap(), Decimal::ZERO);
@@ -384,7 +398,14 @@ proptest! {
) {
let lines: Vec<DocumentLine> = amts
.iter()
.map(|c| DocumentLine { description: "l".into(), amount: cents(*c), class_id: None })
.map(|c| DocumentLine {
description: "l".into(),
amount: cents(*c),
class_id: None,
account: None,
quantity: None,
unit_price: None,
})
.collect();
let ok = !amts.is_empty() && amts.iter().all(|c| *c > 0);
prop_assert_eq!(validate_lines(&lines).is_ok(), ok);
@@ -1088,6 +1109,9 @@ fn doc_lines(doc: &Doc) -> Vec<DocumentLine> {
description: "item".into(),
amount: cents(*c),
class_id: None,
account: None,
quantity: None,
unit_price: None,
})
.collect()
}
+15
View File
@@ -142,6 +142,9 @@ fn dates_outside_the_calendar_are_refused_everywhere() {
description: "work".into(),
amount: Decimal::ONE,
class_id: None,
account: None,
quantity: None,
unit_price: None,
}];
assert_eq!(
b.ledger
@@ -221,6 +224,9 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
description: "work".into(),
amount: Decimal::ONE,
class_id: None,
account: None,
quantity: None,
unit_price: None,
}];
b.ledger
.replay_invoice(1, customer, NaiveDate::MIN, NaiveDate::MAX, line.clone(), 1)
@@ -329,11 +335,17 @@ fn amounts_larger_than_a_line_are_refused_everywhere() {
description: "a".into(),
amount: a,
class_id: None,
account: None,
quantity: None,
unit_price: None,
},
DocumentLine {
description: "b".into(),
amount: Decimal::ONE,
class_id: None,
account: None,
quantity: None,
unit_price: None,
},
]
};
@@ -623,6 +635,9 @@ fn replayed_payments_are_as_journaled() {
description: "work".into(),
amount: Decimal::new(10000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -2092,6 +2092,9 @@ fn a_bill_payment_carries_its_bills_split() {
description: "Equipment and supplies".into(),
amount: x("1700.00"),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
entry,
)
@@ -2517,6 +2520,9 @@ fn a_reversed_bill_payment_leaves_the_purchase_unpaid() {
description: "Equipment".into(),
amount: x("1000.00"),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
entry,
)
@@ -17,8 +17,8 @@ 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,
AccountId, AccountType, ClassId, Currency, Ledger, LedgerError, ProjectFacts, ProjectStatus,
Side, project_entries, project_to_date,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
@@ -36,6 +36,9 @@ fn line(what: &str, c: i64, class: Option<ClassId>) -> DocumentLine {
description: what.into(),
amount: cents(c),
class_id: class,
account: None,
quantity: None,
unit_price: None,
}
}
@@ -436,20 +439,25 @@ proptest! {
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();
let mut first: Vec<(Option<AccountId>, Option<ClassId>)> = Vec::new();
for l in &lines {
if !first.contains(&l.class_id) {
first.push(l.class_id);
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 (k, amount) in &parts {
let sum: Decimal = lines.iter().filter(|l| l.class_id == *k).map(|l| l.amount).sum();
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()) {
prop_assert_eq!(parts, vec![(None, total)]);
if lines.iter().all(|l| l.class_id.is_none() && l.account.is_none()) {
prop_assert_eq!(parts, vec![((None, None), total)]);
}
}
}
@@ -462,6 +470,9 @@ fn document_parts_refuse_past_exact_arithmetic() {
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"),
+521 -1
View File
@@ -11,7 +11,9 @@
//! ```
use chrono::NaiveDate;
use ledger_core::arap::DocumentLine;
use ledger_core::arap::{
BillFields, DocumentKind, DocumentLine, EarlyDiscount, InvoiceFields, Terms, TermsKind,
};
use ledger_core::{
AccountId, AccountType, Currency, DocRef, DocStatus, Ledger, LedgerError, Money, Movement,
PaymentDetails, Side,
@@ -36,6 +38,9 @@ fn line(what: &str, c: i64) -> Vec<DocumentLine> {
description: what.into(),
amount: cents(c),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
}
@@ -1402,3 +1407,518 @@ proptest! {
}
}
}
// ---------------------------------------------------------------------------
// S4.1: terms, numbers, line accounts, quantity and price
// ---------------------------------------------------------------------------
/// A document line naming its account and, when `qty` and `price` are
/// given, its quantity and unit price (plan P4b S4.1).
fn doc_line(
what: &str,
c: i64,
account: Option<AccountId>,
qty: Option<i64>,
price: Option<i64>,
) -> DocumentLine {
let quantity = qty.map(Decimal::from);
let unit_price = price.map(cents);
let amount = match (quantity, unit_price) {
(Some(q), Some(p)) => q * p,
_ => cents(c),
};
DocumentLine {
description: what.into(),
amount,
class_id: None,
account,
quantity,
unit_price,
}
}
/// B-39 SCENARIOS: Net 30, Net 15 EOM, a leap day, due on receipt, a
/// custom date, and the 9999-12 bound refused instead of panicking.
#[test]
fn terms_derive_due_dates() {
let net30 = Terms::new(TermsKind::Net(30), None).unwrap();
assert_eq!(net30.due_date(d(2026, 1, 31)).unwrap(), d(2026, 3, 2));
assert_eq!(net30.describe(), "Net 30");
let eom15 = Terms::new(TermsKind::NetEom(15), None).unwrap();
assert_eq!(eom15.due_date(d(2026, 10, 23)).unwrap(), d(2026, 11, 15));
assert_eq!(eom15.describe(), "Net 15 EOM");
let net28 = Terms::new(TermsKind::Net(28), None).unwrap();
assert_eq!(net28.due_date(d(2028, 2, 1)).unwrap(), d(2028, 2, 29));
let net0 = Terms::new(TermsKind::Net(0), None).unwrap();
assert_eq!(net0.due_date(d(2026, 5, 4)).unwrap(), d(2026, 5, 4));
let on_receipt = Terms::new(TermsKind::DueOnReceipt, None).unwrap();
assert_eq!(on_receipt.due_date(d(2026, 7, 4)).unwrap(), d(2026, 7, 4));
let custom = Terms::custom(d(2026, 11, 15)).unwrap();
assert_eq!(custom.describe(), "Due 2026-11-15");
assert_eq!(custom.due_date(d(2026, 11, 15)).unwrap(), d(2026, 11, 15));
// The calendar's end: refused, not a panic.
let last_day = Terms::new(TermsKind::Net(1), None).unwrap();
assert!(last_day.due_date(d(9999, 12, 31)).is_err());
let last_month = Terms::new(TermsKind::NetEom(1), None).unwrap();
assert!(last_month.due_date(d(9999, 12, 1)).is_err());
// Ten years is the most a term may run.
assert!(Terms::new(TermsKind::Net(3_651), None).is_err());
assert!(Terms::new(TermsKind::NetEom(3_651), None).is_err());
}
/// B-39 INVARIANT 2: the discount's bound, its date, and the terms that
/// cannot carry one.
#[test]
fn discount_bounds_and_late_reason() {
let discount = EarlyDiscount {
percent_bp: 200,
days: 10,
};
let net30 = Terms::new(TermsKind::Net(30), Some(discount)).unwrap();
assert_eq!(net30.describe(), "2/10 net 30");
assert_eq!(net30.discount_by(d(2026, 1, 10)), Some(d(2026, 1, 20)));
// The discount window closes after its last day: the discount is the
// bound, and a payment after it has no discount to take without a
// reason (the payment path's check, S6).
assert!(net30.discount_by(d(2026, 1, 10)).unwrap() < net30.due_date(d(2026, 1, 10)).unwrap());
// Due on receipt and EOM terms have no net days to discount before.
assert_eq!(
Terms::new(TermsKind::DueOnReceipt, Some(discount)),
Err(LedgerError::TermsDiscountNeedsNetDays)
);
assert_eq!(
Terms::new(TermsKind::NetEom(30), Some(discount)),
Err(LedgerError::TermsDiscountNeedsNetDays)
);
// The discount must close before the net days run out.
assert_eq!(
Terms::new(
TermsKind::Net(10),
Some(EarlyDiscount {
percent_bp: 200,
days: 10
})
),
Err(LedgerError::TermsDiscountNeedsNetDays)
);
// A percentage outside 0.01%..99.99% is not a discount.
assert!(
Terms::new(
TermsKind::Net(30),
Some(EarlyDiscount {
percent_bp: 0,
days: 10
})
)
.is_err()
);
assert!(
Terms::new(
TermsKind::Net(30),
Some(EarlyDiscount {
percent_bp: 10_000,
days: 10
})
)
.is_err()
);
// The kernel bounds the exact cents: 2% of 100.00 is 2.00, and 2% of
// 100.01 is 2.0002, never a rounded 2.00.
use ledger_core::kernel;
assert_eq!(kernel::discount_max(cents(10_000), 200), Some(cents(200)));
assert_eq!(
kernel::discount_max(cents(10_001), 200),
Some(Decimal::new(20_002, 4))
);
assert_eq!(kernel::discount_max(cents(10_000), 0), None);
assert_eq!(kernel::discount_max(cents(-1), 200), None);
}
/// B-39 INVARIANT 3: the canonical spellings read back as they print.
#[test]
fn terms_describe_and_parse_round_trip() {
for text in [
"Net 30",
"Net 15 EOM",
"2/10 net 30",
"Due on receipt",
"Due 2026-11-15",
] {
let terms = Terms::parse(text).unwrap();
assert_eq!(terms.describe(), text, "parsing {text}");
assert_eq!(Terms::parse(&terms.describe()).unwrap(), terms);
}
// Case does not matter; the text printed is the canonical spelling.
assert_eq!(Terms::parse("net 30").unwrap().describe(), "Net 30");
assert_eq!(
Terms::parse("2/10 NET 30").unwrap().describe(),
"2/10 net 30"
);
assert_eq!(
Terms::parse("2.5/10 net 30").unwrap().describe(),
"2.5/10 net 30"
);
// Anything else is refused, never guessed.
for bad in [
"net thirty",
"net 30 days",
"2/10 net 5",
"Due 2026-13-01",
"2/10",
"",
] {
assert!(
matches!(Terms::parse(bad), Err(LedgerError::TermsUnreadable(_))),
"{bad:?} was read as {:?}",
Terms::parse(bad)
);
}
}
/// A bill's lines post to the accounts they name, one line per (account,
/// project) part (plan P4b S4.1, 8.3.6 "Record bill").
#[test]
fn bill_lines_post_to_their_accounts() {
let mut b = books();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let supplies =
b.l.add_account("6100", "Supplies", AccountType::Expense, None, false)
.unwrap();
let bill =
b.l.create_bill(
b.vendor,
d(2026, 3, 1),
d(2026, 3, 31),
vec![
doc_line("Chair", 30_000, Some(equipment), None, None),
doc_line("Paper", 5_000, Some(supplies), None, None),
doc_line("Ink", 1_000, Some(supplies), None, None),
],
)
.unwrap();
assert_eq!(b.account("1500"), equipment);
assert_eq!(b.l.account_balance(equipment).unwrap(), cents(30_000));
assert_eq!(b.l.account_balance(supplies).unwrap(), cents(6_000));
let ap = b.account("2000");
assert_eq!(b.l.account_balance(ap).unwrap(), cents(36_000));
// The legacy default account takes nothing when every line names one.
assert_eq!(
b.l.account_balance(b.account("6999")).unwrap(),
Decimal::ZERO
);
// Three lines: one per (account, project) part (equipment, supplies
// for Paper and Ink together) and one payable line.
assert_eq!(
b.l.get_entry(b.l.get_bill(bill).unwrap().entry_id)
.unwrap()
.lines()
.len(),
3
);
b.ties(d(2026, 3, 31));
}
/// An invoice line billed ahead of the work goes to 2400 Deferred Revenue,
/// a contract liability (ASC 606-10-45-2; A-10's invoice rule).
#[test]
fn invoice_line_to_deferred_revenue_posts_to_2400() {
let mut b = books();
let deferred =
b.l.add_account(
"2400",
"Deferred Revenue",
AccountType::Liability,
None,
false,
)
.unwrap();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 4, 1),
d(2026, 5, 1),
vec![doc_line("Retainer", 50_000, Some(deferred), None, None)],
)
.unwrap();
assert_eq!(b.l.account_balance(deferred).unwrap(), cents(50_000));
let revenue = b.account("4000");
assert_eq!(b.l.account_balance(revenue).unwrap(), Decimal::ZERO);
assert_eq!(b.l.get_invoice(inv).unwrap().total(), Ok(cents(50_000)));
b.ties(d(2026, 5, 1));
}
/// A line's amount is exactly its quantity times its unit price; the
/// engine never rounds one (plan P4b S4.1; kernel::line_amount).
#[test]
fn qty_times_price_must_equal_amount() {
let mut b = books();
let inv =
b.l.create_invoice(
b.customer,
d(2026, 5, 1),
d(2026, 5, 31),
vec![doc_line("Hours", 0, None, Some(3), Some(3_333))],
)
.unwrap();
assert_eq!(b.l.get_invoice(inv).unwrap().total(), Ok(cents(9_999)));
// 3 x 33.33 is 99.99, not 100.00: refused, and nothing is posted.
let bad = vec![DocumentLine {
description: "Hours".into(),
amount: cents(10_000),
class_id: None,
account: None,
quantity: Some(Decimal::from(3)),
unit_price: Some(cents(3_333)),
}];
let ar = b.account("1200");
let before = b.l.account_balance(ar).unwrap();
assert!(matches!(
b.l.create_invoice(b.customer, d(2026, 6, 1), d(2026, 6, 30), bad.clone()),
Err(LedgerError::LineArithmetic(_))
));
assert_eq!(b.l.account_balance(ar).unwrap(), before);
assert_eq!(b.l.next_invoice_id(), inv + 1);
// Both or neither: a quantity without a price is refused too.
let mut half = bad.clone();
half[0].amount = cents(9_999);
half[0].unit_price = None;
assert!(matches!(
b.l.create_invoice(b.customer, d(2026, 6, 1), d(2026, 6, 30), half),
Err(LedgerError::LineArithmetic(_))
));
}
/// B-39 line accounts: an invoice takes revenue or deferred revenue; a
/// bill takes an expense or a non-cash asset; roles, cash and headers are
/// refused (plan P4b S4.1, A-10).
#[test]
fn document_line_account_refuses_roles_cash_and_placeholders() {
let mut b = books();
let deferred =
b.l.add_account(
"2400",
"Deferred Revenue",
AccountType::Liability,
None,
false,
)
.unwrap();
let revenue =
b.l.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let equipment =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let supplies =
b.l.add_account("6100", "Supplies", AccountType::Expense, None, false)
.unwrap();
let header =
b.l.add_account("9000", "Header", AccountType::Asset, None, true)
.unwrap();
let control =
b.l.add_account("1150", "Trade Receivables", AccountType::Asset, None, false)
.unwrap();
b.l.assign_role("receivables", Some(control)).unwrap();
let check = |l: &Ledger, kind, account| l.check_document_line_account(kind, account);
// An invoice line: revenue, or a liability holding no role.
assert!(check(&b.l, DocumentKind::Invoice, revenue).is_ok());
assert!(check(&b.l, DocumentKind::Invoice, deferred).is_ok());
assert!(check(&b.l, DocumentKind::Invoice, b.bank).is_err());
assert!(check(&b.l, DocumentKind::Invoice, supplies).is_err());
assert!(check(&b.l, DocumentKind::Invoice, equipment).is_err());
assert!(check(&b.l, DocumentKind::Invoice, control).is_err());
assert!(check(&b.l, DocumentKind::Invoice, header).is_err());
// A bill line: an expense, or a non-cash asset holding no role.
assert!(check(&b.l, DocumentKind::Bill, supplies).is_ok());
assert!(check(&b.l, DocumentKind::Bill, equipment).is_ok());
assert!(check(&b.l, DocumentKind::Bill, b.bank).is_err());
assert!(check(&b.l, DocumentKind::Bill, revenue).is_err());
assert!(check(&b.l, DocumentKind::Bill, deferred).is_err());
assert!(check(&b.l, DocumentKind::Bill, control).is_err());
assert!(check(&b.l, DocumentKind::Bill, header).is_err());
// The create path applies the same rule, and refuses before posting.
let before = b.l.next_bill_id();
let bad = vec![doc_line("Loan", 1_000, Some(b.bank), None, None)];
assert!(matches!(
b.l.create_bill(b.vendor, d(2026, 7, 1), d(2026, 7, 31), bad),
Err(LedgerError::DocumentLineAccount { .. })
));
assert_eq!(b.l.next_bill_id(), before);
}
/// A-2, B-40: numbers run, a void keeps its number, legacy invoices are
/// not counted, and a number is never reused.
#[test]
fn numbers_run_and_voids_keep_theirs() {
let mut b = books();
let first = {
let number = b.l.next_invoice_number("INV-", 1);
assert_eq!(number, "INV-0001");
b.l.create_invoice_with(
b.customer,
d(2026, 1, 5),
d(2026, 2, 4),
line("Work", 10_000),
InvoiceFields {
number: Some(number),
..InvoiceFields::default()
},
)
.unwrap()
};
let second = {
let number = b.l.next_invoice_number("INV-", 1);
assert_eq!(number, "INV-0002");
b.l.create_invoice_with(
b.customer,
d(2026, 1, 6),
d(2026, 2, 5),
line("Work", 20_000),
InvoiceFields {
number: Some(number),
..InvoiceFields::default()
},
)
.unwrap()
};
assert_eq!(
b.l.get_invoice(first).unwrap().number.as_deref(),
Some("INV-0001")
);
// A void keeps its number and still counts: the series never reuses
// one.
b.l.void_document(DocRef::Invoice(first), d(2026, 1, 7), "Duplicate")
.unwrap();
assert_eq!(
b.l.get_invoice(first).unwrap().number.as_deref(),
Some("INV-0001")
);
assert_eq!(b.l.next_invoice_number("INV-", 1), "INV-0003");
// A legacy invoice (no number) is not counted, shown as "Invoice {id}".
let legacy =
b.l.create_invoice(b.customer, d(2026, 1, 8), d(2026, 2, 7), line("Old", 100))
.unwrap();
assert_eq!(b.l.get_invoice(legacy).unwrap().number, None);
assert_eq!(b.l.next_invoice_number("INV-", 1), "INV-0003");
// Numbers are never reused, even by a direct create.
assert_eq!(
b.l.create_invoice_with(
b.customer,
d(2026, 1, 9),
d(2026, 2, 8),
line("Work", 100),
InvoiceFields {
number: Some("INV-0002".into()),
..InvoiceFields::default()
},
),
Err(LedgerError::DuplicateInvoiceNumber("INV-0002".into()))
);
assert_eq!(
b.l.get_invoice(second).unwrap().number.as_deref(),
Some("INV-0002")
);
}
/// B-40: four digits, wider past 9,999, never truncated.
#[test]
fn number_widens_past_9999() {
let mut b = books();
let number = b.l.next_invoice_number("INV-", 9_999);
assert_eq!(number, "INV-9999");
b.l.create_invoice_with(
b.customer,
d(2026, 2, 1),
d(2026, 3, 1),
line("Work", 100),
InvoiceFields {
number: Some(number),
..InvoiceFields::default()
},
)
.unwrap();
assert_eq!(b.l.next_invoice_number("INV-", 9_999), "INV-10000");
}
/// B-40: a vendor's invoice number names one standing bill; case, spaces
/// and hyphens are ignored; a duplicate needs a reason (plan P4b S4.1).
#[test]
fn duplicate_vendor_ref_refused_unless_a_reason_is_given() {
let mut b = books();
let fields = |vendor_ref: &str, reason: Option<&str>| BillFields {
vendor_ref: Some(vendor_ref.into()),
duplicate_reason: reason.map(str::to_string),
..BillFields::default()
};
let first =
b.l.create_bill_with(
b.vendor,
d(2026, 1, 5),
d(2026, 2, 4),
line("Paper", 1_000),
fields("ACME-123", None),
)
.unwrap();
// Case, spaces and hyphens do not make a new number.
for same in ["acme 123", "Acme123", "ACME - 123"] {
assert_eq!(
b.l.create_bill_with(
b.vendor,
d(2026, 1, 6),
d(2026, 2, 5),
line("Paper", 2_000),
fields(same, None),
),
Err(LedgerError::DuplicateVendorRef {
vendor: b.vendor,
vendor_ref: same.into()
})
);
}
// Another vendor's invoice may carry the same number.
let other = b.l.add_vendor("Other Supply").unwrap();
b.l.create_bill_with(
other,
d(2026, 1, 6),
d(2026, 2, 5),
line("Paper", 2_000),
fields("ACME-123", None),
)
.unwrap();
// A reason makes the second one a bill of its own.
let second =
b.l.create_bill_with(
b.vendor,
d(2026, 1, 7),
d(2026, 2, 6),
line("Paper", 3_000),
fields("acme 123", Some("Second shipment, vendor reissued it")),
)
.unwrap();
// A voided bill no longer stands, so its number is free again.
b.l.void_document(DocRef::Bill(first), d(2026, 1, 8), "Entered twice")
.unwrap();
b.l.void_document(DocRef::Bill(second), d(2026, 1, 8), "Entered twice")
.unwrap();
b.l.create_bill_with(
b.vendor,
d(2026, 1, 9),
d(2026, 2, 8),
line("Paper", 4_000),
fields("ACME-123", None),
)
.unwrap();
}
+6
View File
@@ -1914,6 +1914,9 @@ fn one_line(amount: &str) -> Vec<DocumentLine> {
description: "consulting".into(),
amount: dec(amount),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
}
@@ -2798,6 +2801,9 @@ fn a_new_account_named_like_a_sibling_or_holding_a_tin_is_refused() {
description: "work".into(),
amount: dec("100.00"),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -466,6 +466,9 @@ fn audit() -> Audit {
description: "filing".into(),
amount: d("80.00"),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -484,6 +487,9 @@ fn audit() -> Audit {
description: "consulting".into(),
amount: d("120.00"),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -305,6 +305,9 @@ fn documents_and_aging_are_exact_or_refused() {
description: "x".into(),
amount: a,
class_id: None,
account: None,
quantity: None,
unit_price: None,
};
// An entry as replay rebuilds it: Dr `dr` / Cr `cr` for `a`.
let replayed = |l: &mut Ledger, dr, cr, a: Decimal| {
+3
View File
@@ -42,6 +42,9 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
description: desc.to_string(),
amount: dec(amount),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}
}