Payroll accounting, class tracking, CPA handoff package (48 MCP tools)
This commit is contained in:
1 parent
bad75ede4d
commit
4969cb557e
1 file changed
+519
-10
@@ -12,9 +12,11 @@ use crate::journal::{JournalLine, JournalWriter, read_and_verify_journal};
|
||||
use crate::schema;
|
||||
use chrono::{Months, NaiveDate};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, AssetStatus, BillId, Currency, CustomerId, DocumentLine,
|
||||
EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, NewAsset, PeriodId, Side,
|
||||
VendorId, arap, reports,
|
||||
AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, Currency, CustomerId,
|
||||
DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, NewAsset,
|
||||
PayrollLine, PayrollRun, PayrollRunId, PeriodId, Side, VendorId, arap,
|
||||
payroll::{PayrollAccounts, payroll_entry_plan, payroll_remit_plan},
|
||||
reports,
|
||||
};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -115,6 +117,11 @@ pub struct Attachment {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Input to [`Store::run_payroll`]: one employee's gross pay and every
|
||||
/// withholding and deduction. The engine's [`PayrollLine`], aliased so the
|
||||
/// write-path signature reads as input rather than stored state.
|
||||
pub type PayrollLineInput = PayrollLine;
|
||||
|
||||
/// The store: one writer, one data directory.
|
||||
///
|
||||
/// `Store` is `Send` but not `Sync` (it owns a `rusqlite::Connection`).
|
||||
@@ -1405,6 +1412,273 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Payroll
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Ensure the seven payroll chart accounts (`6100`/`6110`/`2110`–`2150`)
|
||||
/// through the journaled account path. The ledger's internal ensure
|
||||
/// then no-ops, which keeps replay deterministic: every account
|
||||
/// creation is an `account.added` event.
|
||||
fn ensure_payroll_accounts(&mut self) -> Result<PayrollAccounts, StoreError> {
|
||||
Ok(PayrollAccounts {
|
||||
salary_expense: self.ensure_control_account(
|
||||
"6100",
|
||||
"Salary & Wage Expense",
|
||||
AccountType::Expense,
|
||||
)?,
|
||||
payroll_tax_expense: self.ensure_control_account(
|
||||
"6110",
|
||||
"Payroll Tax Expense",
|
||||
AccountType::Expense,
|
||||
)?,
|
||||
federal_payable: self.ensure_control_account(
|
||||
"2110",
|
||||
"Federal Withholding Payable",
|
||||
AccountType::Liability,
|
||||
)?,
|
||||
state_payable: self.ensure_control_account(
|
||||
"2120",
|
||||
"State Withholding Payable",
|
||||
AccountType::Liability,
|
||||
)?,
|
||||
ss_payable: self.ensure_control_account(
|
||||
"2130",
|
||||
"SS Tax Payable",
|
||||
AccountType::Liability,
|
||||
)?,
|
||||
medicare_payable: self.ensure_control_account(
|
||||
"2140",
|
||||
"Medicare Tax Payable",
|
||||
AccountType::Liability,
|
||||
)?,
|
||||
other_payable: self.ensure_control_account(
|
||||
"2150",
|
||||
"Other Deductions Payable",
|
||||
AccountType::Liability,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run payroll: validate, journal the auto-posted entry (Dr Salary &
|
||||
/// Wage Expense for gross, Dr Payroll Tax Expense for the employer FICA
|
||||
/// share, Cr the `2110`–`2150` payables, Cr cash for net pay) through
|
||||
/// the standard entry flow, then journal `payroll.run` with the run's
|
||||
/// detail. Accounting only — no tax tables; every withholding number
|
||||
/// comes from the caller.
|
||||
pub fn run_payroll(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
lines: Vec<PayrollLineInput>,
|
||||
cash_account: AccountId,
|
||||
) -> Result<PayrollRunId, StoreError> {
|
||||
// Pure pre-validation mirroring Ledger::run_payroll, so the journal
|
||||
// writes below cannot be followed by a failed mutation.
|
||||
self.ledger.validate_payroll(date, &lines, cash_account)?;
|
||||
let accounts = self.ensure_payroll_accounts()?;
|
||||
let run_id = self.ledger.next_payroll_run_id();
|
||||
let entry_id = self.ledger.next_entry_id();
|
||||
let base = self.base_currency;
|
||||
let plan = payroll_entry_plan(&lines, accounts, cash_account, base);
|
||||
let description = format!("Payroll {date}");
|
||||
let mut journaled = Vec::with_capacity(plan.len() + 3);
|
||||
journaled.push(self.append_journal(
|
||||
"entry.created",
|
||||
json!({
|
||||
"id": entry_id,
|
||||
"date": date.to_string(),
|
||||
"description": description,
|
||||
}),
|
||||
)?);
|
||||
for (account, side, amount) in &plan {
|
||||
journaled.push(self.append_journal(
|
||||
"entry.line_added",
|
||||
json!({
|
||||
"id": entry_id,
|
||||
"account": account,
|
||||
"side": match side {
|
||||
Side::Debit => "debit",
|
||||
Side::Credit => "credit",
|
||||
},
|
||||
"amount": amount.amount.to_string(),
|
||||
"currency": amount.currency.code(),
|
||||
}),
|
||||
)?);
|
||||
}
|
||||
journaled.push(self.append_journal(
|
||||
"entry.posted",
|
||||
json!({
|
||||
"id": entry_id,
|
||||
"date": date.to_string(),
|
||||
"description": description,
|
||||
"reversal_of": Option::<u64>::None,
|
||||
"lines": plan.iter().map(|(a, s, m)| plain_line_json(*a, *s, *m)).collect::<Vec<_>>(),
|
||||
}),
|
||||
)?);
|
||||
journaled.push(self.append_journal(
|
||||
"payroll.run",
|
||||
json!({
|
||||
"id": run_id,
|
||||
"date": date.to_string(),
|
||||
"lines": lines.iter().map(payroll_line_json).collect::<Vec<_>>(),
|
||||
"entry_id": entry_id,
|
||||
}),
|
||||
)?);
|
||||
let live = self.ledger.run_payroll(date, lines, cash_account)?;
|
||||
debug_assert_eq!(live, run_id, "id allocator diverged from journal");
|
||||
debug_assert_eq!(
|
||||
self.ledger
|
||||
.get_payroll_run(live)
|
||||
.expect("payroll run exists")
|
||||
.entry_id,
|
||||
entry_id,
|
||||
"payroll entry diverged from journal"
|
||||
);
|
||||
for line in &journaled {
|
||||
apply_line_to_sqlite(&self.conn, line)?;
|
||||
}
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Remit payroll taxes: journal the payoff entry (Dr each payable with
|
||||
/// a non-zero balance, Cr cash for the total) through the standard
|
||||
/// entry flow, then post it through the ledger. Fails before journaling
|
||||
/// anything when nothing is owed.
|
||||
pub fn remit_payroll_taxes(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
// Pure pre-validation mirroring Ledger::remit_payroll_taxes.
|
||||
if self.ledger.chart().get(cash_account).is_none() {
|
||||
return Err(LedgerError::UnknownAccount(cash_account).into());
|
||||
}
|
||||
let payables = self.ledger.payroll_tax_payables();
|
||||
let total: Decimal = payables.iter().map(|(_, b)| *b).sum();
|
||||
if total.is_zero() {
|
||||
return Err(LedgerError::NothingToRemit.into());
|
||||
}
|
||||
if let Some(pid) = self.ledger.closed_period_containing(date) {
|
||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
|
||||
}
|
||||
let entry_id = self.ledger.next_entry_id();
|
||||
let base = self.base_currency;
|
||||
let plan = payroll_remit_plan(&payables, cash_account, base);
|
||||
let description = format!("Payroll tax remittance {date}");
|
||||
let mut journaled = Vec::with_capacity(plan.len() + 2);
|
||||
journaled.push(self.append_journal(
|
||||
"entry.created",
|
||||
json!({
|
||||
"id": entry_id,
|
||||
"date": date.to_string(),
|
||||
"description": description,
|
||||
}),
|
||||
)?);
|
||||
for (account, side, amount) in &plan {
|
||||
journaled.push(self.append_journal(
|
||||
"entry.line_added",
|
||||
json!({
|
||||
"id": entry_id,
|
||||
"account": account,
|
||||
"side": match side {
|
||||
Side::Debit => "debit",
|
||||
Side::Credit => "credit",
|
||||
},
|
||||
"amount": amount.amount.to_string(),
|
||||
"currency": amount.currency.code(),
|
||||
}),
|
||||
)?);
|
||||
}
|
||||
journaled.push(self.append_journal(
|
||||
"entry.posted",
|
||||
json!({
|
||||
"id": entry_id,
|
||||
"date": date.to_string(),
|
||||
"description": description,
|
||||
"reversal_of": Option::<u64>::None,
|
||||
"lines": plan.iter().map(|(a, s, m)| plain_line_json(*a, *s, *m)).collect::<Vec<_>>(),
|
||||
}),
|
||||
)?);
|
||||
let live = self.ledger.remit_payroll_taxes(date, cash_account)?;
|
||||
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
|
||||
for line in &journaled {
|
||||
apply_line_to_sqlite(&self.conn, line)?;
|
||||
}
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// All payroll runs, in id order.
|
||||
pub fn list_payroll_runs(&self) -> Vec<PayrollRun> {
|
||||
self.ledger
|
||||
.list_payroll_runs()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Classes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Add a class (division) for line-level P&L tracking. Journaled as
|
||||
/// `class.added`.
|
||||
pub fn add_class(&mut self, name: &str) -> Result<ClassId, StoreError> {
|
||||
// The engine's only failure mode is an empty name; replicate it so
|
||||
// the journal write cannot be followed by a failed mutation.
|
||||
if name.trim().is_empty() {
|
||||
return Err(LedgerError::EmptyClassName.into());
|
||||
}
|
||||
let id = self.ledger.next_class_id();
|
||||
let line = self.append_journal("class.added", json!({ "id": id, "name": name }))?;
|
||||
let live = self.ledger.add_class(name)?;
|
||||
debug_assert_eq!(live, id, "id allocator diverged from journal");
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// All classes, in id order.
|
||||
pub fn list_classes(&self) -> Vec<Class> {
|
||||
self.ledger.list_classes().into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Tag a draft entry's line with a class, or clear it with `None`.
|
||||
/// Journaled as `entry.line_class_set`. Draft-only: the ledger rejects
|
||||
/// posted entries, and this checks before journaling.
|
||||
pub fn set_line_class(
|
||||
&mut self,
|
||||
entry_id: EntryId,
|
||||
line_index: usize,
|
||||
class_id: Option<ClassId>,
|
||||
) -> Result<(), StoreError> {
|
||||
// Draft-only check via the ledger, before anything is journaled.
|
||||
let entry = self
|
||||
.ledger
|
||||
.get_entry(entry_id)
|
||||
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
||||
if entry.status() != EntryStatus::Draft {
|
||||
return Err(LedgerError::PostedEntryImmutable.into());
|
||||
}
|
||||
if let Some(cid) = class_id
|
||||
&& self.ledger.get_class(cid).is_none()
|
||||
{
|
||||
return Err(LedgerError::UnknownClass(cid).into());
|
||||
}
|
||||
if line_index >= entry.lines().len() {
|
||||
return Err(LedgerError::LineIndexOutOfRange(line_index, entry_id).into());
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"entry.line_class_set",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"line_index": line_index as u64,
|
||||
"class_id": class_id,
|
||||
}),
|
||||
)?;
|
||||
self.ledger.set_line_class(entry_id, line_index, class_id)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Blobs and attachments
|
||||
// ------------------------------------------------------------------
|
||||
@@ -1656,6 +1930,31 @@ impl Store {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// Classes and payroll runs are master data the snapshot also
|
||||
// ignores; compare them explicitly the same way.
|
||||
let classes_of = |l: &Ledger| l.list_classes().into_iter().cloned().collect::<Vec<_>>();
|
||||
let (ca, cb, cc) = (
|
||||
classes_of(&replayed),
|
||||
classes_of(&self.ledger),
|
||||
classes_of(&c),
|
||||
);
|
||||
if ca != cb || ca != cc {
|
||||
return Err(StoreError::VerifyMismatch(
|
||||
"class list diverged between journal, live ledger, and sqlite projection".into(),
|
||||
));
|
||||
}
|
||||
let runs_of = |l: &Ledger| {
|
||||
l.list_payroll_runs()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let (pa, pb, pc) = (runs_of(&replayed), runs_of(&self.ledger), runs_of(&c));
|
||||
if pa != pb || pa != pc {
|
||||
return Err(StoreError::VerifyMismatch(
|
||||
"payroll runs diverged between journal, live ledger, and sqlite projection".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1736,6 +2035,17 @@ impl Store {
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Classes: master data in id order, before entries (lines may
|
||||
// reference them).
|
||||
let mut class_stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, name FROM classes ORDER BY id")?;
|
||||
let class_rows =
|
||||
class_stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
|
||||
for c in class_rows {
|
||||
let (id, name) = c?;
|
||||
ledger.replay_class(id as u32, name)?;
|
||||
}
|
||||
let mut estmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, date, description, reversal_of FROM entries ORDER BY id")?;
|
||||
@@ -1770,7 +2080,7 @@ impl Store {
|
||||
)));
|
||||
}
|
||||
let mut lstmt = self.conn.prepare(
|
||||
"SELECT account_id, side, amount, currency FROM lines WHERE entry_id = ? ORDER BY id",
|
||||
"SELECT account_id, side, amount, currency, class_id FROM lines WHERE entry_id = ? ORDER BY id",
|
||||
)?;
|
||||
let lrows = lstmt.query_map(params![id], |r| {
|
||||
Ok((
|
||||
@@ -1778,10 +2088,11 @@ impl Store {
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, Option<i64>>(4)?,
|
||||
))
|
||||
})?;
|
||||
for lr in lrows {
|
||||
let (account, side, amount, currency) = lr?;
|
||||
for (idx, lr) in lrows.enumerate() {
|
||||
let (account, side, amount, currency, class_id) = lr?;
|
||||
ledger.add_line(
|
||||
got,
|
||||
account as u64,
|
||||
@@ -1795,6 +2106,10 @@ impl Store {
|
||||
})?,
|
||||
),
|
||||
)?;
|
||||
// Draft-only tagging, before the post below.
|
||||
if let Some(cid) = class_id {
|
||||
ledger.set_line_class(got, idx, Some(cid as u32))?;
|
||||
}
|
||||
}
|
||||
ledger.post_entry(got)?;
|
||||
}
|
||||
@@ -1883,6 +2198,57 @@ impl Store {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Payroll runs: id order; lines in insertion order. Amounts are
|
||||
// TEXT in SQLite, so they parse here with exact Decimal math.
|
||||
let parse_payroll_amount = |s: String| {
|
||||
s.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad payroll amount {s}")))
|
||||
};
|
||||
let mut run_stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, date, entry_id FROM payroll_runs ORDER BY id")?;
|
||||
let run_rows = run_stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})?;
|
||||
for r in run_rows {
|
||||
let (id, date, entry_id) = r?;
|
||||
let mut pl_stmt = self.conn.prepare(
|
||||
"SELECT employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions FROM payroll_lines WHERE run_id = ? ORDER BY id",
|
||||
)?;
|
||||
let pl_rows = pl_stmt.query_map(params![id], |lr| {
|
||||
Ok((
|
||||
lr.get::<_, String>(0)?,
|
||||
lr.get::<_, String>(1)?,
|
||||
lr.get::<_, String>(2)?,
|
||||
lr.get::<_, String>(3)?,
|
||||
lr.get::<_, String>(4)?,
|
||||
lr.get::<_, String>(5)?,
|
||||
lr.get::<_, String>(6)?,
|
||||
lr.get::<_, String>(7)?,
|
||||
lr.get::<_, String>(8)?,
|
||||
))
|
||||
})?;
|
||||
let mut lines = Vec::new();
|
||||
for lr in pl_rows {
|
||||
let (name, gross, fed, state, ss_e, med_e, ss_r, med_r, other) = lr?;
|
||||
lines.push(PayrollLine {
|
||||
employee_name: name,
|
||||
gross_pay: parse_payroll_amount(gross)?,
|
||||
federal_withholding: parse_payroll_amount(fed)?,
|
||||
state_withholding: parse_payroll_amount(state)?,
|
||||
ss_employee: parse_payroll_amount(ss_e)?,
|
||||
medicare_employee: parse_payroll_amount(med_e)?,
|
||||
ss_employer: parse_payroll_amount(ss_r)?,
|
||||
medicare_employer: parse_payroll_amount(med_r)?,
|
||||
other_deductions: parse_payroll_amount(other)?,
|
||||
});
|
||||
}
|
||||
ledger.replay_payroll_run(id as u64, parse_date(&date)?, lines, entry_id as u64)?;
|
||||
}
|
||||
Ok(ledger)
|
||||
}
|
||||
}
|
||||
@@ -1917,6 +2283,16 @@ fn journal_line_json(l: &ledger_core::JournalLine) -> Value {
|
||||
if let Some(fa) = l.foreign_amount {
|
||||
obj.insert("foreign_amount".to_string(), Value::String(fa.to_string()));
|
||||
}
|
||||
// Class tags ride the line JSON so the projection learns them when the
|
||||
// entry posts (drafts never touch the projection). Journals written
|
||||
// before classes existed carry no such lines; readers treat missing as
|
||||
// unclassified.
|
||||
if let Some(c) = l.class_id {
|
||||
obj.insert(
|
||||
"class_id".to_string(),
|
||||
Value::Number(serde_json::Number::from(c)),
|
||||
);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -1928,6 +2304,7 @@ fn plain_line_json(account: AccountId, side: Side, amount: Money) -> Value {
|
||||
amount,
|
||||
foreign_currency: None,
|
||||
foreign_amount: None,
|
||||
class_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1964,14 +2341,70 @@ fn flip_line_json(l: &ledger_core::JournalLine) -> Value {
|
||||
Side::Debit => "credit",
|
||||
Side::Credit => "debit",
|
||||
};
|
||||
json!({
|
||||
let mut v = json!({
|
||||
"account": l.account,
|
||||
"side": side,
|
||||
"amount": l.amount.amount.to_string(),
|
||||
"currency": l.amount.currency.code(),
|
||||
});
|
||||
// Reversals offset the original line for line: carry the class so
|
||||
// divisional P&L nets correctly (mirrors journal_line_json).
|
||||
if let Some(c) = l.class_id
|
||||
&& let Some(obj) = v.as_object_mut()
|
||||
{
|
||||
obj.insert(
|
||||
"class_id".to_string(),
|
||||
Value::Number(serde_json::Number::from(c)),
|
||||
);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// One payroll line as journal JSON. Amounts are decimal strings, never
|
||||
/// floats.
|
||||
fn payroll_line_json(l: &PayrollLine) -> Value {
|
||||
json!({
|
||||
"employee_name": l.employee_name,
|
||||
"gross_pay": l.gross_pay.to_string(),
|
||||
"federal_withholding": l.federal_withholding.to_string(),
|
||||
"state_withholding": l.state_withholding.to_string(),
|
||||
"ss_employee": l.ss_employee.to_string(),
|
||||
"medicare_employee": l.medicare_employee.to_string(),
|
||||
"ss_employer": l.ss_employer.to_string(),
|
||||
"medicare_employer": l.medicare_employer.to_string(),
|
||||
"other_deductions": l.other_deductions.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the `lines` array of a `payroll.run` journal payload back into
|
||||
/// payroll lines.
|
||||
fn parse_payroll_lines(p: &Value) -> Result<Vec<PayrollLine>, StoreError> {
|
||||
let arr = p
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("missing 'lines'".into()))?;
|
||||
arr.iter()
|
||||
.map(|l| {
|
||||
let amount = |key: &str| {
|
||||
get_str(l, key)?
|
||||
.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad payroll amount '{key}'")))
|
||||
};
|
||||
Ok(PayrollLine {
|
||||
employee_name: get_str(l, "employee_name")?.to_string(),
|
||||
gross_pay: amount("gross_pay")?,
|
||||
federal_withholding: amount("federal_withholding")?,
|
||||
state_withholding: amount("state_withholding")?,
|
||||
ss_employee: amount("ss_employee")?,
|
||||
medicare_employee: amount("medicare_employee")?,
|
||||
ss_employer: amount("ss_employer")?,
|
||||
medicare_employer: amount("medicare_employer")?,
|
||||
other_deductions: amount("other_deductions")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_side(s: &str) -> Result<Side, StoreError> {
|
||||
match s {
|
||||
"debit" => Ok(Side::Debit),
|
||||
@@ -2298,6 +2731,29 @@ fn replay_line_into_ledger(
|
||||
.map_err(|_| StoreError::JournalCorrupt("bad rate".into()))?,
|
||||
)?;
|
||||
}
|
||||
"payroll.run" => {
|
||||
// The entry was already rebuilt by the standard entry handlers;
|
||||
// this only restores the run record, without re-posting.
|
||||
ledger.replay_payroll_run(
|
||||
get_u64(p, "id")?,
|
||||
parse_date(get_str(p, "date")?)?,
|
||||
parse_payroll_lines(p)?,
|
||||
get_u64(p, "entry_id")?,
|
||||
)?;
|
||||
}
|
||||
"class.added" => {
|
||||
ledger.replay_class(get_u64(p, "id")? as u32, get_str(p, "name")?.to_string())?;
|
||||
}
|
||||
"entry.line_class_set" => {
|
||||
// Journaled while the entry was a draft, so at replay time the
|
||||
// draft exists and takes the class. Missing/null class_id
|
||||
// clears the tag.
|
||||
ledger.set_line_class(
|
||||
get_u64(p, "entry_id")?,
|
||||
get_u64(p, "line_index")? as usize,
|
||||
p.get("class_id").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
)?;
|
||||
}
|
||||
other => {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"unknown line type: {other}"
|
||||
@@ -2391,7 +2847,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("entry.posted without lines".into()))?
|
||||
{
|
||||
conn.execute(
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount) VALUES (?,?,?,?,?,?,?)",
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
get_u64(p, "id")? as i64,
|
||||
get_u64(l, "account")? as i64,
|
||||
@@ -2400,6 +2856,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
get_str(l, "currency")?,
|
||||
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -2420,7 +2877,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("entry.reversed without lines".into()))?
|
||||
{
|
||||
conn.execute(
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount) VALUES (?,?,?,?,?,?,?)",
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
get_u64(p, "id")? as i64,
|
||||
get_u64(l, "account")? as i64,
|
||||
@@ -2429,6 +2886,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
get_str(l, "currency")?,
|
||||
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -2538,7 +2996,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
StoreError::JournalCorrupt("period.closed entry without lines".into())
|
||||
})? {
|
||||
conn.execute(
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount) VALUES (?,?,?,?,?,?,?)",
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
eid,
|
||||
get_u64(l, "account")? as i64,
|
||||
@@ -2547,6 +3005,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
get_str(l, "currency")?,
|
||||
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -2647,6 +3106,56 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"payroll.run" => {
|
||||
// The entry itself arrived through the standard entry events
|
||||
// journaled just before this line; this records the run.
|
||||
let run_id = get_u64(p, "id")? as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO payroll_runs(id, date, entry_id) VALUES (?,?,?)",
|
||||
params![run_id, get_str(p, "date")?, get_u64(p, "entry_id")? as i64,],
|
||||
)?;
|
||||
for l in parse_payroll_lines(p)? {
|
||||
conn.execute(
|
||||
"INSERT INTO payroll_lines(run_id, employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
run_id,
|
||||
l.employee_name,
|
||||
l.gross_pay.to_string(),
|
||||
l.federal_withholding.to_string(),
|
||||
l.state_withholding.to_string(),
|
||||
l.ss_employee.to_string(),
|
||||
l.medicare_employee.to_string(),
|
||||
l.ss_employer.to_string(),
|
||||
l.medicare_employer.to_string(),
|
||||
l.other_deductions.to_string(),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"class.added" => {
|
||||
conn.execute(
|
||||
"INSERT INTO classes(id, name) VALUES (?,?)",
|
||||
params![get_u64(p, "id")? as i64, get_str(p, "name")?],
|
||||
)?;
|
||||
}
|
||||
"entry.line_class_set" => {
|
||||
// Journaled while the entry is a draft, so the projection
|
||||
// usually has no lines row yet (drafts never touch the
|
||||
// projection): the UPDATE is a no-op then, and the class lands
|
||||
// through the entry.posted payload's per-line class_id. When a
|
||||
// row does exist, the UPDATE applies directly. Rows are
|
||||
// inserted in line order, so OFFSET maps line_index to the row.
|
||||
let class_id: Option<i64> =
|
||||
p.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64);
|
||||
conn.execute(
|
||||
"UPDATE lines SET class_id = ? WHERE id = (SELECT id FROM lines WHERE entry_id = ? ORDER BY id LIMIT 1 OFFSET ?)",
|
||||
params![
|
||||
class_id,
|
||||
get_u64(p, "entry_id")? as i64,
|
||||
get_u64(p, "line_index")? as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
other => {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"unknown line type: {other}"
|
||||
|
||||
Reference in new issue
Block a user