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
482 lines
18 KiB
Rust
482 lines
18 KiB
Rust
//! Figures derived from what replay accepts: computed exactly where exact
|
||
//! arithmetic and chrono's calendar reach, refused (TooLargeToCompute,
|
||
//! DateOutOfRange) where they don't, never a panic (rules.md B-28; P0
|
||
//! review round 4, 2026-10-17).
|
||
//!
|
||
//! ```text
|
||
//! GOAL: books an earlier version wrote (a draft line of Decimal's
|
||
//! largest, shares and bases past the line limit, a document's lines
|
||
//! or a payroll run as journaled, dates at chrono's last day) can be
|
||
//! read every way the engine reads books, and the server stays up.
|
||
//! SOURCE: DECISION (P0 review round 4: posted is forever; derived
|
||
//! computations refuse rather than panic).
|
||
//! ```
|
||
|
||
use chrono::{Datelike, NaiveDate};
|
||
use ledger_core::{
|
||
AccountType, Currency, DocumentLine, Ledger, LedgerError, Money, NewAsset, PartyShare,
|
||
PayrollLine, Side,
|
||
};
|
||
use rust_decimal::Decimal;
|
||
|
||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||
}
|
||
|
||
fn usd(a: Decimal) -> Money {
|
||
Money::new(a, Currency::USD)
|
||
}
|
||
|
||
fn too_large(r: Result<impl std::fmt::Debug, LedgerError>) -> bool {
|
||
matches!(r, Err(LedgerError::TooLargeToCompute(_)))
|
||
}
|
||
|
||
/// An asset whose life ends in chrono's last month (an earlier version
|
||
/// accepted any life whose end chrono holds): its schedule walks into
|
||
/// December 262142, prorates that month by its 31 days, and sums to its
|
||
/// cost exactly.
|
||
#[test]
|
||
fn depreciation_reaches_chronos_last_month() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
|
||
let cost_account = add("1500", AccountType::Asset);
|
||
let accum = add("1510", AccountType::Asset);
|
||
let expense = add("6100", AccountType::Expense);
|
||
let y = NaiveDate::MAX.year();
|
||
let id = l
|
||
.replay_register_asset(NewAsset {
|
||
description: "edge".into(),
|
||
in_service_date: d(y, 1, 15),
|
||
cost: usd(Decimal::new(120000, 2)),
|
||
salvage_value: usd(Decimal::ZERO),
|
||
useful_life_months: 11,
|
||
cost_account,
|
||
accum_depr_account: accum,
|
||
expense_account: expense,
|
||
})
|
||
.unwrap();
|
||
let a = l.get_asset(id).unwrap();
|
||
let full = ledger_core::depreciation_schedule(a, NaiveDate::MAX).unwrap();
|
||
assert_eq!(full.len(), 12);
|
||
assert_eq!(full[0].period, format!("{y}-01"));
|
||
assert_eq!(full[11].period, format!("{y}-12"));
|
||
assert_eq!(full[11].accumulated, Decimal::new(120000, 2));
|
||
assert_eq!(
|
||
full.iter().map(|l| l.amount).sum::<Decimal>(),
|
||
Decimal::new(120000, 2)
|
||
);
|
||
// Through November: eleven months, December not yet.
|
||
let nov = ledger_core::depreciation_schedule(a, d(y, 11, 30)).unwrap();
|
||
assert_eq!(nov.len(), 11);
|
||
assert!(nov.last().unwrap().accumulated < Decimal::new(120000, 2));
|
||
// December is walked without stepping past chrono's last day, and,
|
||
// the life's final month (its days 1-14), charged by the day (P3,
|
||
// defect 22): the life holds Jan 15-31 (17/31 of a month), ten whole
|
||
// months, then what they leave of 1,200.00, 1,200 × (14/31)/11, over
|
||
// December's 14 days, so 1,200/(31 × 11) a day, January's own rate.
|
||
// Through Dec 10: 1,200 × (17/31 + 10 + 10/31)/11 = 404,400/341 =
|
||
// 1,185.9237 → 1,185.92.
|
||
let dec = ledger_core::depreciation_schedule(a, d(y, 12, 10)).unwrap();
|
||
assert_eq!(dec.last().unwrap().period, format!("{y}-12"));
|
||
let want = (Decimal::from(404_400) / Decimal::from(341))
|
||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||
assert_eq!(want, Decimal::new(118592, 2));
|
||
assert_eq!(dec.last().unwrap().accumulated, want);
|
||
assert_eq!(
|
||
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||
Decimal::new(120000, 2)
|
||
);
|
||
assert_eq!(
|
||
ledger_core::scheduled_book_value(a, NaiveDate::MAX).unwrap(),
|
||
Decimal::ZERO
|
||
);
|
||
}
|
||
|
||
/// An asset whose life runs past 9999 (or starts before year 1) trues up
|
||
/// in its final month like any other: its lifetime is exactly its cost.
|
||
#[test]
|
||
fn a_life_past_9999_trues_up_to_the_cent() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
|
||
let cost_account = add("1500", AccountType::Asset);
|
||
let accum = add("1510", AccountType::Asset);
|
||
let expense = add("6100", AccountType::Expense);
|
||
// March 17th to April 17th: a 31-day first month and a 30-day last, so
|
||
// the partial months drift and the true-up is needed.
|
||
for (start, months) in [
|
||
(d(9999, 3, 17), 61),
|
||
(d(-10, 3, 17), 61),
|
||
(d(9998, 3, 17), 25),
|
||
] {
|
||
let id = l
|
||
.replay_register_asset(NewAsset {
|
||
description: "far".into(),
|
||
in_service_date: start,
|
||
cost: usd(Decimal::new(100_000, 2)),
|
||
salvage_value: usd(Decimal::new(1, 2)),
|
||
useful_life_months: months,
|
||
cost_account,
|
||
accum_depr_account: accum,
|
||
expense_account: expense,
|
||
})
|
||
.unwrap();
|
||
let a = l.get_asset(id).unwrap();
|
||
let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX).unwrap();
|
||
assert_eq!(
|
||
s.last().unwrap().accumulated,
|
||
Decimal::new(99_999, 2),
|
||
"{start} + {months}"
|
||
);
|
||
assert_eq!(
|
||
s.iter().map(|l| l.amount).sum::<Decimal>(),
|
||
Decimal::new(99_999, 2),
|
||
"{start} + {months}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// February has 29 days exactly in the years chrono calls leap, from its
|
||
/// first year to its last (year 0 and the negative years included), so a
|
||
/// schedule in any of them prorates February rightly.
|
||
#[test]
|
||
fn february_is_prorated_by_chronos_calendar_in_every_year() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
|
||
let cost_account = add("1500", AccountType::Asset);
|
||
let accum = add("1510", AccountType::Asset);
|
||
let expense = add("6100", AccountType::Expense);
|
||
let min = NaiveDate::MIN.year();
|
||
let max = NaiveDate::MAX.year();
|
||
for year in [
|
||
min,
|
||
min + 1,
|
||
-400,
|
||
-100,
|
||
-4,
|
||
-1,
|
||
0,
|
||
1,
|
||
4,
|
||
100,
|
||
400,
|
||
1900,
|
||
2000,
|
||
2024,
|
||
2026,
|
||
max - 1,
|
||
max,
|
||
] {
|
||
let leap = NaiveDate::from_ymd_opt(year, 2, 29).is_some();
|
||
// Bought February 1st, two months of life, so February is a whole
|
||
// month of it.
|
||
let id = l
|
||
.replay_register_asset(NewAsset {
|
||
description: format!("{year}"),
|
||
in_service_date: d(year, 2, 1),
|
||
cost: usd(Decimal::from(2900)),
|
||
salvage_value: usd(Decimal::ZERO),
|
||
useful_life_months: 2,
|
||
cost_account,
|
||
accum_depr_account: accum,
|
||
expense_account: expense,
|
||
})
|
||
.unwrap();
|
||
let a = l.get_asset(id).unwrap();
|
||
// Through the 14th: 14 of February's days.
|
||
let half = ledger_core::depreciation_schedule(a, d(year, 2, 14)).unwrap();
|
||
let days = if leap { 29 } else { 28 };
|
||
let want = (Decimal::from(1450) * Decimal::from(14) / Decimal::from(days))
|
||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||
assert_eq!(half.last().unwrap().amount, want, "{year}");
|
||
}
|
||
}
|
||
|
||
/// A draft an earlier version journaled with lines of Decimal's largest:
|
||
/// its money flow and debits are refused, its shares don't resolve, its
|
||
/// post refuses it.
|
||
#[test]
|
||
fn a_draft_of_decimals_largest_is_refused_where_summed() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let card = l
|
||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||
.unwrap();
|
||
let rent = l
|
||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||
.unwrap();
|
||
let p = l
|
||
.add_party("Acme", &[ledger_core::Role::Contractor])
|
||
.unwrap();
|
||
let q = l
|
||
.add_party("Beta", &[ledger_core::Role::Contractor])
|
||
.unwrap();
|
||
let capital = l
|
||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let member = l
|
||
.add_party("Tobin Harlow", &[ledger_core::Role::Member])
|
||
.unwrap();
|
||
let money = ledger_core::parties::money_accounts(&l, &[cash], &[]);
|
||
let resolve = |l: &Ledger, e, shares: &[PartyShare]| {
|
||
ledger_core::shares::resolve(l, e, shares, &money, &[])
|
||
};
|
||
for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] {
|
||
let e = l.create_entry(d(2026, 3, 1), "huge");
|
||
for side in [Side::Debit, Side::Debit] {
|
||
l.replay_add_line(e, dr, side, usd(Decimal::MAX)).unwrap();
|
||
}
|
||
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
|
||
.unwrap();
|
||
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
|
||
.unwrap();
|
||
let entry = l.get_entry(e).unwrap();
|
||
assert!(too_large(ledger_core::parties::entry_flow(entry, &money)));
|
||
assert!(too_large(ledger_core::shares::target(
|
||
&l,
|
||
e,
|
||
&[PartyShare::whole(p)],
|
||
&money
|
||
)));
|
||
let why = resolve(&l, e, &[PartyShare::whole(p)]).unwrap_err();
|
||
assert!(why.contains("too large to compute"), "{why}");
|
||
assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange);
|
||
}
|
||
// A member's capital moved past exact arithmetic (rules.md B-35, case
|
||
// 3, B-28): the target is refused, never a panic.
|
||
let e = l.create_entry(d(2026, 3, 1), "huge capital");
|
||
for _ in 0..2 {
|
||
l.replay_add_line(e, cash, Side::Debit, usd(Decimal::MAX))
|
||
.unwrap();
|
||
l.replay_add_line(e, capital, Side::Credit, usd(Decimal::MAX))
|
||
.unwrap();
|
||
}
|
||
assert!(too_large(ledger_core::shares::target(
|
||
&l,
|
||
e,
|
||
&[PartyShare::whole(member)],
|
||
&money
|
||
)));
|
||
// One line of Decimal's largest flows, and its share resolves whole.
|
||
let e = l.create_entry(d(2026, 3, 1), "one huge");
|
||
l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX))
|
||
.unwrap();
|
||
l.replay_add_line(e, cash, Side::Credit, usd(Decimal::MAX))
|
||
.unwrap();
|
||
let entry = l.get_entry(e).unwrap();
|
||
assert_eq!(
|
||
ledger_core::parties::entry_flow(entry, &money).unwrap().net,
|
||
Decimal::MAX
|
||
);
|
||
let r = resolve(&l, e, &[PartyShare::whole(p)]).unwrap();
|
||
assert_eq!(
|
||
(r.parts[0].party, r.parts[0].amount, r.parts[0].cash),
|
||
(p, Decimal::MAX, Decimal::MAX)
|
||
);
|
||
// A percent of it: the product is past exact arithmetic.
|
||
let halves = [p, q].map(|party| PartyShare {
|
||
party,
|
||
share: ledger_core::shares::Share::Percent(Decimal::from(50)),
|
||
});
|
||
let why = resolve(&l, e, &halves).unwrap_err();
|
||
assert!(why.contains("too large to compute"), "{why}");
|
||
}
|
||
|
||
/// A document is restored as journaled: its total is exact, or refused
|
||
/// past exact arithmetic; what it owes is what the ledger lines tagged
|
||
/// with it carry (rules.md B-38), exact, whatever the journal held (paid
|
||
/// above its total included); aging refuses a document whose total it
|
||
/// can't compute.
|
||
#[test]
|
||
fn documents_and_aging_are_exact_or_refused() {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let ar = l
|
||
.add_account("1200", "Receivables", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let revenue = l
|
||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||
.unwrap();
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let customer = l.add_customer("Client").unwrap();
|
||
let line = |a: Decimal| DocumentLine {
|
||
description: "x".into(),
|
||
amount: a,
|
||
class_id: None,
|
||
};
|
||
// An entry as replay rebuilds it: Dr `dr` / Cr `cr` for `a`.
|
||
let replayed = |l: &mut Ledger, dr, cr, a: Decimal| {
|
||
let e = l.create_entry(d(2026, 3, 1), "replayed");
|
||
l.replay_add_line(e, dr, Side::Debit, usd(a)).unwrap();
|
||
l.replay_add_line(e, cr, Side::Credit, usd(a)).unwrap();
|
||
l.replay_post_entry(e).unwrap();
|
||
e
|
||
};
|
||
let fine = vec![
|
||
line(Decimal::new(1, 11)),
|
||
line("9.99999999999".parse().unwrap()),
|
||
];
|
||
let e1 = replayed(&mut l, ar, revenue, Decimal::TEN);
|
||
l.replay_invoice(1, customer, d(2026, 3, 1), d(2026, 3, 31), fine, e1)
|
||
.unwrap();
|
||
let inv = l.get_invoice(1).unwrap();
|
||
assert_eq!(inv.total(), Ok(Decimal::TEN));
|
||
assert_eq!(inv.balance(), Ok(Decimal::TEN));
|
||
let rows = l.ar_aging(d(2026, 6, 1)).unwrap();
|
||
// 62 days past due on June 1st: 61-90.
|
||
assert_eq!(rows[0].days_90, Decimal::TEN);
|
||
assert_eq!(rows[0].total(), Ok(Decimal::TEN));
|
||
// Two documents of half Decimal's largest each, their totals exact; no
|
||
// line carries them (no entry could), so they owe nothing and age
|
||
// nowhere.
|
||
let half = Decimal::MAX / Decimal::TWO;
|
||
for id in [2, 3] {
|
||
l.replay_invoice(
|
||
id,
|
||
customer,
|
||
d(2026, 3, 1),
|
||
d(2026, 3, 31),
|
||
vec![line(half)],
|
||
100 + id,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(l.get_invoice(id).unwrap().total(), Ok(half));
|
||
let st = l.invoice_state(id, d(2026, 6, 1)).unwrap().unwrap();
|
||
assert_eq!(st.outstanding, Decimal::ZERO);
|
||
}
|
||
assert_eq!(
|
||
l.ar_aging(d(2026, 6, 1)).unwrap()[0].total(),
|
||
Ok(Decimal::TEN)
|
||
);
|
||
// Paid more than its total, as journaled: a negative balance, exact.
|
||
let p = replayed(&mut l, cash, ar, Decimal::from(11));
|
||
l.replay_invoice_payment(1, Decimal::from(11), p).unwrap();
|
||
assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE));
|
||
let st = l.invoice_state(1, d(2026, 6, 1)).unwrap().unwrap();
|
||
assert_eq!(
|
||
(st.paid, st.outstanding),
|
||
(Decimal::from(11), -Decimal::ONE)
|
||
);
|
||
// A payment event naming an amount no line of its entry carries tags
|
||
// nothing, and is refused nothing (B-28).
|
||
l.replay_invoice_payment(1, -Decimal::ONE, p).unwrap();
|
||
assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE));
|
||
// A total of lines that overflow, and its balance; aging refuses it.
|
||
l.replay_invoice(
|
||
4,
|
||
customer,
|
||
d(2026, 3, 1),
|
||
d(2026, 3, 31),
|
||
vec![line(Decimal::MAX), line(Decimal::MAX)],
|
||
104,
|
||
)
|
||
.unwrap();
|
||
assert!(too_large(l.get_invoice(4).unwrap().total()));
|
||
assert!(too_large(l.get_invoice(4).unwrap().balance()));
|
||
assert!(too_large(l.ar_aging(d(2026, 6, 1))));
|
||
assert!(too_large(l.invoice_state(4, d(2026, 6, 1))));
|
||
// Bills alike.
|
||
let vendor = l.add_vendor("Supplier").unwrap();
|
||
l.replay_bill(
|
||
1,
|
||
vendor,
|
||
d(2026, 3, 1),
|
||
d(2026, 3, 31),
|
||
vec![line(Decimal::MAX), line(Decimal::ONE)],
|
||
106,
|
||
)
|
||
.unwrap();
|
||
assert!(too_large(l.get_bill(1).unwrap().total()));
|
||
assert!(too_large(l.ap_aging(d(2026, 6, 1))));
|
||
let row = ledger_core::AgingRow {
|
||
name: "x".into(),
|
||
current: Decimal::MAX,
|
||
days_30: Decimal::ONE,
|
||
days_60: Decimal::ZERO,
|
||
days_90: Decimal::ZERO,
|
||
days_90_plus: Decimal::ZERO,
|
||
};
|
||
assert!(too_large(row.total()));
|
||
for (i, one) in [
|
||
(0, Decimal::ONE),
|
||
(1, Decimal::TWO),
|
||
(2, Decimal::TEN),
|
||
(3, Decimal::ONE_HUNDRED),
|
||
(4, Decimal::ONE_THOUSAND),
|
||
] {
|
||
let mut r = ledger_core::AgingRow {
|
||
current: Decimal::ZERO,
|
||
..row.clone()
|
||
};
|
||
r.days_30 = Decimal::ZERO;
|
||
match i {
|
||
0 => r.current = one,
|
||
1 => r.days_30 = one,
|
||
2 => r.days_60 = one,
|
||
3 => r.days_90 = one,
|
||
_ => r.days_90_plus = one,
|
||
}
|
||
assert_eq!(r.total(), Ok(one), "bucket {i}");
|
||
}
|
||
}
|
||
|
||
/// A payroll run restored as journaled: net pay is exact, or refused past
|
||
/// exact arithmetic; a run's plan refuses totals it can't hold.
|
||
#[test]
|
||
fn payroll_figures_are_exact_or_refused() {
|
||
let line = |gross: Decimal, fed: Decimal| PayrollLine {
|
||
employee_name: "Dana".into(),
|
||
employee: None,
|
||
gross_pay: gross,
|
||
federal_withholding: fed,
|
||
state_withholding: Decimal::new(1, 2),
|
||
ss_employee: Decimal::new(2, 2),
|
||
medicare_employee: Decimal::new(3, 2),
|
||
ss_employer: Decimal::ZERO,
|
||
medicare_employer: Decimal::ZERO,
|
||
other_deductions: Decimal::new(4, 2),
|
||
};
|
||
assert_eq!(
|
||
line(Decimal::from(100), Decimal::new(1, 12)).net_pay(),
|
||
Ok("99.899999999999".parse().unwrap())
|
||
);
|
||
assert!(too_large(line(-Decimal::MAX, Decimal::ONE).net_pay()));
|
||
let accounts = ledger_core::payroll::PayrollAccounts {
|
||
salary_expense: 1,
|
||
payroll_tax_expense: 2,
|
||
federal_payable: 3,
|
||
state_payable: 4,
|
||
ss_payable: 5,
|
||
medicare_payable: 6,
|
||
other_payable: 7,
|
||
};
|
||
let big = line(Decimal::MAX, Decimal::ZERO);
|
||
assert!(too_large(ledger_core::payroll::payroll_entry_plan(
|
||
&[big.clone(), big],
|
||
accounts,
|
||
8,
|
||
Currency::USD
|
||
)));
|
||
let plan = ledger_core::payroll::payroll_entry_plan(
|
||
&[line(Decimal::from(100), Decimal::ONE)],
|
||
accounts,
|
||
8,
|
||
Currency::USD,
|
||
)
|
||
.unwrap();
|
||
let debits: Decimal = plan
|
||
.iter()
|
||
.filter(|(_, s, _)| *s == Side::Debit)
|
||
.map(|(_, _, m)| m.amount)
|
||
.sum();
|
||
let credits: Decimal = plan
|
||
.iter()
|
||
.filter(|(_, s, _)| *s == Side::Credit)
|
||
.map(|(_, _, m)| m.amount)
|
||
.sum();
|
||
assert_eq!(debits, Decimal::from(100));
|
||
assert_eq!(credits, Decimal::from(100));
|
||
}
|