Files
wanderbooks/crates/ledger-core/src/payroll.rs
T
swaitsandClaude Opus 5.5 beb1fcf233 test(core): spec-blind suites and a live GnuCash oracle; four fixes they found
Max-testing pass, steps 2-4.

Spec-blind tests (rank 1-2): four agents, each given only the specs and
the public interface (doc comments and signatures, every body removed,
never src/ or the existing tests), wrote tests/spec_reports.rs (62),
spec_ledger.rs (72), spec_arap.rs (42), spec_assets_payroll.rs (50):
hand-computed examples per scenario and a proptest law per invariant
(conservation, partition, invariance, scaling, reversal cancels,
"a refusal leaves the books unchanged" over random invalid commands).
They found:

1. A refused payroll run changed the books: an all-zero run passed
   validation, then failed at post after creating seven payroll
   accounts and using up an entry id. validate_payroll_lines now
   refuses a run with no gross pay.
2. A refused add_account used up an account id (empty code, duplicate,
   unknown parent). The id is now taken only when the account is added.
3. Stale prose: close_period_to and members_equity still described the
   old last-member rounding.
4. Open (left #[ignore]d pending Stephen): depreciation_schedule's doc
   says "through the month containing" the date; the code prorates that
   month by days.

GnuCash oracle (rank 3, an oracle never the spec): `just oracle` builds
GnuCash 5.10 in a container pinned by base digest and package versions
(oracle/gnucash/) and runs tests/oracle_gnucash.rs: 200 seeded random
books built through wanderbooks (entries, foreign lines, reversals,
monthly closes), every account's balance at month ends and random dates
and each month's P&L, compared exactly: 15,800 comparisons. First run:
4,724 differences, all from foreign lines; without them, zero.
Adjudicated from principles (rules.md B-3): wanderbooks booked
foreign * rate unrounded ($108.34567); a ledger in one money holds no
fractions of its smallest unit, and its cash must match a bank. Stephen
decided: book in the base currency's minor unit, half away from zero.
add_line_fx now rounds; minor units per ISO 4217 list one (2026-09-17,
SIX), generated into Currency::minor_units and recorded in
docs/spec/sources/iso-4217.md. Replay now takes the journaled base
amount (replay_add_line_fx), so lines booked before replay exactly as
booked (contract: books_written_under_older_rules_still_open). Now:
zero differences, foreign lines included.

Tests: 449, from 223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:06 -06:00

425 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Payroll: accounting-only payroll runs (no tax tables).
//!
//! A payroll run posts one balanced entry: gross wages hit Salary & Wage
//! Expense, the employer share of FICA hits Payroll Tax Expense, employee
//! withholdings and deductions accumulate in the 2110–2150 payable accounts,
//! and net pay leaves cash. A later [`crate::Ledger::remit_payroll_taxes`]
//! clears the payables when the tax payments go out.
//!
//! There is no tax law here: every withholding number is supplied by the
//! caller. All amounts are exact [`rust_decimal::Decimal`], never floats.
use crate::{AccountId, Currency, EntryId, LedgerError, Money, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Opaque payroll run handle. Created by [`crate::Ledger`]; never forged.
pub type PayrollRunId = u64;
/// One employee's payroll line: gross pay and every withholding and
/// deduction. All amounts must be non-negative; the engine rejects
/// negatives, and rejects the line when withholdings exceed gross.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PayrollLine {
/// Employee name, for the run record.
pub employee_name: String,
/// Gross pay for the period.
pub gross_pay: Decimal,
/// Federal income tax withheld.
pub federal_withholding: Decimal,
/// State income tax withheld.
pub state_withholding: Decimal,
/// Employee share of Social Security tax.
pub ss_employee: Decimal,
/// Employee share of Medicare tax.
pub medicare_employee: Decimal,
/// Employer share of Social Security tax.
pub ss_employer: Decimal,
/// Employer share of Medicare tax.
pub medicare_employer: Decimal,
/// Other deductions (retirement, insurance, garnishments, ...).
pub other_deductions: Decimal,
}
impl PayrollLine {
/// Gross minus all employee withholdings and deductions. Must be >= 0:
/// you cannot withhold more than the employee earned.
pub fn net_pay(&self) -> Decimal {
self.gross_pay
- self.federal_withholding
- self.state_withholding
- self.ss_employee
- self.medicare_employee
- self.other_deductions
}
}
/// A posted payroll run: the inputs plus the journal entry it posted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PayrollRun {
/// Run id (the allocator's).
pub id: PayrollRunId,
/// Pay date: the date of the posted entry.
pub date: NaiveDate,
/// One line per employee.
pub lines: Vec<PayrollLine>,
/// The posted journal entry.
pub entry_id: EntryId,
}
/// The chart accounts a payroll run posts to, resolved by code.
#[derive(Clone, Copy, Debug)]
pub struct PayrollAccounts {
/// `6100` Salary & Wage Expense.
pub salary_expense: AccountId,
/// `6110` Payroll Tax Expense.
pub payroll_tax_expense: AccountId,
/// `2110` Federal Withholding Payable.
pub federal_payable: AccountId,
/// `2120` State Withholding Payable.
pub state_payable: AccountId,
/// `2130` SS Tax Payable.
pub ss_payable: AccountId,
/// `2140` Medicare Tax Payable.
pub medicare_payable: AccountId,
/// `2150` Other Deductions Payable.
pub other_payable: AccountId,
}
/// Validate a payroll run's lines without mutating: non-empty, no negative
/// amounts, no negative net pay. Journaling stores run this before writing
/// anything, so a doomed run never leaves journal lines behind.
pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError> {
// No wages at all is no payroll: refused before anything changes
// (found by the spec-blind tests, 2026-10-13: an all-zero run passed
// validation, then failed at post after creating accounts).
if lines.is_empty() || lines.iter().all(|l| l.gross_pay.is_zero()) {
return Err(LedgerError::EmptyPayroll);
}
for line in lines {
for amount in [
line.gross_pay,
line.federal_withholding,
line.state_withholding,
line.ss_employee,
line.medicare_employee,
line.ss_employer,
line.medicare_employer,
line.other_deductions,
] {
if amount < Decimal::ZERO {
return Err(LedgerError::NegativePayrollAmount(amount));
}
}
if line.net_pay() < Decimal::ZERO {
return Err(LedgerError::NegativeNetPay(line.employee_name.clone()));
}
}
Ok(())
}
/// The auto-posted payroll entry's lines, in posting order: Dr 6100 gross,
/// Dr 6110 the employer FICA share, Cr 2110–2150 the payables, Cr cash net
/// pay. Zero totals are skipped — the engine rejects zero-amount lines.
///
/// Shared by [`crate::Ledger::run_payroll`] and the journaling store, so the
/// journaled entry and the posted entry are built from the same math. The
/// plan always balances: debits are gross + employer FICA, credits are the
/// payables + net, and net is defined as gross minus employee withholdings.
pub fn payroll_entry_plan(
lines: &[PayrollLine],
accounts: PayrollAccounts,
cash_account: AccountId,
base: Currency,
) -> Vec<(AccountId, Side, Money)> {
let sum = |f: fn(&PayrollLine) -> Decimal| lines.iter().map(f).sum::<Decimal>();
let gross = sum(|l| l.gross_pay);
let employer = sum(|l| l.ss_employer + l.medicare_employer);
let federal = sum(|l| l.federal_withholding);
let state = sum(|l| l.state_withholding);
let ss = sum(|l| l.ss_employee + l.ss_employer);
let medicare = sum(|l| l.medicare_employee + l.medicare_employer);
let other = sum(|l| l.other_deductions);
let net = sum(|l| l.net_pay());
let mut plan = Vec::new();
let mut push = |account: AccountId, side: Side, amount: Decimal| {
if !amount.is_zero() {
plan.push((account, side, Money::new(amount, base)));
}
};
push(accounts.salary_expense, Side::Debit, gross);
push(accounts.payroll_tax_expense, Side::Debit, employer);
push(accounts.federal_payable, Side::Credit, federal);
push(accounts.state_payable, Side::Credit, state);
push(accounts.ss_payable, Side::Credit, ss);
push(accounts.medicare_payable, Side::Credit, medicare);
push(accounts.other_payable, Side::Credit, other);
push(cash_account, Side::Credit, net);
plan
}
/// The remittance entry's lines: Dr each payable with a non-zero balance,
/// Cr cash for the total. Balances are normal-side (positive = owed), so
/// the plan balances by construction.
pub fn payroll_remit_plan(
payables: &[(AccountId, Decimal)],
cash_account: AccountId,
base: Currency,
) -> Vec<(AccountId, Side, Money)> {
let mut plan = Vec::new();
let mut total = Decimal::ZERO;
for (account, balance) in payables {
if balance.is_zero() {
continue;
}
plan.push((*account, Side::Debit, Money::new(*balance, base)));
total += *balance;
}
if !total.is_zero() {
plan.push((cash_account, Side::Credit, Money::new(total, base)));
}
plan
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Ledger};
fn d(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
fn line(name: &str) -> PayrollLine {
PayrollLine {
employee_name: name.to_string(),
gross_pay: d(500_000), // 5000.00
federal_withholding: d(75_000), // 750.00
state_withholding: d(25_000), // 250.00
ss_employee: d(31_000), // 310.00
medicare_employee: d(7_250), // 72.50
ss_employer: d(31_000),
medicare_employer: d(7_250),
other_deductions: d(10_000), // 100.00
}
}
fn ledger_with_cash() -> (Ledger, AccountId) {
let mut ledger = Ledger::new(Currency::USD);
let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
(ledger, cash)
}
#[test]
fn net_pay_math() {
let l = line("Ada");
// 5000 - 750 - 250 - 310 - 72.50 - 100 = 3517.50
assert_eq!(l.net_pay(), d(351_750));
}
#[test]
fn validation_rejects_empty_negative_and_overwithheld() {
assert_eq!(validate_payroll_lines(&[]), Err(LedgerError::EmptyPayroll));
let mut bad = line("Bob");
bad.ss_employee = d(-1);
assert_eq!(
validate_payroll_lines(std::slice::from_ref(&bad)),
Err(LedgerError::NegativePayrollAmount(d(-1)))
);
let mut over = line("Cat");
over.other_deductions = d(99_999_999);
assert!(over.net_pay() < Decimal::ZERO);
assert_eq!(
validate_payroll_lines(std::slice::from_ref(&over)),
Err(LedgerError::NegativeNetPay("Cat".to_string()))
);
assert!(validate_payroll_lines(&[line("Ada")]).is_ok());
}
#[test]
fn entry_plan_balances_and_skips_zeros() {
let cash = ledger_with_cash().1;
let accounts = PayrollAccounts {
salary_expense: 101,
payroll_tax_expense: 102,
federal_payable: 103,
state_payable: 104,
ss_payable: 105,
medicare_payable: 106,
other_payable: 107,
};
// No state withholding and no other deductions: those credits vanish.
let mut l = line("Ada");
l.state_withholding = Decimal::ZERO;
l.other_deductions = Decimal::ZERO;
let plan = payroll_entry_plan(&[l], accounts, cash, Currency::USD);
assert_eq!(plan.len(), 6); // gross, employer tax, fed, ss, medicare, cash
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
for (_, side, amount) in &plan {
match side {
Side::Debit => debits += amount.amount,
Side::Credit => credits += amount.amount,
}
}
assert_eq!(debits, credits);
assert!(debits > Decimal::ZERO);
for (_, _, amount) in &plan {
assert!(!amount.amount.is_zero());
}
}
#[test]
fn run_payroll_posts_balanced_entry_and_records_run() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
let id = ledger
.run_payroll(date, vec![line("Ada"), line("Bob")], cash)
.unwrap();
assert_eq!(id, 1);
assert_eq!(ledger.next_payroll_run_id(), 2);
let run = ledger.get_payroll_run(id).unwrap();
assert_eq!(run.date, date);
assert_eq!(run.lines.len(), 2);
let entry = ledger.get_entry(run.entry_id).unwrap();
assert_eq!(entry.status(), crate::EntryStatus::Posted);
// Debits equal credits, exactly.
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
for l in entry.lines() {
match l.side {
Side::Debit => debits += l.amount.amount,
Side::Credit => credits += l.amount.amount,
}
}
assert_eq!(debits, credits);
// Gross x2 = 10000 hits Salary & Wage Expense (debit-normal).
let salary = ledger.chart().find_by_code("6100").unwrap().id;
assert_eq!(ledger.account_balance(salary).unwrap(), d(1_000_000));
// Net x2 = 7035 leaves cash (debit-normal): -7035.
assert_eq!(ledger.account_balance(cash).unwrap(), -d(703_500));
// The seven payroll accounts were auto-created.
for code in ["6100", "6110", "2110", "2120", "2130", "2140", "2150"] {
assert!(
ledger.chart().find_by_code(code).is_some(),
"missing {code}"
);
}
// Runs list in id order.
let id2 = ledger.run_payroll(date, vec![line("Cat")], cash).unwrap();
let ids: Vec<_> = ledger.list_payroll_runs().iter().map(|r| r.id).collect();
assert_eq!(ids, vec![id, id2]);
}
#[test]
fn run_payroll_rejects_closed_period() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
let pid = ledger
.create_period("Sep", NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), date)
.unwrap();
ledger.close_period(pid).unwrap();
let err = ledger.run_payroll(date, vec![line("Ada")], cash);
assert_eq!(err, Err(LedgerError::ClosedPeriod(pid, date.to_string())));
}
#[test]
fn run_payroll_rejects_bad_inputs() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
assert_eq!(
ledger.run_payroll(date, vec![], cash),
Err(LedgerError::EmptyPayroll)
);
let mut bad = line("Ada");
bad.gross_pay = d(-100);
assert_eq!(
ledger.run_payroll(date, vec![bad], cash),
Err(LedgerError::NegativePayrollAmount(d(-100)))
);
assert_eq!(
ledger.run_payroll(date, vec![line("Ada")], 999),
Err(LedgerError::UnknownAccount(999))
);
}
#[test]
fn remit_clears_payables() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
ledger.run_payroll(date, vec![line("Ada")], cash).unwrap();
// Withholdings owed: 750 + 250 + (310+310) + (72.50+72.50) + 100 = 1865.
let fed = ledger.chart().find_by_code("2110").unwrap().id;
assert_eq!(ledger.account_balance(fed).unwrap(), d(75_000));
// Nothing owed before any payroll: fresh ledger remits nothing.
let (mut fresh, fresh_cash) = ledger_with_cash();
assert_eq!(
fresh.remit_payroll_taxes(date, fresh_cash),
Err(LedgerError::NothingToRemit)
);
// Remit: payables go to zero, cash drops by the total.
let cash_before = ledger.account_balance(cash).unwrap();
let entry = ledger.remit_payroll_taxes(date, cash).unwrap();
let posted = ledger.get_entry(entry).unwrap();
assert_eq!(posted.status(), crate::EntryStatus::Posted);
for code in ["2110", "2120", "2130", "2140", "2150"] {
let id = ledger.chart().find_by_code(code).unwrap().id;
assert_eq!(
ledger.account_balance(id).unwrap(),
Decimal::ZERO,
"payable {code} not cleared"
);
}
assert_eq!(
ledger.account_balance(cash).unwrap(),
cash_before - d(186_500)
);
// Second remit finds nothing left.
assert_eq!(
ledger.remit_payroll_taxes(date, cash),
Err(LedgerError::NothingToRemit)
);
}
#[test]
fn remit_rejects_closed_period() {
let (mut ledger, cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
ledger
.run_payroll(
NaiveDate::from_ymd_opt(2026, 9, 15).unwrap(),
vec![line("Ada")],
cash,
)
.unwrap();
let pid = ledger
.create_period("Sep", NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), date)
.unwrap();
ledger.close_period(pid).unwrap();
assert_eq!(
ledger.remit_payroll_taxes(date, cash),
Err(LedgerError::ClosedPeriod(pid, date.to_string()))
);
}
#[test]
fn replay_payroll_run_checks_id() {
let (mut ledger, _cash) = ledger_with_cash();
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
let entry = ledger.create_entry(date, "x");
let err = ledger.replay_payroll_run(7, date, vec![line("Ada")], entry);
assert_eq!(err, Err(LedgerError::ReplayIdMismatch(7, 1)));
// The allocator did not move on failure.
assert_eq!(ledger.next_payroll_run_id(), 1);
ledger
.replay_payroll_run(1, date, vec![line("Ada")], entry)
.unwrap();
assert_eq!(ledger.next_payroll_run_id(), 2);
assert!(ledger.get_payroll_run(1).is_some());
assert!(ledger.get_payroll_run(2).is_none());
}
}