Payroll accounting, class tracking, CPA handoff package (48 MCP tools)

This commit is contained in:
swaits committed 2026-10-11 15:31:51 +00:00
1 parent c7a47ce76f
commit 6fdd8898a9
1 file changed
+421
+421
View File
@@ -0,0 +1,421 @@
//! 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> {
if lines.is_empty() {
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());
}
}