diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs new file mode 100644 index 0000000..c6d27bf --- /dev/null +++ b/crates/ledger-core/src/arap.rs @@ -0,0 +1,399 @@ +//! Accounts receivable and payable: customers, vendors, invoices, bills. +//! +//! Invoices and bills are business documents that auto-post journal entries: +//! +//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue. +//! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable. +//! * `receive_payment`: Dr Cash, Cr A/R. Updates the invoice's paid amount. +//! * `pay_bill`: Dr A/P, Cr Cash. Updates the bill's paid amount. +//! +//! The control accounts (`1200`, `2000`) and the default revenue/expense +//! accounts (`4000`, `6999`) are created on demand if missing. Invoices and +//! bills respect closed periods: they cannot be created with dates in a +//! closed period. + +use crate::LedgerError; +use chrono::NaiveDate; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// Opaque customer handle. +pub type CustomerId = u64; +/// Opaque vendor handle. +pub type VendorId = u64; +/// Opaque invoice handle. +pub type InvoiceId = u64; +/// Opaque bill handle. +pub type BillId = u64; + +/// A customer: someone we invoice. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Customer { + /// Opaque handle. + pub id: CustomerId, + /// Display name. + pub name: String, +} + +/// A vendor: someone who bills us. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Vendor { + /// Opaque handle. + pub id: VendorId, + /// Display name. + pub name: String, +} + +/// Payment status of an invoice or bill. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +pub enum DocumentStatus { + /// Nothing paid yet. + Open, + /// Partially paid. + Partial, + /// Fully paid. + Paid, +} + +/// One line on an invoice or bill. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DocumentLine { + /// Description of the goods/services. + pub description: String, + /// Line amount (positive). + pub amount: Decimal, +} + +/// A sales invoice: we billed a customer. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Invoice { + /// Opaque handle. + pub id: InvoiceId, + /// The customer. + pub customer_id: CustomerId, + /// Invoice date. + pub date: NaiveDate, + /// When payment is due. + pub due_date: NaiveDate, + /// Line items. + pub lines: Vec, + /// How much has been paid. + pub amount_paid: Decimal, + /// Derived from amount_paid vs total. + pub status: DocumentStatus, + /// The auto-posted journal entry. + pub entry_id: u64, +} + +impl Invoice { + /// Total of all lines. + pub fn total(&self) -> Decimal { + self.lines.iter().map(|l| l.amount).sum() + } + + /// Amount still owed. + pub fn balance(&self) -> Decimal { + self.total() - self.amount_paid + } +} + +/// A vendor bill: we owe a vendor. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Bill { + /// Opaque handle. + pub id: BillId, + /// The vendor. + pub vendor_id: VendorId, + /// Bill date. + pub date: NaiveDate, + /// When payment is due. + pub due_date: NaiveDate, + /// Line items. + pub lines: Vec, + /// How much has been paid. + pub amount_paid: Decimal, + /// Derived from amount_paid vs total. + pub status: DocumentStatus, + /// The auto-posted journal entry. + pub entry_id: u64, +} + +impl Bill { + /// Total of all lines. + pub fn total(&self) -> Decimal { + self.lines.iter().map(|l| l.amount).sum() + } + + /// Amount still owed. + pub fn balance(&self) -> Decimal { + self.total() - self.amount_paid + } +} + +/// Validate document lines: at least one, all positive amounts. +pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> { + if lines.is_empty() { + return Err(LedgerError::EmptyDocumentLines); + } + for line in lines { + if line.amount <= Decimal::ZERO { + return Err(LedgerError::InvalidDocumentLineAmount(line.amount)); + } + } + Ok(()) +} + +/// Validate that due date is not before the document date. +pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), LedgerError> { + if due_date < date { + return Err(LedgerError::DueBeforeDate(due_date.to_string())); + } + Ok(()) +} + +/// Derive status from paid vs total. +pub fn derive_status(paid: Decimal, total: Decimal) -> DocumentStatus { + if paid.is_zero() { + DocumentStatus::Open + } else if paid >= total { + DocumentStatus::Paid + } else { + DocumentStatus::Partial + } +} + +/// One row of an aging report. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AgingRow { + /// Customer or vendor name. + pub name: String, + /// Not yet due. + pub current: Decimal, + /// 1-30 days past due. + pub days_30: Decimal, + /// 31-60 days past due. + pub days_60: Decimal, + /// 61-90 days past due. + pub days_90: Decimal, + /// Over 90 days past due. + pub days_90_plus: Decimal, +} + +impl AgingRow { + /// Total outstanding. + pub fn total(&self) -> Decimal { + self.current + self.days_30 + self.days_60 + self.days_90 + self.days_90_plus + } +} + +/// Bucket a past-due balance by days overdue. +pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow { + let mut row = AgingRow { + name: String::new(), + current: Decimal::ZERO, + days_30: Decimal::ZERO, + days_60: Decimal::ZERO, + days_90: Decimal::ZERO, + days_90_plus: Decimal::ZERO, + }; + if due_date > as_of { + row.current = balance; + } else { + let days = (as_of - due_date).num_days(); + if days <= 30 { + row.days_30 = balance; + } else if days <= 60 { + row.days_60 = balance; + } else if days <= 90 { + row.days_90 = balance; + } else { + row.days_90_plus = balance; + } + } + row +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AccountType, Currency, Ledger, Money, Side}; + + fn arap_ledger() -> Ledger { + let mut l = Ledger::new(Currency::USD); + l.add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + l + } + + #[test] + fn invoice_lifecycle() { + let mut l = arap_ledger(); + let cust = l.add_customer("Acme Corp").unwrap(); + let inv = l + .create_invoice( + cust, + NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(), + vec![DocumentLine { + description: "Widgets".to_string(), + amount: "1000.00".parse().unwrap(), + }], + ) + .unwrap(); + let invoice = l.get_invoice(inv).unwrap(); + assert_eq!(invoice.total(), "1000.00".parse().unwrap()); + assert_eq!(invoice.status, DocumentStatus::Open); + + // A/R has a debit balance of 1000. + let ar = l.chart().find_by_code("1200").unwrap().id; + assert_eq!(l.account_balance(ar).unwrap(), "1000".parse().unwrap()); + + // Partial payment. + let cash = l.chart().find_by_code("1000").unwrap().id; + l.receive_payment( + inv, + NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), + "400.00".parse().unwrap(), + cash, + ) + .unwrap(); + let invoice = l.get_invoice(inv).unwrap(); + assert_eq!(invoice.status, DocumentStatus::Partial); + assert_eq!(invoice.balance(), "600.00".parse().unwrap()); + + // Full payment. + l.receive_payment( + inv, + NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(), + "600.00".parse().unwrap(), + cash, + ) + .unwrap(); + let invoice = l.get_invoice(inv).unwrap(); + assert_eq!(invoice.status, DocumentStatus::Paid); + assert_eq!(l.account_balance(ar).unwrap(), Decimal::ZERO); + } + + #[test] + fn bill_lifecycle() { + let mut l = arap_ledger(); + let vend = l.add_vendor("Supply Co").unwrap(); + let bill = l + .create_bill( + vend, + NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(), + vec![DocumentLine { + description: "Paper".to_string(), + amount: "250.00".parse().unwrap(), + }], + ) + .unwrap(); + let ap = l.chart().find_by_code("2000").unwrap().id; + // A/P is credit-normal; balance should be 250 (credit). + assert_eq!(l.account_balance(ap).unwrap(), "250".parse().unwrap()); + + let cash = l.chart().find_by_code("1000").unwrap().id; + // Seed cash so the payment doesn't go negative (not required, but tidy). + let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), "seed"); + let m = Money::new("1000.00".parse().unwrap(), Currency::USD); + l.add_line(e, cash, Side::Debit, m).unwrap(); + let eq = l + .add_account("3000", "Equity", AccountType::Equity, None, false) + .unwrap(); + l.add_line(e, eq, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + + l.pay_bill( + bill, + NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), + "250.00".parse().unwrap(), + cash, + ) + .unwrap(); + assert_eq!(l.get_bill(bill).unwrap().status, DocumentStatus::Paid); + assert_eq!(l.account_balance(ap).unwrap(), Decimal::ZERO); + } + + #[test] + fn overpayment_rejected() { + let mut l = arap_ledger(); + let cust = l.add_customer("Acme").unwrap(); + let inv = l + .create_invoice( + cust, + NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(), + vec![DocumentLine { + description: "X".to_string(), + amount: "100.00".parse().unwrap(), + }], + ) + .unwrap(); + let cash = l.chart().find_by_code("1000").unwrap().id; + let err = l.receive_payment( + inv, + NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), + "150.00".parse().unwrap(), + cash, + ); + assert!(matches!(err, Err(LedgerError::Overpayment(_, _)))); + } + + #[test] + fn ar_aging_buckets_correctly() { + let mut l = arap_ledger(); + let cust = l.add_customer("Acme").unwrap(); + // Due in the future: current. + l.create_invoice( + cust, + NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(), + vec![DocumentLine { + description: "A".to_string(), + amount: "100.00".parse().unwrap(), + }], + ) + .unwrap(); + // 45 days past due: 60 bucket. + l.create_invoice( + cust, + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 8, 17).unwrap(), + vec![DocumentLine { + description: "B".to_string(), + amount: "200.00".parse().unwrap(), + }], + ) + .unwrap(); + let as_of = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(); + let aging = l.ar_aging(as_of); + assert_eq!(aging.len(), 1); + assert_eq!(aging[0].current, "100".parse().unwrap()); + assert_eq!(aging[0].days_60, "200".parse().unwrap()); + } + + #[test] + fn invoice_in_closed_period_rejected() { + let mut l = arap_ledger(); + let cust = l.add_customer("Acme").unwrap(); + let pid = l + .create_period( + "2026-10", + NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(), + ) + .unwrap(); + l.close_period(pid).unwrap(); + let err = l.create_invoice( + cust, + NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), + NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(), + vec![DocumentLine { + description: "X".to_string(), + amount: "100.00".parse().unwrap(), + }], + ); + assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _)))); + } +}