Every report a CPA expects, laid out the way US small-business books read (QuickBooks as the reference): - Exports: books.export gains general_ledger (by account, running balances, splits), general_journal (the entry-ordered listing, which the CPA package used to mislabel "general ledger"; alias journal), members_equity, and trial_balance as of a date, in xlsx/csv/json, and PDF for all five statements (P&L, balance sheet, cash flow, trial balance, members' equity). books.trial_balance takes as_of. - CPA package: every statement as PDF and XLSX, plus the general ledger and general journal, all for the fiscal year to date. - Layout (ledger-import::report): multi-column statements (Total; Debit/Credit; one column per member plus Total), "$" on subtotals and totals, a rule above every sum (none over an empty section), a double rule under statement totals, zero as 0.00, negatives in parentheses with the closing paren hanging so decimal points align. - Terms: "Net income (loss)"; for a multi-member LLC, "Members' equity", member contributions and distributions named in the cash flow, and the balance sheet splitting retained earnings (the Retained Earnings account plus earnings not yet closed from earlier years) from the fiscal year's net income (loss); the cash flow's "Adjustments to reconcile net income (loss) to net cash from operating activities" and "Net increase (decrease) in cash". - PDF: Inter replaces the typst-assets serif fonts (bundled, SIL OFL 1.1, license in fonts/; from the official v4.1 release), centered title block, column-header row, full-width rules, footer "Accrual basis · Prepared <date> · Journal seq N" with page numbers. Amounts on total rows stay regular weight so bold figures can't leave the decimal column. typst-assets dependency removed. - XLSX: centered title block, header row, Excel accounting formats with "$" attached on sums, general ledger and general journal workbooks. - Config: wanderbooks.toml [[company.members]] (name, share "50%" or "0.5", capital_account; shares must total 100%) and [books] accounting_basis (accrual | cash). - Core: RETAINED_EARNINGS_CODE names the "3999" that period close uses. - Fix: "today" is local time, not UTC; in US evenings UTC is tomorrow, so reports defaulting to "through today" included the next day and footers carried the wrong date. Tests: layout (currency and rule placement, retained earnings vs. this year's net income for a member LLC, footer text); exact template data; deterministic PDFs; config parsing (members, shares, basis); stdio: trial balance as of, February general ledger (beginning 1,004.00, splits, ending 1,003.50), members' equity (contributions 1,000.00, net income 3.50, ending 1,003.50), members' equity PDF, the CPA package's ten files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
208 lines
7.2 KiB
Rust
208 lines
7.2 KiB
Rust
//! Accounting periods and period close.
|
|
//!
|
|
//! A [`Period`] is a named date range (a month, a quarter, a year). Periods
|
|
//! start open; closing one is a one-way operation that:
|
|
//!
|
|
//! 1. Rejects the close if any draft entries have dates inside the period.
|
|
//! 2. Posts a closing entry dated at period end: revenue accounts are
|
|
//! debited to zero, expense accounts are credited to zero, and the net
|
|
//! difference lands in Retained Earnings (created as `3999` if missing).
|
|
//! 3. Marks the period closed.
|
|
//!
|
|
//! Once closed, [`Ledger::create_entry`] and [`Ledger::post_entry`] reject
|
|
//! any entry dated inside the period. Corrections to closed periods go
|
|
//! through the *next* open period (or a reversal dated in an open period).
|
|
|
|
use crate::LedgerError;
|
|
use chrono::NaiveDate;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Opaque period handle. Created by [`crate::Ledger`]; never forged.
|
|
pub type PeriodId = u64;
|
|
|
|
/// Lifecycle of an accounting period. `Open -> Closed` is one-way.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum PeriodStatus {
|
|
/// Accepting entries.
|
|
Open,
|
|
/// Closed. Entries dated inside are rejected.
|
|
Closed,
|
|
}
|
|
|
|
/// Code of the Retained Earnings account that closing entries credit (or
|
|
/// debit), created on the first close if the chart lacks it.
|
|
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
|
|
|
/// A named accounting period: a month, quarter, or year.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct Period {
|
|
/// Opaque handle.
|
|
pub id: PeriodId,
|
|
/// Human name, e.g. `"2026-10"` or `"Q4 2026"`.
|
|
pub name: String,
|
|
/// First date in the period (inclusive).
|
|
pub start: NaiveDate,
|
|
/// Last date in the period (inclusive).
|
|
pub end: NaiveDate,
|
|
/// Open or closed.
|
|
pub status: PeriodStatus,
|
|
/// The closing entry's id, once closed.
|
|
pub closing_entry: Option<u64>,
|
|
}
|
|
|
|
impl Period {
|
|
/// True when `date` falls inside the period (inclusive).
|
|
pub fn contains(&self, date: NaiveDate) -> bool {
|
|
date >= self.start && date <= self.end
|
|
}
|
|
}
|
|
|
|
/// Validate a period's date range before creation.
|
|
pub fn validate_period_range(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
|
|
if end < start {
|
|
return Err(LedgerError::InvalidPeriodRange {
|
|
start: start.to_string(),
|
|
end: end.to_string(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Currency, Ledger};
|
|
|
|
fn period_ledger() -> Ledger {
|
|
let mut l = Ledger::new(Currency::USD);
|
|
l.add_account("4000", "Revenue", crate::AccountType::Revenue, None, false)
|
|
.unwrap();
|
|
l.add_account("6000", "Expenses", crate::AccountType::Expense, None, false)
|
|
.unwrap();
|
|
l.add_account("1000", "Cash", crate::AccountType::Asset, None, false)
|
|
.unwrap();
|
|
l
|
|
}
|
|
|
|
fn post_sale(l: &mut Ledger, date: NaiveDate, amount: &str) {
|
|
let e = l.create_entry(date, "sale");
|
|
let rev = l.chart().find_by_code("4000").unwrap().id;
|
|
let cash = l.chart().find_by_code("1000").unwrap().id;
|
|
l.add_line(
|
|
e,
|
|
cash,
|
|
crate::Side::Debit,
|
|
crate::Money::new(amount.parse().unwrap(), Currency::USD),
|
|
)
|
|
.unwrap();
|
|
l.add_line(
|
|
e,
|
|
rev,
|
|
crate::Side::Credit,
|
|
crate::Money::new(amount.parse().unwrap(), Currency::USD),
|
|
)
|
|
.unwrap();
|
|
l.post_entry(e).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn close_zeroes_revenue_and_expense_to_retained_earnings() {
|
|
let mut l = period_ledger();
|
|
let start = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
|
let end = NaiveDate::from_ymd_opt(2026, 10, 31).unwrap();
|
|
let pid = l.create_period("2026-10", start, end).unwrap();
|
|
|
|
post_sale(
|
|
&mut l,
|
|
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
|
"1000.00",
|
|
);
|
|
// An expense: debit expense, credit cash.
|
|
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(), "rent");
|
|
let exp = l.chart().find_by_code("6000").unwrap().id;
|
|
let cash = l.chart().find_by_code("1000").unwrap().id;
|
|
let m = crate::Money::new("400.00".parse().unwrap(), Currency::USD);
|
|
l.add_line(e, exp, crate::Side::Debit, m).unwrap();
|
|
l.add_line(e, cash, crate::Side::Credit, m).unwrap();
|
|
l.post_entry(e).unwrap();
|
|
|
|
l.close_period(pid).unwrap();
|
|
|
|
// Revenue and expense are zeroed.
|
|
let rev = l.chart().find_by_code("4000").unwrap().id;
|
|
let exp = l.chart().find_by_code("6000").unwrap().id;
|
|
assert_eq!(l.account_balance(rev).unwrap(), rust_decimal::Decimal::ZERO);
|
|
assert_eq!(l.account_balance(exp).unwrap(), rust_decimal::Decimal::ZERO);
|
|
// Retained earnings holds the net: 1000 - 400 = 600 (credit-normal).
|
|
let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id;
|
|
assert_eq!(
|
|
l.account_balance(re).unwrap(),
|
|
"600".parse::<rust_decimal::Decimal>().unwrap()
|
|
);
|
|
// Period is closed.
|
|
assert_eq!(l.get_period(pid).unwrap().status, PeriodStatus::Closed);
|
|
}
|
|
|
|
#[test]
|
|
fn close_rejects_when_already_closed() {
|
|
let mut l = period_ledger();
|
|
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();
|
|
assert_eq!(
|
|
l.close_period(pid).unwrap_err(),
|
|
LedgerError::PeriodAlreadyClosed(pid)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn close_rejects_with_drafts_in_period() {
|
|
let mut l = period_ledger();
|
|
let pid = l
|
|
.create_period(
|
|
"2026-10",
|
|
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
|
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
|
)
|
|
.unwrap();
|
|
// A draft dated inside the period blocks the close.
|
|
l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "draft");
|
|
assert_eq!(
|
|
l.close_period(pid).unwrap_err(),
|
|
LedgerError::DraftsInPeriod(pid)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn closed_period_rejects_new_entries() {
|
|
let mut l = period_ledger();
|
|
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();
|
|
// create_entry with a date in the closed period fails.
|
|
let err = l.try_create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "late");
|
|
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_range_rejected() {
|
|
let mut l = period_ledger();
|
|
let err = l.create_period(
|
|
"bad",
|
|
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
|
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
|
);
|
|
assert!(matches!(err, Err(LedgerError::InvalidPeriodRange { .. })));
|
|
}
|
|
}
|