Payroll accounting, class tracking, CPA handoff package (48 MCP tools)
This commit is contained in:
1 parent
c7a47ce76f
commit
6fdd8898a9
1 file changed
+421
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user