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
471 lines
16 KiB
Rust
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(_))
|
|
));
|
|
}
|