Compare commits
7
Commits
3bfea47a59
...
f4cb01b3f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4cb01b3f9
|
||
|
|
42e600bbf2 | ||
|
|
03212265ce | ||
|
|
b5af21bb0e | ||
|
|
c6faeeb827 | ||
|
|
5548902f27 | ||
|
|
e59e8001c6 |
No files matched your search
Generated
+2367
-417
File diff suppressed because it is too large.
Load diff
@@ -84,40 +84,20 @@ pub struct ChartOfAccounts {
|
||||
impl ChartOfAccounts {
|
||||
/// Register an account. Codes must be unique and non-empty; a parent, if
|
||||
/// given, must already exist.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
code: &str,
|
||||
name: &str,
|
||||
account_type: AccountType,
|
||||
parent: Option<AccountId>,
|
||||
placeholder: bool,
|
||||
currency: Currency,
|
||||
) -> Result<(), LedgerError> {
|
||||
if code.is_empty() {
|
||||
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
|
||||
if account.code.is_empty() {
|
||||
return Err(LedgerError::EmptyAccountCode);
|
||||
}
|
||||
if self.by_code.contains_key(code) {
|
||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||||
if self.by_code.contains_key(&account.code) {
|
||||
return Err(LedgerError::DuplicateAccountCode(account.code));
|
||||
}
|
||||
if let Some(p) = parent
|
||||
if let Some(p) = account.parent
|
||||
&& !self.accounts.contains_key(&p)
|
||||
{
|
||||
return Err(LedgerError::UnknownParentAccount(p));
|
||||
}
|
||||
self.by_code.insert(code.to_string(), id);
|
||||
self.accounts.insert(
|
||||
id,
|
||||
Account {
|
||||
id,
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
account_type,
|
||||
parent,
|
||||
placeholder,
|
||||
currency,
|
||||
},
|
||||
);
|
||||
self.by_code.insert(account.code.clone(), account.id);
|
||||
self.accounts.insert(account.id, account);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
//! audit trail and what will make the future oracle harness trustworthy.
|
||||
|
||||
use crate::{
|
||||
AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId, ChartOfAccounts,
|
||||
Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus, EntryId,
|
||||
EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money, NewAsset,
|
||||
Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets,
|
||||
Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId,
|
||||
ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus,
|
||||
EntryId, EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money,
|
||||
NewAsset, Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets,
|
||||
payroll::{
|
||||
PayrollAccounts, PayrollLine, PayrollRun, PayrollRunId, payroll_entry_plan,
|
||||
payroll_remit_plan, validate_payroll_lines,
|
||||
@@ -220,15 +220,15 @@ impl Ledger {
|
||||
) -> Result<AccountId, LedgerError> {
|
||||
let id = self.next_account_id;
|
||||
self.next_account_id += 1;
|
||||
self.chart.add(
|
||||
self.chart.add(Account {
|
||||
id,
|
||||
code,
|
||||
name,
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
account_type,
|
||||
parent,
|
||||
placeholder,
|
||||
self.base_currency,
|
||||
)?;
|
||||
currency: self.base_currency,
|
||||
})?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -568,6 +568,16 @@ impl Ledger {
|
||||
self.periods.get(&id)
|
||||
}
|
||||
|
||||
/// Ids of every period-closing entry. Period statements (P&L, cash
|
||||
/// flow) exclude them: a close moves earnings into retained earnings; it
|
||||
/// earns and spends nothing, so counting it would zero out the period.
|
||||
pub fn closing_entry_ids(&self) -> std::collections::BTreeSet<EntryId> {
|
||||
self.periods
|
||||
.values()
|
||||
.filter_map(|p| p.closing_entry)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All periods, in creation order.
|
||||
pub fn list_periods(&self) -> Vec<&Period> {
|
||||
let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect();
|
||||
|
||||
@@ -45,6 +45,7 @@ pub use money::{Currency, Money};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{Period, PeriodId, PeriodStatus};
|
||||
pub use reports::{
|
||||
BalanceSheet, CashFlowLine, CashFlowParams, CashFlowStatement, ClassProfitAndLoss,
|
||||
ProfitAndLoss, TrialBalance, TrialBalanceLine, cash_flow,
|
||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||
ClassProfitAndLoss, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
|
||||
TrialBalanceLine, balance_sheet_as_of, cash_flow, profit_and_loss_for_period,
|
||||
};
|
||||
@@ -4,10 +4,11 @@
|
||||
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||
//! produce byte-identical [`snapshot`] output.
|
||||
|
||||
use crate::{AccountType, Class, ClassId, JournalLine, Ledger, LedgerError};
|
||||
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// One line of a trial balance.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -78,8 +79,8 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
|
||||
}
|
||||
}
|
||||
|
||||
/// Profit and loss: revenue and expenses for the life of the ledger.
|
||||
/// (Period filtering is R-FUTURE work; see `docs/requirements.md` R-7.)
|
||||
/// Profit and loss: revenue and expenses for the life of the ledger. For a
|
||||
/// date range, with a line per account, use [`profit_and_loss_for_period`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProfitAndLoss {
|
||||
/// Total revenue (credit-normal balances, reported positive).
|
||||
@@ -265,6 +266,200 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// One account's line on a financial statement.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StatementLine {
|
||||
/// Account code, e.g. `"6000"`.
|
||||
pub code: String,
|
||||
/// Account name.
|
||||
pub name: String,
|
||||
/// Amount on the account's normal side: positive is a normal balance
|
||||
/// (or movement), negative a contra one.
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
/// Profit and loss for a date range, with a line per account.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProfitAndLossStatement {
|
||||
/// First date, inclusive.
|
||||
pub start: NaiveDate,
|
||||
/// Last date, inclusive.
|
||||
pub end: NaiveDate,
|
||||
/// Revenue accounts with movement in the range, by code.
|
||||
pub revenue: Vec<StatementLine>,
|
||||
/// Expense accounts with movement in the range, by code.
|
||||
pub expenses: Vec<StatementLine>,
|
||||
/// Sum of the revenue lines.
|
||||
pub total_revenue: Decimal,
|
||||
/// Sum of the expense lines.
|
||||
pub total_expenses: Decimal,
|
||||
}
|
||||
|
||||
impl ProfitAndLossStatement {
|
||||
/// Revenue minus expenses. Positive is profit.
|
||||
pub fn net_income(&self) -> Decimal {
|
||||
self.total_revenue - self.total_expenses
|
||||
}
|
||||
}
|
||||
|
||||
/// Balance sheet as of a date, with a line per account.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BalanceSheetStatement {
|
||||
/// The as-of date, inclusive.
|
||||
pub as_of: NaiveDate,
|
||||
/// Asset accounts with a balance, by code.
|
||||
pub assets: Vec<StatementLine>,
|
||||
/// Liability accounts with a balance, by code.
|
||||
pub liabilities: Vec<StatementLine>,
|
||||
/// Equity accounts with a balance, by code (current earnings excluded).
|
||||
pub equity: Vec<StatementLine>,
|
||||
/// Revenue minus expenses through `as_of` not yet closed to retained
|
||||
/// earnings. Shown as its own equity line.
|
||||
pub current_earnings: Decimal,
|
||||
/// Sum of the asset lines.
|
||||
pub total_assets: Decimal,
|
||||
/// Sum of the liability lines.
|
||||
pub total_liabilities: Decimal,
|
||||
/// Sum of the equity lines plus current earnings.
|
||||
pub total_equity: Decimal,
|
||||
}
|
||||
|
||||
impl BalanceSheetStatement {
|
||||
/// The accounting equation: assets == liabilities + equity, exactly.
|
||||
pub fn balanced(&self) -> bool {
|
||||
self.total_assets == self.total_liabilities + self.total_equity
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal-side amount per account over the posted lines accepted by
|
||||
/// `include(entry, line)`, as statement lines keyed (and so sorted) by
|
||||
/// code, skipping placeholders and zero amounts.
|
||||
fn statement_lines(
|
||||
ledger: &Ledger,
|
||||
include: &dyn Fn(&JournalEntry, &JournalLine) -> bool,
|
||||
) -> Result<BTreeMap<String, (AccountType, StatementLine)>, LedgerError> {
|
||||
let mut movement: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
|
||||
for entry in ledger.posted_entries() {
|
||||
for line in entry.lines() {
|
||||
if !include(entry, line) {
|
||||
continue;
|
||||
}
|
||||
let signed = match line.side {
|
||||
crate::Side::Debit => line.amount.amount,
|
||||
crate::Side::Credit => -line.amount.amount,
|
||||
};
|
||||
*movement.entry(line.account).or_default() += signed;
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
for (id, m) in movement {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if account.placeholder || m.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let amount = match account.account_type.normal_side() {
|
||||
crate::Side::Debit => m,
|
||||
crate::Side::Credit => -m,
|
||||
};
|
||||
out.insert(
|
||||
account.code.clone(),
|
||||
(
|
||||
account.account_type,
|
||||
StatementLine {
|
||||
code: account.code.clone(),
|
||||
name: account.name.clone(),
|
||||
amount,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Profit and loss for `[start, end]`, optionally for one class (`None`
|
||||
/// counts every line). Closing entries are excluded: they move a period's
|
||||
/// earnings into retained earnings, so counting them would report a closed
|
||||
/// period as breaking even.
|
||||
pub fn profit_and_loss_for_period(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
class_id: Option<ClassId>,
|
||||
) -> Result<ProfitAndLossStatement, LedgerError> {
|
||||
if end < start {
|
||||
return Err(LedgerError::InvalidDateRange {
|
||||
start: start.to_string(),
|
||||
end: end.to_string(),
|
||||
});
|
||||
}
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let lines = statement_lines(ledger, &|entry, line| {
|
||||
entry.date() >= start
|
||||
&& entry.date() <= end
|
||||
&& !closings.contains(&entry.id())
|
||||
&& class_id.is_none_or(|c| line.class_id == Some(c))
|
||||
})?;
|
||||
let mut revenue = Vec::new();
|
||||
let mut expenses = Vec::new();
|
||||
for (typ, line) in lines.into_values() {
|
||||
match typ {
|
||||
AccountType::Revenue => revenue.push(line),
|
||||
AccountType::Expense => expenses.push(line),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let total_revenue = revenue.iter().map(|l| l.amount).sum();
|
||||
let total_expenses = expenses.iter().map(|l| l.amount).sum();
|
||||
Ok(ProfitAndLossStatement {
|
||||
start,
|
||||
end,
|
||||
revenue,
|
||||
expenses,
|
||||
total_revenue,
|
||||
total_expenses,
|
||||
})
|
||||
}
|
||||
|
||||
/// Balance sheet as of `as_of`: every posted entry dated on or before it.
|
||||
/// Revenue and expense not yet closed to retained earnings appear as
|
||||
/// current earnings inside equity, so the accounting equation holds on
|
||||
/// every date, before and after closes.
|
||||
pub fn balance_sheet_as_of(
|
||||
ledger: &Ledger,
|
||||
as_of: NaiveDate,
|
||||
) -> Result<BalanceSheetStatement, LedgerError> {
|
||||
let lines = statement_lines(ledger, &|entry, _| entry.date() <= as_of)?;
|
||||
let mut assets = Vec::new();
|
||||
let mut liabilities = Vec::new();
|
||||
let mut equity = Vec::new();
|
||||
let mut current_earnings = Decimal::ZERO;
|
||||
for (typ, line) in lines.into_values() {
|
||||
match typ {
|
||||
AccountType::Asset => assets.push(line),
|
||||
AccountType::Liability => liabilities.push(line),
|
||||
AccountType::Equity => equity.push(line),
|
||||
AccountType::Revenue => current_earnings += line.amount,
|
||||
AccountType::Expense => current_earnings -= line.amount,
|
||||
}
|
||||
}
|
||||
let total_assets = assets.iter().map(|l| l.amount).sum();
|
||||
let total_liabilities = liabilities.iter().map(|l| l.amount).sum();
|
||||
let total_equity = equity.iter().map(|l| l.amount).sum::<Decimal>() + current_earnings;
|
||||
Ok(BalanceSheetStatement {
|
||||
as_of,
|
||||
assets,
|
||||
liabilities,
|
||||
equity,
|
||||
current_earnings,
|
||||
total_assets,
|
||||
total_liabilities,
|
||||
total_equity,
|
||||
})
|
||||
}
|
||||
|
||||
/// Render the three core reports as canonical JSON for oracle diffing.
|
||||
///
|
||||
/// Amounts are decimal strings (never floats, never locale-formatted), lines
|
||||
@@ -406,11 +601,14 @@ pub fn cash_flow(
|
||||
let financing_set: HashSet<crate::AccountId> =
|
||||
params.financing_accounts.iter().copied().collect();
|
||||
|
||||
// Movement helpers: debits minus credits over a date predicate.
|
||||
// Movement helpers: debits minus credits over a date predicate. Closing
|
||||
// entries are skipped: a close moves earnings into retained earnings,
|
||||
// which is neither net income nor an owner flow.
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
|
||||
let mut total = Decimal::ZERO;
|
||||
for entry in ledger.posted_entries() {
|
||||
if !pred(entry.date()) {
|
||||
if !pred(entry.date()) || closings.contains(&entry.id()) {
|
||||
continue;
|
||||
}
|
||||
for line in entry.lines() {
|
||||
@@ -471,7 +669,11 @@ pub fn cash_flow(
|
||||
format!("{} {}", a.code, a.name)
|
||||
};
|
||||
|
||||
for account in ledger.chart().all() {
|
||||
// By code: the chart iterates in hash order, and a statement's lines
|
||||
// must come out in the same order every time.
|
||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -550,7 +550,7 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let revenue = ledger
|
||||
ledger
|
||||
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let gain = ledger
|
||||
|
||||
@@ -9,7 +9,10 @@ use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
||||
depreciation_schedule,
|
||||
reports::{balance_sheet, trial_balance},
|
||||
reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
||||
trial_balance,
|
||||
},
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -136,6 +139,52 @@ proptest! {
|
||||
prop_assert_eq!(bs.assets, bs.liabilities + bs.equity);
|
||||
}
|
||||
|
||||
/// Period P&Ls partition the year: split anywhere, the two halves sum
|
||||
/// to the whole, and the whole is the life-of-ledger P&L.
|
||||
#[test]
|
||||
fn prop_period_pnl_partitions((chart, entries) in arb_ops(), split in 0u32..365) {
|
||||
let (ledger, _) = build_ledger(&chart, &entries);
|
||||
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let dec31 = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
|
||||
let cut = jan1 + chrono::Days::new(split as u64);
|
||||
let first = profit_and_loss_for_period(&ledger, jan1, cut, None).unwrap();
|
||||
let rest = if cut < dec31 {
|
||||
Some(profit_and_loss_for_period(&ledger, cut.succ_opt().unwrap(), dec31, None).unwrap())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let whole = profit_and_loss_for_period(&ledger, jan1, dec31, None).unwrap();
|
||||
let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses));
|
||||
prop_assert_eq!(first.total_revenue + r2, whole.total_revenue);
|
||||
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
|
||||
let all = profit_and_loss(&ledger).unwrap();
|
||||
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
|
||||
}
|
||||
|
||||
/// The accounting equation holds as of every date, and a period's net
|
||||
/// income is exactly the change in current earnings across it.
|
||||
#[test]
|
||||
fn prop_balance_sheet_as_of_balances((chart, entries) in arb_ops(), a in 0u32..365, b in 0u32..365) {
|
||||
let (ledger, _) = build_ledger(&chart, &entries);
|
||||
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
let start = jan1 + chrono::Days::new(lo as u64);
|
||||
let end = jan1 + chrono::Days::new(hi as u64);
|
||||
let before = balance_sheet_as_of(&ledger, start.pred_opt().unwrap()).unwrap();
|
||||
let after = balance_sheet_as_of(&ledger, end).unwrap();
|
||||
prop_assert!(before.balanced());
|
||||
prop_assert!(after.balanced());
|
||||
let pnl = profit_and_loss_for_period(&ledger, start, end, None).unwrap();
|
||||
prop_assert_eq!(after.current_earnings - before.current_earnings, pnl.net_income());
|
||||
// Past the last entry, as-of agrees with the life-of-ledger sheet.
|
||||
let all = balance_sheet(&ledger).unwrap();
|
||||
let last = balance_sheet_as_of(&ledger, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()).unwrap();
|
||||
prop_assert_eq!(
|
||||
(last.total_assets, last.total_liabilities, last.total_equity),
|
||||
(all.assets, all.liabilities, all.equity)
|
||||
);
|
||||
}
|
||||
|
||||
/// Every posted entry balances on its own, exactly.
|
||||
#[test]
|
||||
fn prop_posted_entries_balance((chart, entries) in arb_ops()) {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Period-scoped financial statements: profit and loss for a date range and
|
||||
//! the balance sheet as of a date, line per account.
|
||||
//!
|
||||
//! Every expected number below is computed by hand from the entries, not
|
||||
//! read off the engine. The fixture is two months of a small company:
|
||||
//!
|
||||
//! | date | entry | Dr | Cr |
|
||||
//! |--------|-------------------------------|---------------|---------------|
|
||||
//! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital |
|
||||
//! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue |
|
||||
//! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash |
|
||||
//! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card |
|
||||
//! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue |
|
||||
//! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash |
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
||||
};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, Money, Side, StatementLine,
|
||||
cash_flow,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(n: i64) -> Money {
|
||||
Money::new(Decimal::from(n), Currency::USD)
|
||||
}
|
||||
|
||||
fn dec(n: i64) -> Decimal {
|
||||
Decimal::from(n)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
ledger: Ledger,
|
||||
cash: AccountId,
|
||||
software_class: ClassId,
|
||||
}
|
||||
|
||||
fn post(ledger: &mut Ledger, date: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, n: i64) {
|
||||
let e = ledger.create_entry(date, desc);
|
||||
ledger.add_line(e, dr, Side::Debit, usd(n)).unwrap();
|
||||
ledger.add_line(e, cr, Side::Credit, usd(n)).unwrap();
|
||||
ledger.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
/// The fixture. With `close_january`, January is closed before any February
|
||||
/// entry exists (the close sweeps every revenue and expense balance).
|
||||
fn books(close_january: bool) -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2000", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let capital = l
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let revenue = l
|
||||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let software = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6100", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let software_class = l.add_class("Apps").unwrap();
|
||||
|
||||
post(&mut l, d(1, 2), "Owner contribution", cash, capital, 10_000);
|
||||
post(&mut l, d(1, 15), "Client payment", cash, revenue, 2_500);
|
||||
post(&mut l, d(1, 20), "Rent", rent, cash, 800);
|
||||
if close_january {
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
l.close_period(jan).unwrap();
|
||||
}
|
||||
// Software on the card, tagged to the Apps class.
|
||||
let e = l.create_entry(d(2, 3), "Software");
|
||||
l.add_line(e, software, Side::Debit, usd(120)).unwrap();
|
||||
l.add_line(e, card, Side::Credit, usd(120)).unwrap();
|
||||
l.set_line_class(e, 0, Some(software_class)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
post(&mut l, d(2, 10), "Client payment", cash, revenue, 1_000);
|
||||
post(&mut l, d(2, 25), "Pay card", card, cash, 120);
|
||||
Books {
|
||||
ledger: l,
|
||||
cash,
|
||||
software_class,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(code: &str, name: &str, n: i64) -> StatementLine {
|
||||
StatementLine {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
amount: dec(n),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profit_and_loss_is_scoped_to_the_period() {
|
||||
let b = books(false);
|
||||
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
|
||||
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
|
||||
assert_eq!(jan.expenses, vec![line("6100", "Rent", 800)]);
|
||||
assert_eq!(jan.net_income(), dec(1_700));
|
||||
|
||||
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.revenue, vec![line("4000", "Revenue", 1_000)]);
|
||||
assert_eq!(feb.expenses, vec![line("6000", "Software", 120)]);
|
||||
assert_eq!(feb.net_income(), dec(880));
|
||||
|
||||
// Both months: 3,500 revenue, 920 expenses, lines sorted by code.
|
||||
let both = profit_and_loss_for_period(&b.ledger, d(1, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(
|
||||
both.expenses,
|
||||
vec![line("6000", "Software", 120), line("6100", "Rent", 800)]
|
||||
);
|
||||
assert_eq!(
|
||||
(both.total_revenue, both.total_expenses),
|
||||
(dec(3_500), dec(920))
|
||||
);
|
||||
// ... which is the life-of-ledger P&L, because nothing is outside it.
|
||||
let all = profit_and_loss(&b.ledger).unwrap();
|
||||
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
|
||||
|
||||
// One class: only the tagged software line.
|
||||
let apps =
|
||||
profit_and_loss_for_period(&b.ledger, d(1, 1), d(12, 31), Some(b.software_class)).unwrap();
|
||||
assert!(apps.revenue.is_empty());
|
||||
assert_eq!(apps.expenses, vec![line("6000", "Software", 120)]);
|
||||
|
||||
// A backwards range is rejected, not silently empty.
|
||||
assert!(profit_and_loss_for_period(&b.ledger, d(2, 1), d(1, 1), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_sheet_is_as_of_the_date() {
|
||||
let b = books(false);
|
||||
// Before anything happened: empty and balanced.
|
||||
let start = balance_sheet_as_of(&b.ledger, d(1, 1)).unwrap();
|
||||
assert!(start.assets.is_empty() && start.equity.is_empty());
|
||||
assert!(start.balanced());
|
||||
|
||||
// Jan 31: cash 10,000 + 2,500 − 800 = 11,700; equity 10,000 + 1,700.
|
||||
let jan = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
|
||||
assert_eq!(jan.assets, vec![line("1000", "Cash", 11_700)]);
|
||||
assert!(jan.liabilities.is_empty());
|
||||
assert_eq!(jan.equity, vec![line("3000", "Capital", 10_000)]);
|
||||
assert_eq!(jan.current_earnings, dec(1_700));
|
||||
assert_eq!(jan.total_equity, dec(11_700));
|
||||
assert!(jan.balanced());
|
||||
|
||||
// Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880.
|
||||
let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap();
|
||||
assert_eq!(mid.total_assets, dec(12_700));
|
||||
assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]);
|
||||
assert_eq!(mid.current_earnings, dec(2_580));
|
||||
assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700));
|
||||
|
||||
// Feb 28: card paid off, so it drops off the statement.
|
||||
let end = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||||
assert_eq!(end.total_assets, dec(12_580));
|
||||
assert!(end.liabilities.is_empty());
|
||||
assert!(end.balanced());
|
||||
// ... and agrees with the life-of-ledger balance sheet.
|
||||
let all = balance_sheet(&b.ledger).unwrap();
|
||||
assert_eq!(
|
||||
(all.assets, all.liabilities, all.equity),
|
||||
(end.total_assets, end.total_liabilities, end.total_equity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_period_still_reports_its_earnings() {
|
||||
let b = books(true);
|
||||
// The close posted Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 on Jan
|
||||
// 31. The January P&L must not count it: January earned 1,700.
|
||||
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
|
||||
assert_eq!(jan.net_income(), dec(1_700));
|
||||
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
|
||||
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.net_income(), dec(880));
|
||||
|
||||
// After the close, January's earnings sit in retained earnings, not in
|
||||
// current earnings; February's are current.
|
||||
let jan_bs = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
|
||||
assert_eq!(
|
||||
jan_bs.equity,
|
||||
vec![
|
||||
line("3000", "Capital", 10_000),
|
||||
line("3999", "Retained Earnings", 1_700)
|
||||
]
|
||||
);
|
||||
assert_eq!(jan_bs.current_earnings, Decimal::ZERO);
|
||||
assert!(jan_bs.balanced());
|
||||
let feb_bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||||
assert_eq!(feb_bs.current_earnings, dec(880));
|
||||
assert_eq!(feb_bs.total_equity, dec(12_580));
|
||||
assert!(feb_bs.balanced());
|
||||
|
||||
// The cash flow for January: net income 1,700, and the only financing
|
||||
// flow is the owner's 10,000. The close is not an owner contribution.
|
||||
let cf = cash_flow(
|
||||
&b.ledger,
|
||||
&CashFlowParams {
|
||||
start: d(1, 1),
|
||||
end: d(1, 31),
|
||||
cash_accounts: vec![b.cash],
|
||||
financing_accounts: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cf.net_income, dec(1_700));
|
||||
assert_eq!(cf.financing.len(), 1);
|
||||
assert_eq!(cf.cash_from_financing, dec(10_000));
|
||||
assert_eq!(cf.net_change_in_cash, dec(11_700));
|
||||
}
|
||||
|
||||
/// Cash flow lines come out in account-code order, every time. (The chart
|
||||
/// iterates in hash order; the statement must not.)
|
||||
#[test]
|
||||
fn cash_flow_lines_are_in_code_order() {
|
||||
for _ in 0..20 {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let mut owners = Vec::new();
|
||||
for code in ["3005", "3001", "3009", "3000", "3003"] {
|
||||
owners.push(
|
||||
l.add_account(code, code, AccountType::Equity, None, false)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for o in &owners {
|
||||
post(&mut l, d(3, 1), "Contribution", cash, *o, 100);
|
||||
}
|
||||
let cf = cash_flow(
|
||||
&l,
|
||||
&CashFlowParams {
|
||||
start: d(1, 1),
|
||||
end: d(12, 31),
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Labels read "Owner contributions — 3000 3000" (code, then name).
|
||||
let codes: Vec<&str> = cf
|
||||
.financing
|
||||
.iter()
|
||||
.map(|f| f.label.rsplit(' ').next().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
codes,
|
||||
["3000", "3001", "3003", "3005", "3009"],
|
||||
"{:?}",
|
||||
cf.financing
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,9 @@ chrono = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
rust_xlsxwriter = "0.90"
|
||||
printpdf = "0.12.8"
|
||||
typst-as-lib = { version = "0.17", default-features = false }
|
||||
typst-pdf = "0.15.1"
|
||||
typst-assets = { version = "0.15.1", features = ["fonts"] }
|
||||
typst = "0.15.1"
|
||||
serde_json = "1"
|
||||
typst-layout = "0.15.1"
|
||||
@@ -1,14 +1,14 @@
|
||||
//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks
|
||||
//! Desktop, which many CPAs still run.
|
||||
//!
|
||||
//! Layout per entry:
|
||||
//! Layout per entry (fields are tab-separated; `\t` marks each tab):
|
||||
//!
|
||||
//! ```text
|
||||
//! !TRNS TRNSTYPE DATE ACCNT NAME AMOUNT DOCNUM MEMO
|
||||
//! !SPL TRNSTYPE DATE ACCNT AMOUNT DOCNUM MEMO
|
||||
//! !TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO
|
||||
//! !SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO
|
||||
//! !ENDTRNS
|
||||
//! TRNS GENERAL JOURNAL 01/15/2026 Cash 1000.00 1 Memo
|
||||
//! SPL GENERAL JOURNAL 01/15/2026 Revenue -1000.00 1 Memo
|
||||
//! TRNS\tGENERAL JOURNAL\t01/15/2026\tCash\t\t1000.00\t1\tMemo
|
||||
//! SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00\t1\tMemo
|
||||
//! ENDTRNS
|
||||
//! ```
|
||||
//!
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
|
||||
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
|
||||
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
|
||||
//! - [`report`]: the statement layout every renderer draws from.
|
||||
//! - [`xlsx`]: generate XLSX reports.
|
||||
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
||||
//!
|
||||
@@ -12,10 +13,12 @@ pub mod bank_csv;
|
||||
pub mod chart_csv;
|
||||
pub mod iif;
|
||||
pub mod pdf;
|
||||
pub mod report;
|
||||
pub mod xlsx;
|
||||
|
||||
pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||
pub use iif::{IifEntry, journal_to_iif};
|
||||
pub use pdf::{PdfError, balance_sheet_pdf, profit_and_loss_pdf};
|
||||
pub use pdf::{PdfError, statement_pdf};
|
||||
pub use report::{ReportMeta, Row, RowKind, Statement, format_amount};
|
||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||
+159
-180
@@ -1,205 +1,184 @@
|
||||
//! PDF financial statements for the CPA handoff package.
|
||||
//! PDF financial statements, typeset with Typst.
|
||||
//!
|
||||
//! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in
|
||||
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
|
||||
//! [`crate::report`] lays a statement out as rows; this module hands those
|
||||
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
||||
//! renders the PDF. Amounts arrive already split by
|
||||
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
||||
//! and labels are data, never markup. Fonts are bundled (`typst-assets`),
|
||||
//! so output doesn't depend on what's installed, and it is deterministic:
|
||||
//! the same statement renders to the same bytes.
|
||||
|
||||
use ledger_core::{AccountType, Ledger};
|
||||
use printpdf::{
|
||||
BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt,
|
||||
Rgb, TextItem,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||
use std::sync::OnceLock;
|
||||
use thiserror::Error;
|
||||
use typst::foundations::{Dict, IntoValue};
|
||||
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
|
||||
use typst_layout::PagedDocument;
|
||||
|
||||
/// PDF rendering errors.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PdfError {
|
||||
/// printpdf failed to save.
|
||||
/// Typst failed to compile the statement.
|
||||
#[error("pdf typesetting failed: {0}")]
|
||||
Compile(String),
|
||||
/// The PDF writer failed.
|
||||
#[error("pdf save failed: {0}")]
|
||||
Save(String),
|
||||
}
|
||||
|
||||
fn money(d: Decimal) -> String {
|
||||
// Two decimals, no float anywhere.
|
||||
format!("{:.2}", d)
|
||||
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||||
|
||||
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||||
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
|
||||
ENGINE.get_or_init(|| {
|
||||
TypstEngine::builder()
|
||||
.main_file(TEMPLATE)
|
||||
.fonts(typst_assets::fonts())
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
fn text_ops(
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
rows: &[(String, String)],
|
||||
totals: &[(String, String)],
|
||||
) -> Vec<Op> {
|
||||
let mut ops = vec![
|
||||
Op::StartTextSection,
|
||||
Op::SetTextCursor {
|
||||
pos: Point::new(Mm(20.0), Mm(280.0)),
|
||||
},
|
||||
Op::SetFont {
|
||||
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
|
||||
size: Pt(18.0),
|
||||
},
|
||||
Op::SetLineHeight { lh: Pt(22.0) },
|
||||
Op::SetFillColor {
|
||||
col: Color::Rgb(Rgb {
|
||||
r: 0.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
icc_profile: None,
|
||||
/// The template's input: the statement with every amount formatted.
|
||||
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
let kind = |k: RowKind| match k {
|
||||
RowKind::Section => "section",
|
||||
RowKind::Line => "line",
|
||||
RowKind::Subtotal => "subtotal",
|
||||
RowKind::Total => "total",
|
||||
RowKind::Spacer => "spacer",
|
||||
};
|
||||
serde_json::json!({
|
||||
"company": meta.company,
|
||||
"title": st.title,
|
||||
"period": st.period,
|
||||
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||||
"kind": kind(r.kind),
|
||||
"label": r.label,
|
||||
"rule": r.rule_above,
|
||||
"amount": r.amount.map(|a| {
|
||||
let p = amount_parts(a);
|
||||
serde_json::json!({
|
||||
"digits": p.digits,
|
||||
"negative": p.negative,
|
||||
"zero": p.zero,
|
||||
"currency": r.currency,
|
||||
})
|
||||
}),
|
||||
},
|
||||
Op::ShowText {
|
||||
items: vec![TextItem::Text(title.to_string())],
|
||||
},
|
||||
Op::AddLineBreak,
|
||||
Op::SetFont {
|
||||
font: PdfFontHandle::Builtin(BuiltinFont::Helvetica),
|
||||
size: Pt(11.0),
|
||||
},
|
||||
Op::SetLineHeight { lh: Pt(14.0) },
|
||||
Op::ShowText {
|
||||
items: vec![TextItem::Text(subtitle.to_string())],
|
||||
},
|
||||
Op::AddLineBreak,
|
||||
Op::AddLineBreak,
|
||||
];
|
||||
|
||||
for (label, amount) in rows {
|
||||
// Label left, amount right-ish via padding. Monospace would be
|
||||
// nicer; Helvetica with padding is readable enough for a CPA file.
|
||||
let line = format!("{:<50} {:>14}", label, amount);
|
||||
ops.push(Op::ShowText {
|
||||
items: vec![TextItem::Text(line)],
|
||||
});
|
||||
ops.push(Op::AddLineBreak);
|
||||
}
|
||||
|
||||
if !totals.is_empty() {
|
||||
ops.push(Op::AddLineBreak);
|
||||
ops.push(Op::SetFont {
|
||||
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
|
||||
size: Pt(11.0),
|
||||
});
|
||||
for (label, amount) in totals {
|
||||
let line = format!("{:<50} {:>14}", label, amount);
|
||||
ops.push(Op::ShowText {
|
||||
items: vec![TextItem::Text(line)],
|
||||
});
|
||||
ops.push(Op::AddLineBreak);
|
||||
}
|
||||
}
|
||||
|
||||
ops.push(Op::EndTextSection);
|
||||
ops
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn save_pdf(title: &str, ops: Vec<Op>) -> Result<Vec<u8>, PdfError> {
|
||||
let mut doc = PdfDocument::new(title);
|
||||
let page = PdfPage::new(Mm(210.0), Mm(297.0), ops);
|
||||
let bytes = doc
|
||||
.with_pages(vec![page])
|
||||
.save(&PdfSaveOptions::default(), &mut Vec::new());
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Render a profit-and-loss statement PDF: revenue and expense accounts with
|
||||
/// balances, then totals and net income.
|
||||
pub fn profit_and_loss_pdf(ledger: &Ledger, start: &str, end: &str) -> Result<Vec<u8>, PdfError> {
|
||||
let mut revenue_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut expense_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let balance = ledger
|
||||
.account_balance(account.id)
|
||||
.map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
if balance.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let label = format!("{} {}", account.code, account.name);
|
||||
match account.account_type {
|
||||
AccountType::Revenue => revenue_rows.push((label, money(balance))),
|
||||
AccountType::Expense => expense_rows.push((label, money(balance))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let pl =
|
||||
ledger_core::reports::profit_and_loss(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
|
||||
let mut rows = vec![("REVENUE".to_string(), "".to_string())];
|
||||
rows.extend(revenue_rows);
|
||||
rows.push(("".to_string(), "".to_string()));
|
||||
rows.push(("EXPENSES".to_string(), "".to_string()));
|
||||
rows.extend(expense_rows);
|
||||
|
||||
let totals = vec![
|
||||
("Total revenue".to_string(), money(pl.revenue)),
|
||||
("Total expenses".to_string(), money(pl.expenses)),
|
||||
("Net income".to_string(), money(pl.net_income())),
|
||||
];
|
||||
|
||||
let ops = text_ops(
|
||||
"Profit and Loss",
|
||||
&format!("{} to {}", start, end),
|
||||
&rows,
|
||||
&totals,
|
||||
/// Typeset a statement into a document (pages, before PDF encoding).
|
||||
fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError> {
|
||||
let mut inputs = Dict::new();
|
||||
inputs.insert(
|
||||
"data".into(),
|
||||
template_data(st, meta).to_string().into_value(),
|
||||
);
|
||||
save_pdf("Profit and Loss", ops)
|
||||
engine()
|
||||
.compile_with_input(inputs)
|
||||
.output
|
||||
.map_err(|e| PdfError::Compile(format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Render a balance-sheet PDF: assets, liabilities, equity with balances,
|
||||
/// then totals proving the accounting equation.
|
||||
pub fn balance_sheet_pdf(ledger: &Ledger, as_of: &str) -> Result<Vec<u8>, PdfError> {
|
||||
let mut asset_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut liab_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut equity_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let balance = ledger
|
||||
.account_balance(account.id)
|
||||
.map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
if balance.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let label = format!("{} {}", account.code, account.name);
|
||||
match account.account_type {
|
||||
AccountType::Asset => asset_rows.push((label, money(balance))),
|
||||
AccountType::Liability => liab_rows.push((label, money(balance))),
|
||||
AccountType::Equity => equity_rows.push((label, money(balance))),
|
||||
_ => {}
|
||||
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
|
||||
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
||||
let doc = typeset(st, meta)?;
|
||||
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
|
||||
.map_err(|e| PdfError::Save(format!("{e:?}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::report::{Row, profit_and_loss};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{ProfitAndLossStatement, StatementLine};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn meta() -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
journal_seq: 42,
|
||||
}
|
||||
}
|
||||
let bs =
|
||||
ledger_core::reports::balance_sheet(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
|
||||
let mut rows = vec![("ASSETS".to_string(), "".to_string())];
|
||||
rows.extend(asset_rows);
|
||||
rows.push(("".to_string(), "".to_string()));
|
||||
rows.push(("LIABILITIES".to_string(), "".to_string()));
|
||||
rows.extend(liab_rows);
|
||||
rows.push(("".to_string(), "".to_string()));
|
||||
rows.push(("EQUITY".to_string(), "".to_string()));
|
||||
rows.extend(equity_rows);
|
||||
rows.push((
|
||||
"Current earnings (in equity)".to_string(),
|
||||
money(bs.current_earnings),
|
||||
));
|
||||
fn pl() -> ProfitAndLossStatement {
|
||||
let line = |code: &str, name: &str, cents| StatementLine {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
amount: Decimal::new(cents, 2),
|
||||
};
|
||||
ProfitAndLossStatement {
|
||||
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
|
||||
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
revenue: vec![],
|
||||
expenses: vec![
|
||||
line("6000", "Organizational Costs", 5900),
|
||||
line("6400", "Memberships & Licenses", 10638),
|
||||
],
|
||||
total_revenue: Decimal::ZERO,
|
||||
total_expenses: Decimal::new(16538, 2),
|
||||
}
|
||||
}
|
||||
|
||||
let totals = vec![
|
||||
("Total assets".to_string(), money(bs.assets)),
|
||||
(
|
||||
"Total liabilities + equity".to_string(),
|
||||
money(bs.liabilities + bs.equity),
|
||||
),
|
||||
];
|
||||
/// The exact data the template typesets: hand-checked, like a golden.
|
||||
#[test]
|
||||
fn template_data_is_exact() {
|
||||
let data = template_data(&profit_and_loss(&pl()), &meta());
|
||||
assert_eq!(
|
||||
data,
|
||||
serde_json::json!({
|
||||
"company": "Test Co",
|
||||
"title": "Profit and Loss",
|
||||
"period": "October 4 – October 11, 2026",
|
||||
"footer": "Prepared from journal through seq 42",
|
||||
"rows": [
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "amount": null},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount":
|
||||
{"digits": "", "negative": false, "zero": true, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "amount": null},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount":
|
||||
{"digits": "59.00", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount":
|
||||
{"digits": "106.38", "negative": false, "zero": false, "currency": false}},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": false, "zero": false, "currency": true}},
|
||||
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||
{"kind": "total", "label": "Net income", "rule": true, "amount":
|
||||
{"digits": "165.38", "negative": true, "zero": false, "currency": true}},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
let ops = text_ops("Balance Sheet", &format!("As of {}", as_of), &rows, &totals);
|
||||
save_pdf("Balance Sheet", ops)
|
||||
#[test]
|
||||
fn renders_a_pdf_deterministically() {
|
||||
let st = profit_and_loss(&pl());
|
||||
let a = statement_pdf(&st, &meta()).unwrap();
|
||||
assert!(a.starts_with(b"%PDF"));
|
||||
assert_eq!(a, statement_pdf(&st, &meta()).unwrap());
|
||||
}
|
||||
|
||||
/// Labels are data: Typst markup characters print literally, and a
|
||||
/// statement too long for one page flows onto more instead of being cut.
|
||||
#[test]
|
||||
fn markup_is_inert_and_long_statements_paginate() {
|
||||
let mut st = profit_and_loss(&pl());
|
||||
st.rows = (0..200)
|
||||
.map(|i| Row {
|
||||
kind: RowKind::Line,
|
||||
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
|
||||
amount: Some(Decimal::new(i, 0)),
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
})
|
||||
.collect();
|
||||
let doc = typeset(&st, &meta()).unwrap();
|
||||
assert!(doc.pages().len() > 1, "200 rows should span pages");
|
||||
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! The presentation model every statement renderer (XLSX, PDF) draws from.
|
||||
//!
|
||||
//! Core computes the statements; this module lays them out as rows —
|
||||
//! sections, account lines, subtotals, totals — once, so the formats can't
|
||||
//! drift apart. Amounts stay exact `Decimal`s here; [`format_amount`] is the
|
||||
//! one place they become text ("1,234.56", "(207.68)").
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{
|
||||
BalanceSheetStatement, CashFlowLine, CashFlowStatement, ProfitAndLossStatement, StatementLine,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Who the report is for and exactly what it was prepared from.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ReportMeta {
|
||||
/// Company name for the header, e.g. "Wanderland Labs LLC".
|
||||
pub company: String,
|
||||
/// Sequence number of the last journal line the report reflects.
|
||||
pub journal_seq: u64,
|
||||
}
|
||||
|
||||
/// What a row is, which decides how renderers style it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RowKind {
|
||||
/// A section heading ("Revenue"); no amount.
|
||||
Section,
|
||||
/// One account or one cash-flow line, indented under its section.
|
||||
Line,
|
||||
/// A section total ("Total revenue").
|
||||
Subtotal,
|
||||
/// A statement total ("Net income").
|
||||
Total,
|
||||
/// Vertical space between sections.
|
||||
Spacer,
|
||||
}
|
||||
|
||||
/// One row of a statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Row {
|
||||
/// Row kind.
|
||||
pub kind: RowKind,
|
||||
/// Label text.
|
||||
pub label: String,
|
||||
/// Amount, when the row has one.
|
||||
pub amount: Option<Decimal>,
|
||||
/// Print a currency sign with the amount: US accounting style puts "$"
|
||||
/// on the first amount under each section heading and on every
|
||||
/// subtotal and total.
|
||||
pub currency: bool,
|
||||
/// Draw a single rule above the amount: the "sum of the above" line.
|
||||
/// Totals always have one; a subtotal only when amounts sit above it
|
||||
/// (an empty section gets no rule over nothing).
|
||||
pub rule_above: bool,
|
||||
}
|
||||
|
||||
/// A laid-out financial statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Statement {
|
||||
/// "Profit and Loss", "Balance Sheet", "Statement of Cash Flows".
|
||||
pub title: String,
|
||||
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
|
||||
pub period: String,
|
||||
/// The rows, top to bottom.
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row {
|
||||
Row {
|
||||
kind,
|
||||
label: label.into(),
|
||||
amount,
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a statement: mark which amounts carry the currency sign and which
|
||||
/// get a rule above.
|
||||
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement {
|
||||
let mut first_in_section = true;
|
||||
let mut above: Option<RowKind> = None;
|
||||
for r in &mut rows {
|
||||
r.rule_above = match r.kind {
|
||||
RowKind::Total => true,
|
||||
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
|
||||
_ => false,
|
||||
};
|
||||
if r.kind != RowKind::Spacer {
|
||||
above = Some(r.kind);
|
||||
}
|
||||
match r.kind {
|
||||
RowKind::Section => first_in_section = true,
|
||||
RowKind::Subtotal | RowKind::Total => r.currency = true,
|
||||
RowKind::Line if r.amount.is_some() && first_in_section => {
|
||||
r.currency = true;
|
||||
first_in_section = false;
|
||||
}
|
||||
RowKind::Line | RowKind::Spacer => {}
|
||||
}
|
||||
}
|
||||
Statement {
|
||||
title: title.to_string(),
|
||||
period,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// An amount split for column alignment: the digits, and whether it is a
|
||||
/// negative (printed in parentheses) or zero (printed as a dash). Renderers
|
||||
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||
/// row, so decimal points line up whether or not a value is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AmountParts {
|
||||
/// "1,234.56" — absolute value, grouped, two decimals. Empty for zero.
|
||||
pub digits: String,
|
||||
/// Below zero.
|
||||
pub negative: bool,
|
||||
/// Exactly zero (after rounding to cents).
|
||||
pub zero: bool,
|
||||
}
|
||||
|
||||
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||
let rounded = d.round_dp(2);
|
||||
if rounded.is_zero() {
|
||||
return AmountParts {
|
||||
digits: String::new(),
|
||||
negative: false,
|
||||
zero: true,
|
||||
};
|
||||
}
|
||||
let text = format_amount(rounded.abs());
|
||||
AmountParts {
|
||||
digits: text,
|
||||
negative: rounded.is_sign_negative(),
|
||||
zero: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn section(label: &str) -> Row {
|
||||
row(RowKind::Section, label, None)
|
||||
}
|
||||
|
||||
fn spacer() -> Row {
|
||||
row(RowKind::Spacer, "", None)
|
||||
}
|
||||
|
||||
fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
|
||||
lines.iter().map(|l| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} {}", l.code, l.name),
|
||||
Some(l.amount),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn flow_lines(lines: &[CashFlowLine]) -> impl Iterator<Item = Row> + '_ {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| row(RowKind::Line, l.label.clone(), Some(l.amount)))
|
||||
}
|
||||
|
||||
/// Lay out a profit and loss statement.
|
||||
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
|
||||
let mut rows = vec![section("Revenue")];
|
||||
rows.extend(account_lines(&pl.revenue));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total revenue",
|
||||
Some(pl.total_revenue),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Expenses"));
|
||||
rows.extend(account_lines(&pl.expenses));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total expenses",
|
||||
Some(pl.total_expenses),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
|
||||
statement("Profit and Loss", period_label(pl.start, pl.end), rows)
|
||||
}
|
||||
|
||||
/// Lay out a balance sheet.
|
||||
pub fn balance_sheet(bs: &BalanceSheetStatement) -> Statement {
|
||||
let mut rows = vec![section("Assets")];
|
||||
rows.extend(account_lines(&bs.assets));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total assets",
|
||||
Some(bs.total_assets),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Liabilities"));
|
||||
rows.extend(account_lines(&bs.liabilities));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total liabilities",
|
||||
Some(bs.total_liabilities),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Equity"));
|
||||
rows.extend(account_lines(&bs.equity));
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Current earnings",
|
||||
Some(bs.current_earnings),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total equity",
|
||||
Some(bs.total_equity),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Total liabilities and equity",
|
||||
Some(bs.total_liabilities + bs.total_equity),
|
||||
));
|
||||
statement(
|
||||
"Balance Sheet",
|
||||
format!("As of {}", long_date(bs.as_of)),
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out a cash flow statement (indirect method).
|
||||
pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
||||
let mut rows = vec![section("Operating activities")];
|
||||
rows.push(row(RowKind::Line, "Net income", Some(cf.net_income)));
|
||||
if !cf.depreciation.is_zero() {
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Depreciation (non-cash)",
|
||||
Some(cf.depreciation),
|
||||
));
|
||||
}
|
||||
rows.extend(flow_lines(&cf.working_capital));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from operating activities",
|
||||
Some(cf.cash_from_operations),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Investing activities"));
|
||||
rows.extend(flow_lines(&cf.investing));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from investing activities",
|
||||
Some(cf.cash_from_investing),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Financing activities"));
|
||||
rows.extend(flow_lines(&cf.financing));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from financing activities",
|
||||
Some(cf.cash_from_financing),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Net change in cash",
|
||||
Some(cf.net_change_in_cash),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Cash at beginning of period",
|
||||
Some(cf.cash_beginning),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Cash at end of period",
|
||||
Some(cf.cash_ending),
|
||||
));
|
||||
statement(
|
||||
"Statement of Cash Flows",
|
||||
period_label(cf.start, cf.end),
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// "October 11, 2026".
|
||||
pub fn long_date(d: NaiveDate) -> String {
|
||||
d.format("%B %-d, %Y").to_string()
|
||||
}
|
||||
|
||||
/// "October 4 – October 11, 2026", or with both years when they differ.
|
||||
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
|
||||
use chrono::Datelike;
|
||||
if start.year() == end.year() {
|
||||
format!("{} – {}", start.format("%B %-d"), long_date(end))
|
||||
} else {
|
||||
format!("{} – {}", long_date(start), long_date(end))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact accounting format: thousands separators, two decimals, negatives
|
||||
/// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float.
|
||||
pub fn format_amount(d: Decimal) -> String {
|
||||
let negative = d.is_sign_negative() && !d.is_zero();
|
||||
let fixed = format!("{:.2}", d.abs());
|
||||
let (int, frac) = fixed.split_once('.').expect("two decimals");
|
||||
let mut grouped = String::new();
|
||||
for (i, c) in int.chars().enumerate() {
|
||||
if i > 0 && (int.len() - i) % 3 == 0 {
|
||||
grouped.push(',');
|
||||
}
|
||||
grouped.push(c);
|
||||
}
|
||||
if negative {
|
||||
format!("({grouped}.{frac})")
|
||||
} else {
|
||||
format!("{grouped}.{frac}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts_format_exactly() {
|
||||
assert_eq!(format_amount(dec("0")), "0.00");
|
||||
assert_eq!(format_amount(dec("-0")), "0.00");
|
||||
assert_eq!(format_amount(dec("5.8")), "5.80");
|
||||
assert_eq!(format_amount(dec("207.68")), "207.68");
|
||||
assert_eq!(format_amount(dec("-207.68")), "(207.68)");
|
||||
assert_eq!(format_amount(dec("1000")), "1,000.00");
|
||||
assert_eq!(format_amount(dec("1234567.891")), "1,234,567.89");
|
||||
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts_split_for_alignment() {
|
||||
let p = amount_parts(dec("-1234.5"));
|
||||
assert_eq!(
|
||||
(p.digits.as_str(), p.negative, p.zero),
|
||||
("1,234.50", true, false)
|
||||
);
|
||||
assert!(amount_parts(dec("0.004")).zero);
|
||||
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_signs_go_on_first_lines_and_totals() {
|
||||
let rows = vec![
|
||||
section("Assets"),
|
||||
row(RowKind::Line, "a", Some(dec("1"))),
|
||||
row(RowKind::Line, "b", Some(dec("2"))),
|
||||
row(RowKind::Subtotal, "t", Some(dec("3"))),
|
||||
spacer(),
|
||||
section("Equity"),
|
||||
row(RowKind::Line, "c", Some(dec("3"))),
|
||||
row(RowKind::Total, "T", Some(dec("3"))),
|
||||
];
|
||||
let st = statement("X", String::new(), rows);
|
||||
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
|
||||
assert_eq!(signs, [false, true, false, true, false, false, true, true]);
|
||||
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
|
||||
assert_eq!(
|
||||
rules,
|
||||
[false, false, false, true, false, false, false, true]
|
||||
);
|
||||
// A subtotal straight under its heading (empty section): no rule.
|
||||
let empty = statement(
|
||||
"Y",
|
||||
String::new(),
|
||||
vec![
|
||||
section("Investing"),
|
||||
row(RowKind::Subtotal, "t", Some(dec("0"))),
|
||||
],
|
||||
);
|
||||
assert!(!empty.rows[1].rule_above);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periods_read_like_english() {
|
||||
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
|
||||
assert_eq!(
|
||||
period_label(d(2026, 10, 4), d(2026, 10, 11)),
|
||||
"October 4 – October 11, 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
period_label(d(2026, 12, 1), d(2027, 1, 31)),
|
||||
"December 1, 2026 – January 31, 2027"
|
||||
);
|
||||
}
|
||||
}
|
||||
+136
-161
@@ -2,12 +2,15 @@
|
||||
//!
|
||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
||||
//! when writing a cell, rounded to cents.
|
||||
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
|
||||
//! sum them) with an accounting number format: thousands separators,
|
||||
//! negatives in parentheses.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement};
|
||||
use ledger_core::Ledger;
|
||||
use ledger_core::reports::{BalanceSheet, CashFlowStatement, ProfitAndLoss, TrialBalance};
|
||||
use ledger_core::reports::TrialBalance;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_xlsxwriter::{Format, Workbook};
|
||||
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Which report to render.
|
||||
@@ -15,12 +18,9 @@ use thiserror::Error;
|
||||
pub enum XlsxReport<'a> {
|
||||
/// Trial balance lines.
|
||||
TrialBalance(&'a TrialBalance),
|
||||
/// Profit and loss.
|
||||
ProfitAndLoss(&'a ProfitAndLoss, String, String),
|
||||
/// Balance sheet.
|
||||
BalanceSheet(&'a BalanceSheet, String),
|
||||
/// Cash flow statement.
|
||||
CashFlow(&'a CashFlowStatement),
|
||||
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
|
||||
/// [`crate::report`].
|
||||
Statement(&'a Statement),
|
||||
/// General ledger: every posted journal line. Needs the ledger for
|
||||
/// account names and class names.
|
||||
GeneralLedger(&'a Ledger),
|
||||
@@ -34,155 +34,133 @@ pub enum XlsxError {
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl From<rust_xlsxwriter::XlsxError> for XlsxError {
|
||||
fn from(e: rust_xlsxwriter::XlsxError) -> Self {
|
||||
XlsxError::Write(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's Accounting format, with and without the currency symbol: "$"
|
||||
/// pinned left, digits right, negatives in parentheses with the closing
|
||||
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
|
||||
/// points line up), zero as a dash.
|
||||
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
|
||||
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
|
||||
|
||||
/// Company name, title, and period across the top; returns the next row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: Option<&str>,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let bold = Format::new().set_bold();
|
||||
sheet.write_string_with_format(0, 0, &meta.company, &bold.clone().set_font_size(14))?;
|
||||
sheet.write_string_with_format(1, 0, title, &bold)?;
|
||||
let mut row = 2;
|
||||
if let Some(period) = period {
|
||||
sheet.write_string(row, 0, period)?;
|
||||
row += 1;
|
||||
}
|
||||
Ok(row + 1)
|
||||
}
|
||||
|
||||
/// "Prepared from journal through seq N" under the last row.
|
||||
fn footer(sheet: &mut Worksheet, meta: &ReportMeta, row: u32) -> Result<(), XlsxError> {
|
||||
let note = Format::new().set_italic().set_font_color("#666666");
|
||||
sheet.write_string_with_format(
|
||||
row + 1,
|
||||
0,
|
||||
format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
¬e,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a report to XLSX bytes.
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
let bold = Format::new().set_bold();
|
||||
let money_fmt = Format::new().set_num_format("#,##0.00");
|
||||
let money = Format::new().set_num_format(ACCOUNTING);
|
||||
let usd = Format::new().set_num_format(ACCOUNTING_USD);
|
||||
// Totals are marked by rules, not bold: bold figures are wider and would
|
||||
// break the decimal column.
|
||||
let usd_bold = usd.clone();
|
||||
|
||||
let mut row: u32 = 0;
|
||||
match report {
|
||||
XlsxReport::TrialBalance(tb) => {
|
||||
sheet.write_string(row, 0, "Trial Balance").unwrap();
|
||||
row += 1;
|
||||
sheet.set_name("Trial Balance")?;
|
||||
let mut row = header(sheet, meta, "Trial Balance", None)?;
|
||||
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
||||
sheet
|
||||
.write_string_with_format(row, i as u16, *h, &bold)
|
||||
.unwrap();
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
}
|
||||
row += 1;
|
||||
for line in &tb.lines {
|
||||
sheet.write_string(row, 0, &line.code).unwrap();
|
||||
sheet.write_string(row, 1, &line.name).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 2, dec_to_f64(line.debit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 3, dec_to_f64(line.credit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet.write_string(row, 0, &line.code)?;
|
||||
sheet.write_string(row, 1, &line.name)?;
|
||||
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
|
||||
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
||||
row += 1;
|
||||
}
|
||||
let total = usd_bold
|
||||
.clone()
|
||||
.set_border_top(FormatBorder::Thin)
|
||||
.set_border_bottom(FormatBorder::Double);
|
||||
sheet.write_string_with_format(row, 1, "Total", &bold)?;
|
||||
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
|
||||
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
|
||||
sheet.set_column_width(1, 40)?;
|
||||
sheet.set_column_width(2, 14)?;
|
||||
sheet.set_column_width(3, 14)?;
|
||||
footer(sheet, meta, row + 1)?;
|
||||
}
|
||||
XlsxReport::ProfitAndLoss(pl, start, end) => {
|
||||
sheet
|
||||
.write_string(row, 0, format!("Profit and Loss ({start} to {end})"))
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet.write_string(row, 0, "Revenue").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(pl.revenue), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet.write_string(row, 0, "Expenses").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(pl.expenses), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Net income", &bold)
|
||||
.unwrap();
|
||||
let net = pl.revenue - pl.expenses;
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(net), &money_fmt)
|
||||
.unwrap();
|
||||
}
|
||||
XlsxReport::BalanceSheet(bs, as_of) => {
|
||||
sheet
|
||||
.write_string(row, 0, format!("Balance Sheet (as of {as_of})"))
|
||||
.unwrap();
|
||||
row += 2;
|
||||
for (label, val) in [
|
||||
("Assets", bs.assets),
|
||||
("Liabilities", bs.liabilities),
|
||||
("Equity", bs.equity),
|
||||
] {
|
||||
sheet.write_string(row, 0, label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(val), &money_fmt)
|
||||
.unwrap();
|
||||
XlsxReport::Statement(st) => {
|
||||
sheet.set_name(&st.title)?;
|
||||
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
||||
let line_label = Format::new().set_indent(1);
|
||||
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
|
||||
let total = usd_bold
|
||||
.clone()
|
||||
.set_border_top(FormatBorder::Thin)
|
||||
.set_border_bottom(FormatBorder::Double);
|
||||
for r in &st.rows {
|
||||
let amount = r.amount.map(dec_to_f64);
|
||||
match r.kind {
|
||||
RowKind::Spacer => {}
|
||||
RowKind::Section => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
}
|
||||
RowKind::Line => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
||||
if let Some(a) = amount {
|
||||
let fmt = if r.currency { &usd } else { &money };
|
||||
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||
}
|
||||
}
|
||||
RowKind::Subtotal => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
if let Some(a) = amount {
|
||||
let fmt = if r.rule_above { &subtotal } else { &usd_bold };
|
||||
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||
}
|
||||
}
|
||||
RowKind::Total => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
if let Some(a) = amount {
|
||||
sheet.write_number_with_format(row, 1, a, &total)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
XlsxReport::CashFlow(cf) => {
|
||||
sheet
|
||||
.write_string(row, 0, format!("Cash Flow ({} to {})", cf.start, cf.end))
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Operating activities", &bold)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet.write_string(row, 0, "Net income").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.net_income), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet.write_string(row, 0, "Depreciation").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.depreciation), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
for line in &cf.working_capital {
|
||||
sheet.write_string(row, 0, &line.label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
}
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Cash from operations", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_operations), &money_fmt)
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Investing activities", &bold)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
for line in &cf.investing {
|
||||
sheet.write_string(row, 0, &line.label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
}
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Cash from investing", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_investing), &money_fmt)
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Financing activities", &bold)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
for line in &cf.financing {
|
||||
sheet.write_string(row, 0, &line.label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
}
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Cash from financing", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_financing), &money_fmt)
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Net change in cash", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.net_change_in_cash), &money_fmt)
|
||||
.unwrap();
|
||||
sheet.set_column_width(0, 60)?;
|
||||
sheet.set_column_width(1, 16)?;
|
||||
footer(sheet, meta, row)?;
|
||||
}
|
||||
XlsxReport::GeneralLedger(ledger) => {
|
||||
sheet.write_string(row, 0, "General Ledger").unwrap();
|
||||
row += 1;
|
||||
sheet.set_name("General Ledger")?;
|
||||
let mut row = header(sheet, meta, "General Ledger", None)?;
|
||||
for (i, h) in [
|
||||
"Date",
|
||||
"Entry",
|
||||
@@ -196,9 +174,7 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
sheet
|
||||
.write_string_with_format(row, i as u16, *h, &bold)
|
||||
.unwrap();
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
}
|
||||
row += 1;
|
||||
for entry in ledger.posted_entries() {
|
||||
@@ -217,29 +193,24 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
||||
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
|
||||
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
||||
};
|
||||
sheet
|
||||
.write_string(row, 0, entry.date().to_string())
|
||||
.unwrap();
|
||||
sheet.write_number(row, 1, entry.id() as f64).unwrap();
|
||||
sheet.write_string(row, 2, entry.description()).unwrap();
|
||||
sheet.write_string(row, 3, code).unwrap();
|
||||
sheet.write_string(row, 4, name).unwrap();
|
||||
sheet.write_string(row, 5, class_name).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 6, dec_to_f64(debit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 7, dec_to_f64(credit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||
sheet.write_string(row, 2, entry.description())?;
|
||||
sheet.write_string(row, 3, code)?;
|
||||
sheet.write_string(row, 4, name)?;
|
||||
sheet.write_string(row, 5, class_name)?;
|
||||
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
|
||||
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_column_width(2, 50)?;
|
||||
sheet.set_column_width(4, 32)?;
|
||||
footer(sheet, meta, row)?;
|
||||
}
|
||||
}
|
||||
|
||||
workbook
|
||||
.save_to_buffer()
|
||||
.map_err(|e| XlsxError::Write(e.to_string()))
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
}
|
||||
|
||||
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
||||
@@ -265,7 +236,11 @@ mod tests {
|
||||
total_debits: Decimal::new(10000, 2),
|
||||
total_credits: Decimal::new(10000, 2),
|
||||
};
|
||||
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).unwrap();
|
||||
let meta = ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
journal_seq: 7,
|
||||
};
|
||||
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta).unwrap();
|
||||
// XLSX is a ZIP: starts with PK.
|
||||
assert_eq!(&bytes[0..2], b"PK");
|
||||
assert!(bytes.len() > 1000);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// A financial statement: P&L, balance sheet, or cash flow.
|
||||
//
|
||||
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
|
||||
// ledger-import::report, with amounts already split into exact parts. The
|
||||
// template only typesets: no arithmetic happens here, and no label is ever
|
||||
// parsed as markup.
|
||||
//
|
||||
// Shape: { company, title, period, footer,
|
||||
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
|
||||
// | "spacer", label, rule (single rule above),
|
||||
// amount: null | { digits: "1,234.56", negative, zero,
|
||||
// currency } }] }
|
||||
//
|
||||
// Amounts follow US accounting presentation: tabular figures, "$" pinned
|
||||
// to the left of the amount column on the first line of each section and on
|
||||
// totals, negatives in parentheses with the closing parenthesis hanging
|
||||
// (its width is reserved on every row, so decimal points always line up),
|
||||
// and zero as a dash in the ones position. Amounts are never bold: bold
|
||||
// figures are wider, which would knock totals out of the decimal column —
|
||||
// the rules mark subtotals and totals instead.
|
||||
|
||||
#let d = json(bytes(sys.inputs.data))
|
||||
|
||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
||||
#set text(
|
||||
font: "Libertinus Serif",
|
||||
size: 10.5pt,
|
||||
number-type: "lining",
|
||||
number-width: "tabular",
|
||||
)
|
||||
#set page(
|
||||
paper: "us-letter",
|
||||
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
|
||||
header: context {
|
||||
if counter(page).get().first() > 1 {
|
||||
set text(8.5pt, fill: luma(110))
|
||||
[#d.company — #d.title #h(1fr) #d.period]
|
||||
}
|
||||
},
|
||||
footer: context {
|
||||
set text(8pt, fill: luma(110))
|
||||
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
|
||||
},
|
||||
)
|
||||
|
||||
#align(center)[
|
||||
#text(15pt, weight: "bold", d.company) \
|
||||
#v(2pt)
|
||||
#text(12.5pt, d.title) \
|
||||
#v(1pt)
|
||||
#text(10pt, style: "italic", d.period)
|
||||
]
|
||||
#v(16pt)
|
||||
|
||||
// Two lines under a statement total, the accounting double rule, set
|
||||
// clear of the descenders.
|
||||
#let double-rule(body) = stack(
|
||||
body,
|
||||
v(2.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
v(1.4pt),
|
||||
line(length: 100%, stroke: 0.5pt),
|
||||
)
|
||||
|
||||
#let amount(r) = {
|
||||
let a = r.amount
|
||||
if a == none { return [] }
|
||||
let open = if a.negative { [(] } else { [] }
|
||||
let close = if a.negative { [)] } else { hide[)] }
|
||||
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
|
||||
grid(
|
||||
columns: (auto, 1fr),
|
||||
align: (left, right),
|
||||
if a.currency [\$] else [],
|
||||
[#body#close],
|
||||
)
|
||||
}
|
||||
|
||||
#table(
|
||||
columns: (1fr, 9em),
|
||||
stroke: none,
|
||||
inset: (x: 0pt, y: 3.2pt),
|
||||
align: (left + horizon, right + horizon),
|
||||
..d
|
||||
.rows
|
||||
.map(r => {
|
||||
if r.kind == "spacer" {
|
||||
(table.cell(colspan: 2, v(3pt)),)
|
||||
} else if r.kind == "section" {
|
||||
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
|
||||
} else if r.kind == "line" {
|
||||
(pad(left: 1.4em, r.label), amount(r))
|
||||
} else if r.kind == "subtotal" {
|
||||
(
|
||||
text(weight: "bold", r.label),
|
||||
table.cell(
|
||||
stroke: if r.rule { (top: 0.5pt) } else { none },
|
||||
amount(r),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
align(top, text(weight: "bold", r.label)),
|
||||
table.cell(
|
||||
stroke: (top: 0.5pt),
|
||||
double-rule(amount(r)),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
.flatten(),
|
||||
)
|
||||
@@ -27,6 +27,7 @@ chrono = "0.4"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
|
||||
zip = "9.0.1"
|
||||
toml = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Company settings from the books dir's `wanderbooks.toml`.
|
||||
//!
|
||||
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
|
||||
//! fills in `[company]`. The server reads the company name (report headers),
|
||||
//! the fiscal year start (fiscal-year-to-date reports), and
|
||||
//! `[books] receipt_threshold` (the receipt policy, unless `config.json`
|
||||
//! sets one). The fiscal year *end* is never stored: it is the day before
|
||||
//! the next start, so the two can't disagree.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// The company the books belong to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Company {
|
||||
/// Legal name, for report headers.
|
||||
pub name: String,
|
||||
/// Month and day the fiscal year starts. Default January 1.
|
||||
pub fiscal_year_start: (u32, u32),
|
||||
}
|
||||
|
||||
impl Default for Company {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Unnamed company".to_string(),
|
||||
fiscal_year_start: (1, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Company {
|
||||
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
|
||||
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
|
||||
let (m, day) = self.fiscal_year_start;
|
||||
let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day");
|
||||
let start = if on(d.year()) <= d {
|
||||
on(d.year())
|
||||
} else {
|
||||
on(d.year() - 1)
|
||||
};
|
||||
let end = on(start.year() + 1)
|
||||
.pred_opt()
|
||||
.expect("not the minimum date");
|
||||
(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server takes from `wanderbooks.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BooksSettings {
|
||||
/// `[company]`.
|
||||
pub company: Company,
|
||||
/// `[books] receipt_threshold`, validated as a decimal string.
|
||||
pub receipt_threshold: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BooksToml {
|
||||
#[serde(default)]
|
||||
company: CompanyToml,
|
||||
#[serde(default)]
|
||||
books: BooksSection,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct CompanyToml {
|
||||
name: Option<String>,
|
||||
fiscal_year_start: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct BooksSection {
|
||||
receipt_threshold: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
|
||||
/// day every year has.
|
||||
fn parse_month_day(s: &str) -> Result<(u32, u32), String> {
|
||||
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\"");
|
||||
let (m, d) = s.split_once('-').ok_or_else(bad)?;
|
||||
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
|
||||
// 2001 is not a leap year: a valid date there exists every year.
|
||||
NaiveDate::from_ymd_opt(2001, m, d)
|
||||
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?;
|
||||
Ok((m, d))
|
||||
}
|
||||
|
||||
/// Parse `wanderbooks.toml` text.
|
||||
pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut company = Company::default();
|
||||
if let Some(name) = raw.company.name.filter(|n| !n.trim().is_empty()) {
|
||||
company.name = name.trim().to_string();
|
||||
}
|
||||
if let Some(fy) = raw.company.fiscal_year_start {
|
||||
company.fiscal_year_start = parse_month_day(fy.trim())?;
|
||||
}
|
||||
let receipt_threshold = match raw.books.receipt_threshold {
|
||||
Some(t) => {
|
||||
t.trim()
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?;
|
||||
Some(t.trim().to_string())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(BooksSettings {
|
||||
company,
|
||||
receipt_threshold,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load `wanderbooks.toml` from a path.
|
||||
pub fn load(path: &Path) -> Result<BooksSettings, String> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
parse(&text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_year_by_default() {
|
||||
let c = parse("").unwrap().company;
|
||||
assert_eq!(c.fiscal_year_start, (1, 1));
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 10, 11)),
|
||||
(d(2026, 1, 1), d(2026, 12, 31))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 1, 1)),
|
||||
(d(2026, 1, 1), d(2026, 12, 31))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_july_fiscal_year_spans_two_calendar_years() {
|
||||
let c = parse("[company]\nfiscal_year_start = \"07-01\"\n")
|
||||
.unwrap()
|
||||
.company;
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 3, 15)),
|
||||
(d(2025, 7, 1), d(2026, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 7, 1)),
|
||||
(d(2026, 7, 1), d(2027, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 6, 30)),
|
||||
(d(2025, 7, 1), d(2026, 6, 30))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_scaffolded_file() {
|
||||
let s = parse(
|
||||
"[company]\nname = \"Wanderland Labs LLC\"\nentity_type = \"x\"\nfiscal_year_start = \"01-01\"\n\n[books]\ndata_dir = \"books-data\"\nreceipt_threshold = \"25.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.company.name, "Wanderland Labs LLC");
|
||||
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
|
||||
// An empty name (fresh scaffold) keeps the placeholder.
|
||||
assert_eq!(
|
||||
parse("[company]\nname = \"\"\n").unwrap().company.name,
|
||||
"Unnamed company"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_settings() {
|
||||
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
|
||||
assert!(parse("[company]\nfiscal_year_start = \"13-01\"\n").is_err());
|
||||
assert!(parse("[company]\nfiscal_year_start = \"Jan 1\"\n").is_err());
|
||||
assert!(parse("[books]\nreceipt_threshold = \"25 dollars\"\n").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
|
||||
//!
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH]`
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
|
||||
//!
|
||||
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||
//! and optionally `config.json`. If no store exists, one is created with the
|
||||
//! configured base currency.
|
||||
//! configured base currency. Company settings (name, fiscal year start,
|
||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||
//! default the one next to the data dir.
|
||||
|
||||
mod company;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod types;
|
||||
@@ -25,9 +28,10 @@ struct Config {
|
||||
/// ISO 4217 base currency for a new store. Default "USD".
|
||||
#[serde(default)]
|
||||
base_currency: Option<String>,
|
||||
/// Receipt policy. Defaults to require-receipts-above $25.00.
|
||||
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
|
||||
/// with neither, receipts are required above $25.00.
|
||||
#[serde(default)]
|
||||
receipt_policy: ReceiptPolicy,
|
||||
receipt_policy: Option<ReceiptPolicy>,
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
@@ -36,15 +40,23 @@ fn default_data_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!("usage: ledger-mcp [--data-dir PATH] [--config PATH] [--http ADDR]");
|
||||
eprintln!(
|
||||
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR]"
|
||||
);
|
||||
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
||||
eprintln!(" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio");
|
||||
eprintln!(
|
||||
" --books-toml company settings (default <data-dir>/../wanderbooks.toml, optional)"
|
||||
);
|
||||
eprintln!(
|
||||
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
|
||||
);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir = default_data_dir();
|
||||
let mut config_path: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut http_addr: Option<String> = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
@@ -55,6 +67,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"--config" => {
|
||||
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
|
||||
}
|
||||
"--books-toml" => {
|
||||
books_toml = Some(PathBuf::from(
|
||||
args.next().ok_or("--books-toml needs a path")?,
|
||||
));
|
||||
}
|
||||
"--http" => {
|
||||
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
||||
}
|
||||
@@ -79,6 +96,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
// Logs go to stderr: stdout is the MCP channel.
|
||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
||||
// An explicit --books-toml must exist; the default is optional.
|
||||
let settings = match books_toml {
|
||||
Some(path) => company::load(&path)?,
|
||||
None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) {
|
||||
Some(path) if path.exists() => company::load(&path)?,
|
||||
_ => company::BooksSettings::default(),
|
||||
},
|
||||
};
|
||||
eprintln!("ledger-mcp: company {:?}", settings.company.name);
|
||||
let policy = match (config.receipt_policy, settings.receipt_threshold) {
|
||||
(Some(policy), _) => policy,
|
||||
(None, Some(threshold)) => ReceiptPolicy {
|
||||
require_receipts_above: threshold,
|
||||
..ReceiptPolicy::default()
|
||||
},
|
||||
(None, None) => ReceiptPolicy::default(),
|
||||
};
|
||||
let company = settings.company;
|
||||
|
||||
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||
Store::open(&data_dir)?
|
||||
@@ -99,12 +134,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.build()?;
|
||||
if let Some(addr) = http_addr {
|
||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
|
||||
rt.block_on(serve_http(addr, store, config.receipt_policy))?;
|
||||
rt.block_on(serve_http(addr, store, policy, company))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||
let server = BooksServer::new(store, config.receipt_policy);
|
||||
let server = BooksServer::new(store, policy).with_company(company);
|
||||
rt.block_on(async {
|
||||
let running = server.serve(stdio()).await?;
|
||||
let _ = running.waiting().await;
|
||||
@@ -117,6 +152,7 @@ async fn serve_http(
|
||||
addr: String,
|
||||
store: Store,
|
||||
policy: ReceiptPolicy,
|
||||
company: company::Company,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
@@ -126,7 +162,8 @@ async fn serve_http(
|
||||
let store = Arc::new(std::sync::Mutex::new(store));
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
let server = server::BooksServer::new_shared(store.clone(), policy.clone());
|
||||
let server = server::BooksServer::new_shared(store.clone(), policy.clone())
|
||||
.with_company(company.clone());
|
||||
Ok(server)
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
|
||||
+430
-290
@@ -1,14 +1,16 @@
|
||||
//! The MCP server: narrow typed tools over the store, with the receipt
|
||||
//! policy enforced server-side.
|
||||
|
||||
use crate::company::Company;
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use crate::types::*;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
||||
depreciation_schedule, reports,
|
||||
};
|
||||
use ledger_import::ReportMeta;
|
||||
use ledger_store::Store;
|
||||
use rmcp::handler::server::wrapper::{Json, Parameters};
|
||||
use rmcp::model::{
|
||||
@@ -21,7 +23,7 @@ use rust_decimal::Decimal;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The agent operating contract, delivered via initialize.
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.";
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.";
|
||||
|
||||
/// Max receipt/document upload: 25 MB.
|
||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
@@ -33,6 +35,7 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
pub struct BooksServer {
|
||||
store: Arc<Mutex<Store>>,
|
||||
policy: ReceiptPolicy,
|
||||
company: Company,
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
@@ -41,13 +44,32 @@ impl BooksServer {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(store)),
|
||||
policy,
|
||||
company: Company::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the server around a shared store (for HTTP: each connection
|
||||
/// gets a handle to the same store).
|
||||
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
|
||||
Self { store, policy }
|
||||
Self {
|
||||
store,
|
||||
policy,
|
||||
company: Company::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the company (name for report headers, fiscal year).
|
||||
pub fn with_company(mut self, company: Company) -> Self {
|
||||
self.company = company;
|
||||
self
|
||||
}
|
||||
|
||||
/// Header and provenance for a rendered report.
|
||||
fn report_meta(&self, store: &Store) -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: self.company.name.clone(),
|
||||
journal_seq: store.journal_seq(),
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
|
||||
@@ -55,6 +77,43 @@ impl BooksServer {
|
||||
.lock()
|
||||
.map_err(|_| "store lock poisoned".to_string())
|
||||
}
|
||||
|
||||
/// Fail with the structured `receipt_required` error when the receipt
|
||||
/// policy requires this posted entry to keep at least one receipt.
|
||||
/// Drafts pass (posting enforces the policy later); reversals pass (they
|
||||
/// are exempt: the original carries the evidence).
|
||||
fn ensure_receipt_not_required(&self, store: &Store, entry_id: u64) -> Result<(), String> {
|
||||
let entry = store
|
||||
.ledger()
|
||||
.get_entry(entry_id)
|
||||
.ok_or_else(|| format!("unknown entry {entry_id}"))?;
|
||||
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let split_group = self.policy.split_group(store.ledger(), entry_id, entry);
|
||||
if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let total = ReceiptPolicy::entry_debits(entry);
|
||||
Err(ReceiptRequiredError {
|
||||
code: "receipt_required".to_string(),
|
||||
message: format!(
|
||||
"entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.",
|
||||
money(total),
|
||||
self.policy.describe()
|
||||
),
|
||||
entry_id: Some(entry_id),
|
||||
asset_id: None,
|
||||
debits: total.to_string(),
|
||||
policy: self.policy.describe(),
|
||||
remedy_tool: "books.attach_receipt".to_string(),
|
||||
remedy_hint: format!(
|
||||
"attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt"
|
||||
),
|
||||
split_group,
|
||||
}
|
||||
.to_tool_error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -145,8 +204,14 @@ fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A money amount as a decimal string with at least two places: "0.00",
|
||||
/// "207.68", "1000.00". Padding only, never rounding: an exact amount with
|
||||
/// more places (an FX-converted line) keeps them. Never "-0".
|
||||
fn money(n: Decimal) -> String {
|
||||
// Decimal's Display never emits floats or "-0" for exact values.
|
||||
let mut n = if n.is_zero() { Decimal::ZERO } else { n };
|
||||
if n.scale() < 2 {
|
||||
n.rescale(2);
|
||||
}
|
||||
n.to_string()
|
||||
}
|
||||
|
||||
@@ -219,50 +284,53 @@ fn preview_receipts(store: &Store, entry_id: u64) -> Result<Vec<PreviewReceipt>,
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Net movement of one account over entries on or before `through`, in the
|
||||
/// account's normal side.
|
||||
fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) -> Decimal {
|
||||
let movement: Decimal = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() <= through)
|
||||
.flat_map(|e| e.lines())
|
||||
.filter(|l| l.account == account)
|
||||
.map(|l| match l.side {
|
||||
Side::Debit => l.amount.amount,
|
||||
Side::Credit => -l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
let account = ledger.chart().get(account).expect("account exists");
|
||||
match account.account_type.normal_side() {
|
||||
Side::Debit => movement,
|
||||
Side::Credit => -movement,
|
||||
/// Build the cash flow statement from account codes, as the exports need it:
|
||||
/// `start` defaults to the beginning of time, `end` to today, and at least
|
||||
/// one cash account is required (the statement reconciles to them).
|
||||
fn cash_flow_statement(
|
||||
ledger: &Ledger,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
cash_codes: &[String],
|
||||
financing_codes: &[String],
|
||||
) -> Result<ledger_core::reports::CashFlowStatement, String> {
|
||||
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, String> {
|
||||
codes
|
||||
.iter()
|
||||
.map(|code| {
|
||||
ledger
|
||||
.chart()
|
||||
.find_by_code(code)
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("unknown {what} account code: {code}"))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let cash_accounts = resolve(cash_codes, "cash")?;
|
||||
if cash_accounts.is_empty() {
|
||||
return Err("cash_accounts is required for the cash flow statement".to_string());
|
||||
}
|
||||
let financing_accounts = resolve(financing_codes, "financing")?;
|
||||
let (start, end) = statement_range(ledger, start, end)?;
|
||||
ledger_core::cash_flow(
|
||||
ledger,
|
||||
&ledger_core::CashFlowParams {
|
||||
start,
|
||||
end,
|
||||
cash_accounts,
|
||||
financing_accounts,
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Net movement of one account over entries in `[from, to]`, normal side.
|
||||
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal {
|
||||
movement_in_class(ledger, account, from, to, None)
|
||||
}
|
||||
|
||||
/// Signed movement of an account in its normal side over a date range,
|
||||
/// optionally filtered to one class. `None` class matches all lines
|
||||
/// (including untagged ones); `Some(id)` matches only lines tagged with it.
|
||||
fn movement_in_class(
|
||||
ledger: &Ledger,
|
||||
account: AccountId,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
class_id: Option<u32>,
|
||||
) -> Decimal {
|
||||
let movement: Decimal = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() >= from && e.date() <= to)
|
||||
.flat_map(|e| e.lines())
|
||||
.filter(|l| l.account == account)
|
||||
.filter(|l| match class_id {
|
||||
None => true,
|
||||
Some(cid) => l.class_id == Some(cid),
|
||||
})
|
||||
.map(|l| match l.side {
|
||||
Side::Debit => l.amount.amount,
|
||||
Side::Credit => -l.amount.amount,
|
||||
@@ -275,6 +343,70 @@ fn movement_in_class(
|
||||
}
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
chrono::Utc::now().date_naive()
|
||||
}
|
||||
|
||||
/// Resolve a statement's date range: `end` defaults to today and `start` to
|
||||
/// the first posted entry (where "from the beginning" really begins, so no
|
||||
/// statement says "0001-01-01"), or to `end` for empty books.
|
||||
fn statement_range(
|
||||
ledger: &Ledger,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<(NaiveDate, NaiveDate), String> {
|
||||
let end = match end {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
let start = match start {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => ledger
|
||||
.posted_entries()
|
||||
.map(|e| e.date())
|
||||
.min()
|
||||
.map_or(end, |first| first.min(end)),
|
||||
};
|
||||
if end < start {
|
||||
return Err("end is before start".to_string());
|
||||
}
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
/// Profit and loss for a range (see [`statement_range`]), optionally one class.
|
||||
fn pnl_statement(
|
||||
ledger: &Ledger,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
class_id: Option<ledger_core::ClassId>,
|
||||
) -> Result<ledger_core::ProfitAndLossStatement, String> {
|
||||
let (start, end) = statement_range(ledger, start, end)?;
|
||||
ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Balance sheet as of a date (default today).
|
||||
fn bs_statement(
|
||||
ledger: &Ledger,
|
||||
as_of: Option<&str>,
|
||||
) -> Result<ledger_core::BalanceSheetStatement, String> {
|
||||
let as_of = match as_of {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| StatementLineOut {
|
||||
code: l.code.clone(),
|
||||
name: l.name.clone(),
|
||||
amount: money(l.amount),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -372,44 +504,24 @@ impl BooksServer {
|
||||
) -> Result<Json<ProfitAndLossOut>, String> {
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let start = match p.start {
|
||||
Some(s) => parse_date(&s)?,
|
||||
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
|
||||
};
|
||||
let end = match p.end {
|
||||
Some(s) => parse_date(&s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
};
|
||||
if end < start {
|
||||
return Err("end is before start".to_string());
|
||||
}
|
||||
let class_id = match p.class {
|
||||
Some(c) => Some(resolve_class(&store, &c)?),
|
||||
let class_id = match &p.class {
|
||||
Some(c) => Some(resolve_class(&store, c)?),
|
||||
None => None,
|
||||
};
|
||||
let mut revenue = Decimal::ZERO;
|
||||
let mut expenses = Decimal::ZERO;
|
||||
for a in ledger.chart().all() {
|
||||
if a.placeholder {
|
||||
continue;
|
||||
}
|
||||
match a.account_type {
|
||||
AccountType::Revenue => {
|
||||
revenue += movement_in_class(ledger, a.id, start, end, class_id)
|
||||
}
|
||||
AccountType::Expense => {
|
||||
expenses += movement_in_class(ledger, a.id, start, end, class_id)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let pl = pnl_statement(
|
||||
store.ledger(),
|
||||
p.start.as_deref(),
|
||||
p.end.as_deref(),
|
||||
class_id,
|
||||
)?;
|
||||
Ok(Json(ProfitAndLossOut {
|
||||
start: start.to_string(),
|
||||
end: end.to_string(),
|
||||
revenue: money(revenue),
|
||||
expenses: money(expenses),
|
||||
net_income: money(revenue - expenses),
|
||||
start: pl.start.to_string(),
|
||||
end: pl.end.to_string(),
|
||||
revenue: money(pl.total_revenue),
|
||||
expenses: money(pl.total_expenses),
|
||||
net_income: money(pl.net_income()),
|
||||
revenue_accounts: lines_out(&pl.revenue),
|
||||
expense_accounts: lines_out(&pl.expenses),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -424,38 +536,17 @@ impl BooksServer {
|
||||
) -> Result<Json<BalanceSheetOut>, String> {
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let as_of = match p.as_of {
|
||||
Some(s) => parse_date(&s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
};
|
||||
let mut assets = Decimal::ZERO;
|
||||
let mut liabilities = Decimal::ZERO;
|
||||
let mut equity = Decimal::ZERO;
|
||||
let mut revenue = Decimal::ZERO;
|
||||
let mut expenses = Decimal::ZERO;
|
||||
for a in ledger.chart().all() {
|
||||
if a.placeholder {
|
||||
continue;
|
||||
}
|
||||
let m = movement_through(ledger, a.id, as_of);
|
||||
match a.account_type {
|
||||
AccountType::Asset => assets += m,
|
||||
AccountType::Liability => liabilities += m,
|
||||
AccountType::Equity => equity += m,
|
||||
AccountType::Revenue => revenue += m,
|
||||
AccountType::Expense => expenses += m,
|
||||
}
|
||||
}
|
||||
let current_earnings = revenue - expenses;
|
||||
equity += current_earnings;
|
||||
let bs = bs_statement(store.ledger(), p.as_of.as_deref())?;
|
||||
Ok(Json(BalanceSheetOut {
|
||||
as_of: as_of.to_string(),
|
||||
assets: money(assets),
|
||||
liabilities: money(liabilities),
|
||||
equity: money(equity),
|
||||
current_earnings: money(current_earnings),
|
||||
balanced: assets == liabilities + equity,
|
||||
as_of: bs.as_of.to_string(),
|
||||
assets: money(bs.total_assets),
|
||||
liabilities: money(bs.total_liabilities),
|
||||
equity: money(bs.total_equity),
|
||||
current_earnings: money(bs.current_earnings),
|
||||
balanced: bs.balanced(),
|
||||
asset_accounts: lines_out(&bs.assets),
|
||||
liability_accounts: lines_out(&bs.liabilities),
|
||||
equity_accounts: lines_out(&bs.equity),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -846,9 +937,65 @@ impl BooksServer {
|
||||
Ok(Json(LinkReceiptOut { ok: true }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.unlink_receipt",
|
||||
description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old."
|
||||
)]
|
||||
async fn unlink_receipt(
|
||||
&self,
|
||||
params: Parameters<UnlinkReceiptParams>,
|
||||
) -> Result<Json<UnlinkReceiptOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let attachment = store
|
||||
.list_attachments(None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|a| a.id == p.attachment_id)
|
||||
.ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?;
|
||||
if let Some(entry_id) = attachment.entry_id {
|
||||
let remaining = store
|
||||
.list_attachments(Some(entry_id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.len()
|
||||
- 1;
|
||||
if remaining == 0 {
|
||||
self.ensure_receipt_not_required(&store, entry_id)?;
|
||||
}
|
||||
}
|
||||
let entry_id = store
|
||||
.unlink_receipt(p.attachment_id, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let remaining_receipts = store
|
||||
.list_attachments(Some(entry_id))
|
||||
.map_err(|e| e.to_string())?
|
||||
.len() as u64;
|
||||
Ok(Json(UnlinkReceiptOut {
|
||||
ok: true,
|
||||
entry_id,
|
||||
remaining_receipts,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.withdraw_attachment",
|
||||
description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink a linked receipt first."
|
||||
)]
|
||||
async fn withdraw_attachment(
|
||||
&self,
|
||||
params: Parameters<WithdrawAttachmentParams>,
|
||||
) -> Result<Json<WithdrawAttachmentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
store
|
||||
.withdraw_attachment(p.attachment_id, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(WithdrawAttachmentOut { ok: true }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_receipts",
|
||||
description = "List receipts and filed documents, optionally filtered to one entry.",
|
||||
description = "List receipts and filed documents, optionally filtered to one entry. Withdrawn attachments are hidden unless include_withdrawn=true.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn list_receipts(
|
||||
@@ -857,10 +1004,12 @@ impl BooksServer {
|
||||
) -> Result<Json<ListReceiptsOut>, String> {
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let include_withdrawn = p.include_withdrawn.unwrap_or(false);
|
||||
let receipts = store
|
||||
.list_attachments(p.entry_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.filter(|a| include_withdrawn || a.withdrawn_at.is_none())
|
||||
.map(|a| ReceiptOut {
|
||||
attachment_id: a.id,
|
||||
sha256: a.blob_sha,
|
||||
@@ -870,6 +1019,8 @@ impl BooksServer {
|
||||
mime_type: a.mime_type,
|
||||
size_bytes: a.size_bytes,
|
||||
description: a.description,
|
||||
withdrawn: a.withdrawn_at.is_some(),
|
||||
withdrawn_reason: a.withdrawn_reason,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ListReceiptsOut { receipts }))
|
||||
@@ -1234,15 +1385,15 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.export",
|
||||
description = "Export a report. Formats: csv, json, xlsx (base64), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow.",
|
||||
description = "Export a report. Formats: csv, json, xlsx (base64), pdf (base64; profit_and_loss, balance_sheet, cash_flow), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow. cash_flow needs cash_accounts.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||
let p = params.0;
|
||||
let format = p.format.to_lowercase();
|
||||
if !["csv", "json", "xlsx", "iif"].contains(&format.as_str()) {
|
||||
if !["csv", "json", "xlsx", "pdf", "iif"].contains(&format.as_str()) {
|
||||
return Err(format!(
|
||||
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", or \"iif\"",
|
||||
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"",
|
||||
p.format
|
||||
));
|
||||
}
|
||||
@@ -1250,8 +1401,12 @@ impl BooksServer {
|
||||
if format == "iif" && report != "journal" {
|
||||
return Err("iif format is only valid for the journal report".to_string());
|
||||
}
|
||||
if format == "xlsx" {
|
||||
let bytes = self.export_xlsx(&report, &p)?;
|
||||
if format == "xlsx" || format == "pdf" {
|
||||
let bytes = if format == "xlsx" {
|
||||
self.export_xlsx(&report, &p)?
|
||||
} else {
|
||||
self.export_pdf(&report, &p)?
|
||||
};
|
||||
return Ok(Json(ExportOut {
|
||||
format,
|
||||
report,
|
||||
@@ -1289,94 +1444,60 @@ impl BooksServer {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Lay out a financial statement for rendering, from export params.
|
||||
fn layout_statement(
|
||||
&self,
|
||||
ledger: &Ledger,
|
||||
report: &str,
|
||||
p: &ExportParams,
|
||||
) -> Result<ledger_import::Statement, String> {
|
||||
use ledger_import::report;
|
||||
Ok(match report {
|
||||
"profit_and_loss" => report::profit_and_loss(&pnl_statement(
|
||||
ledger,
|
||||
p.start.as_deref(),
|
||||
p.end.as_deref(),
|
||||
None,
|
||||
)?),
|
||||
"balance_sheet" => report::balance_sheet(&bs_statement(ledger, p.as_of.as_deref())?),
|
||||
"cash_flow" => report::cash_flow(&cash_flow_statement(
|
||||
ledger,
|
||||
p.start.as_deref(),
|
||||
p.end.as_deref(),
|
||||
&p.cash_accounts,
|
||||
&p.financing_accounts,
|
||||
)?),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad report {report:?}: expected profit_and_loss, balance_sheet, or cash_flow"
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a report to XLSX bytes via ledger-import.
|
||||
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||
use ledger_import::{XlsxReport, report_to_xlsx};
|
||||
let store = self.lock()?;
|
||||
let meta = self.report_meta(&store);
|
||||
let ledger = store.ledger();
|
||||
// Build owned report data first (all report structs are owned).
|
||||
enum Owned {
|
||||
Tb(ledger_core::reports::TrialBalance),
|
||||
Pnl(ledger_core::reports::ProfitAndLoss, String, String),
|
||||
Bs(ledger_core::reports::BalanceSheet, String),
|
||||
Cf(ledger_core::reports::CashFlowStatement),
|
||||
if report == "trial_balance" {
|
||||
let tb = reports::trial_balance(ledger);
|
||||
return report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
let owned = match report {
|
||||
"trial_balance" => Owned::Tb(reports::trial_balance(ledger)),
|
||||
"profit_and_loss" => {
|
||||
let start = match &p.start {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
|
||||
};
|
||||
let end = match &p.end {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
};
|
||||
Owned::Pnl(
|
||||
reports::profit_and_loss(ledger).map_err(|e| e.to_string())?,
|
||||
start.to_string(),
|
||||
end.to_string(),
|
||||
)
|
||||
}
|
||||
"balance_sheet" => {
|
||||
let as_of = match &p.as_of {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
};
|
||||
// Engine balance_sheet is as-of now; xlsx uses the same.
|
||||
// (The books.balance_sheet tool does as-of via movement_through.)
|
||||
Owned::Bs(
|
||||
reports::balance_sheet(ledger).map_err(|e| e.to_string())?,
|
||||
as_of.to_string(),
|
||||
)
|
||||
}
|
||||
"cash_flow" => {
|
||||
let mut cash_accounts = Vec::new();
|
||||
for code in &p.cash_accounts {
|
||||
let id = ledger
|
||||
.chart()
|
||||
.find_by_code(code)
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("unknown cash account code: {code}"))?;
|
||||
cash_accounts.push(id);
|
||||
}
|
||||
if cash_accounts.is_empty() {
|
||||
return Err("cash_accounts is required for cash_flow".to_string());
|
||||
}
|
||||
let start = match &p.start {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
|
||||
};
|
||||
let end = match &p.end {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
};
|
||||
Owned::Cf(
|
||||
ledger_core::cash_flow(
|
||||
ledger,
|
||||
&ledger_core::CashFlowParams {
|
||||
start,
|
||||
end,
|
||||
cash_accounts,
|
||||
financing_accounts: vec![],
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad report {report:?} for xlsx: expected trial_balance, profit_and_loss, balance_sheet, or cash_flow"
|
||||
));
|
||||
}
|
||||
};
|
||||
let xlsx_report = match &owned {
|
||||
Owned::Tb(tb) => XlsxReport::TrialBalance(tb),
|
||||
Owned::Pnl(pl, s, e) => XlsxReport::ProfitAndLoss(pl, s.clone(), e.clone()),
|
||||
Owned::Bs(bs, a) => XlsxReport::BalanceSheet(bs, a.clone()),
|
||||
Owned::Cf(cf) => XlsxReport::CashFlow(cf),
|
||||
};
|
||||
report_to_xlsx(&xlsx_report).map_err(|e| e.to_string())
|
||||
let st = self.layout_statement(ledger, report, p)?;
|
||||
report_to_xlsx(&XlsxReport::Statement(&st), &meta).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Render a financial statement to PDF bytes via ledger-import.
|
||||
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||
let store = self.lock()?;
|
||||
let meta = self.report_meta(&store);
|
||||
let st = self
|
||||
.layout_statement(store.ledger(), report, p)
|
||||
.map_err(|e| e.replace("bad report", "bad report for pdf"))?;
|
||||
ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Render the journal as IIF text via ledger-import.
|
||||
@@ -1687,6 +1808,9 @@ impl BooksServer {
|
||||
}
|
||||
.to_tool_error());
|
||||
}
|
||||
for aid in &attachment_ids {
|
||||
store.check_linkable(*aid).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let entry_id = store
|
||||
.dispose_asset(
|
||||
p.asset_id,
|
||||
@@ -1775,17 +1899,17 @@ impl BooksServer {
|
||||
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
|
||||
// Build line descriptions from the posted closing entry.
|
||||
let mut lines = Vec::new();
|
||||
if let Some(eid) = closing {
|
||||
if let Some(entry) = store.ledger().get_entry(eid) {
|
||||
for line in entry.lines() {
|
||||
let code = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(line.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default();
|
||||
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
|
||||
}
|
||||
if let Some(eid) = closing
|
||||
&& let Some(entry) = store.ledger().get_entry(eid)
|
||||
{
|
||||
for line in entry.lines() {
|
||||
let code = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(line.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default();
|
||||
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
|
||||
}
|
||||
}
|
||||
Ok(Json(ClosePeriodOut {
|
||||
@@ -2380,41 +2504,49 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.export_cpa_package",
|
||||
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf. The as_of date defaults to today; the P&L covers the fiscal year to date.",
|
||||
description = "Build the CPA handoff package as a zip (base64): trial-balance.xlsx, general-ledger.xlsx (every posted line), profit-and-loss.pdf, balance-sheet.pdf, cash-flow.pdf. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. The as_of date defaults to today; the P&L and cash flow cover the fiscal year to date.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn export_cpa_package(
|
||||
&self,
|
||||
params: Parameters<ExportCpaPackageParams>,
|
||||
) -> Result<Json<ExportCpaPackageOut>, String> {
|
||||
use ledger_import::{XlsxReport, report_to_xlsx};
|
||||
use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf};
|
||||
let p = params.0;
|
||||
let as_of = match p.as_of {
|
||||
Some(s) => parse_date(&s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
None => today(),
|
||||
};
|
||||
let store = self.lock()?;
|
||||
let meta = self.report_meta(&store);
|
||||
let ledger = store.ledger();
|
||||
|
||||
// Fiscal year to date for the P&L: Jan 1 of the as_of year.
|
||||
let fy_start = NaiveDate::from_ymd_opt(as_of.year(), 1, 1).unwrap();
|
||||
// Fiscal year to date, per wanderbooks.toml's fiscal_year_start.
|
||||
let (fy_start, _) = self.company.fiscal_year_containing(as_of);
|
||||
let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string());
|
||||
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
|
||||
let pdf =
|
||||
|st: ledger_import::Statement| statement_pdf(&st, &meta).map_err(|e| e.to_string());
|
||||
|
||||
// 1. Trial balance XLSX.
|
||||
let tb = ledger_core::reports::trial_balance(ledger);
|
||||
let tb_xlsx = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).map_err(|e| e.to_string())?;
|
||||
|
||||
// 2. General ledger XLSX (every posted line).
|
||||
let gl_xlsx =
|
||||
report_to_xlsx(&XlsxReport::GeneralLedger(ledger)).map_err(|e| e.to_string())?;
|
||||
|
||||
// 3. P&L PDF (fiscal year to date).
|
||||
let pnl_pdf =
|
||||
ledger_import::profit_and_loss_pdf(ledger, &fy_start.to_string(), &as_of.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 4. Balance sheet PDF.
|
||||
let bs_pdf = ledger_import::balance_sheet_pdf(ledger, &as_of.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tb_xlsx = xlsx(XlsxReport::TrialBalance(&reports::trial_balance(ledger)))?;
|
||||
let gl_xlsx = xlsx(XlsxReport::GeneralLedger(ledger))?;
|
||||
let pnl_pdf = pdf(report::profit_and_loss(&pnl_statement(
|
||||
ledger,
|
||||
Some(&fy_start),
|
||||
Some(&as_of_s),
|
||||
None,
|
||||
)?))?;
|
||||
let bs_pdf = pdf(report::balance_sheet(&bs_statement(
|
||||
ledger,
|
||||
Some(&as_of_s),
|
||||
)?))?;
|
||||
let cf_pdf = pdf(report::cash_flow(&cash_flow_statement(
|
||||
ledger,
|
||||
Some(&fy_start),
|
||||
Some(&as_of_s),
|
||||
&p.cash_accounts,
|
||||
&p.financing_accounts,
|
||||
)?))?;
|
||||
drop(store);
|
||||
|
||||
// Zip it.
|
||||
@@ -2427,6 +2559,7 @@ impl BooksServer {
|
||||
("general-ledger.xlsx", gl_xlsx.as_slice()),
|
||||
("profit-and-loss.pdf", pnl_pdf.as_slice()),
|
||||
("balance-sheet.pdf", bs_pdf.as_slice()),
|
||||
("cash-flow.pdf", cf_pdf.as_slice()),
|
||||
] {
|
||||
zip.start_file(name, opts).map_err(|e| e.to_string())?;
|
||||
use std::io::Write;
|
||||
@@ -2543,86 +2676,75 @@ impl BooksServer {
|
||||
end: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let start_d = match start {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
|
||||
};
|
||||
let end_d = match end {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
};
|
||||
let mut revenue = Decimal::ZERO;
|
||||
let mut expenses = Decimal::ZERO;
|
||||
for a in ledger.chart().all() {
|
||||
if a.placeholder {
|
||||
continue;
|
||||
}
|
||||
match a.account_type {
|
||||
AccountType::Revenue => revenue += movement_in(ledger, a.id, start_d, end_d),
|
||||
AccountType::Expense => expenses += movement_in(ledger, a.id, start_d, end_d),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let pl = pnl_statement(store.ledger(), start, end, None)?;
|
||||
if format == "json" {
|
||||
return serde_json::to_string_pretty(&serde_json::json!({
|
||||
"start": start_d.to_string(), "end": end_d.to_string(),
|
||||
"revenue": money(revenue), "expenses": money(expenses),
|
||||
"net_income": money(revenue - expenses),
|
||||
"start": pl.start.to_string(), "end": pl.end.to_string(),
|
||||
"revenue_accounts": lines_out(&pl.revenue),
|
||||
"expense_accounts": lines_out(&pl.expenses),
|
||||
"revenue": money(pl.total_revenue), "expenses": money(pl.total_expenses),
|
||||
"net_income": money(pl.net_income()),
|
||||
}))
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
Ok(format!(
|
||||
"metric,amount\nrevenue,{}\nexpenses,{}\nnet_income,{}\n",
|
||||
money(revenue),
|
||||
money(expenses),
|
||||
money(revenue - expenses)
|
||||
))
|
||||
let mut csv = String::from("section,code,name,amount\n");
|
||||
for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] {
|
||||
for l in lines {
|
||||
csv.push_str(&format!(
|
||||
"{section},{},{},{}\n",
|
||||
csv_escape(&l.code),
|
||||
csv_escape(&l.name),
|
||||
money(l.amount)
|
||||
));
|
||||
}
|
||||
}
|
||||
csv.push_str(&format!(
|
||||
"total,,revenue,{}\ntotal,,expenses,{}\ntotal,,net_income,{}\n",
|
||||
money(pl.total_revenue),
|
||||
money(pl.total_expenses),
|
||||
money(pl.net_income())
|
||||
));
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result<String, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let as_of_d = match as_of {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => chrono::Utc::now().date_naive(),
|
||||
};
|
||||
let mut assets = Decimal::ZERO;
|
||||
let mut liabilities = Decimal::ZERO;
|
||||
let mut equity = Decimal::ZERO;
|
||||
let mut revenue = Decimal::ZERO;
|
||||
let mut expenses = Decimal::ZERO;
|
||||
for a in ledger.chart().all() {
|
||||
if a.placeholder {
|
||||
continue;
|
||||
}
|
||||
let m = movement_through(ledger, a.id, as_of_d);
|
||||
match a.account_type {
|
||||
AccountType::Asset => assets += m,
|
||||
AccountType::Liability => liabilities += m,
|
||||
AccountType::Equity => equity += m,
|
||||
AccountType::Revenue => revenue += m,
|
||||
AccountType::Expense => expenses += m,
|
||||
}
|
||||
}
|
||||
let current_earnings = revenue - expenses;
|
||||
equity += current_earnings;
|
||||
let bs = bs_statement(store.ledger(), as_of)?;
|
||||
if format == "json" {
|
||||
return serde_json::to_string_pretty(&serde_json::json!({
|
||||
"as_of": as_of_d.to_string(),
|
||||
"assets": money(assets), "liabilities": money(liabilities),
|
||||
"equity": money(equity), "current_earnings": money(current_earnings),
|
||||
"balanced": assets == liabilities + equity,
|
||||
"as_of": bs.as_of.to_string(),
|
||||
"asset_accounts": lines_out(&bs.assets),
|
||||
"liability_accounts": lines_out(&bs.liabilities),
|
||||
"equity_accounts": lines_out(&bs.equity),
|
||||
"assets": money(bs.total_assets), "liabilities": money(bs.total_liabilities),
|
||||
"equity": money(bs.total_equity), "current_earnings": money(bs.current_earnings),
|
||||
"balanced": bs.balanced(),
|
||||
}))
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
Ok(format!(
|
||||
"metric,amount\nassets,{}\nliabilities,{}\nequity,{}\ncurrent_earnings,{}\n",
|
||||
money(assets),
|
||||
money(liabilities),
|
||||
money(equity),
|
||||
money(current_earnings)
|
||||
))
|
||||
let mut csv = String::from("section,code,name,amount\n");
|
||||
for (section, lines) in [
|
||||
("assets", &bs.assets),
|
||||
("liabilities", &bs.liabilities),
|
||||
("equity", &bs.equity),
|
||||
] {
|
||||
for l in lines {
|
||||
csv.push_str(&format!(
|
||||
"{section},{},{},{}\n",
|
||||
csv_escape(&l.code),
|
||||
csv_escape(&l.name),
|
||||
money(l.amount)
|
||||
));
|
||||
}
|
||||
}
|
||||
csv.push_str(&format!(
|
||||
"equity,,current_earnings,{}\ntotal,,assets,{}\ntotal,,liabilities,{}\ntotal,,equity,{}\n",
|
||||
money(bs.current_earnings),
|
||||
money(bs.total_assets),
|
||||
money(bs.total_liabilities),
|
||||
money(bs.total_equity)
|
||||
));
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result<String, String> {
|
||||
@@ -2883,3 +3005,21 @@ impl ServerHandler for BooksServer {
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::money;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn money_always_has_cents_and_never_rounds() {
|
||||
let m = |s: &str| money(Decimal::from_str(s).unwrap());
|
||||
assert_eq!(money(Decimal::ZERO), "0.00");
|
||||
assert_eq!(m("-0"), "0.00");
|
||||
assert_eq!(m("1000"), "1000.00");
|
||||
assert_eq!(m("5.8"), "5.80");
|
||||
assert_eq!(m("-207.68"), "-207.68");
|
||||
assert_eq!(m("12.3456"), "12.3456");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,21 @@ pub struct ProfitAndLossOut {
|
||||
pub expenses: String,
|
||||
/// Revenue minus expenses, decimal string. Positive is profit.
|
||||
pub net_income: String,
|
||||
/// Revenue accounts with movement in the period.
|
||||
pub revenue_accounts: Vec<StatementLineOut>,
|
||||
/// Expense accounts with movement in the period.
|
||||
pub expense_accounts: Vec<StatementLineOut>,
|
||||
}
|
||||
|
||||
/// One account's line on a statement.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StatementLineOut {
|
||||
/// Account code.
|
||||
pub code: String,
|
||||
/// Account name.
|
||||
pub name: String,
|
||||
/// Amount on the account's normal side, decimal string.
|
||||
pub amount: String,
|
||||
}
|
||||
|
||||
/// Parameters for `books.cash_flow`.
|
||||
@@ -285,6 +300,12 @@ pub struct BalanceSheetOut {
|
||||
pub current_earnings: String,
|
||||
/// True when assets equal liabilities plus equity exactly.
|
||||
pub balanced: bool,
|
||||
/// Asset accounts with a balance.
|
||||
pub asset_accounts: Vec<StatementLineOut>,
|
||||
/// Liability accounts with a balance.
|
||||
pub liability_accounts: Vec<StatementLineOut>,
|
||||
/// Equity accounts with a balance (current earnings is separate).
|
||||
pub equity_accounts: Vec<StatementLineOut>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -503,11 +524,51 @@ pub struct LinkReceiptOut {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Parameters for `books.unlink_receipt`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct UnlinkReceiptParams {
|
||||
/// Attachment id of the linked receipt.
|
||||
pub attachment_id: u64,
|
||||
/// Why it is being unlinked, recorded in the journal (e.g. "replaced by
|
||||
/// the PDF render, attachment 9").
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Result of `books.unlink_receipt`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct UnlinkReceiptOut {
|
||||
/// True when unlinked.
|
||||
pub ok: bool,
|
||||
/// The entry it was unlinked from.
|
||||
pub entry_id: u64,
|
||||
/// Receipts still linked to that entry.
|
||||
pub remaining_receipts: u64,
|
||||
}
|
||||
|
||||
/// Parameters for `books.withdraw_attachment`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct WithdrawAttachmentParams {
|
||||
/// Attachment id of an unlinked receipt or a filed document.
|
||||
pub attachment_id: u64,
|
||||
/// Why it is being withdrawn, recorded in the journal.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Result of `books.withdraw_attachment`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct WithdrawAttachmentOut {
|
||||
/// True when withdrawn.
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Parameters for `books.list_receipts`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ListReceiptsParams {
|
||||
/// Filter to one entry. Omit for all.
|
||||
pub entry_id: Option<u64>,
|
||||
/// Include withdrawn attachments. Default false.
|
||||
#[serde(default)]
|
||||
pub include_withdrawn: Option<bool>,
|
||||
}
|
||||
|
||||
/// One receipt or document.
|
||||
@@ -529,6 +590,10 @@ pub struct ReceiptOut {
|
||||
pub size_bytes: u64,
|
||||
/// Description, if any.
|
||||
pub description: Option<String>,
|
||||
/// True when withdrawn (only listed with include_withdrawn).
|
||||
pub withdrawn: bool,
|
||||
/// Why it was withdrawn, if it was.
|
||||
pub withdrawn_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.list_receipts`.
|
||||
@@ -713,8 +778,9 @@ pub struct DigestOut {
|
||||
/// Parameters for `books.export`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ExportParams {
|
||||
/// "csv", "json", "xlsx", or "iif". XLSX is base64-encoded; IIF is only
|
||||
/// valid with report "journal".
|
||||
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
|
||||
/// base64-encoded; PDF covers profit_and_loss, balance_sheet, and
|
||||
/// cash_flow; IIF is only valid with report "journal".
|
||||
pub format: String,
|
||||
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
||||
/// "cash_flow".
|
||||
@@ -728,6 +794,10 @@ pub struct ExportParams {
|
||||
/// Cash account codes for cash_flow (required for that report).
|
||||
#[serde(default)]
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// Liability account codes treated as financing (debt) for cash_flow.
|
||||
/// Defaults to empty: liabilities are operating working capital.
|
||||
#[serde(default)]
|
||||
pub financing_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.export`.
|
||||
@@ -738,9 +808,9 @@ pub struct ExportOut {
|
||||
/// The requested report.
|
||||
pub report: String,
|
||||
/// The exported content. Base64-encoded when `content_encoding` is
|
||||
/// "base64" (XLSX); otherwise raw text.
|
||||
/// "base64" (XLSX, PDF); otherwise raw text.
|
||||
pub content: String,
|
||||
/// "base64" for XLSX, otherwise absent.
|
||||
/// "base64" for XLSX and PDF, otherwise absent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_encoding: Option<String>,
|
||||
}
|
||||
@@ -1403,6 +1473,14 @@ pub struct ProfitAndLossByClassOut {
|
||||
pub struct ExportCpaPackageParams {
|
||||
/// As-of date, YYYY-MM-DD. Defaults to today.
|
||||
pub as_of: Option<String>,
|
||||
/// Cash account codes the cash flow statement reconciles to (required),
|
||||
/// e.g. `["1000"]`.
|
||||
#[serde(default)]
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// Liability account codes treated as financing (debt). Defaults to
|
||||
/// empty: liabilities are operating working capital.
|
||||
#[serde(default)]
|
||||
pub financing_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Output of `books.export_cpa_package`.
|
||||
|
||||
@@ -15,10 +15,15 @@ struct Mcp {
|
||||
|
||||
impl Mcp {
|
||||
fn spawn(data_dir: &std::path::Path) -> Self {
|
||||
Self::spawn_with(data_dir, &[])
|
||||
}
|
||||
|
||||
fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self {
|
||||
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||
let mut child = Command::new(bin)
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir)
|
||||
.args(extra)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
@@ -134,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: 23 narrow tools, chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 48, "expected 48 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 50, "expected 50 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
@@ -196,7 +201,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
.iter()
|
||||
.find(|l| l["code"] == "6000")
|
||||
.unwrap();
|
||||
assert_eq!(rent_line["balance_before"], "0");
|
||||
assert_eq!(rent_line["balance_before"], "0.00");
|
||||
assert_eq!(rent_line["balance_after"], "100.00");
|
||||
|
||||
// Confirmed post without a receipt is rejected: >$25, no receipt.
|
||||
@@ -270,7 +275,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
assert_eq!(rev["status"], "posted");
|
||||
let tb2 = mcp.call("books.trial_balance", json!({}));
|
||||
assert_eq!(tb2["balanced"], true);
|
||||
assert_eq!(tb2["total_debits"], "0");
|
||||
assert_eq!(tb2["total_debits"], "0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -376,7 +381,7 @@ fn mcp_assets_end_to_end_over_stdio() {
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(preview["book_value"], "58451.61");
|
||||
assert_eq!(preview["gain_loss"], "1548.39");
|
||||
assert_eq!(preview["final_depreciation"], "0");
|
||||
assert_eq!(preview["final_depreciation"], "0.00");
|
||||
assert_eq!(preview["receipt_required"], true);
|
||||
|
||||
// Confirmed without the bill of sale: rejected.
|
||||
@@ -534,6 +539,141 @@ fn mcp_receipt_policy_rejects_split_entries() {
|
||||
assert_eq!(p3["status"], "posted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_receipt_corrections_over_stdio() {
|
||||
let dir = test_dir("receipt-corrections");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
for (code, name, typ) in [("1000", "Cash", "asset"), ("6000", "Software", "expense")] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
let post = |mcp: &mut Mcp, desc: &str, amount: &str, receipt: &[u8]| -> (u64, u64) {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-04-01", "description": desc}),
|
||||
);
|
||||
let id = e["entry_id"].as_u64().unwrap();
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": amount}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}),
|
||||
);
|
||||
let att = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
|
||||
"content_base64": base64_like(receipt)}),
|
||||
);
|
||||
let posted = mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(posted["status"], "posted");
|
||||
(id, att["attachment_id"].as_u64().unwrap())
|
||||
};
|
||||
|
||||
// A $100 entry whose only receipt is an .eml.
|
||||
let (big, eml) = post(&mut mcp, "Annual license", "100.00", b"eml-bytes");
|
||||
|
||||
// Unlinking its only receipt would break the policy: refused, with the remedy.
|
||||
let err = mcp.call_err(
|
||||
"books.unlink_receipt",
|
||||
json!({"attachment_id": eml, "reason": "replacing with PDF"}),
|
||||
);
|
||||
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
|
||||
assert_eq!(err_json["code"], "receipt_required");
|
||||
assert_eq!(err_json["entry_id"], big);
|
||||
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
|
||||
|
||||
// Replace: attach the PDF first, then unlink the .eml.
|
||||
let pdf = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"pdf-bytes")}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let un = mcp.call(
|
||||
"books.unlink_receipt",
|
||||
json!({"attachment_id": eml, "reason": "replaced by the PDF render"}),
|
||||
);
|
||||
assert_eq!(un["entry_id"], big);
|
||||
assert_eq!(un["remaining_receipts"], 1);
|
||||
|
||||
// A linked receipt can't be withdrawn; an unlinked one can, with a reason.
|
||||
let err = mcp.call_err(
|
||||
"books.withdraw_attachment",
|
||||
json!({"attachment_id": pdf, "reason": "x"}),
|
||||
);
|
||||
assert!(err.contains("unlink it first"), "{err}");
|
||||
let err = mcp.call_err(
|
||||
"books.withdraw_attachment",
|
||||
json!({"attachment_id": eml, "reason": " "}),
|
||||
);
|
||||
assert!(err.contains("reason"), "{err}");
|
||||
mcp.call(
|
||||
"books.withdraw_attachment",
|
||||
json!({"attachment_id": eml, "reason": "file PDFs, not .eml"}),
|
||||
);
|
||||
|
||||
// Listings hide the withdrawn .eml unless asked; the entry keeps the PDF.
|
||||
let listed = mcp.call("books.list_receipts", json!({}));
|
||||
let ids: Vec<u64> = listed["receipts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["attachment_id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![pdf]);
|
||||
let all = mcp.call("books.list_receipts", json!({"include_withdrawn": true}));
|
||||
let gone = all["receipts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["attachment_id"] == eml)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(gone["withdrawn"], true);
|
||||
assert_eq!(gone["withdrawn_reason"], "file PDFs, not .eml");
|
||||
assert_eq!(gone["entry_id"], serde_json::Value::Null);
|
||||
let entry = mcp.call("books.get_entry", json!({"entry_id": big}));
|
||||
assert_eq!(entry["receipts"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(entry["receipts"][0]["attachment_id"], pdf);
|
||||
// A withdrawn attachment can never be linked again.
|
||||
let err = mcp.call_err(
|
||||
"books.link_receipt",
|
||||
json!({"attachment_id": eml, "entry_id": big}),
|
||||
);
|
||||
assert!(err.contains("withdrawn"), "{err}");
|
||||
|
||||
// Under the threshold, the last receipt may go.
|
||||
let (small, small_eml) = post(&mut mcp, "Domain renewal", "10.00", b"small-eml");
|
||||
let un = mcp.call(
|
||||
"books.unlink_receipt",
|
||||
json!({"attachment_id": small_eml, "reason": "wrong file"}),
|
||||
);
|
||||
assert_eq!(un["entry_id"], small);
|
||||
assert_eq!(un["remaining_receipts"], 0);
|
||||
|
||||
// The books never moved.
|
||||
let tb = mcp.call("books.trial_balance", json!({}));
|
||||
assert_eq!(tb["balanced"], true);
|
||||
assert_eq!(tb["total_debits"], "110.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_resources_over_stdio() {
|
||||
let dir = test_dir("resources");
|
||||
@@ -548,7 +688,7 @@ fn mcp_resources_over_stdio() {
|
||||
);
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
||||
// Resources are advertised.
|
||||
assert_eq!(init["result"]["capabilities"]["resources"].is_null(), false);
|
||||
assert!(!init["result"]["capabilities"]["resources"].is_null());
|
||||
mcp.notify("notifications/initialized");
|
||||
|
||||
mcp.call(
|
||||
@@ -641,3 +781,201 @@ fn mcp_import_export_over_stdio() {
|
||||
assert_eq!(xlsx["content_encoding"], "base64");
|
||||
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let dir = test_dir("statement-pdfs");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n"}),
|
||||
);
|
||||
for (desc, debit, credit, amount) in [
|
||||
("Owner contribution", "1000", "3000", "1000.00"),
|
||||
("Domain", "6000", "1000", "12.00"),
|
||||
] {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-10-05", "description": desc}),
|
||||
);
|
||||
let id = e["entry_id"].as_u64().unwrap();
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": debit, "side": "debit", "amount": amount}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": credit, "side": "credit", "amount": amount}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(desc.as_bytes())}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
}
|
||||
|
||||
// All three statements export as PDF.
|
||||
for (report, extra) in [
|
||||
("profit_and_loss", json!({})),
|
||||
("balance_sheet", json!({})),
|
||||
("cash_flow", json!({"cash_accounts": ["1000"]})),
|
||||
] {
|
||||
let mut args = json!({"format": "pdf", "report": report});
|
||||
args.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(extra.as_object().unwrap().clone());
|
||||
let out = mcp.call("books.export", args);
|
||||
assert_eq!(out["content_encoding"], "base64");
|
||||
let bytes = b64.decode(out["content"].as_str().unwrap()).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF"), "{report} is not a PDF");
|
||||
}
|
||||
// The cash flow statement reconciles to named cash accounts: required.
|
||||
let err = mcp.call_err(
|
||||
"books.export",
|
||||
json!({"format": "pdf", "report": "cash_flow"}),
|
||||
);
|
||||
assert!(err.contains("cash_accounts"), "{err}");
|
||||
let err = mcp.call_err(
|
||||
"books.export",
|
||||
json!({"format": "pdf", "report": "trial_balance"}),
|
||||
);
|
||||
assert!(err.contains("for pdf"), "{err}");
|
||||
|
||||
// The CPA package carries all three statements.
|
||||
let err = mcp.call_err("books.export_cpa_package", json!({"as_of": "2026-10-31"}));
|
||||
assert!(err.contains("cash_accounts"), "{err}");
|
||||
let pkg = mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
let zip_bytes = b64.decode(pkg["content_base64"].as_str().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
|
||||
let mut names: Vec<String> = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"balance-sheet.pdf",
|
||||
"cash-flow.pdf",
|
||||
"general-ledger.xlsx",
|
||||
"profit-and-loss.pdf",
|
||||
"trial-balance.xlsx"
|
||||
]
|
||||
);
|
||||
let mut cf = Vec::new();
|
||||
std::io::Read::read_to_end(&mut zip.by_name("cash-flow.pdf").unwrap(), &mut cf).unwrap();
|
||||
assert!(cf.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_period_statements_over_stdio() {
|
||||
let dir = test_dir("period-statements");
|
||||
// A rejected receipt policy would show up as a failed post below: the
|
||||
// toml lowers the threshold from 25.00 to 5.00.
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\n[books]\nreceipt_threshold = \"5.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let data = dir.join("books-data");
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&data,
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
|
||||
);
|
||||
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": "x"}),
|
||||
);
|
||||
let id = e["entry_id"].as_u64().unwrap();
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": dr, "side": "debit", "amount": amount}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": cr, "side": "credit", "amount": amount}),
|
||||
);
|
||||
id
|
||||
};
|
||||
let id = post(&mut mcp, "2026-01-02", "1000", "3000", "1000.00");
|
||||
// 1000.00 > 5.00 (the toml threshold): a receipt is required.
|
||||
let err = mcp.call_err(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
assert!(err.contains("5.00"), "{err}");
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"r")}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
for (date, dr, cr, amt) in [
|
||||
("2026-01-20", "1000", "4000", "4.00"),
|
||||
("2026-02-05", "6000", "1000", "3.00"),
|
||||
("2026-02-09", "1000", "4000", "2.50"),
|
||||
] {
|
||||
let id = post(&mut mcp, date, dr, cr, amt);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
}
|
||||
|
||||
// February only: 2.50 sales, 3.00 software.
|
||||
let feb = mcp.call(
|
||||
"books.profit_and_loss",
|
||||
json!({"start": "2026-02-01", "end": "2026-02-28"}),
|
||||
);
|
||||
assert_eq!(feb["revenue"], "2.50");
|
||||
assert_eq!(feb["expenses"], "3.00");
|
||||
assert_eq!(feb["net_income"], "-0.50");
|
||||
assert_eq!(feb["expense_accounts"][0]["code"], "6000");
|
||||
assert_eq!(feb["expense_accounts"][0]["amount"], "3.00");
|
||||
// No start: the statement begins at the first entry, not year 1.
|
||||
let all = mcp.call("books.profit_and_loss", json!({"end": "2026-02-28"}));
|
||||
assert_eq!(all["start"], "2026-01-02");
|
||||
assert_eq!(all["revenue"], "6.50");
|
||||
|
||||
// As of Jan 31: cash 1,004.00 = capital 1,000.00 + earnings 4.00.
|
||||
let jan = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
|
||||
assert_eq!(jan["assets"], "1004.00");
|
||||
assert_eq!(jan["current_earnings"], "4.00");
|
||||
assert_eq!(jan["equity_accounts"][0]["amount"], "1000.00");
|
||||
assert_eq!(jan["balanced"], true);
|
||||
}
|
||||
@@ -41,6 +41,22 @@ pub enum StoreError {
|
||||
#[error("unknown attachment {0}")]
|
||||
UnknownAttachment(u64),
|
||||
|
||||
/// The attachment is not linked to any entry.
|
||||
#[error("attachment {0} is not linked to an entry")]
|
||||
AttachmentNotLinked(u64),
|
||||
|
||||
/// The attachment is linked to an entry; unlink it first.
|
||||
#[error("attachment {0} is linked to entry {1}; unlink it first with books.unlink_receipt")]
|
||||
AttachmentLinked(u64, u64),
|
||||
|
||||
/// The attachment was withdrawn and can no longer be linked or changed.
|
||||
#[error("attachment {0} is withdrawn")]
|
||||
AttachmentWithdrawn(u64),
|
||||
|
||||
/// A correction to the evidence trail needs a reason.
|
||||
#[error("a non-empty reason is required")]
|
||||
ReasonRequired,
|
||||
|
||||
/// The store's schema is newer than this binary understands.
|
||||
#[error("schema version {0} newer than supported {1}")]
|
||||
SchemaTooNew(u32, u32),
|
||||
|
||||
@@ -29,7 +29,8 @@ pub struct JournalLine {
|
||||
pub ts: String,
|
||||
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
|
||||
/// `entry.posted`, `entry.reversed`, `entry.line_class_set`,
|
||||
/// `receipt.attached`, `receipt.linked`, `document.filed`,
|
||||
/// `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||
/// `document.filed`, `attachment.withdrawn`,
|
||||
/// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`,
|
||||
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
|
||||
/// `vendor.added`, `invoice.created`, `bill.created`,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
pub const SCHEMA_VERSION: u32 = 5;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -172,5 +172,12 @@ CREATE TABLE IF NOT EXISTS payroll_lines(
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
|
||||
ALTER TABLE lines ADD COLUMN class_id INTEGER;
|
||||
"#,
|
||||
r#"
|
||||
-- v5: withdrawn attachments. Withdrawal retires a receipt or document from
|
||||
-- listings and linking; the bytes stay in the blob store and the journal
|
||||
-- keeps the filing and the withdrawal.
|
||||
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
|
||||
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
|
||||
"#,
|
||||
];
|
||||
@@ -115,6 +115,10 @@ pub struct Attachment {
|
||||
pub description: Option<String>,
|
||||
/// UTC timestamp when filed.
|
||||
pub created_at: String,
|
||||
/// UTC timestamp when withdrawn, if it was.
|
||||
pub withdrawn_at: Option<String>,
|
||||
/// Why it was withdrawn, if it was.
|
||||
pub withdrawn_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Input to [`Store::run_payroll`]: one employee's gross pay and every
|
||||
@@ -1757,7 +1761,10 @@ impl Store {
|
||||
Ok((id, sha, dedup))
|
||||
}
|
||||
|
||||
/// Link a previously filed receipt to an entry.
|
||||
/// Link a previously filed (unlinked) receipt to an entry. A receipt
|
||||
/// already linked elsewhere must be unlinked first, so moving evidence
|
||||
/// off an entry always goes through [`Store::unlink_receipt`] and its
|
||||
/// recorded reason.
|
||||
pub fn link_receipt(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
@@ -1766,14 +1773,7 @@ impl Store {
|
||||
self.ledger
|
||||
.get_entry(entry_id)
|
||||
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
||||
let exists: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM attachments WHERE id = ?",
|
||||
params![attachment_id as i64],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if exists == 0 {
|
||||
return Err(StoreError::UnknownAttachment(attachment_id));
|
||||
}
|
||||
self.check_linkable(attachment_id)?;
|
||||
let line = self.append_journal(
|
||||
"receipt.linked",
|
||||
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
||||
@@ -1782,6 +1782,89 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fail unless the attachment exists, is unlinked, and is not
|
||||
/// withdrawn: the precondition of [`Store::link_receipt`], checkable
|
||||
/// before posting anything that will link it.
|
||||
pub fn check_linkable(&self, attachment_id: u64) -> Result<(), StoreError> {
|
||||
let (entry_id, withdrawn) = self.attachment_state(attachment_id)?;
|
||||
if withdrawn {
|
||||
return Err(StoreError::AttachmentWithdrawn(attachment_id));
|
||||
}
|
||||
if let Some(eid) = entry_id {
|
||||
return Err(StoreError::AttachmentLinked(attachment_id, eid));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detach a receipt from its entry, recording why. The attachment stays
|
||||
/// filed (unlinked) and can be linked again or withdrawn. The journal
|
||||
/// keeps both the link and the unlink: the evidence history is
|
||||
/// append-only even when the current evidence set changes. Returns the
|
||||
/// entry it was linked to.
|
||||
///
|
||||
/// Policy-free like the rest of the store: whether an entry may be left
|
||||
/// without a receipt is the MCP layer's call.
|
||||
pub fn unlink_receipt(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
reason: &str,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
return Err(StoreError::ReasonRequired);
|
||||
}
|
||||
let (entry_id, withdrawn) = self.attachment_state(attachment_id)?;
|
||||
if withdrawn {
|
||||
return Err(StoreError::AttachmentWithdrawn(attachment_id));
|
||||
}
|
||||
let entry_id = entry_id.ok_or(StoreError::AttachmentNotLinked(attachment_id))?;
|
||||
let line = self.append_journal(
|
||||
"receipt.unlinked",
|
||||
json!({ "attachment_id": attachment_id, "entry_id": entry_id, "reason": reason }),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(entry_id)
|
||||
}
|
||||
|
||||
/// Withdraw a receipt or document, recording why: it drops out of
|
||||
/// listings and can never be linked again. Nothing is deleted: the bytes
|
||||
/// stay in the blob store and the journal keeps the filing and the
|
||||
/// withdrawal. A linked receipt must be unlinked first.
|
||||
pub fn withdraw_attachment(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
reason: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
return Err(StoreError::ReasonRequired);
|
||||
}
|
||||
self.check_linkable(attachment_id)?;
|
||||
let line = self.append_journal(
|
||||
"attachment.withdrawn",
|
||||
json!({ "attachment_id": attachment_id, "reason": reason }),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `(linked entry, withdrawn?)` for an attachment.
|
||||
fn attachment_state(&self, attachment_id: u64) -> Result<(Option<EntryId>, bool), StoreError> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT entry_id, withdrawn_at IS NOT NULL FROM attachments WHERE id = ?",
|
||||
params![attachment_id as i64],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, Option<i64>>(0)?.map(|v| v as EntryId),
|
||||
r.get::<_, bool>(1)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(StoreError::UnknownAttachment(attachment_id))
|
||||
}
|
||||
|
||||
/// List attachments, optionally filtered to one entry.
|
||||
pub fn list_attachments(
|
||||
&self,
|
||||
@@ -1789,7 +1872,8 @@ impl Store {
|
||||
) -> Result<Vec<Attachment>, StoreError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
||||
b.size_bytes, a.description, a.created_at FROM attachments a \
|
||||
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
||||
FROM attachments a \
|
||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||
);
|
||||
if entry_id.is_some() {
|
||||
@@ -1897,6 +1981,12 @@ impl Store {
|
||||
// Maintenance: verify and rebuild
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Sequence number of the last journal line: the exact journal state a
|
||||
/// report reflects. Zero for an empty journal.
|
||||
pub fn journal_seq(&self) -> u64 {
|
||||
self.journal.next_seq() - 1
|
||||
}
|
||||
|
||||
/// Verify everything: the journal hash chain, a fresh replay's reports
|
||||
/// against the live ledger, and the SQLite projection against the replay.
|
||||
/// Loud on any divergence.
|
||||
@@ -2594,7 +2684,11 @@ fn replay_line_into_ledger(
|
||||
)));
|
||||
}
|
||||
}
|
||||
"receipt.attached" | "receipt.linked" | "document.filed" => {
|
||||
"receipt.attached"
|
||||
| "receipt.linked"
|
||||
| "receipt.unlinked"
|
||||
| "document.filed"
|
||||
| "attachment.withdrawn" => {
|
||||
// Not accounting state; the projection records them.
|
||||
}
|
||||
"asset.registered" => {
|
||||
@@ -2923,6 +3017,22 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"receipt.unlinked" => {
|
||||
conn.execute(
|
||||
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
|
||||
params![get_u64(p, "attachment_id")? as i64],
|
||||
)?;
|
||||
}
|
||||
"attachment.withdrawn" => {
|
||||
conn.execute(
|
||||
"UPDATE attachments SET withdrawn_at = ?, withdrawn_reason = ? WHERE id = ?",
|
||||
params![
|
||||
line.ts,
|
||||
get_str(p, "reason")?,
|
||||
get_u64(p, "attachment_id")? as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"asset.registered" => {
|
||||
let cost = &p["cost"];
|
||||
let salvage = &p["salvage_value"];
|
||||
@@ -3184,5 +3294,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
||||
size_bytes: r.get::<_, i64>(6)? as u64,
|
||||
description: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
withdrawn_at: r.get(9)?,
|
||||
withdrawn_reason: r.get(10)?,
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::{JournalWriter, Store};
|
||||
use ledger_store::{JournalWriter, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -347,3 +347,113 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
|
||||
assert_eq!(tb.total_debits, Decimal::from(14000));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_corrections_are_journaled_and_survive_rebuild() {
|
||||
let dir = test_dir("receipt-corrections");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rent = store
|
||||
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let e = store
|
||||
.create_entry(
|
||||
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||
"February rent",
|
||||
)
|
||||
.unwrap();
|
||||
store.add_line(e, rent, Side::Debit, usd(800)).unwrap();
|
||||
store.add_line(e, cash, Side::Credit, usd(800)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
let (eml, _, _) = store
|
||||
.attach_receipt(Some(e), "rent.eml", "message/rfc822", b"eml-bytes", None)
|
||||
.unwrap();
|
||||
let (pdf, _, _) = store
|
||||
.attach_receipt(Some(e), "rent.pdf", "application/pdf", b"pdf-bytes", None)
|
||||
.unwrap();
|
||||
|
||||
// Every correction needs a reason.
|
||||
assert!(matches!(
|
||||
store.unlink_receipt(eml, " "),
|
||||
Err(StoreError::ReasonRequired)
|
||||
));
|
||||
// A linked receipt can't be withdrawn or moved: unlink it first.
|
||||
assert!(matches!(
|
||||
store.withdraw_attachment(eml, "dup"),
|
||||
Err(StoreError::AttachmentLinked(a, b)) if a == eml && b == e
|
||||
));
|
||||
assert!(matches!(
|
||||
store.link_receipt(pdf, e),
|
||||
Err(StoreError::AttachmentLinked(_, _))
|
||||
));
|
||||
|
||||
// Swap the .eml for the PDF: unlink, then withdraw.
|
||||
assert_eq!(
|
||||
store
|
||||
.unlink_receipt(eml, "replaced by the PDF render")
|
||||
.unwrap(),
|
||||
e
|
||||
);
|
||||
assert!(matches!(
|
||||
store.unlink_receipt(eml, "again"),
|
||||
Err(StoreError::AttachmentNotLinked(_))
|
||||
));
|
||||
store
|
||||
.withdraw_attachment(eml, "file PDFs, not .eml")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.link_receipt(eml, e),
|
||||
Err(StoreError::AttachmentWithdrawn(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.withdraw_attachment(eml, "again"),
|
||||
Err(StoreError::AttachmentWithdrawn(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.unlink_receipt(999, "x"),
|
||||
Err(StoreError::UnknownAttachment(999))
|
||||
));
|
||||
|
||||
let check = |store: &Store| {
|
||||
let linked: Vec<u64> = store
|
||||
.list_attachments(Some(e))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| a.id)
|
||||
.collect();
|
||||
assert_eq!(linked, vec![pdf]);
|
||||
let all = store.list_attachments(None).unwrap();
|
||||
let gone = all.iter().find(|a| a.id == eml).unwrap();
|
||||
assert_eq!(gone.entry_id, None);
|
||||
assert!(gone.withdrawn_at.is_some());
|
||||
assert_eq!(
|
||||
gone.withdrawn_reason.as_deref(),
|
||||
Some("file PDFs, not .eml")
|
||||
);
|
||||
// Withdrawn is not deleted: the bytes are still there, hash-verified.
|
||||
assert_eq!(store.attachment_bytes(eml).unwrap(), b"eml-bytes");
|
||||
};
|
||||
check(&store);
|
||||
|
||||
// The journal is the history: the attach, the unlink, and the withdrawal.
|
||||
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
|
||||
for t in [
|
||||
"receipt.attached",
|
||||
"receipt.unlinked",
|
||||
"attachment.withdrawn",
|
||||
] {
|
||||
assert!(
|
||||
journal.contains(&format!("\"type\":\"{t}\"")),
|
||||
"missing {t}"
|
||||
);
|
||||
}
|
||||
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
check(&store);
|
||||
store.rebuild().unwrap();
|
||||
check(&store);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed),
|
||||
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
|
||||
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered |
|
||||
| 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered |
|
||||
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` | covered |
|
||||
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered |
|
||||
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
|
||||
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
|
||||
| 10 | Human review of what the assistant recorded | `books.digest` | covered |
|
||||
|
||||
+46
-13
@@ -21,6 +21,15 @@ If a behavior can't be expressed through `ledger-core`'s public API, the
|
||||
core is missing something — fix the core, don't work around it in the MCP
|
||||
layer.
|
||||
|
||||
## Company settings
|
||||
|
||||
`ledger-mcp` reads the books dir's `wanderbooks.toml` (default: next to
|
||||
`--data-dir`; override with `--books-toml PATH`): `[company] name` heads
|
||||
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01";
|
||||
the year's end is derived, never stored) defines fiscal-year-to-date for the
|
||||
CPA package, and `[books] receipt_threshold` sets the receipt policy unless
|
||||
`config.json` sets `receipt_policy`.
|
||||
|
||||
## Transports
|
||||
|
||||
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
|
||||
@@ -78,8 +87,14 @@ below.
|
||||
→ entries with lines, newest first. `limit` caps at 100.
|
||||
- `books.trial_balance {}` → canonical JSON (same shape as
|
||||
`reports::snapshot` trial-balance section).
|
||||
- `books.balance_sheet {}` → canonical JSON.
|
||||
- `books.profit_and_loss {}` → canonical JSON.
|
||||
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||||
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
|
||||
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
|
||||
the date (default today).
|
||||
- `books.profit_and_loss { start?, end?, class? }` → totals plus
|
||||
`revenue_accounts` and `expense_accounts` for the range. `end` defaults to
|
||||
today, `start` to the first posted entry. Closing entries are excluded, so
|
||||
a closed period still shows what it earned.
|
||||
- `books.cash_flow { start?, end?, cash_accounts: ["1000"], financing_accounts? }`
|
||||
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
|
||||
accounts (required); the three sections always sum to their net change.
|
||||
@@ -117,8 +132,22 @@ so the lifetime total is exactly (cost − salvage).
|
||||
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
||||
optional: a receipt can be filed before its entry exists.
|
||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||
receipt to its entry.
|
||||
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
|
||||
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||
must be unlinked first.
|
||||
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
|
||||
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
|
||||
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
|
||||
receipt policy: an entry that requires a receipt cannot be left with none
|
||||
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
|
||||
receipt is attach-new, then unlink-old.
|
||||
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
|
||||
an unlinked receipt or a filed document: hidden from listings, never
|
||||
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
|
||||
blob store. Nothing in the evidence trail is ever erased — receipts change
|
||||
by new events, the same way entries change by reversals.
|
||||
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
|
||||
(no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn
|
||||
attachments are omitted unless `include_withdrawn` is true.
|
||||
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
|
||||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||||
"accounting" | "other", file_name, mime_type, content_base64,
|
||||
@@ -163,10 +192,12 @@ posting.
|
||||
|
||||
## Tools — export
|
||||
|
||||
- `books.export { format: "csv" | "json" | "xlsx" | "iif", report: "journal" |
|
||||
"trial_balance" | "profit_and_loss" | "balance_sheet" | "cash_flow" }` →
|
||||
`{ content, content_encoding? }`. CPA-ready output. `xlsx` is base64-encoded;
|
||||
`iif` is journal-only (QuickBooks Desktop). Cash flow needs `cash_accounts`.
|
||||
- `books.export { format: "csv" | "json" | "xlsx" | "pdf" | "iif", report:
|
||||
"journal" | "trial_balance" | "profit_and_loss" | "balance_sheet" |
|
||||
"cash_flow", start?, end?, as_of?, cash_accounts?, financing_accounts? }` →
|
||||
`{ content, content_encoding? }`. CPA-ready output. `xlsx` and `pdf` are
|
||||
base64-encoded; `pdf` covers the three financial statements; `iif` is
|
||||
journal-only (QuickBooks Desktop). Cash flow needs `cash_accounts`.
|
||||
|
||||
## Tools — import
|
||||
|
||||
@@ -304,9 +335,11 @@ entries.
|
||||
|
||||
## Tools — CPA handoff
|
||||
|
||||
- `books.export_cpa_package { as_of? }` → `{ file_name, content_base64 }`.
|
||||
A zip with `trial-balance.xlsx`, `general-ledger.xlsx` (every posted
|
||||
line: date, entry, description, account, class, debit, credit),
|
||||
`profit-and-loss.pdf` (fiscal year to date, per-account detail), and
|
||||
`balance-sheet.pdf` (per-account detail). What the CPA needs; they never
|
||||
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
|
||||
→ `{ file_name, content_base64 }`. A zip with `trial-balance.xlsx`,
|
||||
`general-ledger.xlsx` (every posted line: date, entry, description,
|
||||
account, class, debit, credit), `profit-and-loss.pdf` (fiscal year to
|
||||
date, per-account detail), `balance-sheet.pdf` (per-account detail), and
|
||||
`cash-flow.pdf` (fiscal year to date, indirect method, reconciled to
|
||||
`cash_accounts`). What the CPA needs; they never
|
||||
log into the system.
|
||||
@@ -19,6 +19,8 @@ No other statuses.
|
||||
| Trial balance | Reports → Trial Balance | done | golden-covered |
|
||||
| Balance sheet (A = L + E) | Reports → Balance Sheet | done | current earnings folded in pre-close; golden-covered |
|
||||
| Profit & loss | Reports → Income Statement | done | golden-covered |
|
||||
| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet |
|
||||
| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet |
|
||||
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
||||
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
||||
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
|
||||
|
||||
+11
-1
@@ -50,7 +50,9 @@ apply is safe — on startup the applier replays every journal line with
|
||||
- `seq`: 1-based, gapless, assigned by the writer.
|
||||
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
||||
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
|
||||
`receipt.attached`, `receipt.linked`, `document.filed`.
|
||||
`receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
|
||||
period, payroll, and class events listed on `JournalLine`).
|
||||
- `payload`: the full domain facts (account fields, entry with lines, blob
|
||||
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
||||
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
|
||||
@@ -99,6 +101,14 @@ before backups.
|
||||
plus `receipts`.
|
||||
- Documents filed via `books.file_document` and receipts via
|
||||
`books.attach_receipt` share the blob store; they differ only in metadata.
|
||||
- **Receipts change by events, never by edits.** `receipt.unlinked`
|
||||
(`attachment_id`, `entry_id`, `reason`) detaches a receipt from its entry;
|
||||
`attachment.withdrawn` (`attachment_id`, `reason`) retires an unlinked
|
||||
receipt or a document (`withdrawn_at`, `withdrawn_reason` columns, schema
|
||||
v5). Linking requires an unlinked, non-withdrawn attachment, so evidence
|
||||
only leaves an entry through a recorded unlink. Blobs are untouched: a
|
||||
withdrawn file is still hash-verified and readable, and the journal shows
|
||||
when it was filed, linked, unlinked, and withdrawn, and why.
|
||||
- The store is policy-free: it records attachments faithfully. Whether a
|
||||
posting *requires* a receipt is decided by the MCP layer's receipt policy
|
||||
(`docs/mcp-interface.md`), which reads the `attachments` table.
|
||||
|
||||
+19
-4
@@ -34,10 +34,19 @@ requirement conflict, the requirement wins.
|
||||
total credits (the `balanced()` check).
|
||||
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
|
||||
type. The accounting equation holds exactly: assets = liabilities + equity.
|
||||
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net
|
||||
income to date) are folded into equity so the equation holds on a live book.
|
||||
- **R-9 — Profit and loss.** Revenue and expenses for the life of the ledger,
|
||||
with net income. Period filtering arrives with R-FUTURE-2.
|
||||
Current earnings (revenue minus expenses not yet closed to retained
|
||||
earnings) are folded into equity so the equation holds on a live book.
|
||||
`balance_sheet_as_of` gives the statement as of any date, a line per
|
||||
account, counting every entry dated on or before it (closing entries
|
||||
included: after a close the earnings sit in retained earnings).
|
||||
- **R-9 — Profit and loss.** Revenue and expenses with net income.
|
||||
`profit_and_loss_for_period` scopes it to a date range (optionally one
|
||||
class), a line per account, and **excludes closing entries**: a close moves
|
||||
earnings into retained earnings and must not make a closed period read as
|
||||
break-even. The cash flow statement excludes closing entries for the same
|
||||
reason. Period P&Ls partition: split a range anywhere and the halves sum
|
||||
to the whole, and a range's net income equals the change in current
|
||||
earnings across it (property-tested).
|
||||
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
|
||||
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
||||
keys). Two engines fed the same books must produce identical output. This
|
||||
@@ -52,6 +61,12 @@ requirement conflict, the requirement wins.
|
||||
|
||||
## Receipt policy (MCP phase)
|
||||
|
||||
- **R-13a — Receipt corrections.** Posted entries are immutable (R-3); their
|
||||
evidence is not frozen but is append-only. A receipt is replaced by
|
||||
attaching the new one and unlinking the old with a reason; unwanted
|
||||
receipts and documents are withdrawn with a reason. Unlinking may never
|
||||
leave an entry the policy (R-13) requires a receipt for with none. Every
|
||||
step is a journal event; no blob is deleted.
|
||||
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
|
||||
stays policy-free) enforces a configurable receipt policy: postings at or
|
||||
above a threshold (`require_receipts_above`, default `"25.00"` in base
|
||||
|
||||
+41
-2
@@ -51,8 +51,9 @@ public. Parity is 12 of 12 against the spreadsheet workflow.
|
||||
the last caught a real month-length-drift bug, fixed with the final-month
|
||||
true-up); `cargo-mutants` run on `ledger-core` (228 mutants); `cargo-fuzz`
|
||||
targets (journal-line parsing, entry validation, op sequences) scaffolded.
|
||||
- 40+ tests green workspace-wide; `cargo fmt` clean; clippy clean except one
|
||||
pre-existing phase-1 style lint on a public engine signature.
|
||||
- 120+ tests green workspace-wide; `cargo fmt` clean; `cargo clippy
|
||||
--workspace --all-targets` clean, zero warnings (a hard rule: Stephen,
|
||||
2026-10-11).
|
||||
- Docs: AGENTS.md (operating manual), requirements, architecture, oracle
|
||||
design, parity catalog (floor: 10 done), glossary, mcp-interface (incl. the
|
||||
5 asset tools), receipt-store (journal design), capability-parity
|
||||
@@ -151,3 +152,41 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
||||
(QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy
|
||||
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
|
||||
`mise run mcp-add`, then `claude` in the dir.
|
||||
- 2026-10-11: Receipts are mutable through events, never edits (Stephen:
|
||||
"the journal is just a history of every single thing that happens").
|
||||
`books.unlink_receipt` and `books.withdraw_attachment` (both require a
|
||||
reason) journal `receipt.unlinked` / `attachment.withdrawn`; nothing is
|
||||
erased from the journal or blob store. Unlinking can't strand an entry the
|
||||
receipt policy requires evidence for (attach the replacement first).
|
||||
`link_receipt` now refuses an already-linked or withdrawn attachment, which
|
||||
closes a silent path for moving a receipt off a posted entry; disposals
|
||||
check their receipt ids before posting. Schema v5. First real use: swapping
|
||||
the migrated Wanderland Labs `.eml` receipts for their PDF renders.
|
||||
- 2026-10-11: The cash flow statement is a first-class output everywhere the
|
||||
other two statements are: `cash_flow_pdf` in ledger-import, a `pdf`
|
||||
format on `books.export` (P&L, balance sheet, cash flow), and
|
||||
`cash-flow.pdf` in the CPA package, which now requires `cash_accounts`
|
||||
(the statement reconciles to them). Known gaps, next: the P&L and balance
|
||||
sheet PDF/XLSX ignore start/end/as_of (all-time balances under the
|
||||
requested label; core needs period-scoped statements); P&L and balance
|
||||
sheet XLSX are totals-only; PDF amounts don't align (padding in a
|
||||
proportional font), no company name, negatives as "-x".
|
||||
- 2026-10-11: Every statement export (csv, json, xlsx, pdf, CPA package)
|
||||
now renders core's period statements: the date range and as-of date are
|
||||
real, with a line per account. One layout (`ledger-import::report`:
|
||||
sections, lines, subtotals, totals; exact "(1,234.56)" formatting) feeds
|
||||
every renderer. The server reads `wanderbooks.toml`: company name for
|
||||
report headers, `fiscal_year_start` for fiscal YTD (end derived), and
|
||||
`receipt_threshold` (it was silently ignored before). Reports carry
|
||||
"Prepared from journal through seq N".
|
||||
- 2026-10-11: PDFs are typeset with Typst (typst 0.15 via typst-as-lib,
|
||||
fonts bundled by typst-assets; printpdf removed). One template,
|
||||
`ledger-import/templates/statement.typ`, takes the laid-out statement as
|
||||
JSON data (labels are never markup). US accounting presentation in PDF
|
||||
and XLSX alike: tabular figures, "$" on each section's first amount and
|
||||
on totals, negatives in parentheses with the closing paren hanging so
|
||||
decimal points align, zero as a dash, single rule over sums, double rule
|
||||
under totals, amounts never bold (bold figures are wider). Pages flow
|
||||
with a repeated header and "Page n of m". Core's cash flow now orders
|
||||
lines by account code (it followed HashMap order, so line order could
|
||||
change between runs).
|
||||
Reference in new issue
Block a user