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
425 lines
16 KiB
Rust
425 lines
16 KiB
Rust
//! 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());
|
||
}
|
||
}
|