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 {
|
impl ChartOfAccounts {
|
||||||
/// Register an account. Codes must be unique and non-empty; a parent, if
|
/// Register an account. Codes must be unique and non-empty; a parent, if
|
||||||
/// given, must already exist.
|
/// given, must already exist.
|
||||||
pub fn add(
|
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
|
||||||
&mut self,
|
if account.code.is_empty() {
|
||||||
id: AccountId,
|
|
||||||
code: &str,
|
|
||||||
name: &str,
|
|
||||||
account_type: AccountType,
|
|
||||||
parent: Option<AccountId>,
|
|
||||||
placeholder: bool,
|
|
||||||
currency: Currency,
|
|
||||||
) -> Result<(), LedgerError> {
|
|
||||||
if code.is_empty() {
|
|
||||||
return Err(LedgerError::EmptyAccountCode);
|
return Err(LedgerError::EmptyAccountCode);
|
||||||
}
|
}
|
||||||
if self.by_code.contains_key(code) {
|
if self.by_code.contains_key(&account.code) {
|
||||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
return Err(LedgerError::DuplicateAccountCode(account.code));
|
||||||
}
|
}
|
||||||
if let Some(p) = parent
|
if let Some(p) = account.parent
|
||||||
&& !self.accounts.contains_key(&p)
|
&& !self.accounts.contains_key(&p)
|
||||||
{
|
{
|
||||||
return Err(LedgerError::UnknownParentAccount(p));
|
return Err(LedgerError::UnknownParentAccount(p));
|
||||||
}
|
}
|
||||||
self.by_code.insert(code.to_string(), id);
|
self.by_code.insert(account.code.clone(), account.id);
|
||||||
self.accounts.insert(
|
self.accounts.insert(account.id, account);
|
||||||
id,
|
|
||||||
Account {
|
|
||||||
id,
|
|
||||||
code: code.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
account_type,
|
|
||||||
parent,
|
|
||||||
placeholder,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
//! audit trail and what will make the future oracle harness trustworthy.
|
//! audit trail and what will make the future oracle harness trustworthy.
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId, ChartOfAccounts,
|
Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId,
|
||||||
Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus, EntryId,
|
ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus,
|
||||||
EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money, NewAsset,
|
EntryId, EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money,
|
||||||
Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets,
|
NewAsset, Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets,
|
||||||
payroll::{
|
payroll::{
|
||||||
PayrollAccounts, PayrollLine, PayrollRun, PayrollRunId, payroll_entry_plan,
|
PayrollAccounts, PayrollLine, PayrollRun, PayrollRunId, payroll_entry_plan,
|
||||||
payroll_remit_plan, validate_payroll_lines,
|
payroll_remit_plan, validate_payroll_lines,
|
||||||
@@ -220,15 +220,15 @@ impl Ledger {
|
|||||||
) -> Result<AccountId, LedgerError> {
|
) -> Result<AccountId, LedgerError> {
|
||||||
let id = self.next_account_id;
|
let id = self.next_account_id;
|
||||||
self.next_account_id += 1;
|
self.next_account_id += 1;
|
||||||
self.chart.add(
|
self.chart.add(Account {
|
||||||
id,
|
id,
|
||||||
code,
|
code: code.to_string(),
|
||||||
name,
|
name: name.to_string(),
|
||||||
account_type,
|
account_type,
|
||||||
parent,
|
parent,
|
||||||
placeholder,
|
placeholder,
|
||||||
self.base_currency,
|
currency: self.base_currency,
|
||||||
)?;
|
})?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +568,16 @@ impl Ledger {
|
|||||||
self.periods.get(&id)
|
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.
|
/// All periods, in creation order.
|
||||||
pub fn list_periods(&self) -> Vec<&Period> {
|
pub fn list_periods(&self) -> Vec<&Period> {
|
||||||
let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect();
|
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 payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||||
pub use periods::{Period, PeriodId, PeriodStatus};
|
pub use periods::{Period, PeriodId, PeriodStatus};
|
||||||
pub use reports::{
|
pub use reports::{
|
||||||
BalanceSheet, CashFlowLine, CashFlowParams, CashFlowStatement, ClassProfitAndLoss,
|
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||||
ProfitAndLoss, TrialBalance, TrialBalanceLine, cash_flow,
|
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
|
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||||
//! produce byte-identical [`snapshot`] output.
|
//! 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 rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
/// One line of a trial balance.
|
/// One line of a trial balance.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[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.
|
/// Profit and loss: revenue and expenses for the life of the ledger. For a
|
||||||
/// (Period filtering is R-FUTURE work; see `docs/requirements.md` R-7.)
|
/// date range, with a line per account, use [`profit_and_loss_for_period`].
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ProfitAndLoss {
|
pub struct ProfitAndLoss {
|
||||||
/// Total revenue (credit-normal balances, reported positive).
|
/// 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.
|
/// Render the three core reports as canonical JSON for oracle diffing.
|
||||||
///
|
///
|
||||||
/// Amounts are decimal strings (never floats, never locale-formatted), lines
|
/// Amounts are decimal strings (never floats, never locale-formatted), lines
|
||||||
@@ -406,11 +601,14 @@ pub fn cash_flow(
|
|||||||
let financing_set: HashSet<crate::AccountId> =
|
let financing_set: HashSet<crate::AccountId> =
|
||||||
params.financing_accounts.iter().copied().collect();
|
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 movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| {
|
||||||
let mut total = Decimal::ZERO;
|
let mut total = Decimal::ZERO;
|
||||||
for entry in ledger.posted_entries() {
|
for entry in ledger.posted_entries() {
|
||||||
if !pred(entry.date()) {
|
if !pred(entry.date()) || closings.contains(&entry.id()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for line in entry.lines() {
|
for line in entry.lines() {
|
||||||
@@ -471,7 +669,11 @@ pub fn cash_flow(
|
|||||||
format!("{} {}", a.code, a.name)
|
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 {
|
if account.placeholder {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let revenue = ledger
|
ledger
|
||||||
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
|
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let gain = ledger
|
let gain = ledger
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use chrono::NaiveDate;
|
|||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
||||||
depreciation_schedule,
|
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 proptest::prelude::*;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -136,6 +139,52 @@ proptest! {
|
|||||||
prop_assert_eq!(bs.assets, bs.liabilities + bs.equity);
|
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.
|
/// Every posted entry balances on its own, exactly.
|
||||||
#[test]
|
#[test]
|
||||||
fn prop_posted_entries_balance((chart, entries) in arb_ops()) {
|
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"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rust_xlsxwriter = "0.90"
|
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
|
//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks
|
||||||
//! Desktop, which many CPAs still run.
|
//! Desktop, which many CPAs still run.
|
||||||
//!
|
//!
|
||||||
//! Layout per entry:
|
//! Layout per entry (fields are tab-separated; `\t` marks each tab):
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! !TRNS TRNSTYPE DATE ACCNT NAME AMOUNT DOCNUM MEMO
|
//! !TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO
|
||||||
//! !SPL TRNSTYPE DATE ACCNT AMOUNT DOCNUM MEMO
|
//! !SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO
|
||||||
//! !ENDTRNS
|
//! !ENDTRNS
|
||||||
//! TRNS GENERAL JOURNAL 01/15/2026 Cash 1000.00 1 Memo
|
//! TRNS\tGENERAL JOURNAL\t01/15/2026\tCash\t\t1000.00\t1\tMemo
|
||||||
//! SPL GENERAL JOURNAL 01/15/2026 Revenue -1000.00 1 Memo
|
//! SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00\t1\tMemo
|
||||||
//! ENDTRNS
|
//! ENDTRNS
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
|
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
|
||||||
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
|
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
|
||||||
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
|
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
|
||||||
|
//! - [`report`]: the statement layout every renderer draws from.
|
||||||
//! - [`xlsx`]: generate XLSX reports.
|
//! - [`xlsx`]: generate XLSX reports.
|
||||||
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
||||||
//!
|
//!
|
||||||
@@ -12,10 +13,12 @@ pub mod bank_csv;
|
|||||||
pub mod chart_csv;
|
pub mod chart_csv;
|
||||||
pub mod iif;
|
pub mod iif;
|
||||||
pub mod pdf;
|
pub mod pdf;
|
||||||
|
pub mod report;
|
||||||
pub mod xlsx;
|
pub mod xlsx;
|
||||||
|
|
||||||
pub use bank_csv::{BankTransaction, parse_bank_csv};
|
pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||||
pub use iif::{IifEntry, journal_to_iif};
|
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};
|
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||||
+161
-182
@@ -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
|
//! [`crate::report`] lays a statement out as rows; this module hands those
|
||||||
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
|
//! 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 crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||||
use printpdf::{
|
use std::sync::OnceLock;
|
||||||
BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt,
|
|
||||||
Rgb, TextItem,
|
|
||||||
};
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use typst::foundations::{Dict, IntoValue};
|
||||||
|
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
|
||||||
|
use typst_layout::PagedDocument;
|
||||||
|
|
||||||
/// PDF rendering errors.
|
/// PDF rendering errors.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PdfError {
|
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}")]
|
#[error("pdf save failed: {0}")]
|
||||||
Save(String),
|
Save(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn money(d: Decimal) -> String {
|
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||||||
// Two decimals, no float anywhere.
|
|
||||||
format!("{:.2}", d)
|
/// 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(
|
/// The template's input: the statement with every amount formatted.
|
||||||
title: &str,
|
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||||
subtitle: &str,
|
let kind = |k: RowKind| match k {
|
||||||
rows: &[(String, String)],
|
RowKind::Section => "section",
|
||||||
totals: &[(String, String)],
|
RowKind::Line => "line",
|
||||||
) -> Vec<Op> {
|
RowKind::Subtotal => "subtotal",
|
||||||
let mut ops = vec![
|
RowKind::Total => "total",
|
||||||
Op::StartTextSection,
|
RowKind::Spacer => "spacer",
|
||||||
Op::SetTextCursor {
|
};
|
||||||
pos: Point::new(Mm(20.0), Mm(280.0)),
|
serde_json::json!({
|
||||||
},
|
"company": meta.company,
|
||||||
Op::SetFont {
|
"title": st.title,
|
||||||
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
|
"period": st.period,
|
||||||
size: Pt(18.0),
|
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||||
},
|
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||||||
Op::SetLineHeight { lh: Pt(22.0) },
|
"kind": kind(r.kind),
|
||||||
Op::SetFillColor {
|
"label": r.label,
|
||||||
col: Color::Rgb(Rgb {
|
"rule": r.rule_above,
|
||||||
r: 0.0,
|
"amount": r.amount.map(|a| {
|
||||||
g: 0.0,
|
let p = amount_parts(a);
|
||||||
b: 0.0,
|
serde_json::json!({
|
||||||
icc_profile: None,
|
"digits": p.digits,
|
||||||
|
"negative": p.negative,
|
||||||
|
"zero": p.zero,
|
||||||
|
"currency": r.currency,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
},
|
})).collect::<Vec<_>>(),
|
||||||
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() {
|
/// Typeset a statement into a document (pages, before PDF encoding).
|
||||||
ops.push(Op::AddLineBreak);
|
fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError> {
|
||||||
ops.push(Op::SetFont {
|
let mut inputs = Dict::new();
|
||||||
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
|
inputs.insert(
|
||||||
size: Pt(11.0),
|
"data".into(),
|
||||||
});
|
template_data(st, meta).to_string().into_value(),
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
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,
|
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
|
||||||
/// then totals proving the accounting equation.
|
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
||||||
pub fn balance_sheet_pdf(ledger: &Ledger, as_of: &str) -> Result<Vec<u8>, PdfError> {
|
let doc = typeset(st, meta)?;
|
||||||
let mut asset_rows: Vec<(String, String)> = Vec::new();
|
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
|
||||||
let mut liab_rows: Vec<(String, String)> = Vec::new();
|
.map_err(|e| PdfError::Save(format!("{e:?}")))
|
||||||
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))),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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())];
|
#[cfg(test)]
|
||||||
rows.extend(asset_rows);
|
mod tests {
|
||||||
rows.push(("".to_string(), "".to_string()));
|
use super::*;
|
||||||
rows.push(("LIABILITIES".to_string(), "".to_string()));
|
use crate::report::{Row, profit_and_loss};
|
||||||
rows.extend(liab_rows);
|
use chrono::NaiveDate;
|
||||||
rows.push(("".to_string(), "".to_string()));
|
use ledger_core::reports::{ProfitAndLossStatement, StatementLine};
|
||||||
rows.push(("EQUITY".to_string(), "".to_string()));
|
use rust_decimal::Decimal;
|
||||||
rows.extend(equity_rows);
|
|
||||||
rows.push((
|
|
||||||
"Current earnings (in equity)".to_string(),
|
|
||||||
money(bs.current_earnings),
|
|
||||||
));
|
|
||||||
|
|
||||||
let totals = vec![
|
fn meta() -> ReportMeta {
|
||||||
("Total assets".to_string(), money(bs.assets)),
|
ReportMeta {
|
||||||
(
|
company: "Test Co".to_string(),
|
||||||
"Total liabilities + equity".to_string(),
|
journal_seq: 42,
|
||||||
money(bs.liabilities + bs.equity),
|
}
|
||||||
),
|
}
|
||||||
];
|
|
||||||
|
fn pl() -> ProfitAndLossStatement {
|
||||||
let ops = text_ops("Balance Sheet", &format!("As of {}", as_of), &rows, &totals);
|
let line = |code: &str, name: &str, cents| StatementLine {
|
||||||
save_pdf("Balance Sheet", ops)
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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}},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-158
@@ -2,12 +2,15 @@
|
|||||||
//!
|
//!
|
||||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
//! 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::Ledger;
|
||||||
use ledger_core::reports::{BalanceSheet, CashFlowStatement, ProfitAndLoss, TrialBalance};
|
use ledger_core::reports::TrialBalance;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use rust_xlsxwriter::{Format, Workbook};
|
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Which report to render.
|
/// Which report to render.
|
||||||
@@ -15,12 +18,9 @@ use thiserror::Error;
|
|||||||
pub enum XlsxReport<'a> {
|
pub enum XlsxReport<'a> {
|
||||||
/// Trial balance lines.
|
/// Trial balance lines.
|
||||||
TrialBalance(&'a TrialBalance),
|
TrialBalance(&'a TrialBalance),
|
||||||
/// Profit and loss.
|
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
|
||||||
ProfitAndLoss(&'a ProfitAndLoss, String, String),
|
/// [`crate::report`].
|
||||||
/// Balance sheet.
|
Statement(&'a Statement),
|
||||||
BalanceSheet(&'a BalanceSheet, String),
|
|
||||||
/// Cash flow statement.
|
|
||||||
CashFlow(&'a CashFlowStatement),
|
|
||||||
/// General ledger: every posted journal line. Needs the ledger for
|
/// General ledger: every posted journal line. Needs the ledger for
|
||||||
/// account names and class names.
|
/// account names and class names.
|
||||||
GeneralLedger(&'a Ledger),
|
GeneralLedger(&'a Ledger),
|
||||||
@@ -34,155 +34,133 @@ pub enum XlsxError {
|
|||||||
Write(String),
|
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.
|
/// 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 mut workbook = Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
let bold = Format::new().set_bold();
|
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 {
|
match report {
|
||||||
XlsxReport::TrialBalance(tb) => {
|
XlsxReport::TrialBalance(tb) => {
|
||||||
sheet.write_string(row, 0, "Trial Balance").unwrap();
|
sheet.set_name("Trial Balance")?;
|
||||||
row += 1;
|
let mut row = header(sheet, meta, "Trial Balance", None)?;
|
||||||
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
||||||
sheet
|
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||||
.write_string_with_format(row, i as u16, *h, &bold)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
row += 1;
|
row += 1;
|
||||||
for line in &tb.lines {
|
for line in &tb.lines {
|
||||||
sheet.write_string(row, 0, &line.code).unwrap();
|
sheet.write_string(row, 0, &line.code)?;
|
||||||
sheet.write_string(row, 1, &line.name).unwrap();
|
sheet.write_string(row, 1, &line.name)?;
|
||||||
sheet
|
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
|
||||||
.write_number_with_format(row, 2, dec_to_f64(line.debit), &money_fmt)
|
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
||||||
.unwrap();
|
|
||||||
sheet
|
|
||||||
.write_number_with_format(row, 3, dec_to_f64(line.credit), &money_fmt)
|
|
||||||
.unwrap();
|
|
||||||
row += 1;
|
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) => {
|
XlsxReport::Statement(st) => {
|
||||||
sheet
|
sheet.set_name(&st.title)?;
|
||||||
.write_string(row, 0, format!("Profit and Loss ({start} to {end})"))
|
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
||||||
.unwrap();
|
let line_label = Format::new().set_indent(1);
|
||||||
row += 2;
|
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
|
||||||
sheet.write_string(row, 0, "Revenue").unwrap();
|
let total = usd_bold
|
||||||
sheet
|
.clone()
|
||||||
.write_number_with_format(row, 1, dec_to_f64(pl.revenue), &money_fmt)
|
.set_border_top(FormatBorder::Thin)
|
||||||
.unwrap();
|
.set_border_bottom(FormatBorder::Double);
|
||||||
row += 1;
|
for r in &st.rows {
|
||||||
sheet.write_string(row, 0, "Expenses").unwrap();
|
let amount = r.amount.map(dec_to_f64);
|
||||||
sheet
|
match r.kind {
|
||||||
.write_number_with_format(row, 1, dec_to_f64(pl.expenses), &money_fmt)
|
RowKind::Spacer => {}
|
||||||
.unwrap();
|
RowKind::Section => {
|
||||||
row += 1;
|
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||||
sheet
|
}
|
||||||
.write_string_with_format(row, 0, "Net income", &bold)
|
RowKind::Line => {
|
||||||
.unwrap();
|
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
||||||
let net = pl.revenue - pl.expenses;
|
if let Some(a) = amount {
|
||||||
sheet
|
let fmt = if r.currency { &usd } else { &money };
|
||||||
.write_number_with_format(row, 1, dec_to_f64(net), &money_fmt)
|
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||||
.unwrap();
|
}
|
||||||
|
}
|
||||||
|
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)?;
|
||||||
}
|
}
|
||||||
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();
|
|
||||||
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;
|
row += 1;
|
||||||
}
|
}
|
||||||
sheet
|
sheet.set_column_width(0, 60)?;
|
||||||
.write_string_with_format(row, 0, "Cash from operations", &bold)
|
sheet.set_column_width(1, 16)?;
|
||||||
.unwrap();
|
footer(sheet, meta, row)?;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
XlsxReport::GeneralLedger(ledger) => {
|
XlsxReport::GeneralLedger(ledger) => {
|
||||||
sheet.write_string(row, 0, "General Ledger").unwrap();
|
sheet.set_name("General Ledger")?;
|
||||||
row += 1;
|
let mut row = header(sheet, meta, "General Ledger", None)?;
|
||||||
for (i, h) in [
|
for (i, h) in [
|
||||||
"Date",
|
"Date",
|
||||||
"Entry",
|
"Entry",
|
||||||
@@ -196,9 +174,7 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
sheet
|
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||||
.write_string_with_format(row, i as u16, *h, &bold)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
row += 1;
|
row += 1;
|
||||||
for entry in ledger.posted_entries() {
|
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::Debit => (line.amount.amount, Decimal::ZERO),
|
||||||
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
||||||
};
|
};
|
||||||
sheet
|
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||||
.write_string(row, 0, entry.date().to_string())
|
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||||
.unwrap();
|
sheet.write_string(row, 2, entry.description())?;
|
||||||
sheet.write_number(row, 1, entry.id() as f64).unwrap();
|
sheet.write_string(row, 3, code)?;
|
||||||
sheet.write_string(row, 2, entry.description()).unwrap();
|
sheet.write_string(row, 4, name)?;
|
||||||
sheet.write_string(row, 3, code).unwrap();
|
sheet.write_string(row, 5, class_name)?;
|
||||||
sheet.write_string(row, 4, name).unwrap();
|
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
|
||||||
sheet.write_string(row, 5, class_name).unwrap();
|
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
|
||||||
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();
|
|
||||||
row += 1;
|
row += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sheet.set_column_width(2, 50)?;
|
||||||
|
sheet.set_column_width(4, 32)?;
|
||||||
|
footer(sheet, meta, row)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
workbook
|
Ok(workbook.save_to_buffer()?)
|
||||||
.save_to_buffer()
|
|
||||||
.map_err(|e| XlsxError::Write(e.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
||||||
@@ -265,7 +236,11 @@ mod tests {
|
|||||||
total_debits: Decimal::new(10000, 2),
|
total_debits: Decimal::new(10000, 2),
|
||||||
total_credits: 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.
|
// XLSX is a ZIP: starts with PK.
|
||||||
assert_eq!(&bytes[0..2], b"PK");
|
assert_eq!(&bytes[0..2], b"PK");
|
||||||
assert!(bytes.len() > 1000);
|
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"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
|
||||||
zip = "9.0.1"
|
zip = "9.0.1"
|
||||||
|
toml = "1"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
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.
|
//! `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 data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||||
//! and optionally `config.json`. If no store exists, one is created with the
|
//! 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 policy;
|
||||||
mod server;
|
mod server;
|
||||||
mod types;
|
mod types;
|
||||||
@@ -25,9 +28,10 @@ struct Config {
|
|||||||
/// ISO 4217 base currency for a new store. Default "USD".
|
/// ISO 4217 base currency for a new store. Default "USD".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
base_currency: Option<String>,
|
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)]
|
#[serde(default)]
|
||||||
receipt_policy: ReceiptPolicy,
|
receipt_policy: Option<ReceiptPolicy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_data_dir() -> PathBuf {
|
fn default_data_dir() -> PathBuf {
|
||||||
@@ -36,15 +40,23 @@ fn default_data_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn print_usage() {
|
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!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
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>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut data_dir = default_data_dir();
|
let mut data_dir = default_data_dir();
|
||||||
let mut config_path: Option<PathBuf> = None;
|
let mut config_path: Option<PathBuf> = None;
|
||||||
|
let mut books_toml: Option<PathBuf> = None;
|
||||||
let mut http_addr: Option<String> = None;
|
let mut http_addr: Option<String> = None;
|
||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
@@ -55,6 +67,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
"--config" => {
|
"--config" => {
|
||||||
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
|
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" => {
|
||||||
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
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.
|
// Logs go to stderr: stdout is the MCP channel.
|
||||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
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() {
|
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||||
Store::open(&data_dir)?
|
Store::open(&data_dir)?
|
||||||
@@ -99,12 +134,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.build()?;
|
.build()?;
|
||||||
if let Some(addr) = http_addr {
|
if let Some(addr) = http_addr {
|
||||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
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 {
|
rt.block_on(async {
|
||||||
let running = server.serve(stdio()).await?;
|
let running = server.serve(stdio()).await?;
|
||||||
let _ = running.waiting().await;
|
let _ = running.waiting().await;
|
||||||
@@ -117,6 +152,7 @@ async fn serve_http(
|
|||||||
addr: String,
|
addr: String,
|
||||||
store: Store,
|
store: Store,
|
||||||
policy: ReceiptPolicy,
|
policy: ReceiptPolicy,
|
||||||
|
company: company::Company,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use rmcp::transport::streamable_http_server::{
|
use rmcp::transport::streamable_http_server::{
|
||||||
StreamableHttpService, session::local::LocalSessionManager,
|
StreamableHttpService, session::local::LocalSessionManager,
|
||||||
@@ -126,7 +162,8 @@ async fn serve_http(
|
|||||||
let store = Arc::new(std::sync::Mutex::new(store));
|
let store = Arc::new(std::sync::Mutex::new(store));
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || {
|
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)
|
Ok(server)
|
||||||
},
|
},
|
||||||
Arc::new(LocalSessionManager::default()),
|
Arc::new(LocalSessionManager::default()),
|
||||||
|
|||||||
+420
-280
@@ -1,14 +1,16 @@
|
|||||||
//! The MCP server: narrow typed tools over the store, with the receipt
|
//! The MCP server: narrow typed tools over the store, with the receipt
|
||||||
//! policy enforced server-side.
|
//! policy enforced server-side.
|
||||||
|
|
||||||
|
use crate::company::Company;
|
||||||
use crate::policy::ReceiptPolicy;
|
use crate::policy::ReceiptPolicy;
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::NaiveDate;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||||
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
||||||
depreciation_schedule, reports,
|
depreciation_schedule, reports,
|
||||||
};
|
};
|
||||||
|
use ledger_import::ReportMeta;
|
||||||
use ledger_store::Store;
|
use ledger_store::Store;
|
||||||
use rmcp::handler::server::wrapper::{Json, Parameters};
|
use rmcp::handler::server::wrapper::{Json, Parameters};
|
||||||
use rmcp::model::{
|
use rmcp::model::{
|
||||||
@@ -21,7 +23,7 @@ use rust_decimal::Decimal;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// The agent operating contract, delivered via initialize.
|
/// 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.
|
/// Max receipt/document upload: 25 MB.
|
||||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||||
@@ -33,6 +35,7 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
|||||||
pub struct BooksServer {
|
pub struct BooksServer {
|
||||||
store: Arc<Mutex<Store>>,
|
store: Arc<Mutex<Store>>,
|
||||||
policy: ReceiptPolicy,
|
policy: ReceiptPolicy,
|
||||||
|
company: Company,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BooksServer {
|
impl BooksServer {
|
||||||
@@ -41,13 +44,32 @@ impl BooksServer {
|
|||||||
Self {
|
Self {
|
||||||
store: Arc::new(Mutex::new(store)),
|
store: Arc::new(Mutex::new(store)),
|
||||||
policy,
|
policy,
|
||||||
|
company: Company::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the server around a shared store (for HTTP: each connection
|
/// Build the server around a shared store (for HTTP: each connection
|
||||||
/// gets a handle to the same store).
|
/// gets a handle to the same store).
|
||||||
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
|
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> {
|
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Store>, String> {
|
||||||
@@ -55,6 +77,43 @@ impl BooksServer {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "store lock poisoned".to_string())
|
.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 {
|
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()
|
n.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,50 +284,53 @@ fn preview_receipts(store: &Store, entry_id: u64) -> Result<Vec<PreviewReceipt>,
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Net movement of one account over entries on or before `through`, in the
|
/// Build the cash flow statement from account codes, as the exports need it:
|
||||||
/// account's normal side.
|
/// `start` defaults to the beginning of time, `end` to today, and at least
|
||||||
fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) -> Decimal {
|
/// one cash account is required (the statement reconciles to them).
|
||||||
let movement: Decimal = ledger
|
fn cash_flow_statement(
|
||||||
.posted_entries()
|
ledger: &Ledger,
|
||||||
.filter(|e| e.date() <= through)
|
start: Option<&str>,
|
||||||
.flat_map(|e| e.lines())
|
end: Option<&str>,
|
||||||
.filter(|l| l.account == account)
|
cash_codes: &[String],
|
||||||
.map(|l| match l.side {
|
financing_codes: &[String],
|
||||||
Side::Debit => l.amount.amount,
|
) -> Result<ledger_core::reports::CashFlowStatement, String> {
|
||||||
Side::Credit => -l.amount.amount,
|
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}"))
|
||||||
})
|
})
|
||||||
.sum();
|
.collect()
|
||||||
let account = ledger.chart().get(account).expect("account exists");
|
};
|
||||||
match account.account_type.normal_side() {
|
let cash_accounts = resolve(cash_codes, "cash")?;
|
||||||
Side::Debit => movement,
|
if cash_accounts.is_empty() {
|
||||||
Side::Credit => -movement,
|
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.
|
/// Net movement of one account over entries in `[from, to]`, normal side.
|
||||||
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal {
|
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
|
let movement: Decimal = ledger
|
||||||
.posted_entries()
|
.posted_entries()
|
||||||
.filter(|e| e.date() >= from && e.date() <= to)
|
.filter(|e| e.date() >= from && e.date() <= to)
|
||||||
.flat_map(|e| e.lines())
|
.flat_map(|e| e.lines())
|
||||||
.filter(|l| l.account == account)
|
.filter(|l| l.account == account)
|
||||||
.filter(|l| match class_id {
|
|
||||||
None => true,
|
|
||||||
Some(cid) => l.class_id == Some(cid),
|
|
||||||
})
|
|
||||||
.map(|l| match l.side {
|
.map(|l| match l.side {
|
||||||
Side::Debit => l.amount.amount,
|
Side::Debit => l.amount.amount,
|
||||||
Side::Credit => -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
|
// Tools
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -372,44 +504,24 @@ impl BooksServer {
|
|||||||
) -> Result<Json<ProfitAndLossOut>, String> {
|
) -> Result<Json<ProfitAndLossOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let class_id = match &p.class {
|
||||||
let start = match p.start {
|
Some(c) => Some(resolve_class(&store, c)?),
|
||||||
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)?),
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let mut revenue = Decimal::ZERO;
|
let pl = pnl_statement(
|
||||||
let mut expenses = Decimal::ZERO;
|
store.ledger(),
|
||||||
for a in ledger.chart().all() {
|
p.start.as_deref(),
|
||||||
if a.placeholder {
|
p.end.as_deref(),
|
||||||
continue;
|
class_id,
|
||||||
}
|
)?;
|
||||||
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)
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Json(ProfitAndLossOut {
|
Ok(Json(ProfitAndLossOut {
|
||||||
start: start.to_string(),
|
start: pl.start.to_string(),
|
||||||
end: end.to_string(),
|
end: pl.end.to_string(),
|
||||||
revenue: money(revenue),
|
revenue: money(pl.total_revenue),
|
||||||
expenses: money(expenses),
|
expenses: money(pl.total_expenses),
|
||||||
net_income: money(revenue - 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> {
|
) -> Result<Json<BalanceSheetOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let bs = bs_statement(store.ledger(), p.as_of.as_deref())?;
|
||||||
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;
|
|
||||||
Ok(Json(BalanceSheetOut {
|
Ok(Json(BalanceSheetOut {
|
||||||
as_of: as_of.to_string(),
|
as_of: bs.as_of.to_string(),
|
||||||
assets: money(assets),
|
assets: money(bs.total_assets),
|
||||||
liabilities: money(liabilities),
|
liabilities: money(bs.total_liabilities),
|
||||||
equity: money(equity),
|
equity: money(bs.total_equity),
|
||||||
current_earnings: money(current_earnings),
|
current_earnings: money(bs.current_earnings),
|
||||||
balanced: assets == liabilities + equity,
|
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 }))
|
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(
|
#[tool(
|
||||||
name = "books.list_receipts",
|
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)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn list_receipts(
|
async fn list_receipts(
|
||||||
@@ -857,10 +1004,12 @@ impl BooksServer {
|
|||||||
) -> Result<Json<ListReceiptsOut>, String> {
|
) -> Result<Json<ListReceiptsOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
|
let include_withdrawn = p.include_withdrawn.unwrap_or(false);
|
||||||
let receipts = store
|
let receipts = store
|
||||||
.list_attachments(p.entry_id)
|
.list_attachments(p.entry_id)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|a| include_withdrawn || a.withdrawn_at.is_none())
|
||||||
.map(|a| ReceiptOut {
|
.map(|a| ReceiptOut {
|
||||||
attachment_id: a.id,
|
attachment_id: a.id,
|
||||||
sha256: a.blob_sha,
|
sha256: a.blob_sha,
|
||||||
@@ -870,6 +1019,8 @@ impl BooksServer {
|
|||||||
mime_type: a.mime_type,
|
mime_type: a.mime_type,
|
||||||
size_bytes: a.size_bytes,
|
size_bytes: a.size_bytes,
|
||||||
description: a.description,
|
description: a.description,
|
||||||
|
withdrawn: a.withdrawn_at.is_some(),
|
||||||
|
withdrawn_reason: a.withdrawn_reason,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Json(ListReceiptsOut { receipts }))
|
Ok(Json(ListReceiptsOut { receipts }))
|
||||||
@@ -1234,15 +1385,15 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export",
|
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)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let format = p.format.to_lowercase();
|
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!(
|
return Err(format!(
|
||||||
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", or \"iif\"",
|
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"",
|
||||||
p.format
|
p.format
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1250,8 +1401,12 @@ impl BooksServer {
|
|||||||
if format == "iif" && report != "journal" {
|
if format == "iif" && report != "journal" {
|
||||||
return Err("iif format is only valid for the journal report".to_string());
|
return Err("iif format is only valid for the journal report".to_string());
|
||||||
}
|
}
|
||||||
if format == "xlsx" {
|
if format == "xlsx" || format == "pdf" {
|
||||||
let bytes = self.export_xlsx(&report, &p)?;
|
let bytes = if format == "xlsx" {
|
||||||
|
self.export_xlsx(&report, &p)?
|
||||||
|
} else {
|
||||||
|
self.export_pdf(&report, &p)?
|
||||||
|
};
|
||||||
return Ok(Json(ExportOut {
|
return Ok(Json(ExportOut {
|
||||||
format,
|
format,
|
||||||
report,
|
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.
|
/// Render a report to XLSX bytes via ledger-import.
|
||||||
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||||
use ledger_import::{XlsxReport, report_to_xlsx};
|
use ledger_import::{XlsxReport, report_to_xlsx};
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
|
let meta = self.report_meta(&store);
|
||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
// Build owned report data first (all report structs are owned).
|
if report == "trial_balance" {
|
||||||
enum Owned {
|
let tb = reports::trial_balance(ledger);
|
||||||
Tb(ledger_core::reports::TrialBalance),
|
return report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta)
|
||||||
Pnl(ledger_core::reports::ProfitAndLoss, String, String),
|
.map_err(|e| e.to_string());
|
||||||
Bs(ledger_core::reports::BalanceSheet, String),
|
|
||||||
Cf(ledger_core::reports::CashFlowStatement),
|
|
||||||
}
|
}
|
||||||
let owned = match report {
|
let st = self.layout_statement(ledger, report, p)?;
|
||||||
"trial_balance" => Owned::Tb(reports::trial_balance(ledger)),
|
report_to_xlsx(&XlsxReport::Statement(&st), &meta).map_err(|e| e.to_string())
|
||||||
"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 {
|
/// Render a financial statement to PDF bytes via ledger-import.
|
||||||
Some(s) => parse_date(s)?,
|
fn export_pdf(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||||
None => chrono::Utc::now().date_naive(),
|
let store = self.lock()?;
|
||||||
};
|
let meta = self.report_meta(&store);
|
||||||
// Engine balance_sheet is as-of now; xlsx uses the same.
|
let st = self
|
||||||
// (The books.balance_sheet tool does as-of via movement_through.)
|
.layout_statement(store.ledger(), report, p)
|
||||||
Owned::Bs(
|
.map_err(|e| e.replace("bad report", "bad report for pdf"))?;
|
||||||
reports::balance_sheet(ledger).map_err(|e| e.to_string())?,
|
ledger_import::statement_pdf(&st, &meta).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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the journal as IIF text via ledger-import.
|
/// Render the journal as IIF text via ledger-import.
|
||||||
@@ -1687,6 +1808,9 @@ impl BooksServer {
|
|||||||
}
|
}
|
||||||
.to_tool_error());
|
.to_tool_error());
|
||||||
}
|
}
|
||||||
|
for aid in &attachment_ids {
|
||||||
|
store.check_linkable(*aid).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
let entry_id = store
|
let entry_id = store
|
||||||
.dispose_asset(
|
.dispose_asset(
|
||||||
p.asset_id,
|
p.asset_id,
|
||||||
@@ -1775,8 +1899,9 @@ impl BooksServer {
|
|||||||
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
|
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
|
||||||
// Build line descriptions from the posted closing entry.
|
// Build line descriptions from the posted closing entry.
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
if let Some(eid) = closing {
|
if let Some(eid) = closing
|
||||||
if let Some(entry) = store.ledger().get_entry(eid) {
|
&& let Some(entry) = store.ledger().get_entry(eid)
|
||||||
|
{
|
||||||
for line in entry.lines() {
|
for line in entry.lines() {
|
||||||
let code = store
|
let code = store
|
||||||
.ledger()
|
.ledger()
|
||||||
@@ -1787,7 +1912,6 @@ impl BooksServer {
|
|||||||
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
|
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(Json(ClosePeriodOut {
|
Ok(Json(ClosePeriodOut {
|
||||||
status: "closed".to_string(),
|
status: "closed".to_string(),
|
||||||
period_id: p.period_id,
|
period_id: p.period_id,
|
||||||
@@ -2380,41 +2504,49 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export_cpa_package",
|
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)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn export_cpa_package(
|
async fn export_cpa_package(
|
||||||
&self,
|
&self,
|
||||||
params: Parameters<ExportCpaPackageParams>,
|
params: Parameters<ExportCpaPackageParams>,
|
||||||
) -> Result<Json<ExportCpaPackageOut>, String> {
|
) -> 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 p = params.0;
|
||||||
let as_of = match p.as_of {
|
let as_of = match p.as_of {
|
||||||
Some(s) => parse_date(&s)?,
|
Some(s) => parse_date(&s)?,
|
||||||
None => chrono::Utc::now().date_naive(),
|
None => today(),
|
||||||
};
|
};
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
|
let meta = self.report_meta(&store);
|
||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
|
|
||||||
// Fiscal year to date for the P&L: Jan 1 of the as_of year.
|
// Fiscal year to date, per wanderbooks.toml's fiscal_year_start.
|
||||||
let fy_start = NaiveDate::from_ymd_opt(as_of.year(), 1, 1).unwrap();
|
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_xlsx = xlsx(XlsxReport::TrialBalance(&reports::trial_balance(ledger)))?;
|
||||||
let tb = ledger_core::reports::trial_balance(ledger);
|
let gl_xlsx = xlsx(XlsxReport::GeneralLedger(ledger))?;
|
||||||
let tb_xlsx = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).map_err(|e| e.to_string())?;
|
let pnl_pdf = pdf(report::profit_and_loss(&pnl_statement(
|
||||||
|
ledger,
|
||||||
// 2. General ledger XLSX (every posted line).
|
Some(&fy_start),
|
||||||
let gl_xlsx =
|
Some(&as_of_s),
|
||||||
report_to_xlsx(&XlsxReport::GeneralLedger(ledger)).map_err(|e| e.to_string())?;
|
None,
|
||||||
|
)?))?;
|
||||||
// 3. P&L PDF (fiscal year to date).
|
let bs_pdf = pdf(report::balance_sheet(&bs_statement(
|
||||||
let pnl_pdf =
|
ledger,
|
||||||
ledger_import::profit_and_loss_pdf(ledger, &fy_start.to_string(), &as_of.to_string())
|
Some(&as_of_s),
|
||||||
.map_err(|e| e.to_string())?;
|
)?))?;
|
||||||
|
let cf_pdf = pdf(report::cash_flow(&cash_flow_statement(
|
||||||
// 4. Balance sheet PDF.
|
ledger,
|
||||||
let bs_pdf = ledger_import::balance_sheet_pdf(ledger, &as_of.to_string())
|
Some(&fy_start),
|
||||||
.map_err(|e| e.to_string())?;
|
Some(&as_of_s),
|
||||||
|
&p.cash_accounts,
|
||||||
|
&p.financing_accounts,
|
||||||
|
)?))?;
|
||||||
drop(store);
|
drop(store);
|
||||||
|
|
||||||
// Zip it.
|
// Zip it.
|
||||||
@@ -2427,6 +2559,7 @@ impl BooksServer {
|
|||||||
("general-ledger.xlsx", gl_xlsx.as_slice()),
|
("general-ledger.xlsx", gl_xlsx.as_slice()),
|
||||||
("profit-and-loss.pdf", pnl_pdf.as_slice()),
|
("profit-and-loss.pdf", pnl_pdf.as_slice()),
|
||||||
("balance-sheet.pdf", bs_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())?;
|
zip.start_file(name, opts).map_err(|e| e.to_string())?;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -2543,86 +2676,75 @@ impl BooksServer {
|
|||||||
end: Option<&str>,
|
end: Option<&str>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let pl = pnl_statement(store.ledger(), start, end, None)?;
|
||||||
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),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if format == "json" {
|
if format == "json" {
|
||||||
return serde_json::to_string_pretty(&serde_json::json!({
|
return serde_json::to_string_pretty(&serde_json::json!({
|
||||||
"start": start_d.to_string(), "end": end_d.to_string(),
|
"start": pl.start.to_string(), "end": pl.end.to_string(),
|
||||||
"revenue": money(revenue), "expenses": money(expenses),
|
"revenue_accounts": lines_out(&pl.revenue),
|
||||||
"net_income": money(revenue - expenses),
|
"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());
|
.map_err(|e| e.to_string());
|
||||||
}
|
}
|
||||||
Ok(format!(
|
let mut csv = String::from("section,code,name,amount\n");
|
||||||
"metric,amount\nrevenue,{}\nexpenses,{}\nnet_income,{}\n",
|
for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] {
|
||||||
money(revenue),
|
for l in lines {
|
||||||
money(expenses),
|
csv.push_str(&format!(
|
||||||
money(revenue - expenses)
|
"{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> {
|
fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result<String, String> {
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let bs = bs_statement(store.ledger(), as_of)?;
|
||||||
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;
|
|
||||||
if format == "json" {
|
if format == "json" {
|
||||||
return serde_json::to_string_pretty(&serde_json::json!({
|
return serde_json::to_string_pretty(&serde_json::json!({
|
||||||
"as_of": as_of_d.to_string(),
|
"as_of": bs.as_of.to_string(),
|
||||||
"assets": money(assets), "liabilities": money(liabilities),
|
"asset_accounts": lines_out(&bs.assets),
|
||||||
"equity": money(equity), "current_earnings": money(current_earnings),
|
"liability_accounts": lines_out(&bs.liabilities),
|
||||||
"balanced": assets == liabilities + equity,
|
"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());
|
.map_err(|e| e.to_string());
|
||||||
}
|
}
|
||||||
Ok(format!(
|
let mut csv = String::from("section,code,name,amount\n");
|
||||||
"metric,amount\nassets,{}\nliabilities,{}\nequity,{}\ncurrent_earnings,{}\n",
|
for (section, lines) in [
|
||||||
money(assets),
|
("assets", &bs.assets),
|
||||||
money(liabilities),
|
("liabilities", &bs.liabilities),
|
||||||
money(equity),
|
("equity", &bs.equity),
|
||||||
money(current_earnings)
|
] {
|
||||||
))
|
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> {
|
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,
|
pub expenses: String,
|
||||||
/// Revenue minus expenses, decimal string. Positive is profit.
|
/// Revenue minus expenses, decimal string. Positive is profit.
|
||||||
pub net_income: String,
|
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`.
|
/// Parameters for `books.cash_flow`.
|
||||||
@@ -285,6 +300,12 @@ pub struct BalanceSheetOut {
|
|||||||
pub current_earnings: String,
|
pub current_earnings: String,
|
||||||
/// True when assets equal liabilities plus equity exactly.
|
/// True when assets equal liabilities plus equity exactly.
|
||||||
pub balanced: bool,
|
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,
|
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`.
|
/// Parameters for `books.list_receipts`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ListReceiptsParams {
|
pub struct ListReceiptsParams {
|
||||||
/// Filter to one entry. Omit for all.
|
/// Filter to one entry. Omit for all.
|
||||||
pub entry_id: Option<u64>,
|
pub entry_id: Option<u64>,
|
||||||
|
/// Include withdrawn attachments. Default false.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_withdrawn: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One receipt or document.
|
/// One receipt or document.
|
||||||
@@ -529,6 +590,10 @@ pub struct ReceiptOut {
|
|||||||
pub size_bytes: u64,
|
pub size_bytes: u64,
|
||||||
/// Description, if any.
|
/// Description, if any.
|
||||||
pub description: Option<String>,
|
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`.
|
/// Result of `books.list_receipts`.
|
||||||
@@ -713,8 +778,9 @@ pub struct DigestOut {
|
|||||||
/// Parameters for `books.export`.
|
/// Parameters for `books.export`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ExportParams {
|
pub struct ExportParams {
|
||||||
/// "csv", "json", "xlsx", or "iif". XLSX is base64-encoded; IIF is only
|
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
|
||||||
/// valid with report "journal".
|
/// base64-encoded; PDF covers profit_and_loss, balance_sheet, and
|
||||||
|
/// cash_flow; IIF is only valid with report "journal".
|
||||||
pub format: String,
|
pub format: String,
|
||||||
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
/// "journal", "trial_balance", "profit_and_loss", "balance_sheet", or
|
||||||
/// "cash_flow".
|
/// "cash_flow".
|
||||||
@@ -728,6 +794,10 @@ pub struct ExportParams {
|
|||||||
/// Cash account codes for cash_flow (required for that report).
|
/// Cash account codes for cash_flow (required for that report).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cash_accounts: Vec<String>,
|
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`.
|
/// Result of `books.export`.
|
||||||
@@ -738,9 +808,9 @@ pub struct ExportOut {
|
|||||||
/// The requested report.
|
/// The requested report.
|
||||||
pub report: String,
|
pub report: String,
|
||||||
/// The exported content. Base64-encoded when `content_encoding` is
|
/// The exported content. Base64-encoded when `content_encoding` is
|
||||||
/// "base64" (XLSX); otherwise raw text.
|
/// "base64" (XLSX, PDF); otherwise raw text.
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// "base64" for XLSX, otherwise absent.
|
/// "base64" for XLSX and PDF, otherwise absent.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub content_encoding: Option<String>,
|
pub content_encoding: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -1403,6 +1473,14 @@ pub struct ProfitAndLossByClassOut {
|
|||||||
pub struct ExportCpaPackageParams {
|
pub struct ExportCpaPackageParams {
|
||||||
/// As-of date, YYYY-MM-DD. Defaults to today.
|
/// As-of date, YYYY-MM-DD. Defaults to today.
|
||||||
pub as_of: Option<String>,
|
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`.
|
/// Output of `books.export_cpa_package`.
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ struct Mcp {
|
|||||||
|
|
||||||
impl Mcp {
|
impl Mcp {
|
||||||
fn spawn(data_dir: &std::path::Path) -> Self {
|
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 bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.arg("--data-dir")
|
.arg("--data-dir")
|
||||||
.arg(data_dir)
|
.arg(data_dir)
|
||||||
|
.args(extra)
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
@@ -134,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
// Tool listing: 23 narrow tools, chart is read-only.
|
// Tool listing: 23 narrow tools, chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
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();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -196,7 +201,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|l| l["code"] == "6000")
|
.find(|l| l["code"] == "6000")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rent_line["balance_before"], "0");
|
assert_eq!(rent_line["balance_before"], "0.00");
|
||||||
assert_eq!(rent_line["balance_after"], "100.00");
|
assert_eq!(rent_line["balance_after"], "100.00");
|
||||||
|
|
||||||
// Confirmed post without a receipt is rejected: >$25, no receipt.
|
// 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");
|
assert_eq!(rev["status"], "posted");
|
||||||
let tb2 = mcp.call("books.trial_balance", json!({}));
|
let tb2 = mcp.call("books.trial_balance", json!({}));
|
||||||
assert_eq!(tb2["balanced"], true);
|
assert_eq!(tb2["balanced"], true);
|
||||||
assert_eq!(tb2["total_debits"], "0");
|
assert_eq!(tb2["total_debits"], "0.00");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -376,7 +381,7 @@ fn mcp_assets_end_to_end_over_stdio() {
|
|||||||
assert_eq!(preview["status"], "preview");
|
assert_eq!(preview["status"], "preview");
|
||||||
assert_eq!(preview["book_value"], "58451.61");
|
assert_eq!(preview["book_value"], "58451.61");
|
||||||
assert_eq!(preview["gain_loss"], "1548.39");
|
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);
|
assert_eq!(preview["receipt_required"], true);
|
||||||
|
|
||||||
// Confirmed without the bill of sale: rejected.
|
// Confirmed without the bill of sale: rejected.
|
||||||
@@ -534,6 +539,141 @@ fn mcp_receipt_policy_rejects_split_entries() {
|
|||||||
assert_eq!(p3["status"], "posted");
|
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]
|
#[test]
|
||||||
fn mcp_resources_over_stdio() {
|
fn mcp_resources_over_stdio() {
|
||||||
let dir = test_dir("resources");
|
let dir = test_dir("resources");
|
||||||
@@ -548,7 +688,7 @@ fn mcp_resources_over_stdio() {
|
|||||||
);
|
);
|
||||||
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
||||||
// Resources are advertised.
|
// Resources are advertised.
|
||||||
assert_eq!(init["result"]["capabilities"]["resources"].is_null(), false);
|
assert!(!init["result"]["capabilities"]["resources"].is_null());
|
||||||
mcp.notify("notifications/initialized");
|
mcp.notify("notifications/initialized");
|
||||||
|
|
||||||
mcp.call(
|
mcp.call(
|
||||||
@@ -641,3 +781,201 @@ fn mcp_import_export_over_stdio() {
|
|||||||
assert_eq!(xlsx["content_encoding"], "base64");
|
assert_eq!(xlsx["content_encoding"], "base64");
|
||||||
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs"));
|
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}")]
|
#[error("unknown attachment {0}")]
|
||||||
UnknownAttachment(u64),
|
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.
|
/// The store's schema is newer than this binary understands.
|
||||||
#[error("schema version {0} newer than supported {1}")]
|
#[error("schema version {0} newer than supported {1}")]
|
||||||
SchemaTooNew(u32, u32),
|
SchemaTooNew(u32, u32),
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ pub struct JournalLine {
|
|||||||
pub ts: String,
|
pub ts: String,
|
||||||
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
|
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
|
||||||
/// `entry.posted`, `entry.reversed`, `entry.line_class_set`,
|
/// `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`,
|
/// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`,
|
||||||
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
|
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
|
||||||
/// `vendor.added`, `invoice.created`, `bill.created`,
|
/// `vendor.added`, `invoice.created`, `bill.created`,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! is the current version.
|
//! is the current version.
|
||||||
|
|
||||||
/// Current schema 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`.
|
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||||
pub const MIGRATIONS: &[&str] = &[
|
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);
|
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
|
||||||
ALTER TABLE lines ADD COLUMN class_id INTEGER;
|
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>,
|
pub description: Option<String>,
|
||||||
/// UTC timestamp when filed.
|
/// UTC timestamp when filed.
|
||||||
pub created_at: String,
|
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
|
/// Input to [`Store::run_payroll`]: one employee's gross pay and every
|
||||||
@@ -1757,7 +1761,10 @@ impl Store {
|
|||||||
Ok((id, sha, dedup))
|
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(
|
pub fn link_receipt(
|
||||||
&mut self,
|
&mut self,
|
||||||
attachment_id: u64,
|
attachment_id: u64,
|
||||||
@@ -1766,14 +1773,7 @@ impl Store {
|
|||||||
self.ledger
|
self.ledger
|
||||||
.get_entry(entry_id)
|
.get_entry(entry_id)
|
||||||
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
||||||
let exists: i64 = self.conn.query_row(
|
self.check_linkable(attachment_id)?;
|
||||||
"SELECT COUNT(*) FROM attachments WHERE id = ?",
|
|
||||||
params![attachment_id as i64],
|
|
||||||
|r| r.get(0),
|
|
||||||
)?;
|
|
||||||
if exists == 0 {
|
|
||||||
return Err(StoreError::UnknownAttachment(attachment_id));
|
|
||||||
}
|
|
||||||
let line = self.append_journal(
|
let line = self.append_journal(
|
||||||
"receipt.linked",
|
"receipt.linked",
|
||||||
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
||||||
@@ -1782,6 +1782,89 @@ impl Store {
|
|||||||
Ok(())
|
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.
|
/// List attachments, optionally filtered to one entry.
|
||||||
pub fn list_attachments(
|
pub fn list_attachments(
|
||||||
&self,
|
&self,
|
||||||
@@ -1789,7 +1872,8 @@ impl Store {
|
|||||||
) -> Result<Vec<Attachment>, StoreError> {
|
) -> Result<Vec<Attachment>, StoreError> {
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
"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",
|
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||||
);
|
);
|
||||||
if entry_id.is_some() {
|
if entry_id.is_some() {
|
||||||
@@ -1897,6 +1981,12 @@ impl Store {
|
|||||||
// Maintenance: verify and rebuild
|
// 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
|
/// Verify everything: the journal hash chain, a fresh replay's reports
|
||||||
/// against the live ledger, and the SQLite projection against the replay.
|
/// against the live ledger, and the SQLite projection against the replay.
|
||||||
/// Loud on any divergence.
|
/// 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.
|
// Not accounting state; the projection records them.
|
||||||
}
|
}
|
||||||
"asset.registered" => {
|
"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" => {
|
"asset.registered" => {
|
||||||
let cost = &p["cost"];
|
let cost = &p["cost"];
|
||||||
let salvage = &p["salvage_value"];
|
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,
|
size_bytes: r.get::<_, i64>(6)? as u64,
|
||||||
description: r.get(7)?,
|
description: r.get(7)?,
|
||||||
created_at: r.get(8)?,
|
created_at: r.get(8)?,
|
||||||
|
withdrawn_at: r.get(9)?,
|
||||||
|
withdrawn_reason: r.get(10)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::{AccountType, Currency, Money, Side};
|
use ledger_core::{AccountType, Currency, Money, Side};
|
||||||
use ledger_store::{JournalWriter, Store};
|
use ledger_store::{JournalWriter, Store, StoreError};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -347,3 +347,113 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
|
|||||||
assert_eq!(tb.total_debits, Decimal::from(14000));
|
assert_eq!(tb.total_debits, Decimal::from(14000));
|
||||||
store.verify().unwrap();
|
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 |
|
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
|
||||||
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
core is missing something — fix the core, don't work around it in the MCP
|
||||||
layer.
|
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
|
## Transports
|
||||||
|
|
||||||
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
|
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.
|
→ entries with lines, newest first. `limit` caps at 100.
|
||||||
- `books.trial_balance {}` → canonical JSON (same shape as
|
- `books.trial_balance {}` → canonical JSON (same shape as
|
||||||
`reports::snapshot` trial-balance section).
|
`reports::snapshot` trial-balance section).
|
||||||
- `books.balance_sheet {}` → canonical JSON.
|
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||||||
- `books.profit_and_loss {}` → canonical JSON.
|
`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? }`
|
- `books.cash_flow { start?, end?, cash_accounts: ["1000"], financing_accounts? }`
|
||||||
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
|
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
|
||||||
accounts (required); the three sections always sum to their net change.
|
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
|
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
||||||
optional: a receipt can be filed before its entry exists.
|
optional: a receipt can be filed before its entry exists.
|
||||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||||
receipt to its entry.
|
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||||
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
|
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.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
|
||||||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||||||
"accounting" | "other", file_name, mime_type, content_base64,
|
"accounting" | "other", file_name, mime_type, content_base64,
|
||||||
@@ -163,10 +192,12 @@ posting.
|
|||||||
|
|
||||||
## Tools — export
|
## Tools — export
|
||||||
|
|
||||||
- `books.export { format: "csv" | "json" | "xlsx" | "iif", report: "journal" |
|
- `books.export { format: "csv" | "json" | "xlsx" | "pdf" | "iif", report:
|
||||||
"trial_balance" | "profit_and_loss" | "balance_sheet" | "cash_flow" }` →
|
"journal" | "trial_balance" | "profit_and_loss" | "balance_sheet" |
|
||||||
`{ content, content_encoding? }`. CPA-ready output. `xlsx` is base64-encoded;
|
"cash_flow", start?, end?, as_of?, cash_accounts?, financing_accounts? }` →
|
||||||
`iif` is journal-only (QuickBooks Desktop). Cash flow needs `cash_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
|
## Tools — import
|
||||||
|
|
||||||
@@ -304,9 +335,11 @@ entries.
|
|||||||
|
|
||||||
## Tools — CPA handoff
|
## Tools — CPA handoff
|
||||||
|
|
||||||
- `books.export_cpa_package { as_of? }` → `{ file_name, content_base64 }`.
|
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
|
||||||
A zip with `trial-balance.xlsx`, `general-ledger.xlsx` (every posted
|
→ `{ file_name, content_base64 }`. A zip with `trial-balance.xlsx`,
|
||||||
line: date, entry, description, account, class, debit, credit),
|
`general-ledger.xlsx` (every posted line: date, entry, description,
|
||||||
`profit-and-loss.pdf` (fiscal year to date, per-account detail), and
|
account, class, debit, credit), `profit-and-loss.pdf` (fiscal year to
|
||||||
`balance-sheet.pdf` (per-account detail). What the CPA needs; they never
|
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.
|
log into the system.
|
||||||
@@ -19,6 +19,8 @@ No other statuses.
|
|||||||
| Trial balance | Reports → Trial Balance | done | golden-covered |
|
| 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 |
|
| 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 |
|
| 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` |
|
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
||||||
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
| 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 |
|
| 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.
|
- `seq`: 1-based, gapless, assigned by the writer.
|
||||||
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
||||||
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
|
- `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
|
- `payload`: the full domain facts (account fields, entry with lines, blob
|
||||||
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
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
|
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
|
||||||
@@ -99,6 +101,14 @@ before backups.
|
|||||||
plus `receipts`.
|
plus `receipts`.
|
||||||
- Documents filed via `books.file_document` and receipts via
|
- Documents filed via `books.file_document` and receipts via
|
||||||
`books.attach_receipt` share the blob store; they differ only in metadata.
|
`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
|
- The store is policy-free: it records attachments faithfully. Whether a
|
||||||
posting *requires* a receipt is decided by the MCP layer's receipt policy
|
posting *requires* a receipt is decided by the MCP layer's receipt policy
|
||||||
(`docs/mcp-interface.md`), which reads the `attachments` table.
|
(`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).
|
total credits (the `balanced()` check).
|
||||||
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
|
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
|
||||||
type. The accounting equation holds exactly: assets = liabilities + equity.
|
type. The accounting equation holds exactly: assets = liabilities + equity.
|
||||||
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net
|
Current earnings (revenue minus expenses not yet closed to retained
|
||||||
income to date) are folded into equity so the equation holds on a live book.
|
earnings) 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,
|
`balance_sheet_as_of` gives the statement as of any date, a line per
|
||||||
with net income. Period filtering arrives with R-FUTURE-2.
|
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
|
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
|
||||||
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
||||||
keys). Two engines fed the same books must produce identical output. This
|
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)
|
## 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
|
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
|
||||||
stays policy-free) enforces a configurable receipt policy: postings at or
|
stays policy-free) enforces a configurable receipt policy: postings at or
|
||||||
above a threshold (`require_receipts_above`, default `"25.00"` in base
|
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
|
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`
|
true-up); `cargo-mutants` run on `ledger-core` (228 mutants); `cargo-fuzz`
|
||||||
targets (journal-line parsing, entry validation, op sequences) scaffolded.
|
targets (journal-line parsing, entry validation, op sequences) scaffolded.
|
||||||
- 40+ tests green workspace-wide; `cargo fmt` clean; clippy clean except one
|
- 120+ tests green workspace-wide; `cargo fmt` clean; `cargo clippy
|
||||||
pre-existing phase-1 style lint on a public engine signature.
|
--workspace --all-targets` clean, zero warnings (a hard rule: Stephen,
|
||||||
|
2026-10-11).
|
||||||
- Docs: AGENTS.md (operating manual), requirements, architecture, oracle
|
- Docs: AGENTS.md (operating manual), requirements, architecture, oracle
|
||||||
design, parity catalog (floor: 10 done), glossary, mcp-interface (incl. the
|
design, parity catalog (floor: 10 done), glossary, mcp-interface (incl. the
|
||||||
5 asset tools), receipt-store (journal design), capability-parity
|
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
|
(QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy
|
||||||
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
|
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
|
||||||
`mise run mcp-add`, then `claude` in the dir.
|
`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