Compare commits
5
Commits
8164abb2b8
...
b48addd0e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b48addd0e0
|
||
|
|
d65f9aa840
|
||
|
|
ddb67a2ca2
|
||
|
|
dbdc8e671f
|
||
|
|
f858fb4cfe
|
No files matched your search
@@ -6,6 +6,7 @@
|
||||
//! grouping on type, so getting this right is the whole game.
|
||||
|
||||
use crate::{Currency, LedgerError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -72,6 +73,14 @@ pub struct Account {
|
||||
pub placeholder: bool,
|
||||
/// Day one: always the ledger's base currency (see R-5).
|
||||
pub currency: Currency,
|
||||
/// Tax category key (see [`crate::tax`]), for revenue and expense
|
||||
/// accounts.
|
||||
#[serde(default)]
|
||||
pub tax_category: Option<String>,
|
||||
/// Percent deductible, overriding the category's default (e.g. 50 for
|
||||
/// meals, 0 for penalties).
|
||||
#[serde(default)]
|
||||
pub deductible_percent: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// The registry of accounts. Owns id allocation for accounts it holds.
|
||||
@@ -116,6 +125,22 @@ impl ChartOfAccounts {
|
||||
self.accounts.values()
|
||||
}
|
||||
|
||||
/// Set (or clear) an account's tax category and deductible percent.
|
||||
pub fn set_tax(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
category: Option<String>,
|
||||
deductible_percent: Option<Decimal>,
|
||||
) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.accounts
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
a.tax_category = category;
|
||||
a.deductible_percent = deductible_percent;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of accounts in the chart.
|
||||
pub fn len(&self) -> usize {
|
||||
self.accounts.len()
|
||||
|
||||
@@ -42,6 +42,14 @@ pub struct Vendor {
|
||||
pub id: VendorId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// The information return this vendor gets, if any (see
|
||||
/// [`crate::form1099`]).
|
||||
#[serde(default)]
|
||||
pub form_1099: Option<crate::form1099::Form1099>,
|
||||
/// A W-9 (taxpayer identification number) is on file. The number
|
||||
/// itself is never stored here.
|
||||
#[serde(default)]
|
||||
pub tin_on_file: bool,
|
||||
}
|
||||
|
||||
/// Payment status of an invoice or bill.
|
||||
|
||||
@@ -132,6 +132,20 @@ pub enum LedgerError {
|
||||
#[error("purchase date plus useful life overflows the calendar")]
|
||||
AssetDateOverflow,
|
||||
|
||||
/// An amortization schedule or recurring entry is invalid, unknown, or
|
||||
/// already posted for that date.
|
||||
#[error("invalid schedule: {0}")]
|
||||
InvalidSchedule(String),
|
||||
|
||||
/// A payout doesn't reconcile or uses the wrong accounts.
|
||||
#[error("invalid payout: {0}")]
|
||||
InvalidPayout(String),
|
||||
|
||||
/// A tax category doesn't exist, doesn't fit the account, or has a bad
|
||||
/// deductible percent.
|
||||
#[error("invalid tax category: {0}")]
|
||||
InvalidTaxCategory(String),
|
||||
|
||||
/// Partnership members are misconfigured (shares, capital accounts).
|
||||
#[error("invalid members: {0}")]
|
||||
InvalidMembers(String),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 1099 tracking: what was paid to each vendor in a calendar year.
|
||||
//!
|
||||
//! A vendor is marked with the information return it gets (1099-NEC for
|
||||
//! nonemployee compensation, 1099-MISC for rents and the like) and whether
|
||||
//! its W-9 is on file. Entries are tagged with the vendor they paid
|
||||
//! (`pay_bill` tags automatically). The amount paid is the cash that left
|
||||
//! asset accounts on the vendor's tagged entries: credits minus debits to
|
||||
//! asset accounts. A card payment (Dr expense, Cr credit card) moves no
|
||||
//! asset, so it is excluded, as it should be: card payments are reported by
|
||||
//! the card processor on 1099-K, not by the payer.
|
||||
//!
|
||||
//! Thresholds are set by law and change; [`default_threshold`] encodes
|
||||
//! them and the report takes an override. Confirm with the CPA.
|
||||
|
||||
use crate::{AccountType, Ledger, VendorId};
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The information return a vendor gets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Form1099 {
|
||||
/// 1099-NEC: nonemployee compensation (contractors, attorneys' fees).
|
||||
Nec,
|
||||
/// 1099-MISC: rents, royalties, prizes, other income.
|
||||
Misc,
|
||||
}
|
||||
|
||||
impl Form1099 {
|
||||
/// Parse "nec", "1099-NEC", "misc", "1099-MISC".
|
||||
pub fn parse(s: &str) -> Option<Form1099> {
|
||||
let s = s.trim().to_lowercase();
|
||||
match s.strip_prefix("1099-").unwrap_or(&s) {
|
||||
"nec" => Some(Form1099::Nec),
|
||||
"misc" => Some(Form1099::Misc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// "1099-NEC" or "1099-MISC".
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Form1099::Nec => "1099-NEC",
|
||||
Form1099::Misc => "1099-MISC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reporting threshold for payments made in `year`: $600 through 2025;
|
||||
/// $2,000 from 2026 (One Big Beautiful Bill Act), indexed for inflation
|
||||
/// after 2026, so later years return $2,000 until updated. Royalties
|
||||
/// (1099-MISC) have a $10 threshold this doesn't model. Pass an explicit
|
||||
/// threshold to override.
|
||||
pub fn default_threshold(year: i32) -> Decimal {
|
||||
if year <= 2025 {
|
||||
Decimal::new(600, 0)
|
||||
} else {
|
||||
Decimal::new(2000, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vendor1099Line {
|
||||
/// The vendor.
|
||||
pub vendor_id: VendorId,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// The return it gets, if marked.
|
||||
pub form: Option<Form1099>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// Cash paid in the year (net of refunds and reversals).
|
||||
pub paid: Decimal,
|
||||
/// Marked for a 1099 and paid at least the threshold.
|
||||
pub reportable: bool,
|
||||
}
|
||||
|
||||
/// The year's 1099 report.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vendor1099Report {
|
||||
/// Calendar year.
|
||||
pub year: i32,
|
||||
/// The threshold applied.
|
||||
pub threshold: Decimal,
|
||||
/// Every vendor marked for a 1099 or paid in the year, by name.
|
||||
pub vendors: Vec<Vendor1099Line>,
|
||||
}
|
||||
|
||||
/// Cash paid per vendor in calendar `year`, from posted entries tagged
|
||||
/// with the vendor.
|
||||
pub fn vendor_1099_report(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> Vendor1099Report {
|
||||
let threshold = threshold.unwrap_or_else(|| default_threshold(year));
|
||||
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
|
||||
for entry in ledger.posted_entries() {
|
||||
if entry.date().year() != year {
|
||||
continue;
|
||||
}
|
||||
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
|
||||
continue;
|
||||
};
|
||||
let cash: Decimal = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(l.account)
|
||||
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||
})
|
||||
.map(|l| match l.side {
|
||||
crate::Side::Credit => l.amount.amount,
|
||||
crate::Side::Debit => -l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
*paid.entry(vendor).or_default() += cash;
|
||||
}
|
||||
let mut vendors: Vec<Vendor1099Line> = ledger
|
||||
.list_vendors()
|
||||
.into_iter()
|
||||
.filter(|v| v.form_1099.is_some() || paid.get(&v.id).is_some_and(|p| !p.is_zero()))
|
||||
.map(|v| {
|
||||
let amount = paid.get(&v.id).copied().unwrap_or_default();
|
||||
Vendor1099Line {
|
||||
vendor_id: v.id,
|
||||
name: v.name.clone(),
|
||||
form: v.form_1099,
|
||||
tin_on_file: v.tin_on_file,
|
||||
paid: amount,
|
||||
reportable: v.form_1099.is_some() && amount >= threshold,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
|
||||
Vendor1099Report {
|
||||
year,
|
||||
threshold,
|
||||
vendors,
|
||||
}
|
||||
}
|
||||
+473
-111
@@ -6,6 +6,9 @@
|
||||
//! is no other way to change the books, which is what makes the ledger an
|
||||
//! audit trail and what will make the future oracle harness trustworthy.
|
||||
|
||||
use crate::schedules::{
|
||||
self, AmortizationSchedule, NewRecurring, NewSchedule, RecurringEntry, RecurringId, ScheduleId,
|
||||
};
|
||||
use crate::{
|
||||
Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId,
|
||||
ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus,
|
||||
@@ -95,6 +98,14 @@ pub struct Ledger {
|
||||
next_bill_id: BillId,
|
||||
next_payroll_run_id: PayrollRunId,
|
||||
next_class_id: ClassId,
|
||||
/// Amortization schedules (prepaid expenses, deferred revenue).
|
||||
schedules: std::collections::BTreeMap<ScheduleId, AmortizationSchedule>,
|
||||
next_schedule_id: ScheduleId,
|
||||
/// Recurring entries.
|
||||
recurring: std::collections::BTreeMap<RecurringId, RecurringEntry>,
|
||||
next_recurring_id: RecurringId,
|
||||
/// Who each entry paid or was billed by, for 1099 reporting.
|
||||
entry_vendors: HashMap<EntryId, VendorId>,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
@@ -125,9 +136,175 @@ impl Ledger {
|
||||
next_bill_id: 1,
|
||||
next_payroll_run_id: 1,
|
||||
next_class_id: 1,
|
||||
schedules: std::collections::BTreeMap::new(),
|
||||
next_schedule_id: 1,
|
||||
recurring: std::collections::BTreeMap::new(),
|
||||
next_recurring_id: 1,
|
||||
entry_vendors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Amortization schedules and recurring entries
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Register an amortization schedule. Returns its id.
|
||||
pub fn register_schedule(&mut self, new: NewSchedule) -> Result<ScheduleId, LedgerError> {
|
||||
schedules::validate_schedule(&new, &self.chart)?;
|
||||
let id = self.next_schedule_id;
|
||||
self.next_schedule_id += 1;
|
||||
self.schedules.insert(
|
||||
id,
|
||||
AmortizationSchedule {
|
||||
id,
|
||||
description: new.description,
|
||||
kind: new.kind,
|
||||
total: new.total,
|
||||
start: new.start,
|
||||
months: new.months,
|
||||
balance_account: new.balance_account,
|
||||
pl_account: new.pl_account,
|
||||
posted: Default::default(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The id the next schedule will receive (for journaling stores).
|
||||
pub fn next_schedule_id(&self) -> ScheduleId {
|
||||
self.next_schedule_id
|
||||
}
|
||||
|
||||
/// All schedules, by id.
|
||||
pub fn schedules(&self) -> impl Iterator<Item = &AmortizationSchedule> + '_ {
|
||||
self.schedules.values()
|
||||
}
|
||||
|
||||
/// Look up a schedule.
|
||||
pub fn get_schedule(&self, id: ScheduleId) -> Option<&AmortizationSchedule> {
|
||||
self.schedules.get(&id)
|
||||
}
|
||||
|
||||
/// The entry [`Self::post_schedule_month`] would post: (date,
|
||||
/// description, lines). Errors if the month isn't due and unposted.
|
||||
pub fn preview_schedule_month(
|
||||
&self,
|
||||
id: ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<(NaiveDate, String, Vec<crate::schedules::TemplateLine>), LedgerError> {
|
||||
let s = self
|
||||
.schedules
|
||||
.get(&id)
|
||||
.ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown schedule {id}")))?;
|
||||
let (date, amount) = schedules::amortization_plan(s.total.amount, s.start, s.months)
|
||||
.into_iter()
|
||||
.find(|(d, _)| *d == month_end)
|
||||
.ok_or_else(|| {
|
||||
LedgerError::InvalidSchedule(format!("{month_end} is not a month of schedule {id}"))
|
||||
})?;
|
||||
if s.posted.contains(&date) {
|
||||
return Err(LedgerError::InvalidSchedule(format!(
|
||||
"schedule {id} already posted {date}"
|
||||
)));
|
||||
}
|
||||
let money = Money::new(amount, s.total.currency);
|
||||
let (debit, credit) = s.accounts();
|
||||
let what = match s.kind {
|
||||
schedules::ScheduleKind::PrepaidExpense => "Prepaid expense",
|
||||
schedules::ScheduleKind::DeferredRevenue => "Deferred revenue",
|
||||
};
|
||||
Ok((
|
||||
date,
|
||||
format!("{what}: {} ({})", s.description, date.format("%Y-%m")),
|
||||
vec![(debit, Side::Debit, money), (credit, Side::Credit, money)],
|
||||
))
|
||||
}
|
||||
|
||||
/// Post one month of a schedule: the entry from
|
||||
/// [`Self::preview_schedule_month`], posted and recorded so the month
|
||||
/// never posts twice. Returns the entry id.
|
||||
pub fn post_schedule_month(
|
||||
&mut self,
|
||||
id: ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
let (date, description, lines) = self.preview_schedule_month(id, month_end)?;
|
||||
if let Some(p) = self.closed_period_containing(date) {
|
||||
return Err(LedgerError::ClosedPeriod(p, date.to_string()));
|
||||
}
|
||||
let entry = self.create_entry(date, &description);
|
||||
for (account, side, money) in lines {
|
||||
self.add_line(entry, account, side, money)?;
|
||||
}
|
||||
self.post_entry(entry)?;
|
||||
self.schedules
|
||||
.get_mut(&id)
|
||||
.expect("previewed")
|
||||
.posted
|
||||
.insert(date);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Register a recurring entry. Returns its id.
|
||||
pub fn register_recurring(&mut self, new: NewRecurring) -> Result<RecurringId, LedgerError> {
|
||||
schedules::validate_recurring(&new, &self.chart)?;
|
||||
let id = self.next_recurring_id;
|
||||
self.next_recurring_id += 1;
|
||||
self.recurring.insert(
|
||||
id,
|
||||
RecurringEntry {
|
||||
id,
|
||||
description: new.description,
|
||||
lines: new.lines,
|
||||
frequency: new.frequency,
|
||||
start: new.start,
|
||||
end: new.end,
|
||||
generated: Default::default(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The id the next recurring entry will receive (for journaling stores).
|
||||
pub fn next_recurring_id(&self) -> RecurringId {
|
||||
self.next_recurring_id
|
||||
}
|
||||
|
||||
/// All recurring entries, by id.
|
||||
pub fn recurring_entries(&self) -> impl Iterator<Item = &RecurringEntry> + '_ {
|
||||
self.recurring.values()
|
||||
}
|
||||
|
||||
/// Generate one occurrence of a recurring entry as a *draft* (never
|
||||
/// posted: the agent attaches the receipt and posts it). `date` must be
|
||||
/// an occurrence not yet generated. Returns the draft's id.
|
||||
pub fn generate_recurring(
|
||||
&mut self,
|
||||
id: RecurringId,
|
||||
date: NaiveDate,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
let r = self
|
||||
.recurring
|
||||
.get(&id)
|
||||
.ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown recurring entry {id}")))?;
|
||||
if !r.due(date).contains(&date) {
|
||||
return Err(LedgerError::InvalidSchedule(format!(
|
||||
"{date} is not an ungenerated occurrence of recurring entry {id}"
|
||||
)));
|
||||
}
|
||||
let (description, lines) = (r.description.clone(), r.lines.clone());
|
||||
let entry = self.create_entry(date, &description);
|
||||
for (account, side, money) in lines {
|
||||
self.add_line(entry, account, side, money)?;
|
||||
}
|
||||
self.recurring
|
||||
.get_mut(&id)
|
||||
.expect("checked")
|
||||
.generated
|
||||
.insert(date);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// The id the next account added will receive.
|
||||
///
|
||||
/// Exposed for journaling stores that record the id before mutating.
|
||||
@@ -228,10 +405,55 @@ impl Ledger {
|
||||
parent,
|
||||
placeholder,
|
||||
currency: self.base_currency,
|
||||
tax_category: None,
|
||||
deductible_percent: None,
|
||||
})?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) an account's tax category, and optionally
|
||||
/// override the category's deductible percent (0 to 100). The category
|
||||
/// must fit the account's type (see [`crate::tax::check_category`]).
|
||||
pub fn set_account_tax(
|
||||
&mut self,
|
||||
account: AccountId,
|
||||
category: Option<&str>,
|
||||
deductible_percent: Option<Decimal>,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_account_tax(account, category, deductible_percent)?;
|
||||
self.chart
|
||||
.set_tax(account, category.map(str::to_string), deductible_percent)
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::set_account_tax`], for stores that
|
||||
/// journal before mutating.
|
||||
pub fn check_account_tax(
|
||||
&self,
|
||||
account: AccountId,
|
||||
category: Option<&str>,
|
||||
deductible_percent: Option<Decimal>,
|
||||
) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
if let Some(key) = category {
|
||||
crate::tax::check_category(key, a.account_type, None)?;
|
||||
} else if deductible_percent.is_some() {
|
||||
return Err(LedgerError::InvalidTaxCategory(
|
||||
"a deductible percent needs a tax category".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(p) = deductible_percent
|
||||
&& (p < Decimal::ZERO || p > Decimal::from(100))
|
||||
{
|
||||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||||
"deductible percent {p} must be between 0 and 100"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a draft journal entry. Returns its id.
|
||||
///
|
||||
/// This is the unchecked constructor: replay and internal callers
|
||||
@@ -416,6 +638,10 @@ impl Ledger {
|
||||
.ok_or(LedgerError::UnknownEntry(id))?
|
||||
.mark_reversal_of(entry);
|
||||
self.post_entry(id)?;
|
||||
// A reversed payment un-pays the vendor: carry the tag.
|
||||
if let Some(v) = self.entry_vendor(entry) {
|
||||
self.entry_vendors.insert(id, v);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -589,108 +815,113 @@ impl Ledger {
|
||||
|
||||
/// Close a period into Retained Earnings: see [`Self::close_period_to`].
|
||||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
||||
self.close_period_to(id, &periods::CloseTo::RetainedEarnings)
|
||||
self.close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] })
|
||||
}
|
||||
|
||||
/// Close a period. This:
|
||||
/// 1. Rejects if the period is already closed, if any draft is dated
|
||||
/// inside it, or if a later period is already closed (closing out of
|
||||
/// order would sweep the same income twice).
|
||||
/// inside it, if a later period is already closed (closing out of
|
||||
/// order would sweep the same income twice), or if `to` is invalid.
|
||||
/// 2. Posts a closing entry dated at period end that zeroes every revenue
|
||||
/// and expense account's balance *as of the period end* (entries
|
||||
/// dated after it stay open for their own period): revenue debited,
|
||||
/// expense credited, and the net to `to` — Retained Earnings (account
|
||||
/// `3999`, created if missing), or members' capital accounts by
|
||||
/// share for a partnership, to the cent with the last member
|
||||
/// absorbing rounding.
|
||||
/// dated after it stay open for their own period), puts the net in
|
||||
/// `to`'s target, and zeroes `to`'s sweep accounts (draws,
|
||||
/// distributions, dividends) into their target too:
|
||||
/// - `RetainedEarnings`: account `3999`, created if missing.
|
||||
/// - `Account`: one equity account (a sole proprietor's capital).
|
||||
/// - `Members`: each member's capital account gets their share of
|
||||
/// the net, to the cent (the last absorbs rounding), and their own
|
||||
/// distribution account.
|
||||
/// 3. Marks the period closed.
|
||||
///
|
||||
/// Returns the closing entry's id, or `None` if there was nothing to
|
||||
/// close (no revenue/expense balance at period end).
|
||||
/// close.
|
||||
pub fn close_period_to(
|
||||
&mut self,
|
||||
id: PeriodId,
|
||||
to: &periods::CloseTo,
|
||||
) -> Result<Option<EntryId>, LedgerError> {
|
||||
self.preview_close_period(id)?;
|
||||
if let periods::CloseTo::Members(members) = to {
|
||||
self.validate_close_members(members)?;
|
||||
}
|
||||
self.preview_close_period_to(id, to)?;
|
||||
let period = self.periods.get(&id).expect("previewed").clone();
|
||||
let (closings, net) = self.income_closings(period.end);
|
||||
|
||||
// Revenue and expense balances as of the period end.
|
||||
let mut closings: Vec<(AccountId, Side, Decimal)> = Vec::new();
|
||||
let mut accounts: Vec<_> = self.chart.all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if !matches!(
|
||||
account.account_type,
|
||||
AccountType::Revenue | AccountType::Expense
|
||||
) {
|
||||
continue;
|
||||
// (account, amount to credit; negative debits): the net to its
|
||||
// targets, and each sweep account's balance into its target.
|
||||
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
|
||||
let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new();
|
||||
let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
|
||||
// An equity balance (credit-normal) is −movement; zeroing it
|
||||
// moves exactly that much into the target.
|
||||
let balance = -this.movement_through(from, period.end);
|
||||
if !balance.is_zero() {
|
||||
sweep_lines.push((from, into, balance));
|
||||
}
|
||||
let movement = self.movement_through(account.id, period.end);
|
||||
if movement.is_zero() {
|
||||
continue;
|
||||
};
|
||||
match to {
|
||||
periods::CloseTo::RetainedEarnings { sweep: accounts } => {
|
||||
let has_work = !closings.is_empty()
|
||||
|| accounts
|
||||
.iter()
|
||||
.any(|a| !self.movement_through(*a, period.end).is_zero());
|
||||
if has_work {
|
||||
let re = self.retained_earnings_account()?;
|
||||
equity.push((re, net));
|
||||
for a in accounts {
|
||||
sweep(*a, re, self);
|
||||
}
|
||||
}
|
||||
}
|
||||
periods::CloseTo::Account {
|
||||
into,
|
||||
sweep: accounts,
|
||||
} => {
|
||||
equity.push((*into, net));
|
||||
for a in accounts {
|
||||
sweep(*a, *into, self);
|
||||
}
|
||||
}
|
||||
periods::CloseTo::Members(members) => {
|
||||
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
||||
for (m, part) in members.iter().zip(periods::allocate(net, &shares)) {
|
||||
equity.push((m.capital, part));
|
||||
if let Some(d) = m.distributions {
|
||||
sweep(d, m.capital, self);
|
||||
}
|
||||
}
|
||||
}
|
||||
// To zero the account: post the opposite of its net movement.
|
||||
let (side, amount) = if movement > Decimal::ZERO {
|
||||
(Side::Credit, movement)
|
||||
} else {
|
||||
(Side::Debit, -movement)
|
||||
};
|
||||
closings.push((account.id, side, amount));
|
||||
}
|
||||
|
||||
let closing_entry = if closings.is_empty() {
|
||||
let closing_entry = if closings.is_empty() && sweep_lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Net income: the closing debits (revenue) minus credits (expense).
|
||||
let net: Decimal = closings
|
||||
.iter()
|
||||
.map(|(_, side, amount)| match side {
|
||||
Side::Debit => *amount,
|
||||
Side::Credit => -*amount,
|
||||
})
|
||||
.sum();
|
||||
let targets: Vec<(AccountId, Decimal)> = match to {
|
||||
periods::CloseTo::RetainedEarnings => {
|
||||
let re_id = match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
||||
Some(a) => a.id,
|
||||
None => self.add_account(
|
||||
periods::RETAINED_EARNINGS_CODE,
|
||||
"Retained Earnings",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)?,
|
||||
};
|
||||
vec![(re_id, net)]
|
||||
}
|
||||
periods::CloseTo::Members(members) => {
|
||||
let shares: Vec<Decimal> = members.iter().map(|(_, s)| *s).collect();
|
||||
members
|
||||
.iter()
|
||||
.map(|(a, _)| *a)
|
||||
.zip(periods::allocate(net, &shares))
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
|
||||
for (account, side, amount) in closings {
|
||||
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
||||
}
|
||||
// Profit credits equity; a loss debits it.
|
||||
for (account, share) in targets {
|
||||
if share.is_zero() {
|
||||
continue;
|
||||
let post = |this: &mut Self, account, credit: Decimal| -> Result<(), LedgerError> {
|
||||
if credit.is_zero() {
|
||||
return Ok(());
|
||||
}
|
||||
let (side, amount) = if share > Decimal::ZERO {
|
||||
(Side::Credit, share)
|
||||
let (side, amount) = if credit > Decimal::ZERO {
|
||||
(Side::Credit, credit)
|
||||
} else {
|
||||
(Side::Debit, -share)
|
||||
(Side::Debit, -credit)
|
||||
};
|
||||
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
|
||||
this.add_line(entry, account, side, Money::new(amount, this.base_currency))
|
||||
};
|
||||
for (account, side, amount) in closings {
|
||||
let credit = if side == Side::Credit {
|
||||
amount
|
||||
} else {
|
||||
-amount
|
||||
};
|
||||
post(self, account, credit)?;
|
||||
}
|
||||
for (account, share) in equity {
|
||||
post(self, account, share)?;
|
||||
}
|
||||
// A sweep zeroes the source (opposite its balance) and moves
|
||||
// the balance into the target.
|
||||
for (from, into, balance) in sweep_lines {
|
||||
post(self, from, -balance)?;
|
||||
post(self, into, balance)?;
|
||||
}
|
||||
self.post_entry(entry)?;
|
||||
Some(entry)
|
||||
@@ -702,24 +933,88 @@ impl Ledger {
|
||||
Ok(closing_entry)
|
||||
}
|
||||
|
||||
/// Members for a close: shares positive and summing to 1, capital
|
||||
/// accounts distinct equity accounts.
|
||||
fn validate_close_members(&self, members: &[(AccountId, Decimal)]) -> Result<(), LedgerError> {
|
||||
let total: Decimal = members.iter().map(|(_, s)| *s).sum();
|
||||
if members.is_empty()
|
||||
|| total != Decimal::ONE
|
||||
|| members.iter().any(|(_, s)| *s <= Decimal::ZERO)
|
||||
{
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"member shares must be positive and sum to 1, got {total}"
|
||||
)));
|
||||
/// Revenue and expense balances as of `end` as closing lines (code
|
||||
/// order), and the net income they carry (revenue debits minus expense
|
||||
/// credits).
|
||||
fn income_closings(&self, end: NaiveDate) -> (Vec<(AccountId, Side, Decimal)>, Decimal) {
|
||||
let mut closings = Vec::new();
|
||||
let mut accounts: Vec<_> = self.chart.all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if !matches!(
|
||||
account.account_type,
|
||||
AccountType::Revenue | AccountType::Expense
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let movement = self.movement_through(account.id, end);
|
||||
if movement.is_zero() {
|
||||
continue;
|
||||
}
|
||||
// To zero the account: post the opposite of its net movement.
|
||||
closings.push(if movement > Decimal::ZERO {
|
||||
(account.id, Side::Credit, movement)
|
||||
} else {
|
||||
(account.id, Side::Debit, -movement)
|
||||
});
|
||||
}
|
||||
let net = closings
|
||||
.iter()
|
||||
.map(|(_, side, amount)| match side {
|
||||
Side::Debit => *amount,
|
||||
Side::Credit => -*amount,
|
||||
})
|
||||
.sum();
|
||||
(closings, net)
|
||||
}
|
||||
|
||||
fn retained_earnings_account(&mut self) -> Result<AccountId, LedgerError> {
|
||||
Ok(
|
||||
match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
||||
Some(a) => a.id,
|
||||
None => self.add_account(
|
||||
periods::RETAINED_EARNINGS_CODE,
|
||||
"Retained Earnings",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)?,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate a close target: every account an equity account, targets
|
||||
/// and sweeps distinct, members' shares positive and summing to 1.
|
||||
fn validate_close_to(&self, to: &periods::CloseTo) -> Result<(), LedgerError> {
|
||||
let mut accounts: Vec<AccountId> = Vec::new();
|
||||
match to {
|
||||
periods::CloseTo::RetainedEarnings { sweep } => accounts.extend(sweep),
|
||||
periods::CloseTo::Account { into, sweep } => {
|
||||
accounts.push(*into);
|
||||
accounts.extend(sweep);
|
||||
}
|
||||
periods::CloseTo::Members(members) => {
|
||||
let total: Decimal = members.iter().map(|m| m.share).sum();
|
||||
if members.is_empty()
|
||||
|| total != Decimal::ONE
|
||||
|| members.iter().any(|m| m.share <= Decimal::ZERO)
|
||||
{
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"member shares must be positive and sum to 1, got {total}"
|
||||
)));
|
||||
}
|
||||
for m in members {
|
||||
accounts.push(m.capital);
|
||||
accounts.extend(m.distributions);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for (a, _) in members {
|
||||
let account = self.chart.get(*a).ok_or(LedgerError::UnknownAccount(*a))?;
|
||||
if account.account_type != AccountType::Equity || !seen.insert(*a) {
|
||||
for a in accounts {
|
||||
let account = self.chart.get(a).ok_or(LedgerError::UnknownAccount(a))?;
|
||||
if account.account_type != AccountType::Equity || !seen.insert(a) {
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"capital account {} must be a distinct equity account",
|
||||
"close account {} must be a distinct equity account",
|
||||
account.code
|
||||
)));
|
||||
}
|
||||
@@ -742,16 +1037,23 @@ impl Ledger {
|
||||
total
|
||||
}
|
||||
|
||||
/// Pure pre-close check for journaling stores: verifies that
|
||||
/// [`Self::close_period`] would succeed — the period exists and is open,
|
||||
/// no draft entries are dated inside it, and the closing entry's date
|
||||
/// (period end) is not inside another closed period — and returns the id
|
||||
/// the closing entry will receive, or `None` when the close posts no
|
||||
/// entry (no revenue/expense movement). [`Self::close_period`] repeats
|
||||
/// these checks before mutating; this exists so stores can journal the
|
||||
/// outcome before mutating, keeping a failed write from leaving a
|
||||
/// journal line behind.
|
||||
/// [`Self::preview_close_period_to`] closing into Retained Earnings.
|
||||
pub fn preview_close_period(&self, id: PeriodId) -> Result<Option<EntryId>, LedgerError> {
|
||||
self.preview_close_period_to(id, &periods::CloseTo::RetainedEarnings { sweep: vec![] })
|
||||
}
|
||||
|
||||
/// Pure pre-close check for journaling stores: verifies that
|
||||
/// [`Self::close_period_to`] would succeed — the period exists and is
|
||||
/// open, no draft entries are dated inside it, no later period is
|
||||
/// closed, the closing date isn't inside another closed period, and the
|
||||
/// target is valid — and returns the id the closing entry will receive,
|
||||
/// or `None` when the close posts no entry. Stores call this before
|
||||
/// journaling so a failed close leaves no journal line behind.
|
||||
pub fn preview_close_period_to(
|
||||
&self,
|
||||
id: PeriodId,
|
||||
to: &periods::CloseTo,
|
||||
) -> Result<Option<EntryId>, LedgerError> {
|
||||
let period = self
|
||||
.periods
|
||||
.get(&id)
|
||||
@@ -780,16 +1082,23 @@ impl Ledger {
|
||||
{
|
||||
return Err(LedgerError::CloseOutOfOrder(id, later.id));
|
||||
}
|
||||
// The close posts an entry exactly when some revenue or expense
|
||||
// account has a balance at period end — the same predicate
|
||||
// close_period_to uses to decide. The entry id is the next one the
|
||||
// allocator hands out; account creation (e.g. Retained Earnings)
|
||||
// does not consume entry ids.
|
||||
let any_movement = self.chart.all().any(|a| {
|
||||
matches!(a.account_type, AccountType::Revenue | AccountType::Expense)
|
||||
&& !self.movement_through(a.id, period.end).is_zero()
|
||||
});
|
||||
Ok(any_movement.then_some(self.next_entry_id))
|
||||
self.validate_close_to(to)?;
|
||||
// An entry posts exactly when there is income to close or a sweep
|
||||
// account with a balance. Account creation (Retained Earnings) does
|
||||
// not consume entry ids.
|
||||
let (closings, _) = self.income_closings(period.end);
|
||||
let sweeps: Vec<AccountId> = match to {
|
||||
periods::CloseTo::RetainedEarnings { sweep }
|
||||
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||||
periods::CloseTo::Members(members) => {
|
||||
members.iter().filter_map(|m| m.distributions).collect()
|
||||
}
|
||||
};
|
||||
let any = !closings.is_empty()
|
||||
|| sweeps
|
||||
.iter()
|
||||
.any(|a| !self.movement_through(*a, period.end).is_zero());
|
||||
Ok(any.then_some(self.next_entry_id))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -825,6 +1134,8 @@ impl Ledger {
|
||||
Vendor {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
form_1099: None,
|
||||
tin_on_file: false,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
@@ -840,6 +1151,53 @@ impl Ledger {
|
||||
self.vendors.get(&id)
|
||||
}
|
||||
|
||||
/// Set which information return a vendor gets (or none), and whether
|
||||
/// their W-9 is on file.
|
||||
pub fn set_vendor_1099(
|
||||
&mut self,
|
||||
id: VendorId,
|
||||
form: Option<crate::form1099::Form1099>,
|
||||
tin_on_file: bool,
|
||||
) -> Result<(), LedgerError> {
|
||||
let v = self
|
||||
.vendors
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownVendor(id))?;
|
||||
v.form_1099 = form;
|
||||
v.tin_on_file = tin_on_file;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tag an entry (draft or posted) with the vendor it paid or was billed
|
||||
/// by, or clear the tag. The tag is reporting metadata, like a receipt
|
||||
/// link: it changes no amounts, so posted entries may be tagged.
|
||||
pub fn set_entry_vendor(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
vendor: Option<VendorId>,
|
||||
) -> Result<(), LedgerError> {
|
||||
if !self.entries.contains_key(&entry) {
|
||||
return Err(LedgerError::UnknownEntry(entry));
|
||||
}
|
||||
match vendor {
|
||||
Some(v) => {
|
||||
if !self.vendors.contains_key(&v) {
|
||||
return Err(LedgerError::UnknownVendor(v));
|
||||
}
|
||||
self.entry_vendors.insert(entry, v);
|
||||
}
|
||||
None => {
|
||||
self.entry_vendors.remove(&entry);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The vendor an entry is tagged with.
|
||||
pub fn entry_vendor(&self, entry: EntryId) -> Option<VendorId> {
|
||||
self.entry_vendors.get(&entry).copied()
|
||||
}
|
||||
|
||||
/// All customers, in id order.
|
||||
pub fn list_customers(&self) -> Vec<&Customer> {
|
||||
let mut ids: Vec<CustomerId> = self.customers.keys().copied().collect();
|
||||
@@ -952,6 +1310,7 @@ impl Ledger {
|
||||
entry_id: entry,
|
||||
},
|
||||
);
|
||||
self.entry_vendors.insert(entry, vendor_id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -1034,6 +1393,7 @@ impl Ledger {
|
||||
let b = self.bills.get_mut(&bill_id).expect("bill exists");
|
||||
b.amount_paid += amount;
|
||||
b.status = arap::derive_status(b.amount_paid, b.total());
|
||||
self.entry_vendors.insert(entry, bill.vendor_id);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
@@ -1101,6 +1461,7 @@ impl Ledger {
|
||||
entry_id,
|
||||
},
|
||||
);
|
||||
self.entry_vendors.insert(entry_id, vendor_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1135,13 +1496,14 @@ impl Ledger {
|
||||
amount: Decimal,
|
||||
entry_id: EntryId,
|
||||
) -> Result<(), LedgerError> {
|
||||
let _ = entry_id;
|
||||
let bill = self
|
||||
.bills
|
||||
.get_mut(&bill_id)
|
||||
.ok_or(LedgerError::UnknownBill(bill_id))?;
|
||||
bill.amount_paid += amount;
|
||||
bill.status = arap::derive_status(bill.amount_paid, bill.total());
|
||||
let vendor = bill.vendor_id;
|
||||
self.entry_vendors.insert(entry_id, vendor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,16 @@ pub mod arap;
|
||||
pub mod assets;
|
||||
pub mod classes;
|
||||
pub mod error;
|
||||
pub mod form1099;
|
||||
pub mod journal;
|
||||
pub mod ledger;
|
||||
pub mod money;
|
||||
pub mod payouts;
|
||||
pub mod payroll;
|
||||
pub mod periods;
|
||||
pub mod reports;
|
||||
pub mod schedules;
|
||||
pub mod tax;
|
||||
|
||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||
pub use arap::{
|
||||
@@ -39,11 +43,13 @@ pub use assets::{
|
||||
};
|
||||
pub use classes::{Class, ClassId};
|
||||
pub use error::LedgerError;
|
||||
pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
|
||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||
pub use money::{Currency, Money};
|
||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{CloseTo, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||
pub use periods::{CloseTo, MemberClose, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||
pub use reports::{
|
||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||
@@ -51,3 +57,8 @@ pub use reports::{
|
||||
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
|
||||
members_equity, profit_and_loss_for_period, trial_balance_as_of,
|
||||
};
|
||||
pub use schedules::{
|
||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||
ScheduleId, ScheduleKind, TemplateLine,
|
||||
};
|
||||
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Payouts: a platform deposit grossed up into what really happened.
|
||||
//!
|
||||
//! App stores, payment processors, and marketplaces collect revenue, keep
|
||||
//! their fees, and deposit the difference. Booking only the deposit as
|
||||
//! revenue understates income and hides the fees; the correct entry grosses
|
||||
//! it up:
|
||||
//!
|
||||
//! ```text
|
||||
//! Dr bank net deposit
|
||||
//! Dr platform fees fees withheld
|
||||
//! Dr/Cr adjustments refunds, chargebacks, reserves, tax withheld, ...
|
||||
//! Cr revenue gross sales (one line per product or class)
|
||||
//! ```
|
||||
//!
|
||||
//! [`payout_plan`] builds those lines and refuses unless gross − fees ±
|
||||
//! adjustments equals the deposit exactly, naming the difference. It knows
|
||||
//! no platform: callers (or a format-specific parser) supply the numbers
|
||||
//! from the payout statement, which is the entry's receipt.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, ClassId, LedgerError, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// A revenue or fee line of a payout.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PayoutLine {
|
||||
/// The account.
|
||||
pub account: AccountId,
|
||||
/// Positive amount.
|
||||
pub amount: Decimal,
|
||||
/// Optional class (product line, app, store).
|
||||
pub class: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// Anything else on the statement: refunds, chargebacks, reserves held or
|
||||
/// released, taxes withheld, currency conversion. A debit reduces the
|
||||
/// deposit; a credit increases it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PayoutAdjustment {
|
||||
/// The account.
|
||||
pub account: AccountId,
|
||||
/// Debit (reduces the deposit) or credit (increases it).
|
||||
pub side: Side,
|
||||
/// Positive amount.
|
||||
pub amount: Decimal,
|
||||
/// Optional class.
|
||||
pub class: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// A payout to gross up.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Payout {
|
||||
/// Where the money landed.
|
||||
pub deposit_account: AccountId,
|
||||
/// The net deposit. Negative when the platform debited the account
|
||||
/// (fees and refunds exceeded sales).
|
||||
pub deposit: Decimal,
|
||||
/// Gross revenue, credited.
|
||||
pub revenue: Vec<PayoutLine>,
|
||||
/// Fees withheld, debited.
|
||||
pub fees: Vec<PayoutLine>,
|
||||
/// Everything else.
|
||||
pub adjustments: Vec<PayoutAdjustment>,
|
||||
}
|
||||
|
||||
/// The payout's totals.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PayoutTotals {
|
||||
/// Sum of revenue lines.
|
||||
pub gross: Decimal,
|
||||
/// Sum of fee lines.
|
||||
pub fees: Decimal,
|
||||
/// Net effect of adjustments on the deposit (credits − debits).
|
||||
pub adjustments: Decimal,
|
||||
/// The deposit.
|
||||
pub deposit: Decimal,
|
||||
}
|
||||
|
||||
/// One planned line: account, side, amount, class.
|
||||
pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
|
||||
|
||||
/// Validate a payout and build its entry's lines: the deposit first, then
|
||||
/// fees, adjustments, and revenue. Errors unless every amount is positive,
|
||||
/// every account is postable and fits its role (revenue lines credit
|
||||
/// revenue or liability accounts, e.g. deferred revenue or sales tax
|
||||
/// collected; fees debit expense accounts), and the payout reconciles to
|
||||
/// the cent.
|
||||
pub fn payout_plan(
|
||||
payout: &Payout,
|
||||
chart: &ChartOfAccounts,
|
||||
) -> Result<(Vec<PayoutPlanLine>, PayoutTotals), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidPayout(m));
|
||||
if payout.revenue.is_empty() {
|
||||
return bad("a payout needs at least one revenue line".into());
|
||||
}
|
||||
let check = |account: AccountId,
|
||||
amount: Decimal,
|
||||
role: &str,
|
||||
allowed: &[AccountType]|
|
||||
-> Result<(), LedgerError> {
|
||||
let a = chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
if a.placeholder {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} account {} is a placeholder",
|
||||
a.code
|
||||
)));
|
||||
}
|
||||
if !allowed.is_empty() && !allowed.contains(&a.account_type) {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} account {} is {:?}; expected {}",
|
||||
a.code,
|
||||
a.account_type,
|
||||
allowed
|
||||
.iter()
|
||||
.map(|t| format!("{t:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ")
|
||||
)));
|
||||
}
|
||||
if amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} amounts must be positive, got {amount} on {}",
|
||||
a.code
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let deposit_account = chart
|
||||
.get(payout.deposit_account)
|
||||
.ok_or(LedgerError::UnknownAccount(payout.deposit_account))?;
|
||||
if deposit_account.placeholder || deposit_account.account_type != AccountType::Asset {
|
||||
return bad(format!(
|
||||
"deposit account {} must be a postable asset account",
|
||||
deposit_account.code
|
||||
));
|
||||
}
|
||||
for l in &payout.revenue {
|
||||
check(
|
||||
l.account,
|
||||
l.amount,
|
||||
"revenue",
|
||||
&[AccountType::Revenue, AccountType::Liability],
|
||||
)?;
|
||||
}
|
||||
for l in &payout.fees {
|
||||
check(l.account, l.amount, "fee", &[AccountType::Expense])?;
|
||||
}
|
||||
for a in &payout.adjustments {
|
||||
check(a.account, a.amount, "adjustment", &[])?;
|
||||
}
|
||||
let gross: Decimal = payout.revenue.iter().map(|l| l.amount).sum();
|
||||
let fees: Decimal = payout.fees.iter().map(|l| l.amount).sum();
|
||||
let adjustments: Decimal = payout
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| match a.side {
|
||||
Side::Credit => a.amount,
|
||||
Side::Debit => -a.amount,
|
||||
})
|
||||
.sum();
|
||||
let expected = gross - fees + adjustments;
|
||||
if expected != payout.deposit {
|
||||
return bad(format!(
|
||||
"payout doesn't reconcile: gross {gross} − fees {fees} + adjustments \
|
||||
{adjustments} = {expected}, but the deposit is {} (off by {})",
|
||||
payout.deposit,
|
||||
payout.deposit - expected
|
||||
));
|
||||
}
|
||||
let mut plan = Vec::new();
|
||||
if payout.deposit > Decimal::ZERO {
|
||||
plan.push((payout.deposit_account, Side::Debit, payout.deposit, None));
|
||||
} else if payout.deposit < Decimal::ZERO {
|
||||
plan.push((payout.deposit_account, Side::Credit, -payout.deposit, None));
|
||||
}
|
||||
plan.extend(
|
||||
payout
|
||||
.fees
|
||||
.iter()
|
||||
.map(|l| (l.account, Side::Debit, l.amount, l.class)),
|
||||
);
|
||||
plan.extend(
|
||||
payout
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| (a.account, a.side, a.amount, a.class)),
|
||||
);
|
||||
plan.extend(
|
||||
payout
|
||||
.revenue
|
||||
.iter()
|
||||
.map(|l| (l.account, Side::Credit, l.amount, l.class)),
|
||||
);
|
||||
Ok((
|
||||
plan,
|
||||
PayoutTotals {
|
||||
gross,
|
||||
fees,
|
||||
adjustments,
|
||||
deposit: payout.deposit,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -29,16 +29,41 @@ pub enum PeriodStatus {
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Where a period close puts the period's net income.
|
||||
/// Where a period close puts the period's net income, and which owner
|
||||
/// accounts (draws, distributions, dividends) it zeroes into equity too.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CloseTo {
|
||||
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
|
||||
/// corporate convention.
|
||||
RetainedEarnings,
|
||||
/// Members' capital accounts, by share (shares sum to 1): the
|
||||
/// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
|
||||
/// missing): the corporate convention. `sweep` lists dividend or
|
||||
/// distribution accounts to close into it.
|
||||
RetainedEarnings {
|
||||
/// Equity accounts zeroed into Retained Earnings.
|
||||
sweep: Vec<crate::AccountId>,
|
||||
},
|
||||
/// One equity account, e.g. a sole proprietor's capital, with `sweep`
|
||||
/// (e.g. the owner's draws) zeroed into it.
|
||||
Account {
|
||||
/// The equity account receiving the net.
|
||||
into: crate::AccountId,
|
||||
/// Equity accounts zeroed into it.
|
||||
sweep: Vec<crate::AccountId>,
|
||||
},
|
||||
/// Members' capital accounts by share (shares sum to 1): the
|
||||
/// partnership convention, where each member's capital account carries
|
||||
/// their allocated earnings.
|
||||
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
|
||||
/// their allocated earnings and absorbs their distributions.
|
||||
Members(Vec<MemberClose>),
|
||||
}
|
||||
|
||||
/// One member in a partnership close.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberClose {
|
||||
/// The member's capital account.
|
||||
pub capital: crate::AccountId,
|
||||
/// Their share of net income.
|
||||
pub share: rust_decimal::Decimal,
|
||||
/// Their distributions account, zeroed into their capital, if they
|
||||
/// have one.
|
||||
pub distributions: Option<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
||||
|
||||
@@ -671,6 +671,10 @@ pub struct MemberCapital {
|
||||
/// Their capital account (equity): contributions credit it,
|
||||
/// distributions debit it.
|
||||
pub capital_account: crate::AccountId,
|
||||
/// Their distributions account (contra-equity), if distributions are
|
||||
/// recorded there rather than directly against capital. Part of their
|
||||
/// capital on every statement; zeroed into capital at close.
|
||||
pub distribution_account: Option<crate::AccountId>,
|
||||
/// Share of income, e.g. 0.5. The shares must sum to exactly 1.
|
||||
pub share: Decimal,
|
||||
}
|
||||
@@ -738,15 +742,17 @@ pub fn members_equity(
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for m in members {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(m.capital_account)
|
||||
.ok_or(LedgerError::UnknownAccount(m.capital_account))?;
|
||||
if account.account_type != AccountType::Equity || !seen.insert(m.capital_account) {
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"{}: capital account {} must be a distinct equity account",
|
||||
m.name, account.code
|
||||
)));
|
||||
for id in std::iter::once(m.capital_account).chain(m.distribution_account) {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if account.account_type != AccountType::Equity || !seen.insert(id) {
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"{}: account {} must be a distinct equity account",
|
||||
m.name, account.code
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange {
|
||||
@@ -788,11 +794,28 @@ pub fn members_equity(
|
||||
}
|
||||
(credits, debits)
|
||||
};
|
||||
// A member's capital is their capital account plus their distributions
|
||||
// account (a debit balance, so it reduces capital).
|
||||
let capital: Vec<Decimal> = members
|
||||
.iter()
|
||||
.map(|m| capital_before(m.capital_account))
|
||||
.map(|m| {
|
||||
capital_before(m.capital_account)
|
||||
+ m.distribution_account.map_or(Decimal::ZERO, capital_before)
|
||||
})
|
||||
.collect();
|
||||
// (contributions, distributions): credits to capital are
|
||||
// contributions; debits to capital and net debits to the distributions
|
||||
// account are distributions.
|
||||
let flow: Vec<(Decimal, Decimal)> = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let (credits, debits) = flows(m.capital_account);
|
||||
let (dc, dd) = m
|
||||
.distribution_account
|
||||
.map_or((Decimal::ZERO, Decimal::ZERO), flows);
|
||||
(credits, debits + dd - dc)
|
||||
})
|
||||
.collect();
|
||||
let flow: Vec<(Decimal, Decimal)> = members.iter().map(|m| flows(m.capital_account)).collect();
|
||||
|
||||
// Everything in equity that isn't a capital account is partnership
|
||||
// earnings not yet distributed: allocate it by share.
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Things that happen on a schedule.
|
||||
//!
|
||||
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
|
||||
//!
|
||||
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
|
||||
//! evenly over the months it covers: a year of insurance paid up front is
|
||||
//! an asset expensed 1/12 a month; a year of subscription revenue
|
||||
//! collected up front is a liability earned 1/12 a month. Like
|
||||
//! depreciation, the schedule is the evidence for its postings, so they
|
||||
//! need no receipts. Each month posts once (idempotent runs).
|
||||
//! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts*
|
||||
//! on their dates, never posted entries: the agent attaches the actual
|
||||
//! receipt and posts through the normal preview, so the receipt policy is
|
||||
//! never bypassed.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side};
|
||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Opaque handle for an amortization schedule.
|
||||
pub type ScheduleId = u64;
|
||||
/// Opaque handle for a recurring entry.
|
||||
pub type RecurringId = u64;
|
||||
/// One line of a template entry: account, side, amount.
|
||||
pub type TemplateLine = (AccountId, Side, Money);
|
||||
|
||||
/// What an amortization schedule recognizes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScheduleKind {
|
||||
/// A prepaid expense: each month debits the expense account and
|
||||
/// credits the prepaid (asset) account.
|
||||
PrepaidExpense,
|
||||
/// Deferred (unearned) revenue: each month debits the deferred revenue
|
||||
/// (liability) account and credits the revenue account.
|
||||
DeferredRevenue,
|
||||
}
|
||||
|
||||
/// An amortization schedule.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AmortizationSchedule {
|
||||
/// Opaque handle.
|
||||
pub id: ScheduleId,
|
||||
/// What it is, e.g. "2027 general liability insurance".
|
||||
pub description: String,
|
||||
/// Prepaid expense or deferred revenue.
|
||||
pub kind: ScheduleKind,
|
||||
/// The amount to spread.
|
||||
pub total: Money,
|
||||
/// Any date in the first month covered.
|
||||
pub start: NaiveDate,
|
||||
/// Months covered.
|
||||
pub months: u32,
|
||||
/// The balance-sheet account: prepaid (asset) or deferred revenue
|
||||
/// (liability).
|
||||
pub balance_account: AccountId,
|
||||
/// The P&L account: expense or revenue.
|
||||
pub pl_account: AccountId,
|
||||
/// Months already posted, as their month-end dates.
|
||||
pub posted: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Parameters for a new schedule.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewSchedule {
|
||||
/// What it is.
|
||||
pub description: String,
|
||||
/// Prepaid expense or deferred revenue.
|
||||
pub kind: ScheduleKind,
|
||||
/// The amount to spread (positive).
|
||||
pub total: Money,
|
||||
/// Any date in the first month covered.
|
||||
pub start: NaiveDate,
|
||||
/// Months covered (at least 1).
|
||||
pub months: u32,
|
||||
/// Prepaid (asset) or deferred revenue (liability) account.
|
||||
pub balance_account: AccountId,
|
||||
/// Expense or revenue account.
|
||||
pub pl_account: AccountId,
|
||||
}
|
||||
|
||||
/// The last day of `d`'s month.
|
||||
pub fn month_end(d: NaiveDate) -> NaiveDate {
|
||||
let first = NaiveDate::from_ymd_opt(d.year(), d.month(), 1).expect("valid");
|
||||
first + Months::new(1) - Days::new(1)
|
||||
}
|
||||
|
||||
/// The schedule's monthly postings: (month-end date, amount). Equal
|
||||
/// amounts to the cent, the last month absorbing rounding, so they sum to
|
||||
/// the total exactly.
|
||||
pub fn amortization_plan(
|
||||
total: Decimal,
|
||||
start: NaiveDate,
|
||||
months: u32,
|
||||
) -> Vec<(NaiveDate, Decimal)> {
|
||||
let each = (total / Decimal::from(months)).round_dp(2);
|
||||
let first = NaiveDate::from_ymd_opt(start.year(), start.month(), 1).expect("valid");
|
||||
(0..months)
|
||||
.map(|i| {
|
||||
let date = month_end(first + Months::new(i));
|
||||
let amount = if i + 1 == months {
|
||||
total - each * Decimal::from(months - 1)
|
||||
} else {
|
||||
each
|
||||
};
|
||||
(date, amount)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate a new schedule against the chart.
|
||||
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||
if new.description.trim().is_empty() {
|
||||
return bad("description is required".into());
|
||||
}
|
||||
if new.total.amount <= Decimal::ZERO {
|
||||
return bad(format!("total must be positive, got {}", new.total.amount));
|
||||
}
|
||||
if new.months == 0 || new.months > 600 {
|
||||
return bad(format!("months must be 1 to 600, got {}", new.months));
|
||||
}
|
||||
let (want_balance, want_pl) = match new.kind {
|
||||
ScheduleKind::PrepaidExpense => (AccountType::Asset, AccountType::Expense),
|
||||
ScheduleKind::DeferredRevenue => (AccountType::Liability, AccountType::Revenue),
|
||||
};
|
||||
for (id, want) in [
|
||||
(new.balance_account, want_balance),
|
||||
(new.pl_account, want_pl),
|
||||
] {
|
||||
let a = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if a.account_type != want || a.placeholder {
|
||||
return bad(format!(
|
||||
"account {} must be a postable {want:?} account for a {:?} schedule",
|
||||
a.code, new.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl AmortizationSchedule {
|
||||
/// The postings for one month: (debit account, credit account).
|
||||
pub fn accounts(&self) -> (AccountId, AccountId) {
|
||||
match self.kind {
|
||||
ScheduleKind::PrepaidExpense => (self.pl_account, self.balance_account),
|
||||
ScheduleKind::DeferredRevenue => (self.balance_account, self.pl_account),
|
||||
}
|
||||
}
|
||||
|
||||
/// Months due through `through` and not yet posted.
|
||||
pub fn due(&self, through: NaiveDate) -> Vec<(NaiveDate, Decimal)> {
|
||||
amortization_plan(self.total.amount, self.start, self.months)
|
||||
.into_iter()
|
||||
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The amount not yet recognized.
|
||||
pub fn remaining(&self) -> Decimal {
|
||||
amortization_plan(self.total.amount, self.start, self.months)
|
||||
.into_iter()
|
||||
.filter(|(d, _)| !self.posted.contains(d))
|
||||
.map(|(_, a)| a)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a recurring entry happens.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Frequency {
|
||||
/// Every 7 days.
|
||||
Weekly,
|
||||
/// Every month, on the start date's day (clamped to month end).
|
||||
Monthly,
|
||||
/// Every three months.
|
||||
Quarterly,
|
||||
/// Every year.
|
||||
Yearly,
|
||||
}
|
||||
|
||||
impl Frequency {
|
||||
/// Parse "weekly", "monthly", "quarterly", "yearly" ("annually").
|
||||
pub fn parse(s: &str) -> Option<Frequency> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"weekly" => Some(Frequency::Weekly),
|
||||
"monthly" => Some(Frequency::Monthly),
|
||||
"quarterly" => Some(Frequency::Quarterly),
|
||||
"yearly" | "annually" | "annual" => Some(Frequency::Yearly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `k`th occurrence from `start`, computed from the start each
|
||||
/// time so month-end dates don't drift (Jan 31 → Feb 28 → Mar 31).
|
||||
pub fn nth(&self, start: NaiveDate, k: u32) -> Option<NaiveDate> {
|
||||
match self {
|
||||
Frequency::Weekly => start.checked_add_days(Days::new(7 * k as u64)),
|
||||
Frequency::Monthly => start.checked_add_months(Months::new(k)),
|
||||
Frequency::Quarterly => start.checked_add_months(Months::new(3 * k)),
|
||||
Frequency::Yearly => start.checked_add_months(Months::new(12 * k)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A recurring entry: a balanced template and when it happens.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RecurringEntry {
|
||||
/// Opaque handle.
|
||||
pub id: RecurringId,
|
||||
/// Description for each generated entry.
|
||||
pub description: String,
|
||||
/// The balanced lines.
|
||||
pub lines: Vec<TemplateLine>,
|
||||
/// How often.
|
||||
pub frequency: Frequency,
|
||||
/// First occurrence.
|
||||
pub start: NaiveDate,
|
||||
/// Last date an occurrence may fall on, if it ends.
|
||||
pub end: Option<NaiveDate>,
|
||||
/// Occurrences already generated, by date.
|
||||
pub generated: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Parameters for a new recurring entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewRecurring {
|
||||
/// Description for each generated entry.
|
||||
pub description: String,
|
||||
/// The balanced lines.
|
||||
pub lines: Vec<TemplateLine>,
|
||||
/// How often.
|
||||
pub frequency: Frequency,
|
||||
/// First occurrence.
|
||||
pub start: NaiveDate,
|
||||
/// Optional last date.
|
||||
pub end: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Validate a new recurring entry: at least two lines, positive amounts,
|
||||
/// postable accounts, balanced, and an end not before the start.
|
||||
pub fn validate_recurring(new: &NewRecurring, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||
if new.description.trim().is_empty() {
|
||||
return bad("description is required".into());
|
||||
}
|
||||
if new.lines.len() < 2 {
|
||||
return bad("a recurring entry needs at least two lines".into());
|
||||
}
|
||||
if new.end.is_some_and(|e| e < new.start) {
|
||||
return bad("end is before start".into());
|
||||
}
|
||||
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for (account, side, money) in &new.lines {
|
||||
let a = chart
|
||||
.get(*account)
|
||||
.ok_or(LedgerError::UnknownAccount(*account))?;
|
||||
if a.placeholder {
|
||||
return bad(format!("account {} is a placeholder", a.code));
|
||||
}
|
||||
if money.amount <= Decimal::ZERO {
|
||||
return bad(format!(
|
||||
"line amounts must be positive, got {}",
|
||||
money.amount
|
||||
));
|
||||
}
|
||||
match side {
|
||||
Side::Debit => debits += money.amount,
|
||||
Side::Credit => credits += money.amount,
|
||||
}
|
||||
}
|
||||
if debits != credits {
|
||||
return bad(format!(
|
||||
"lines don't balance: debits {debits}, credits {credits}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RecurringEntry {
|
||||
/// Occurrences on or before `through` (and the end), not yet generated.
|
||||
pub fn due(&self, through: NaiveDate) -> Vec<NaiveDate> {
|
||||
let last = self.end.map_or(through, |e| e.min(through));
|
||||
(0..)
|
||||
.map_while(|k| self.frequency.nth(self.start, k).filter(|d| *d <= last))
|
||||
.filter(|d| !self.generated.contains(d))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[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 plans_sum_exactly_and_land_on_month_ends() {
|
||||
let plan = amortization_plan(Decimal::new(100_000, 2), d(2026, 11, 15), 3);
|
||||
// 1,000.00 / 3 = 333.33, 333.33, 333.34.
|
||||
assert_eq!(
|
||||
plan,
|
||||
[
|
||||
(d(2026, 11, 30), Decimal::new(33_333, 2)),
|
||||
(d(2026, 12, 31), Decimal::new(33_333, 2)),
|
||||
(d(2027, 1, 31), Decimal::new(33_334, 2)),
|
||||
]
|
||||
);
|
||||
let leap = amortization_plan(Decimal::new(1200, 0), d(2028, 2, 1), 1);
|
||||
assert_eq!(leap, [(d(2028, 2, 29), Decimal::new(1200, 0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monthly_occurrences_do_not_drift() {
|
||||
let f = Frequency::Monthly;
|
||||
let dates: Vec<NaiveDate> = (0..4).map(|k| f.nth(d(2026, 1, 31), k).unwrap()).collect();
|
||||
assert_eq!(
|
||||
dates,
|
||||
[
|
||||
d(2026, 1, 31),
|
||||
d(2026, 2, 28),
|
||||
d(2026, 3, 31),
|
||||
d(2026, 4, 30)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
Frequency::Quarterly.nth(d(2026, 1, 15), 3),
|
||||
Some(d(2026, 10, 15))
|
||||
);
|
||||
assert_eq!(
|
||||
Frequency::Weekly.nth(d(2026, 1, 1), 2),
|
||||
Some(d(2026, 1, 15))
|
||||
);
|
||||
assert_eq!(Frequency::parse("Annually"), Some(Frequency::Yearly));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Tax categories: where each revenue and expense account lands on the
|
||||
//! business's income tax return, and how much of it is deductible.
|
||||
//!
|
||||
//! The books stay book-basis; this is the bridge a CPA (or tax software)
|
||||
//! walks across. Categories are *stable semantic keys* ("meals",
|
||||
//! "guaranteed_payments") rather than form line numbers, which move from
|
||||
//! year to year; each category says which returns it applies to. A
|
||||
//! deductible percentage (meals 50%, penalties 0%) turns book amounts into
|
||||
//! deductible amounts and surfaces the book-to-tax differences (Schedule
|
||||
//! M-1 items) instead of hiding them.
|
||||
//!
|
||||
//! Scope is deliberate, like fixed assets: no tax depreciation (MACRS,
|
||||
//! Section 179, bonus), no basis computations, no filing. Not tax advice.
|
||||
|
||||
use crate::{AccountType, Ledger, LedgerError};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The federal income tax return a business files.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxForm {
|
||||
/// Sole proprietorship or single-member LLC: Schedule C (Form 1040).
|
||||
ScheduleC,
|
||||
/// Partnership or multi-member LLC: Form 1065.
|
||||
Form1065,
|
||||
/// S corporation: Form 1120-S.
|
||||
Form1120S,
|
||||
/// C corporation: Form 1120.
|
||||
Form1120,
|
||||
}
|
||||
|
||||
impl TaxForm {
|
||||
/// Every form.
|
||||
pub const ALL: [TaxForm; 4] = [
|
||||
TaxForm::ScheduleC,
|
||||
TaxForm::Form1065,
|
||||
TaxForm::Form1120S,
|
||||
TaxForm::Form1120,
|
||||
];
|
||||
|
||||
/// "Form 1065", "Schedule C", ...
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
TaxForm::ScheduleC => "Schedule C",
|
||||
TaxForm::Form1065 => "Form 1065",
|
||||
TaxForm::Form1120S => "Form 1120-S",
|
||||
TaxForm::Form1120 => "Form 1120",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "schedule_c", "1065", "1120s", "1120" (and their labels).
|
||||
pub fn parse(s: &str) -> Option<TaxForm> {
|
||||
let k: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
match k.as_str() {
|
||||
"schedulec" | "c" => Some(TaxForm::ScheduleC),
|
||||
"1065" | "form1065" => Some(TaxForm::Form1065),
|
||||
"1120s" | "form1120s" => Some(TaxForm::Form1120S),
|
||||
"1120" | "form1120" => Some(TaxForm::Form1120),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a category sits on the return.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxKind {
|
||||
/// Income (revenue accounts).
|
||||
Income,
|
||||
/// Cost of goods sold (expense accounts).
|
||||
CostOfGoods,
|
||||
/// A deduction (expense accounts).
|
||||
Deduction,
|
||||
}
|
||||
|
||||
/// One tax category.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct TaxCategory {
|
||||
/// Stable key, e.g. `"meals"`.
|
||||
pub key: &'static str,
|
||||
/// Human label, e.g. `"Meals"`.
|
||||
pub label: &'static str,
|
||||
/// Income, COGS, or deduction.
|
||||
pub kind: TaxKind,
|
||||
/// The returns it appears on.
|
||||
pub forms: &'static [TaxForm],
|
||||
/// Percent deductible by default (100 for most; meals 50).
|
||||
pub default_deductible_percent: u32,
|
||||
/// What to know about it, for the agent and the CPA.
|
||||
pub note: &'static str,
|
||||
}
|
||||
|
||||
const ALL: &[TaxForm] = &TaxForm::ALL;
|
||||
const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120];
|
||||
|
||||
const fn cat(
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
kind: TaxKind,
|
||||
forms: &'static [TaxForm],
|
||||
pct: u32,
|
||||
note: &'static str,
|
||||
) -> TaxCategory {
|
||||
TaxCategory {
|
||||
key,
|
||||
label,
|
||||
kind,
|
||||
forms,
|
||||
default_deductible_percent: pct,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
use TaxKind::{CostOfGoods, Deduction, Income};
|
||||
|
||||
/// The catalog.
|
||||
pub const CATEGORIES: &[TaxCategory] = &[
|
||||
cat(
|
||||
"gross_receipts",
|
||||
"Gross receipts or sales",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Sales recorded gross (platform and processor fees are a separate deduction).",
|
||||
),
|
||||
cat(
|
||||
"returns_allowances",
|
||||
"Returns and allowances",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Refunds and allowances; reduces gross receipts.",
|
||||
),
|
||||
cat(
|
||||
"interest_income",
|
||||
"Interest income",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Pass-throughs report it separately to owners (portfolio income).",
|
||||
),
|
||||
cat("other_income", "Other income", Income, ALL, 100, ""),
|
||||
cat(
|
||||
"cost_of_goods_sold",
|
||||
"Cost of goods sold",
|
||||
CostOfGoods,
|
||||
ALL,
|
||||
100,
|
||||
"Inventory and direct costs of what was sold.",
|
||||
),
|
||||
cat("advertising", "Advertising", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"bad_debts",
|
||||
"Bad debts",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Only debts previously included in income (accrual basis).",
|
||||
),
|
||||
cat(
|
||||
"car_truck",
|
||||
"Car and truck expenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Actual costs or the standard mileage rate, not both.",
|
||||
),
|
||||
cat(
|
||||
"charitable_contributions",
|
||||
"Charitable contributions",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Separately stated to owners for partnerships and S corps; limited for C corps; not a Schedule C deduction.",
|
||||
),
|
||||
cat(
|
||||
"commissions_fees",
|
||||
"Commissions and fees",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Includes app store, marketplace, and payment processor fees.",
|
||||
),
|
||||
cat(
|
||||
"contract_labor",
|
||||
"Contract labor",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Payments to non-employees; see 1099 reporting.",
|
||||
),
|
||||
cat(
|
||||
"depreciation",
|
||||
"Depreciation (book)",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Book depreciation; tax depreciation (MACRS, Section 179, bonus) is computed separately by the CPA.",
|
||||
),
|
||||
cat(
|
||||
"employee_benefits",
|
||||
"Employee benefit programs",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"guaranteed_payments",
|
||||
"Guaranteed payments to partners",
|
||||
Deduction,
|
||||
&[TaxForm::Form1065],
|
||||
100,
|
||||
"Payments to partners for services or capital, regardless of profit; reported on each partner's K-1.",
|
||||
),
|
||||
cat(
|
||||
"insurance",
|
||||
"Insurance (other than health)",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat("interest_expense", "Interest", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"legal_professional",
|
||||
"Legal and professional services",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Attorneys, accountants, consultants.",
|
||||
),
|
||||
cat(
|
||||
"meals",
|
||||
"Meals",
|
||||
Deduction,
|
||||
ALL,
|
||||
50,
|
||||
"Generally 50% deductible; the other half is a book-to-tax difference.",
|
||||
),
|
||||
cat("office_expense", "Office expense", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"officer_compensation",
|
||||
"Compensation of officers",
|
||||
Deduction,
|
||||
CORPS,
|
||||
100,
|
||||
"Wages paid to officer-shareholders through payroll.",
|
||||
),
|
||||
cat(
|
||||
"organization_startup",
|
||||
"Organizational and start-up costs",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Tax: up to $5,000 each deductible in the first year, the rest amortized over 180 months; the CPA computes it.",
|
||||
),
|
||||
cat(
|
||||
"pension_profit_sharing",
|
||||
"Pension and profit-sharing plans",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"rent_equipment",
|
||||
"Rent or lease: vehicles, machinery, equipment",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"rent_property",
|
||||
"Rent or lease: other business property",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"repairs",
|
||||
"Repairs and maintenance",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"salaries_wages",
|
||||
"Salaries and wages",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Employees (not owners of a partnership, whose pay is guaranteed payments).",
|
||||
),
|
||||
cat("supplies", "Supplies", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"taxes_licenses",
|
||||
"Taxes and licenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Payroll taxes, business licenses, state fees; not federal income tax.",
|
||||
),
|
||||
cat(
|
||||
"travel",
|
||||
"Travel",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Lodging and transportation away from home; meals go under Meals.",
|
||||
),
|
||||
cat("utilities", "Utilities", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"other_deductions",
|
||||
"Other deductions",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Software, subscriptions, bank fees, dues, and the rest; the CPA itemizes.",
|
||||
),
|
||||
cat(
|
||||
"nondeductible",
|
||||
"Nondeductible expenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
0,
|
||||
"Fines, penalties, political contributions, federal income tax: a book expense with no deduction.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Look up a category by key.
|
||||
pub fn category(key: &str) -> Option<&'static TaxCategory> {
|
||||
CATEGORIES.iter().find(|c| c.key == key)
|
||||
}
|
||||
|
||||
/// Check that `key` can tag an account of `account_type` and, when a form
|
||||
/// is known, that it appears on that form.
|
||||
pub fn check_category(
|
||||
key: &str,
|
||||
account_type: AccountType,
|
||||
form: Option<TaxForm>,
|
||||
) -> Result<&'static TaxCategory, LedgerError> {
|
||||
let c = category(key)
|
||||
.ok_or_else(|| LedgerError::InvalidTaxCategory(format!("unknown tax category {key:?}")))?;
|
||||
let fits = match c.kind {
|
||||
TaxKind::Income => matches!(account_type, AccountType::Revenue | AccountType::Expense),
|
||||
TaxKind::CostOfGoods | TaxKind::Deduction => account_type == AccountType::Expense,
|
||||
};
|
||||
if !fits {
|
||||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||||
"{key} is for {} accounts, not {account_type:?}",
|
||||
if c.kind == TaxKind::Income {
|
||||
"revenue"
|
||||
} else {
|
||||
"expense"
|
||||
}
|
||||
)));
|
||||
}
|
||||
if let Some(f) = form
|
||||
&& !c.forms.contains(&f)
|
||||
{
|
||||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||||
"{key} does not appear on {}",
|
||||
f.label()
|
||||
)));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// One category's line on the tax summary.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TaxSummaryLine {
|
||||
/// Category key.
|
||||
pub category: String,
|
||||
/// Category label.
|
||||
pub label: String,
|
||||
/// The accounts that roll into it, `"CODE Name"`.
|
||||
pub accounts: Vec<String>,
|
||||
/// Book amount: revenue for income, expense for COGS and deductions.
|
||||
pub book: Decimal,
|
||||
/// Deductible (or taxable) amount after the deductible percentage.
|
||||
pub tax: Decimal,
|
||||
}
|
||||
|
||||
/// An account with P&L activity and no tax category.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Uncategorized {
|
||||
/// `"CODE Name"`.
|
||||
pub account: String,
|
||||
/// Revenue or Expense.
|
||||
pub account_type: AccountType,
|
||||
/// Book amount (normal side).
|
||||
pub book: Decimal,
|
||||
}
|
||||
|
||||
/// The tax summary for a period: book amounts by tax category, deductible
|
||||
/// amounts, and every account still needing a category.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TaxSummary {
|
||||
/// First date, inclusive.
|
||||
pub start: NaiveDate,
|
||||
/// Last date, inclusive.
|
||||
pub end: NaiveDate,
|
||||
/// The return, when known.
|
||||
pub form: Option<TaxForm>,
|
||||
/// Income categories.
|
||||
pub income: Vec<TaxSummaryLine>,
|
||||
/// Cost of goods sold.
|
||||
pub cost_of_goods: Vec<TaxSummaryLine>,
|
||||
/// Deductions.
|
||||
pub deductions: Vec<TaxSummaryLine>,
|
||||
/// P&L accounts with activity and no category.
|
||||
pub uncategorized: Vec<Uncategorized>,
|
||||
}
|
||||
|
||||
impl TaxSummary {
|
||||
/// Book net income of the categorized accounts.
|
||||
pub fn book_net(&self) -> Decimal {
|
||||
sum(&self.income, |l| l.book)
|
||||
- sum(&self.cost_of_goods, |l| l.book)
|
||||
- sum(&self.deductions, |l| l.book)
|
||||
}
|
||||
|
||||
/// Income less deductible amounts, before the CPA's other adjustments.
|
||||
pub fn tax_net(&self) -> Decimal {
|
||||
sum(&self.income, |l| l.tax)
|
||||
- sum(&self.cost_of_goods, |l| l.tax)
|
||||
- sum(&self.deductions, |l| l.tax)
|
||||
}
|
||||
}
|
||||
|
||||
fn sum(lines: &[TaxSummaryLine], f: fn(&TaxSummaryLine) -> Decimal) -> Decimal {
|
||||
lines.iter().map(f).sum()
|
||||
}
|
||||
|
||||
/// Build the tax summary for `[start, end]`, closing entries excluded (like
|
||||
/// the P&L). Deductible amounts round to the cent, half away from zero.
|
||||
pub fn tax_summary(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
form: Option<TaxForm>,
|
||||
) -> Result<TaxSummary, LedgerError> {
|
||||
let pl = crate::reports::profit_and_loss_for_period(ledger, start, end, None)?;
|
||||
let mut by_cat: BTreeMap<&'static str, TaxSummaryLine> = BTreeMap::new();
|
||||
let mut uncategorized = Vec::new();
|
||||
for line in pl.revenue.iter().chain(pl.expenses.iter()) {
|
||||
let account = ledger.chart().find_by_code(&line.code).ok_or_else(|| {
|
||||
LedgerError::InvalidTaxCategory(format!("unknown account {}", line.code))
|
||||
})?;
|
||||
let name = format!("{} {}", account.code, account.name);
|
||||
let Some(c) = account.tax_category.as_deref().and_then(category) else {
|
||||
uncategorized.push(Uncategorized {
|
||||
account: name,
|
||||
account_type: account.account_type,
|
||||
book: line.amount,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
// Income from an expense-type account (e.g. a contra) counts with
|
||||
// its sign flipped, so returns reduce receipts.
|
||||
let book = if c.kind == TaxKind::Income && account.account_type == AccountType::Expense {
|
||||
-line.amount
|
||||
} else {
|
||||
line.amount
|
||||
};
|
||||
let pct = account
|
||||
.deductible_percent
|
||||
.unwrap_or_else(|| Decimal::from(c.default_deductible_percent));
|
||||
let tax = (book * pct / Decimal::from(100))
|
||||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
let entry = by_cat.entry(c.key).or_insert_with(|| TaxSummaryLine {
|
||||
category: c.key.to_string(),
|
||||
label: c.label.to_string(),
|
||||
accounts: vec![],
|
||||
book: Decimal::ZERO,
|
||||
tax: Decimal::ZERO,
|
||||
});
|
||||
entry.accounts.push(name);
|
||||
entry.book += book;
|
||||
entry.tax += tax;
|
||||
}
|
||||
let mut out = TaxSummary {
|
||||
start,
|
||||
end,
|
||||
form,
|
||||
income: vec![],
|
||||
cost_of_goods: vec![],
|
||||
deductions: vec![],
|
||||
uncategorized,
|
||||
};
|
||||
// Catalog order, so the summary reads like the return.
|
||||
for c in CATEGORIES {
|
||||
if let Some(line) = by_cat.remove(c.key) {
|
||||
match c.kind {
|
||||
TaxKind::Income => out.income.push(line),
|
||||
TaxKind::CostOfGoods => out.cost_of_goods.push(line),
|
||||
TaxKind::Deduction => out.deductions.push(line),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 1099 tracking. Numbers computed by hand.
|
||||
//!
|
||||
//! 2026 payments:
|
||||
//! - Dana (1099-NEC, W-9): bill 1,500 paid by check + 700 direct = 2,200,
|
||||
//! over the 2,000 threshold. A 300 payment on 2025-12-30 counts in 2025.
|
||||
//! - Ace Rentals (1099-MISC): 1,200 twice, one reversed = 1,200, under.
|
||||
//! - Sam (1099-NEC): 5,000 by credit card = 0 cash: the card processor's
|
||||
//! 1099-K covers it.
|
||||
//! - Bigco (no 1099): 9,000, listed but never reportable.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::form1099::default_threshold;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Form1099, Ledger, LedgerError, Money, Side, vendor_1099_report,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn books() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2100", "Credit Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let contract = l
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6600", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = l.add_vendor("Dana").unwrap();
|
||||
let ace = l.add_vendor("Ace Rentals").unwrap();
|
||||
let sam = l.add_vendor("Sam").unwrap();
|
||||
let bigco = l.add_vendor("Bigco").unwrap();
|
||||
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
|
||||
l.set_vendor_1099(ace, Some(Form1099::Misc), false).unwrap();
|
||||
l.set_vendor_1099(sam, Some(Form1099::Nec), true).unwrap();
|
||||
let pay = |l: &mut Ledger, date, vendor, dr, cr, amt: &str| {
|
||||
let e = l.create_entry(date, "payment");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
e
|
||||
};
|
||||
let bill = l
|
||||
.create_bill(
|
||||
dana,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![DocumentLine {
|
||||
description: "Design work".into(),
|
||||
amount: dec("1500"),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
l.pay_bill(bill, d(2026, 3, 20), dec("1500"), bank).unwrap();
|
||||
pay(&mut l, d(2026, 6, 1), dana, contract, bank, "700");
|
||||
pay(&mut l, d(2025, 12, 30), dana, contract, bank, "300");
|
||||
pay(&mut l, d(2026, 1, 1), ace, rent, bank, "1200");
|
||||
let wrong = pay(&mut l, d(2026, 2, 1), ace, rent, bank, "1200");
|
||||
l.reverse_entry(wrong, d(2026, 2, 2), "duplicate").unwrap();
|
||||
pay(&mut l, d(2026, 4, 1), sam, contract, card, "5000");
|
||||
pay(&mut l, d(2026, 5, 1), bigco, contract, bank, "9000");
|
||||
// Untagged spending is not anyone's 1099.
|
||||
let e = l.create_entry(d(2026, 7, 1), "untagged");
|
||||
let m = Money::new(dec("50"), Currency::USD);
|
||||
l.add_line(e, contract, Side::Debit, m).unwrap();
|
||||
l.add_line(e, bank, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_paid_per_vendor_and_who_is_reportable() {
|
||||
let l = books();
|
||||
let r = vendor_1099_report(&l, 2026, None);
|
||||
assert_eq!(r.threshold, dec("2000"));
|
||||
let rows: Vec<(&str, Decimal, bool)> = r
|
||||
.vendors
|
||||
.iter()
|
||||
.map(|v| (v.name.as_str(), v.paid, v.reportable))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("Ace Rentals", dec("1200"), false),
|
||||
("Bigco", dec("9000"), false),
|
||||
("Dana", dec("2200"), true),
|
||||
("Sam", dec("0"), false),
|
||||
]
|
||||
);
|
||||
let ace = &r.vendors[0];
|
||||
assert_eq!(ace.form, Some(Form1099::Misc));
|
||||
assert!(!ace.tin_on_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn years_and_thresholds() {
|
||||
let l = books();
|
||||
assert_eq!(default_threshold(2025), dec("600"));
|
||||
assert_eq!(default_threshold(2026), dec("2000"));
|
||||
let y2025 = vendor_1099_report(&l, 2025, None);
|
||||
let dana = y2025.vendors.iter().find(|v| v.name == "Dana").unwrap();
|
||||
assert_eq!(dana.paid, dec("300"));
|
||||
assert!(!dana.reportable, "300 < 600");
|
||||
// An override: at 1,000, Ace's 1,200 is reportable.
|
||||
let r = vendor_1099_report(&l, 2026, Some(dec("1000")));
|
||||
assert!(
|
||||
r.vendors
|
||||
.iter()
|
||||
.any(|v| v.name == "Ace Rentals" && v.reportable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_need_real_entries_and_vendors() {
|
||||
let mut l = books();
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(999, Some(1)),
|
||||
Err(LedgerError::UnknownEntry(999))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(1, Some(999)),
|
||||
Err(LedgerError::UnknownVendor(999))
|
||||
));
|
||||
// The bill and its payment were tagged automatically.
|
||||
assert_eq!(l.entry_vendor(1), Some(1));
|
||||
assert_eq!(l.entry_vendor(2), Some(1));
|
||||
l.set_entry_vendor(2, None).unwrap();
|
||||
assert_eq!(l.entry_vendor(2), None);
|
||||
assert_eq!(Form1099::parse("1099-NEC"), Some(Form1099::Nec));
|
||||
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
|
||||
assert_eq!(Form1099::parse("K"), None);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Payout gross-up. Numbers computed by hand.
|
||||
//!
|
||||
//! An app-store month: App A sold 1,000.00, App B 500.00; the store kept
|
||||
//! 15% (225.00) and clawed back a 9.99 refund. Deposit: 1,500.00 − 225.00
|
||||
//! − 9.99 = 1,265.01.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::profit_and_loss_for_period;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Ledger, LedgerError, Money, Payout, PayoutAdjustment, PayoutLine, Side,
|
||||
payout_plan,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn d(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 11, day).unwrap()
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
bank: u64,
|
||||
sales: u64,
|
||||
fees: u64,
|
||||
refunds: u64,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let bank = a("1000", "Bank", AccountType::Asset);
|
||||
let sales = a("4000", "App Sales", AccountType::Revenue);
|
||||
let fees = a("6000", "Platform Fees", AccountType::Expense);
|
||||
let refunds = a("4100", "Refunds", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
bank,
|
||||
sales,
|
||||
fees,
|
||||
refunds,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(account: u64, amount: &str) -> PayoutLine {
|
||||
PayoutLine {
|
||||
account,
|
||||
amount: dec(amount),
|
||||
class: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn month(b: &Books, deposit: &str) -> Payout {
|
||||
Payout {
|
||||
deposit_account: b.bank,
|
||||
deposit: dec(deposit),
|
||||
revenue: vec![line(b.sales, "1000.00"), line(b.sales, "500.00")],
|
||||
fees: vec![line(b.fees, "225.00")],
|
||||
adjustments: vec![PayoutAdjustment {
|
||||
account: b.refunds,
|
||||
side: Side::Debit,
|
||||
amount: dec("9.99"),
|
||||
class: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payout_is_grossed_up_to_revenue_and_fees() {
|
||||
let mut b = books();
|
||||
let (plan, totals) = payout_plan(&month(&b, "1265.01"), b.l.chart()).unwrap();
|
||||
assert_eq!(totals.gross, dec("1500.00"));
|
||||
assert_eq!(totals.fees, dec("225.00"));
|
||||
assert_eq!(totals.adjustments, dec("-9.99"));
|
||||
assert_eq!(
|
||||
plan.iter()
|
||||
.map(|(a, s, m, _)| (*a, *s, *m))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(b.bank, Side::Debit, dec("1265.01")),
|
||||
(b.fees, Side::Debit, dec("225.00")),
|
||||
(b.refunds, Side::Debit, dec("9.99")),
|
||||
(b.sales, Side::Credit, dec("1000.00")),
|
||||
(b.sales, Side::Credit, dec("500.00")),
|
||||
]
|
||||
);
|
||||
let e = b.l.create_entry(d(30), "App store payout");
|
||||
for (a, s, m, _) in plan {
|
||||
b.l.add_line(e, a, s, Money::new(m, Currency::USD)).unwrap();
|
||||
}
|
||||
b.l.post_entry(e).unwrap();
|
||||
let pl = profit_and_loss_for_period(&b.l, d(1), d(30), None).unwrap();
|
||||
assert_eq!(pl.total_revenue, dec("1500.00"));
|
||||
assert_eq!(pl.total_expenses, dec("234.99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payout_must_reconcile_to_the_cent() {
|
||||
let b = books();
|
||||
let err = payout_plan(&month(&b, "1265.00"), b.l.chart()).unwrap_err();
|
||||
let LedgerError::InvalidPayout(msg) = err else {
|
||||
panic!("{err:?}")
|
||||
};
|
||||
assert!(msg.contains("= 1265.01"), "{msg}");
|
||||
assert!(msg.contains("off by -0.01"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_payout_credits_the_bank() {
|
||||
let b = books();
|
||||
// 10.00 of sales, 3.00 of fees, a 50.00 refund: the store debits 43.00.
|
||||
let p = Payout {
|
||||
deposit_account: b.bank,
|
||||
deposit: dec("-43.00"),
|
||||
revenue: vec![line(b.sales, "10.00")],
|
||||
fees: vec![line(b.fees, "3.00")],
|
||||
adjustments: vec![PayoutAdjustment {
|
||||
account: b.refunds,
|
||||
side: Side::Debit,
|
||||
amount: dec("50.00"),
|
||||
class: None,
|
||||
}],
|
||||
};
|
||||
let (plan, _) = payout_plan(&p, b.l.chart()).unwrap();
|
||||
assert_eq!(plan[0], (b.bank, Side::Credit, dec("43.00"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_must_fit_their_roles() {
|
||||
let b = books();
|
||||
let invalid = |p: Payout| {
|
||||
matches!(
|
||||
payout_plan(&p, b.l.chart()),
|
||||
Err(LedgerError::InvalidPayout(_))
|
||||
)
|
||||
};
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.fees[0].account = b.sales;
|
||||
assert!(invalid(p), "fees must be expenses");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.revenue[0].account = b.fees;
|
||||
assert!(invalid(p), "revenue must be revenue or a liability");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.deposit_account = b.sales;
|
||||
assert!(invalid(p), "deposits land in an asset");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.fees[0].amount = dec("-225.00");
|
||||
assert!(invalid(p), "amounts are positive");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.revenue.clear();
|
||||
assert!(invalid(p), "needs revenue");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Amortization schedules and recurring entries. Numbers computed by hand.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
||||
use ledger_core::{
|
||||
AccountType, Currency, EntryStatus, Frequency, Ledger, LedgerError, Money, NewRecurring,
|
||||
NewSchedule, ScheduleKind, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(dec(s), Currency::USD)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
cash: u64,
|
||||
prepaid: u64,
|
||||
deferred: u64,
|
||||
insurance: u64,
|
||||
revenue: u64,
|
||||
rent: u64,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = a("1000", "Cash", AccountType::Asset);
|
||||
let prepaid = a("1300", "Prepaid Expenses", AccountType::Asset);
|
||||
let deferred = a("2400", "Deferred Revenue", AccountType::Liability);
|
||||
let insurance = a("6300", "Insurance", AccountType::Expense);
|
||||
let revenue = a("4000", "Subscription Revenue", AccountType::Revenue);
|
||||
let rent = a("6600", "Rent", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
cash,
|
||||
prepaid,
|
||||
deferred,
|
||||
insurance,
|
||||
revenue,
|
||||
rent,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) {
|
||||
let e = l.create_entry(date, "x");
|
||||
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prepaid_expense_is_expensed_month_by_month() {
|
||||
let mut b = books();
|
||||
// A year of insurance, paid March 15: an asset, not an expense yet.
|
||||
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Liability insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("1200"),
|
||||
start: d(3, 15),
|
||||
months: 12,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
})
|
||||
.unwrap();
|
||||
// Through May: March, April, May, at month end, 100 each.
|
||||
let due: Vec<_> = b.l.get_schedule(id).unwrap().due(d(5, 31));
|
||||
assert_eq!(
|
||||
due,
|
||||
[
|
||||
(d(3, 31), dec("100")),
|
||||
(d(4, 30), dec("100")),
|
||||
(d(5, 31), dec("100"))
|
||||
]
|
||||
);
|
||||
for (month, _) in due {
|
||||
b.l.post_schedule_month(id, month).unwrap();
|
||||
}
|
||||
assert!(
|
||||
b.l.get_schedule(id).unwrap().due(d(5, 31)).is_empty(),
|
||||
"idempotent"
|
||||
);
|
||||
assert!(matches!(
|
||||
b.l.post_schedule_month(id, d(4, 30)),
|
||||
Err(LedgerError::InvalidSchedule(_))
|
||||
));
|
||||
assert_eq!(b.l.get_schedule(id).unwrap().remaining(), dec("900"));
|
||||
let bs = balance_sheet_as_of(&b.l, d(5, 31)).unwrap();
|
||||
let prepaid = bs.assets.iter().find(|a| a.code == "1300").unwrap();
|
||||
assert_eq!(prepaid.amount, dec("900"));
|
||||
let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(5, 31), None).unwrap();
|
||||
assert_eq!(pl.total_expenses, dec("300"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_revenue_is_earned_to_the_cent() {
|
||||
let mut b = books();
|
||||
// Three months of a subscription collected up front.
|
||||
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Quarterly plan, Acme".into(),
|
||||
kind: ScheduleKind::DeferredRevenue,
|
||||
total: usd("1000"),
|
||||
start: d(1, 5),
|
||||
months: 3,
|
||||
balance_account: b.deferred,
|
||||
pl_account: b.revenue,
|
||||
})
|
||||
.unwrap();
|
||||
let plan: Vec<Decimal> =
|
||||
b.l.get_schedule(id)
|
||||
.unwrap()
|
||||
.due(d(12, 31))
|
||||
.into_iter()
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
assert_eq!(plan, [dec("333.33"), dec("333.33"), dec("333.34")]);
|
||||
for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)) {
|
||||
b.l.post_schedule_month(id, m).unwrap();
|
||||
}
|
||||
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
|
||||
assert_eq!(pl.total_revenue, dec("1000"));
|
||||
let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap();
|
||||
assert!(bs.liabilities.is_empty(), "fully earned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_respect_closed_periods_and_account_types() {
|
||||
let mut b = books();
|
||||
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
b.l.close_period(jan).unwrap();
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("120"),
|
||||
start: d(1, 1),
|
||||
months: 12,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
b.l.post_schedule_month(id, d(1, 31)),
|
||||
Err(LedgerError::ClosedPeriod(..))
|
||||
));
|
||||
b.l.post_schedule_month(id, d(2, 28)).unwrap();
|
||||
// A prepaid schedule needs an asset and an expense, not a liability.
|
||||
let bad = b.l.register_schedule(NewSchedule {
|
||||
description: "x".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("120"),
|
||||
start: d(1, 1),
|
||||
months: 12,
|
||||
balance_account: b.deferred,
|
||||
pl_account: b.insurance,
|
||||
});
|
||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurring_entries_generate_drafts_never_posts() {
|
||||
let mut b = books();
|
||||
let id =
|
||||
b.l.register_recurring(NewRecurring {
|
||||
description: "Office rent".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("2000")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 31),
|
||||
end: Some(d(4, 30)),
|
||||
})
|
||||
.unwrap();
|
||||
// Through March: Jan 31, Feb 28, Mar 31 (month ends, no drift).
|
||||
let due = b.l.recurring_entries().next().unwrap().due(d(3, 31));
|
||||
assert_eq!(due, [d(1, 31), d(2, 28), d(3, 31)]);
|
||||
let drafts: Vec<_> = due
|
||||
.iter()
|
||||
.map(|date| b.l.generate_recurring(id, *date).unwrap())
|
||||
.collect();
|
||||
for e in &drafts {
|
||||
assert_eq!(b.l.get_entry(*e).unwrap().status(), EntryStatus::Draft);
|
||||
}
|
||||
// Drafts don't touch the books until posted.
|
||||
let q1 = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
|
||||
assert_eq!(q1.total_expenses, Decimal::ZERO);
|
||||
b.l.post_entry(drafts[1]).unwrap();
|
||||
let feb = profit_and_loss_for_period(&b.l, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.total_expenses, dec("2000"));
|
||||
// Never twice; nothing after the end date.
|
||||
assert!(matches!(
|
||||
b.l.generate_recurring(id, d(2, 28)),
|
||||
Err(LedgerError::InvalidSchedule(_))
|
||||
));
|
||||
assert_eq!(
|
||||
b.l.recurring_entries().next().unwrap().due(d(12, 31)),
|
||||
[d(4, 30)]
|
||||
);
|
||||
// Templates must balance.
|
||||
let bad = b.l.register_recurring(NewRecurring {
|
||||
description: "x".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("1999")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 1),
|
||||
end: None,
|
||||
});
|
||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||
let _ = b.revenue;
|
||||
}
|
||||
@@ -413,11 +413,13 @@ fn partnership(close_january: bool) -> (Ledger, Vec<MemberCapital>) {
|
||||
MemberCapital {
|
||||
name: "Alice".into(),
|
||||
capital_account: alice,
|
||||
distribution_account: None,
|
||||
share: half,
|
||||
},
|
||||
MemberCapital {
|
||||
name: "Bob".into(),
|
||||
capital_account: bob,
|
||||
distribution_account: None,
|
||||
share: half,
|
||||
},
|
||||
];
|
||||
@@ -603,7 +605,11 @@ fn a_partnership_closes_into_members_capital() {
|
||||
let to = ledger_core::CloseTo::Members(
|
||||
members
|
||||
.iter()
|
||||
.map(|m| (m.capital_account, m.share))
|
||||
.map(|m| ledger_core::MemberClose {
|
||||
capital: m.capital_account,
|
||||
share: m.share,
|
||||
distributions: None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
@@ -659,9 +665,226 @@ fn a_partnership_closes_into_members_capital() {
|
||||
|
||||
// Shares must still add up.
|
||||
let feb = l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
|
||||
let bad = ledger_core::CloseTo::Members(vec![(members[0].capital_account, cents("0.5"))]);
|
||||
let bad = ledger_core::CloseTo::Members(vec![ledger_core::MemberClose {
|
||||
capital: members[0].capital_account,
|
||||
share: cents("0.5"),
|
||||
distributions: None,
|
||||
}]);
|
||||
assert!(matches!(
|
||||
l.close_period_to(feb, &bad),
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn entry_lines(l: &Ledger, id: ledger_core::EntryId) -> Vec<(String, Side, Decimal)> {
|
||||
l.get_entry(id)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|x| {
|
||||
(
|
||||
l.chart().get(x.account).unwrap().code.clone(),
|
||||
x.side,
|
||||
x.amount.amount,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sole_proprietor_closes_net_income_and_draws_into_capital() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = l
|
||||
.add_account("3000", "Owner's Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let draws = l
|
||||
.add_account("3100", "Owner's Draws", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
post(&mut l, d(1, 2), "Owner invests", cash, capital, 5_000);
|
||||
post(&mut l, d(1, 10), "Sales", cash, sales, 3_000);
|
||||
post(&mut l, d(1, 15), "Rent", rent, cash, 1_000);
|
||||
post(&mut l, d(1, 20), "Owner draw", draws, cash, 500);
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let to = ledger_core::CloseTo::Account {
|
||||
into: capital,
|
||||
sweep: vec![draws],
|
||||
};
|
||||
let closing = l.close_period_to(jan, &to).unwrap().unwrap();
|
||||
// Net 2,000 to capital; the 500 of draws zeroed into capital.
|
||||
assert_eq!(
|
||||
entry_lines(&l, closing),
|
||||
[
|
||||
("4000".to_string(), Side::Debit, dec(3_000)),
|
||||
("6000".to_string(), Side::Credit, dec(1_000)),
|
||||
("3000".to_string(), Side::Credit, dec(2_000)),
|
||||
("3100".to_string(), Side::Credit, dec(500)),
|
||||
("3000".to_string(), Side::Debit, dec(500)),
|
||||
]
|
||||
);
|
||||
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
|
||||
// 5,000 + 2,000 − 500 = 6,500, which is exactly the cash.
|
||||
assert_eq!(bs.equity, vec![line("3000", "Owner's Capital", 6_500)]);
|
||||
assert_eq!(bs.total_assets, dec(6_500));
|
||||
assert_eq!(bs.current_earnings, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corporation_closes_dividends_into_retained_earnings() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let stock = l
|
||||
.add_account("3000", "Common Stock", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let dividends = l
|
||||
.add_account("3200", "Dividends Paid", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
post(&mut l, d(1, 2), "Stock issued", cash, stock, 1_000);
|
||||
post(&mut l, d(1, 10), "Sales", cash, sales, 900);
|
||||
post(&mut l, d(1, 25), "Dividend", dividends, cash, 300);
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let to = ledger_core::CloseTo::RetainedEarnings {
|
||||
sweep: vec![dividends],
|
||||
};
|
||||
l.close_period_to(jan, &to).unwrap();
|
||||
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
|
||||
// Retained earnings: 900 earned − 300 paid out.
|
||||
assert_eq!(
|
||||
bs.equity,
|
||||
vec![
|
||||
line("3000", "Common Stock", 1_000),
|
||||
line("3999", "Retained Earnings", 600)
|
||||
]
|
||||
);
|
||||
assert!(bs.balanced());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partner_distribution_accounts_roll_into_capital() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let cap_a = l
|
||||
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let cap_b = l
|
||||
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let dist_a = l
|
||||
.add_account(
|
||||
"3100",
|
||||
"Distributions — Alice",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let dist_b = l
|
||||
.add_account(
|
||||
"3101",
|
||||
"Distributions — Bob",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100);
|
||||
post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100);
|
||||
post(&mut l, d(1, 10), "Sales", cash, sales, 50);
|
||||
post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20);
|
||||
let half = cents("0.5");
|
||||
let members = vec![
|
||||
MemberCapital {
|
||||
name: "Alice".into(),
|
||||
capital_account: cap_a,
|
||||
distribution_account: Some(dist_a),
|
||||
share: half,
|
||||
},
|
||||
MemberCapital {
|
||||
name: "Bob".into(),
|
||||
capital_account: cap_b,
|
||||
distribution_account: Some(dist_b),
|
||||
share: half,
|
||||
},
|
||||
];
|
||||
// January: Bob's distribution comes from his distributions account.
|
||||
let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
|
||||
let row = |c: &ledger_core::MemberEquityColumn| {
|
||||
(c.contributions, c.net_income, c.distributions, c.ending)
|
||||
};
|
||||
assert_eq!(
|
||||
row(&jan_me.members[0]),
|
||||
(dec(100), dec(25), dec(0), dec(125))
|
||||
);
|
||||
assert_eq!(
|
||||
row(&jan_me.members[1]),
|
||||
(dec(100), dec(25), dec(20), dec(105))
|
||||
);
|
||||
assert_eq!(
|
||||
jan_me.total.ending,
|
||||
balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
|
||||
);
|
||||
|
||||
// The close sweeps Bob's distributions into his capital.
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let to = ledger_core::CloseTo::Members(
|
||||
members
|
||||
.iter()
|
||||
.map(|m| ledger_core::MemberClose {
|
||||
capital: m.capital_account,
|
||||
share: m.share,
|
||||
distributions: m.distribution_account,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
l.close_period_to(jan, &to).unwrap();
|
||||
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
|
||||
assert_eq!(
|
||||
bs.equity,
|
||||
vec![
|
||||
line("3000", "Capital — Alice", 125),
|
||||
line("3001", "Capital — Bob", 105)
|
||||
]
|
||||
);
|
||||
// February starts there.
|
||||
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||||
assert_eq!(
|
||||
(feb_me.members[0].beginning, feb_me.members[1].beginning),
|
||||
(dec(125), dec(105))
|
||||
);
|
||||
|
||||
// A sweep account must be equity.
|
||||
let mut l2 = Ledger::new(Currency::USD);
|
||||
let c = l2
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let e = l2
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let p = l2.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let bad = ledger_core::CloseTo::Account {
|
||||
into: e,
|
||||
sweep: vec![c],
|
||||
};
|
||||
assert!(matches!(
|
||||
l2.close_period_to(p, &bad),
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Tax categories and the tax summary. Expected numbers computed by hand.
|
||||
//!
|
||||
//! The fixture: a partnership's first quarter.
|
||||
//!
|
||||
//! | account | category | % | book | deductible |
|
||||
//! |-----------------------|-----------------------|---|---------|------------|
|
||||
//! | 4000 Sales | gross_receipts | | 10,000 | 10,000 |
|
||||
//! | 4100 Refunds (exp.) | returns_allowances | | 250 | (250) |
|
||||
//! | 6000 Platform fees | commissions_fees | | 1,500 | 1,500 |
|
||||
//! | 6100 Meals | meals |50%| 333.33 | 166.67 |
|
||||
//! | 6200 Penalties | nondeductible | 0%| 40 | 0 |
|
||||
//! | 6300 Software | other_deductions | | 600 | 600 |
|
||||
//! | 6400 Partner pay | guaranteed_payments | | 2,000 | 2,000 |
|
||||
//! | 6500 Misc | (none) | | 75 | uncategorized |
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::tax::{TaxForm, check_category, tax_summary};
|
||||
use ledger_core::{AccountType, Currency, Ledger, LedgerError, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn books() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let mut acct =
|
||||
|code: &str, name: &str, t: AccountType, cat: Option<&str>, pct: Option<&str>| {
|
||||
let id = l.add_account(code, name, t, None, false).unwrap();
|
||||
if let Some(c) = cat {
|
||||
l.set_account_tax(id, Some(c), pct.map(dec)).unwrap();
|
||||
}
|
||||
id
|
||||
};
|
||||
let sales = acct(
|
||||
"4000",
|
||||
"Sales",
|
||||
AccountType::Revenue,
|
||||
Some("gross_receipts"),
|
||||
None,
|
||||
);
|
||||
let refunds = acct(
|
||||
"4100",
|
||||
"Refunds",
|
||||
AccountType::Expense,
|
||||
Some("returns_allowances"),
|
||||
None,
|
||||
);
|
||||
let fees = acct(
|
||||
"6000",
|
||||
"Platform fees",
|
||||
AccountType::Expense,
|
||||
Some("commissions_fees"),
|
||||
None,
|
||||
);
|
||||
let meals = acct("6100", "Meals", AccountType::Expense, Some("meals"), None);
|
||||
let pen = acct(
|
||||
"6200",
|
||||
"Penalties",
|
||||
AccountType::Expense,
|
||||
Some("nondeductible"),
|
||||
None,
|
||||
);
|
||||
let soft = acct(
|
||||
"6300",
|
||||
"Software",
|
||||
AccountType::Expense,
|
||||
Some("other_deductions"),
|
||||
None,
|
||||
);
|
||||
let gp = acct(
|
||||
"6400",
|
||||
"Partner pay",
|
||||
AccountType::Expense,
|
||||
Some("guaranteed_payments"),
|
||||
None,
|
||||
);
|
||||
let misc = acct("6500", "Misc", AccountType::Expense, None, None);
|
||||
let mut post = |dr, cr, amt: &str| {
|
||||
let e = l.create_entry(d(2, 1), "x");
|
||||
l.add_line(e, dr, Side::Debit, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
l.add_line(e, cr, Side::Credit, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
};
|
||||
post(cash, sales, "10000");
|
||||
for (a, amt) in [
|
||||
(refunds, "250"),
|
||||
(fees, "1500"),
|
||||
(meals, "333.33"),
|
||||
(pen, "40"),
|
||||
(soft, "600"),
|
||||
(gp, "2000"),
|
||||
(misc, "75"),
|
||||
] {
|
||||
post(a, cash, amt);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_groups_by_category_and_applies_deductible_percents() {
|
||||
let l = books();
|
||||
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
|
||||
let rows = |lines: &[ledger_core::TaxSummaryLine]| -> Vec<(String, Decimal, Decimal)> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| (l.category.clone(), l.book, l.tax))
|
||||
.collect()
|
||||
};
|
||||
// Refunds (an expense account) reduce receipts as negative income.
|
||||
assert_eq!(
|
||||
rows(&ts.income),
|
||||
[
|
||||
("gross_receipts".into(), dec("10000"), dec("10000")),
|
||||
("returns_allowances".into(), dec("-250"), dec("-250")),
|
||||
]
|
||||
);
|
||||
// Catalog order; meals at 50% (333.33 → 166.665 → 166.67, half away
|
||||
// from zero); penalties at 0%.
|
||||
assert_eq!(
|
||||
rows(&ts.deductions),
|
||||
[
|
||||
("commissions_fees".into(), dec("1500"), dec("1500")),
|
||||
("guaranteed_payments".into(), dec("2000"), dec("2000")),
|
||||
("meals".into(), dec("333.33"), dec("166.67")),
|
||||
("other_deductions".into(), dec("600"), dec("600")),
|
||||
("nondeductible".into(), dec("40"), dec("0")),
|
||||
]
|
||||
);
|
||||
assert_eq!(ts.uncategorized.len(), 1);
|
||||
assert_eq!(ts.uncategorized[0].account, "6500 Misc");
|
||||
assert_eq!(ts.uncategorized[0].book, dec("75"));
|
||||
// Book: 9,750 − 4,473.33 = 5,276.67. Tax: 9,750 − 4,266.67 = 5,483.33.
|
||||
assert_eq!(ts.book_net(), dec("5276.67"));
|
||||
assert_eq!(ts.tax_net(), dec("5483.33"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_beats_the_category_default() {
|
||||
let mut l = books();
|
||||
let meals = l.chart().find_by_code("6100").unwrap().id;
|
||||
// A company party is 100% deductible: override the 50% default.
|
||||
l.set_account_tax(meals, Some("meals"), Some(dec("100")))
|
||||
.unwrap();
|
||||
let ts = tax_summary(&l, d(1, 1), d(3, 31), None).unwrap();
|
||||
let m = ts
|
||||
.deductions
|
||||
.iter()
|
||||
.find(|x| x.category == "meals")
|
||||
.unwrap();
|
||||
assert_eq!(m.tax, dec("333.33"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_must_fit_the_account_and_the_return() {
|
||||
let mut l = books();
|
||||
let sales = l.chart().find_by_code("4000").unwrap().id;
|
||||
let meals = l.chart().find_by_code("6100").unwrap().id;
|
||||
let bad = |r: Result<(), LedgerError>| matches!(r, Err(LedgerError::InvalidTaxCategory(_)));
|
||||
assert!(
|
||||
bad(l.set_account_tax(sales, Some("meals"), None)),
|
||||
"deduction on revenue"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, Some("no_such"), None)),
|
||||
"unknown key"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, Some("meals"), Some(dec("101")))),
|
||||
"over 100%"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, None, Some(dec("50")))),
|
||||
"percent without category"
|
||||
);
|
||||
// Guaranteed payments exist only on a partnership return.
|
||||
assert!(
|
||||
check_category(
|
||||
"guaranteed_payments",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::Form1065)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
check_category(
|
||||
"guaranteed_payments",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::ScheduleC)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
check_category(
|
||||
"officer_compensation",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::Form1120S)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
// Clearing works.
|
||||
l.set_account_tax(meals, None, None).unwrap();
|
||||
assert!(l.chart().get(meals).unwrap().tax_category.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_parse_from_common_spellings() {
|
||||
for (s, f) in [
|
||||
("schedule_c", TaxForm::ScheduleC),
|
||||
("Schedule C", TaxForm::ScheduleC),
|
||||
("1065", TaxForm::Form1065),
|
||||
("Form 1065", TaxForm::Form1065),
|
||||
("1120s", TaxForm::Form1120S),
|
||||
("1120-S", TaxForm::Form1120S),
|
||||
("1120", TaxForm::Form1120),
|
||||
] {
|
||||
assert_eq!(TaxForm::parse(s), Some(f), "{s}");
|
||||
}
|
||||
assert_eq!(TaxForm::parse("990"), None);
|
||||
}
|
||||
@@ -24,16 +24,19 @@ pub struct ChartRow {
|
||||
pub account_type: String,
|
||||
/// Parent account code, if any.
|
||||
pub parent_code: Option<String>,
|
||||
/// Optional fifth column: the tax category key (see
|
||||
/// `ledger_core::tax`).
|
||||
pub tax_category: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse errors.
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ChartCsvError {
|
||||
/// Wrong column count.
|
||||
#[error("expected 4 columns (code,name,type,parent_code), found {0}")]
|
||||
BadColumnCount(usize),
|
||||
/// Wrong column count: (found, expected by the header).
|
||||
#[error("expected {1} columns to match the header, found {0}")]
|
||||
BadColumnCount(usize, usize),
|
||||
/// Missing or bad header.
|
||||
#[error("bad header: expected code,name,type,parent_code")]
|
||||
#[error("bad header: expected code,name,type,parent_code[,tax_category]")]
|
||||
BadHeader,
|
||||
/// Unknown account type.
|
||||
#[error("bad account type {0:?}: expected asset, liability, equity, revenue, or expense")]
|
||||
@@ -48,9 +51,11 @@ pub enum ChartCsvError {
|
||||
pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.flexible(true)
|
||||
.trim(csv::Trim::All)
|
||||
.from_reader(text.as_bytes());
|
||||
let mut out = Vec::new();
|
||||
let mut width = 4;
|
||||
for (idx, record) in reader.records().enumerate() {
|
||||
let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?;
|
||||
let fields: Vec<&str> = record.iter().collect();
|
||||
@@ -59,13 +64,17 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
||||
}
|
||||
if idx == 0 {
|
||||
let lower: Vec<String> = fields.iter().map(|f| f.to_lowercase()).collect();
|
||||
if lower != ["code", "name", "type", "parent_code"] {
|
||||
width = if lower == ["code", "name", "type", "parent_code"] {
|
||||
4
|
||||
} else if lower == ["code", "name", "type", "parent_code", "tax_category"] {
|
||||
5
|
||||
} else {
|
||||
return Err(ChartCsvError::BadHeader);
|
||||
}
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if fields.len() != 4 {
|
||||
return Err(ChartCsvError::BadColumnCount(fields.len()));
|
||||
if fields.len() != width {
|
||||
return Err(ChartCsvError::BadColumnCount(fields.len(), width));
|
||||
}
|
||||
let account_type = fields[2].to_lowercase();
|
||||
match account_type.as_str() {
|
||||
@@ -82,6 +91,11 @@ pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
||||
} else {
|
||||
Some(parent_code.to_string())
|
||||
},
|
||||
tax_category: fields
|
||||
.get(4)
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -101,6 +115,20 @@ mod tests {
|
||||
assert_eq!(rows[2].account_type, "revenue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_optional_tax_category_column() {
|
||||
let csv = "code,name,type,parent_code,tax_category\n4000,Sales,revenue,,gross_receipts\n1000,Cash,asset,,\n";
|
||||
let rows = parse_chart_csv(csv).unwrap();
|
||||
assert_eq!(rows[0].tax_category.as_deref(), Some("gross_receipts"));
|
||||
assert_eq!(rows[1].tax_category, None);
|
||||
// Rows must match the header's width.
|
||||
let short = "code,name,type,parent_code,tax_category\n1000,Cash,asset,\n";
|
||||
assert_eq!(
|
||||
parse_chart_csv(short),
|
||||
Err(ChartCsvError::BadColumnCount(4, 5))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_type_rejected() {
|
||||
let csv = "code,name,type,parent_code\n1000,Cash,money,\n";
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod chart_csv;
|
||||
pub mod iif;
|
||||
pub mod pdf;
|
||||
pub mod report;
|
||||
pub mod templates;
|
||||
pub mod xlsx;
|
||||
|
||||
pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||
@@ -21,4 +22,5 @@ pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||
pub use iif::{IifEntry, journal_to_iif};
|
||||
pub use pdf::{PdfError, statement_pdf};
|
||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
||||
pub use templates::{ChartTemplate, EntityType, chart_template};
|
||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||
@@ -53,6 +53,8 @@ pub struct Member {
|
||||
pub name: String,
|
||||
/// Code of their capital account.
|
||||
pub capital_code: String,
|
||||
/// Code of their distributions account, if they have one.
|
||||
pub distribution_code: Option<String>,
|
||||
}
|
||||
|
||||
/// What a row is, which decides how renderers style it.
|
||||
@@ -276,9 +278,10 @@ fn flow_lines<'a>(
|
||||
members: &'a [Member],
|
||||
) -> impl Iterator<Item = Row> + 'a {
|
||||
lines.iter().map(move |l| {
|
||||
let member = members
|
||||
.iter()
|
||||
.find(|m| l.account.as_deref() == Some(m.capital_code.as_str()));
|
||||
let member = members.iter().find(|m| {
|
||||
let code = l.account.as_deref();
|
||||
code == Some(m.capital_code.as_str()) || code == m.distribution_code.as_deref()
|
||||
});
|
||||
let label = match member {
|
||||
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
|
||||
Some(m) => format!("Member distributions — {}", m.name),
|
||||
@@ -400,6 +403,85 @@ pub fn members_equity(me: &MembersEquityStatement) -> Statement {
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out the tax summary: book and tax amounts by tax category, the
|
||||
/// difference where they disagree (meals at 50%, penalties at 0%), and any
|
||||
/// account still needing a category.
|
||||
pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement {
|
||||
let diff = |b: Decimal, t: Decimal| if b == t { None } else { Some(b - t) };
|
||||
let lines = |ls: &[ledger_core::TaxSummaryLine]| -> Vec<Row> {
|
||||
ls.iter()
|
||||
.map(|l| {
|
||||
let codes: Vec<&str> = l
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|a| a.split_whitespace().next().unwrap_or(""))
|
||||
.collect();
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} ({})", l.label, codes.join(", ")),
|
||||
vec![Some(l.book), Some(l.tax), diff(l.book, l.tax)],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let total = |kind, label: &str, ls: &[ledger_core::TaxSummaryLine]| {
|
||||
let b: Decimal = ls.iter().map(|l| l.book).sum();
|
||||
let t: Decimal = ls.iter().map(|l| l.tax).sum();
|
||||
row(kind, label, vec![Some(b), Some(t), diff(b, t)])
|
||||
};
|
||||
let mut rows = vec![section("Income")];
|
||||
rows.extend(lines(&ts.income));
|
||||
rows.push(total(RowKind::Subtotal, "Total income", &ts.income));
|
||||
if !ts.cost_of_goods.is_empty() {
|
||||
rows.push(spacer());
|
||||
rows.push(section("Cost of goods sold"));
|
||||
rows.extend(lines(&ts.cost_of_goods));
|
||||
rows.push(total(
|
||||
RowKind::Subtotal,
|
||||
"Total cost of goods sold",
|
||||
&ts.cost_of_goods,
|
||||
));
|
||||
}
|
||||
rows.push(spacer());
|
||||
rows.push(section("Deductions"));
|
||||
rows.extend(lines(&ts.deductions));
|
||||
rows.push(total(RowKind::Subtotal, "Total deductions", &ts.deductions));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Income less deductions",
|
||||
vec![
|
||||
Some(ts.book_net()),
|
||||
Some(ts.tax_net()),
|
||||
diff(ts.book_net(), ts.tax_net()),
|
||||
],
|
||||
));
|
||||
if !ts.uncategorized.is_empty() {
|
||||
rows.push(spacer());
|
||||
rows.push(section("Not yet categorized"));
|
||||
rows.push(subheading(
|
||||
"Assign each a tax category (books.set_tax_category); not included above:",
|
||||
));
|
||||
rows.extend(ts.uncategorized.iter().map(|u| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
u.account.clone(),
|
||||
vec![Some(u.book), None, None],
|
||||
)
|
||||
}));
|
||||
}
|
||||
let title = match ts.form {
|
||||
Some(f) => format!("Tax Summary ({} categories)", f.label()),
|
||||
None => "Tax Summary".to_string(),
|
||||
};
|
||||
statement(
|
||||
&title,
|
||||
period_label(ts.start, ts.end),
|
||||
&["Book", "Tax", "Difference"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// "October 11, 2026".
|
||||
pub fn long_date(d: NaiveDate) -> String {
|
||||
d.format("%B %-d, %Y").to_string()
|
||||
@@ -587,10 +669,12 @@ mod tests {
|
||||
Member {
|
||||
name: "Alice".into(),
|
||||
capital_code: "3000".into(),
|
||||
distribution_code: None,
|
||||
},
|
||||
Member {
|
||||
name: "Bob".into(),
|
||||
capital_code: "3001".into(),
|
||||
distribution_code: None,
|
||||
},
|
||||
];
|
||||
let st = balance_sheet(&bs, e, &members);
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Starter charts of accounts by entity type.
|
||||
//!
|
||||
//! A new business shouldn't start from a blank chart, and the right chart
|
||||
//! depends on what the business *is*: a sole proprietor has an owner's
|
||||
//! capital and draws; a partnership (or multi-member LLC) has a capital and
|
||||
//! distributions account per partner and pays partners guaranteed payments;
|
||||
//! an S corporation pays officer wages and makes shareholder distributions;
|
||||
//! a C corporation pays dividends and income tax. Each template is a
|
||||
//! proposal for the agent to review with the human before importing (via
|
||||
//! `books.import_chart_csv`), with tax categories already assigned, plus
|
||||
//! the `wanderbooks.toml` settings that go with it (tax form, owners, and
|
||||
//! where period closes go).
|
||||
|
||||
use crate::chart_csv::ChartRow;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// The kinds of business a template exists for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityType {
|
||||
/// Sole proprietorship or single-member LLC (Schedule C).
|
||||
SoleProprietorship,
|
||||
/// Partnership or multi-member LLC (Form 1065).
|
||||
Partnership,
|
||||
/// S corporation, including an LLC electing S status (Form 1120-S).
|
||||
SCorporation,
|
||||
/// C corporation (Form 1120).
|
||||
CCorporation,
|
||||
}
|
||||
|
||||
impl EntityType {
|
||||
/// Parse "sole_proprietorship", "partnership", "s_corporation",
|
||||
/// "c_corporation" and common spellings ("llc" alone is ambiguous).
|
||||
pub fn parse(s: &str) -> Option<EntityType> {
|
||||
let k: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
match k.as_str() {
|
||||
"soleproprietorship" | "soleprop" | "singlememberllc" | "smllc" => {
|
||||
Some(EntityType::SoleProprietorship)
|
||||
}
|
||||
"partnership" | "multimemberllc" | "mmllc" => Some(EntityType::Partnership),
|
||||
"scorporation" | "scorp" | "s" => Some(EntityType::SCorporation),
|
||||
"ccorporation" | "ccorp" | "c" | "corporation" => Some(EntityType::CCorporation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A starter chart plus the settings that go with it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChartTemplate {
|
||||
/// Accounts, parents first.
|
||||
pub rows: Vec<ChartRow>,
|
||||
/// The `wanderbooks.toml` additions: tax form, owners, close settings.
|
||||
pub settings_toml: String,
|
||||
/// What the human should decide or know, in plain words.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ChartTemplate {
|
||||
/// The accounts as chart CSV with the tax_category column.
|
||||
pub fn csv(&self) -> String {
|
||||
let mut out = String::from("code,name,type,parent_code,tax_category\n");
|
||||
for r in &self.rows {
|
||||
let field = |s: &str| {
|
||||
if s.contains([',', '"']) {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{},{},{},{},{}\n",
|
||||
r.code,
|
||||
field(&r.name),
|
||||
r.account_type,
|
||||
r.parent_code.as_deref().unwrap_or(""),
|
||||
r.tax_category.as_deref().unwrap_or("")
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn acct(code: &str, name: &str, t: &str, tax: Option<&str>) -> ChartRow {
|
||||
ChartRow {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
account_type: t.to_string(),
|
||||
parent_code: None,
|
||||
tax_category: tax.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal shares for `n` owners as percents to two places, the last
|
||||
/// absorbing the remainder so they sum to exactly 100.
|
||||
fn equal_shares(n: usize) -> Vec<Decimal> {
|
||||
let each = (Decimal::from(100) / Decimal::from(n as u64)).round_dp(2);
|
||||
let mut v = vec![each; n];
|
||||
if let Some((last, rest)) = v.split_last_mut() {
|
||||
*last = Decimal::from(100) - rest.iter().sum::<Decimal>();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Build the template for `entity` with these owners (names; at least two
|
||||
/// for a partnership, which gets a capital and distributions account each).
|
||||
pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemplate, String> {
|
||||
use EntityType::*;
|
||||
if entity == Partnership && owners.len() < 2 {
|
||||
return Err("a partnership template needs at least two owners' names".to_string());
|
||||
}
|
||||
if owners.len() > 90 {
|
||||
return Err("at most 90 owners".to_string());
|
||||
}
|
||||
let owed_to = match entity {
|
||||
SoleProprietorship => "Due to Owner",
|
||||
Partnership => "Due to Members",
|
||||
SCorporation | CCorporation => "Due to Shareholders",
|
||||
};
|
||||
let mut rows = vec![
|
||||
acct("1000", "Business Checking", "asset", None),
|
||||
acct("1100", "Savings", "asset", None),
|
||||
acct("1200", "Accounts Receivable", "asset", None),
|
||||
acct("1300", "Prepaid Expenses", "asset", None),
|
||||
acct("1500", "Equipment", "asset", None),
|
||||
acct("1590", "Accumulated Depreciation", "asset", None),
|
||||
acct("2000", "Accounts Payable", "liability", None),
|
||||
acct("2100", "Credit Card", "liability", None),
|
||||
acct("2200", "Sales Tax Payable", "liability", None),
|
||||
acct("2300", "Payroll Liabilities", "liability", None),
|
||||
acct("2400", "Deferred Revenue", "liability", None),
|
||||
acct("2500", owed_to, "liability", None),
|
||||
acct("2700", "Loans Payable", "liability", None),
|
||||
];
|
||||
let mut settings = String::new();
|
||||
let mut notes = vec![
|
||||
"Rename, add, or drop accounts before importing; codes are a convention, not a rule."
|
||||
.to_string(),
|
||||
format!(
|
||||
"{owed_to}: business expenses an owner paid personally, until the business reimburses them."
|
||||
),
|
||||
];
|
||||
match entity {
|
||||
SoleProprietorship => {
|
||||
rows.push(acct("3000", "Owner's Capital", "equity", None));
|
||||
rows.push(acct("3100", "Owner's Draws", "equity", None));
|
||||
settings.push_str("[company]\ntax_form = \"schedule_c\"\n\n[books]\n# Net income and draws close into the owner's capital.\nclose_into = \"3000\"\nclose_sweep = [\"3100\"]\n");
|
||||
notes
|
||||
.push("Money you take out is a draw (3100), not a wage or an expense.".to_string());
|
||||
}
|
||||
Partnership => {
|
||||
let shares = equal_shares(owners.len());
|
||||
settings.push_str("[company]\ntax_form = \"1065\"\n\n# Each partner's share drives allocations and closes. Equal shares are a\n# placeholder: use the operating agreement's numbers.\n");
|
||||
for (i, name) in owners.iter().enumerate() {
|
||||
let cap = format!("{}", 3000 + i);
|
||||
let dist = format!("{}", 3100 + i);
|
||||
rows.push(acct(&cap, &format!("Capital — {name}"), "equity", None));
|
||||
rows.push(acct(
|
||||
&dist,
|
||||
&format!("Distributions — {name}"),
|
||||
"equity",
|
||||
None,
|
||||
));
|
||||
settings.push_str(&format!(
|
||||
"\n[[company.members]]\nname = \"{name}\"\nshare = \"{}%\"\ncapital_account = \"{cap}\"\ndistribution_account = \"{dist}\"\n",
|
||||
shares[i]
|
||||
));
|
||||
}
|
||||
notes.push("A partner paid for work gets guaranteed payments (7200), not wages; profit taken out is a distribution.".to_string());
|
||||
notes.push(
|
||||
"Shares default to equal; set them from the operating agreement.".to_string(),
|
||||
);
|
||||
}
|
||||
SCorporation | CCorporation => {
|
||||
rows.push(acct("3000", "Common Stock", "equity", None));
|
||||
rows.push(acct("3100", "Additional Paid-in Capital", "equity", None));
|
||||
if entity == SCorporation {
|
||||
rows.push(acct("3200", "Shareholder Distributions", "equity", None));
|
||||
settings.push_str("[company]\ntax_form = \"1120s\"\n\n[books]\n# Net income and distributions close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
|
||||
notes.push("Owner-employees must take reasonable W-2 wages (officer compensation, 7200) before distributions.".to_string());
|
||||
} else {
|
||||
rows.push(acct("3200", "Dividends Paid", "equity", None));
|
||||
settings.push_str("[company]\ntax_form = \"1120\"\n\n[books]\n# Net income and dividends close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
|
||||
notes.push("The corporation pays its own income tax (7900, not deductible); dividends are not expenses.".to_string());
|
||||
}
|
||||
rows.push(acct("3999", "Retained Earnings", "equity", None));
|
||||
}
|
||||
}
|
||||
rows.extend([
|
||||
acct("4000", "Sales", "revenue", Some("gross_receipts")),
|
||||
acct(
|
||||
"4100",
|
||||
"Refunds and Allowances",
|
||||
"revenue",
|
||||
Some("returns_allowances"),
|
||||
),
|
||||
acct(
|
||||
"4900",
|
||||
"Interest Income",
|
||||
"revenue",
|
||||
Some("interest_income"),
|
||||
),
|
||||
acct("4950", "Other Income", "revenue", Some("other_income")),
|
||||
acct(
|
||||
"5000",
|
||||
"Cost of Goods Sold",
|
||||
"expense",
|
||||
Some("cost_of_goods_sold"),
|
||||
),
|
||||
acct("6000", "Advertising", "expense", Some("advertising")),
|
||||
acct("6050", "Bank Fees", "expense", Some("other_deductions")),
|
||||
acct(
|
||||
"6100",
|
||||
"Platform and Payment Processing Fees",
|
||||
"expense",
|
||||
Some("commissions_fees"),
|
||||
),
|
||||
acct("6150", "Contract Labor", "expense", Some("contract_labor")),
|
||||
acct("6200", "Depreciation", "expense", Some("depreciation")),
|
||||
acct(
|
||||
"6250",
|
||||
"Dues and Memberships",
|
||||
"expense",
|
||||
Some("other_deductions"),
|
||||
),
|
||||
acct("6300", "Insurance", "expense", Some("insurance")),
|
||||
acct(
|
||||
"6350",
|
||||
"Interest Expense",
|
||||
"expense",
|
||||
Some("interest_expense"),
|
||||
),
|
||||
acct(
|
||||
"6400",
|
||||
"Legal and Professional Fees",
|
||||
"expense",
|
||||
Some("legal_professional"),
|
||||
),
|
||||
acct("6450", "Meals", "expense", Some("meals")),
|
||||
acct("6500", "Office Expense", "expense", Some("office_expense")),
|
||||
acct(
|
||||
"6550",
|
||||
"Organizational and Start-up Costs",
|
||||
"expense",
|
||||
Some("organization_startup"),
|
||||
),
|
||||
acct("6600", "Rent", "expense", Some("rent_property")),
|
||||
acct(
|
||||
"6650",
|
||||
"Repairs and Maintenance",
|
||||
"expense",
|
||||
Some("repairs"),
|
||||
),
|
||||
acct(
|
||||
"6700",
|
||||
"Software and Subscriptions",
|
||||
"expense",
|
||||
Some("other_deductions"),
|
||||
),
|
||||
acct("6750", "Supplies", "expense", Some("supplies")),
|
||||
acct(
|
||||
"6800",
|
||||
"Taxes and Licenses",
|
||||
"expense",
|
||||
Some("taxes_licenses"),
|
||||
),
|
||||
acct("6850", "Travel", "expense", Some("travel")),
|
||||
acct("6900", "Utilities", "expense", Some("utilities")),
|
||||
acct(
|
||||
"6950",
|
||||
"Penalties and Nondeductible Expenses",
|
||||
"expense",
|
||||
Some("nondeductible"),
|
||||
),
|
||||
acct(
|
||||
"7000",
|
||||
"Salaries and Wages",
|
||||
"expense",
|
||||
Some("salaries_wages"),
|
||||
),
|
||||
acct("7050", "Payroll Taxes", "expense", Some("taxes_licenses")),
|
||||
acct(
|
||||
"7100",
|
||||
"Employee Benefits",
|
||||
"expense",
|
||||
Some("employee_benefits"),
|
||||
),
|
||||
]);
|
||||
match entity {
|
||||
Partnership => rows.push(acct(
|
||||
"7200",
|
||||
"Guaranteed Payments to Partners",
|
||||
"expense",
|
||||
Some("guaranteed_payments"),
|
||||
)),
|
||||
SCorporation | CCorporation => rows.push(acct(
|
||||
"7200",
|
||||
"Officer Compensation",
|
||||
"expense",
|
||||
Some("officer_compensation"),
|
||||
)),
|
||||
SoleProprietorship => {}
|
||||
}
|
||||
if entity == CCorporation {
|
||||
rows.push(acct(
|
||||
"7900",
|
||||
"Income Tax Expense",
|
||||
"expense",
|
||||
Some("nondeductible"),
|
||||
));
|
||||
}
|
||||
Ok(ChartTemplate {
|
||||
rows,
|
||||
settings_toml: settings,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chart_csv::parse_chart_csv;
|
||||
|
||||
#[test]
|
||||
fn templates_round_trip_through_the_chart_parser() {
|
||||
for e in [
|
||||
EntityType::SoleProprietorship,
|
||||
EntityType::Partnership,
|
||||
EntityType::SCorporation,
|
||||
EntityType::CCorporation,
|
||||
] {
|
||||
let t = chart_template(e, &["Ann Lee".into(), "Bo, Jr.".into(), "Cy".into()]).unwrap();
|
||||
let parsed = parse_chart_csv(&t.csv()).unwrap();
|
||||
assert_eq!(parsed, t.rows, "{e:?}");
|
||||
// Codes are unique.
|
||||
let mut codes: Vec<&str> = t.rows.iter().map(|r| r.code.as_str()).collect();
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
assert_eq!(codes.len(), t.rows.len(), "{e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equity_fits_the_entity() {
|
||||
let names = |t: &ChartTemplate| -> Vec<String> {
|
||||
t.rows
|
||||
.iter()
|
||||
.filter(|r| r.account_type == "equity")
|
||||
.map(|r| r.name.clone())
|
||||
.collect()
|
||||
};
|
||||
let sole = chart_template(EntityType::SoleProprietorship, &[]).unwrap();
|
||||
assert_eq!(names(&sole), ["Owner's Capital", "Owner's Draws"]);
|
||||
assert!(sole.settings_toml.contains("close_into = \"3000\""));
|
||||
|
||||
let p = chart_template(
|
||||
EntityType::Partnership,
|
||||
&["A".into(), "B".into(), "C".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
names(&p),
|
||||
[
|
||||
"Capital — A",
|
||||
"Distributions — A",
|
||||
"Capital — B",
|
||||
"Distributions — B",
|
||||
"Capital — C",
|
||||
"Distributions — C"
|
||||
]
|
||||
);
|
||||
// Three equal shares that sum to exactly 100.
|
||||
assert!(p.settings_toml.contains("share = \"33.33%\""));
|
||||
assert!(p.settings_toml.contains("share = \"33.34%\""));
|
||||
assert!(
|
||||
p.rows
|
||||
.iter()
|
||||
.any(|r| r.tax_category.as_deref() == Some("guaranteed_payments"))
|
||||
);
|
||||
assert!(chart_template(EntityType::Partnership, &["solo".into()]).is_err());
|
||||
|
||||
let s = chart_template(EntityType::SCorporation, &[]).unwrap();
|
||||
assert!(names(&s).contains(&"Shareholder Distributions".to_string()));
|
||||
assert!(
|
||||
s.rows
|
||||
.iter()
|
||||
.any(|r| r.tax_category.as_deref() == Some("officer_compensation"))
|
||||
);
|
||||
let c = chart_template(EntityType::CCorporation, &[]).unwrap();
|
||||
assert!(names(&c).contains(&"Dividends Paid".to_string()));
|
||||
assert!(c.rows.iter().any(|r| r.name == "Income Tax Expense"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_is_valid_for_the_entitys_return() {
|
||||
use ledger_core::{AccountType, TaxForm, tax::check_category};
|
||||
for (e, form) in [
|
||||
(EntityType::SoleProprietorship, TaxForm::ScheduleC),
|
||||
(EntityType::Partnership, TaxForm::Form1065),
|
||||
(EntityType::SCorporation, TaxForm::Form1120S),
|
||||
(EntityType::CCorporation, TaxForm::Form1120),
|
||||
] {
|
||||
for r in chart_template(e, &["A".into(), "B".into()]).unwrap().rows {
|
||||
let Some(cat) = &r.tax_category else { continue };
|
||||
let t = if r.account_type == "revenue" {
|
||||
AccountType::Revenue
|
||||
} else {
|
||||
AccountType::Expense
|
||||
};
|
||||
check_category(cat, t, Some(form))
|
||||
.unwrap_or_else(|err| panic!("{e:?} {}: {err}", r.code));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_types_parse() {
|
||||
assert_eq!(
|
||||
EntityType::parse("multi-member LLC"),
|
||||
Some(EntityType::Partnership)
|
||||
);
|
||||
assert_eq!(EntityType::parse("S corp"), Some(EntityType::SCorporation));
|
||||
assert_eq!(
|
||||
EntityType::parse("sole_proprietorship"),
|
||||
Some(EntityType::SoleProprietorship)
|
||||
);
|
||||
assert_eq!(EntityType::parse("LLC"), None, "ambiguous");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,16 @@ pub struct Company {
|
||||
pub members: Vec<Member>,
|
||||
/// "Accrual" or "Cash", for report footers. Default "Accrual".
|
||||
pub basis: String,
|
||||
/// The income tax return the business files, if known: from
|
||||
/// `tax_form`, else from `tax_classification`.
|
||||
pub tax_form: Option<ledger_core::TaxForm>,
|
||||
/// `[books] close_into`: the equity account a period close puts net
|
||||
/// income in (a sole proprietor's capital, say). Unset: Retained
|
||||
/// Earnings, or members' capital accounts when there are members.
|
||||
pub close_into: Option<String>,
|
||||
/// `[books] close_sweep`: equity accounts (draws, dividends,
|
||||
/// distributions) a close zeroes into `close_into`.
|
||||
pub close_sweep: Vec<String>,
|
||||
}
|
||||
|
||||
/// One member of a multi-member LLC.
|
||||
@@ -36,6 +46,9 @@ pub struct Member {
|
||||
pub share: Decimal,
|
||||
/// Code of their capital account in the chart.
|
||||
pub capital_account: String,
|
||||
/// Code of their distributions account, if distributions are recorded
|
||||
/// separately from capital (zeroed into capital at close).
|
||||
pub distribution_account: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Company {
|
||||
@@ -45,6 +58,9 @@ impl Default for Company {
|
||||
fiscal_year_start: (1, 1),
|
||||
members: Vec::new(),
|
||||
basis: "Accrual".to_string(),
|
||||
tax_form: None,
|
||||
close_into: None,
|
||||
close_sweep: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +103,8 @@ struct BooksToml {
|
||||
struct CompanyToml {
|
||||
name: Option<String>,
|
||||
fiscal_year_start: Option<String>,
|
||||
tax_form: Option<String>,
|
||||
tax_classification: Option<String>,
|
||||
#[serde(default)]
|
||||
members: Vec<MemberToml>,
|
||||
}
|
||||
@@ -96,12 +114,34 @@ struct MemberToml {
|
||||
name: String,
|
||||
share: String,
|
||||
capital_account: Option<String>,
|
||||
distribution_account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct BooksSection {
|
||||
receipt_threshold: Option<String>,
|
||||
accounting_basis: Option<String>,
|
||||
close_into: Option<String>,
|
||||
#[serde(default)]
|
||||
close_sweep: Vec<String>,
|
||||
}
|
||||
|
||||
/// The return a federal tax classification files: "partnership" → 1065,
|
||||
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship"
|
||||
/// or "disregarded entity" → Schedule C. Unrecognized: unknown.
|
||||
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
|
||||
let c = c.to_lowercase();
|
||||
if c.contains("partnership") {
|
||||
Some(ledger_core::TaxForm::Form1065)
|
||||
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") {
|
||||
Some(ledger_core::TaxForm::Form1120S)
|
||||
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") {
|
||||
Some(ledger_core::TaxForm::Form1120)
|
||||
} else if c.contains("sole") || c.contains("disregarded") {
|
||||
Some(ledger_core::TaxForm::ScheduleC)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a share: "50%" or "0.5".
|
||||
@@ -150,6 +190,10 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
name: m.name.trim().to_string(),
|
||||
share,
|
||||
capital_account: capital_account.trim().to_string(),
|
||||
distribution_account: m
|
||||
.distribution_account
|
||||
.map(|d| d.trim().to_string())
|
||||
.filter(|d| !d.is_empty()),
|
||||
});
|
||||
}
|
||||
if !company.members.is_empty() {
|
||||
@@ -158,6 +202,22 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
return Err(format!("member shares sum to {total}, not 100%"));
|
||||
}
|
||||
}
|
||||
company.tax_form =
|
||||
match (&raw.company.tax_form, &raw.company.tax_classification) {
|
||||
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
|
||||
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
|
||||
})?),
|
||||
(None, Some(c)) => form_for_classification(c),
|
||||
(None, None) => None,
|
||||
};
|
||||
if !company.members.is_empty() && raw.books.close_into.is_some() {
|
||||
return Err(
|
||||
"close_into is for single-owner businesses; with [[company.members]] a close goes to each member's capital account"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
|
||||
company.close_sweep = raw.books.close_sweep;
|
||||
if let Some(basis) = raw.books.accounting_basis {
|
||||
company.basis = match basis.trim().to_lowercase().as_str() {
|
||||
"accrual" => "Accrual".to_string(),
|
||||
@@ -259,12 +319,14 @@ mod tests {
|
||||
Member {
|
||||
name: "Stephen Waits".into(),
|
||||
share: half,
|
||||
capital_account: "3000".into()
|
||||
capital_account: "3000".into(),
|
||||
distribution_account: None,
|
||||
},
|
||||
Member {
|
||||
name: "Katherine Pendergast".into(),
|
||||
share: half,
|
||||
capital_account: "3001".into()
|
||||
capital_account: "3001".into(),
|
||||
distribution_account: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -282,6 +344,34 @@ mod tests {
|
||||
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tax_form_from_setting_or_classification() {
|
||||
use ledger_core::TaxForm;
|
||||
let form = |toml: &str| parse(toml).unwrap().company.tax_form;
|
||||
assert_eq!(
|
||||
form("[company]\ntax_form = \"1120s\"\n"),
|
||||
Some(TaxForm::Form1120S)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"partnership\"\n"),
|
||||
Some(TaxForm::Form1065)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"S corporation\"\n"),
|
||||
Some(TaxForm::Form1120S)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"disregarded entity\"\n"),
|
||||
Some(TaxForm::ScheduleC)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"partnership\"\ntax_form = \"1120\"\n"),
|
||||
Some(TaxForm::Form1120)
|
||||
);
|
||||
assert_eq!(form(""), None);
|
||||
assert!(parse("[company]\ntax_form = \"990\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_settings() {
|
||||
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
|
||||
|
||||
+748
-16
@@ -23,7 +23,7 @@ use rust_decimal::Decimal;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The agent operating contract, delivered via initialize.
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- 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.";
|
||||
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.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.";
|
||||
|
||||
/// Max receipt/document upload: 25 MB.
|
||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
@@ -82,6 +82,7 @@ impl BooksServer {
|
||||
.map(|m| ledger_import::Member {
|
||||
name: m.name.clone(),
|
||||
capital_code: m.capital_account.clone(),
|
||||
distribution_code: m.distribution_account.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -108,15 +109,69 @@ impl BooksServer {
|
||||
m.name, m.capital_account
|
||||
)
|
||||
})?;
|
||||
let distribution_account = match &m.distribution_account {
|
||||
Some(code) => Some(
|
||||
ledger
|
||||
.chart()
|
||||
.find_by_code(code)
|
||||
.ok_or_else(|| {
|
||||
format!("member {}: unknown distribution account {code}", m.name)
|
||||
})?
|
||||
.id,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
Ok(ledger_core::MemberCapital {
|
||||
name: m.name.clone(),
|
||||
capital_account: account.id,
|
||||
distribution_account,
|
||||
share: m.share,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where a period close goes, from wanderbooks.toml: members' capital
|
||||
/// accounts by share (with their distribution accounts swept in) for a
|
||||
/// partnership; else `close_into` (Retained Earnings when unset or
|
||||
/// `3999`) with `close_sweep` zeroed into it.
|
||||
fn close_target(&self, ledger: &Ledger) -> Result<ledger_core::CloseTo, String> {
|
||||
let code = |c: &str| -> Result<AccountId, String> {
|
||||
ledger
|
||||
.chart()
|
||||
.find_by_code(c)
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("wanderbooks.toml names unknown account {c}"))
|
||||
};
|
||||
if !self.company.members.is_empty() {
|
||||
return Ok(ledger_core::CloseTo::Members(
|
||||
self.member_capitals(ledger)?
|
||||
.into_iter()
|
||||
.map(|m| ledger_core::MemberClose {
|
||||
capital: m.capital_account,
|
||||
share: m.share,
|
||||
distributions: m.distribution_account,
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
let sweep = self
|
||||
.company
|
||||
.close_sweep
|
||||
.iter()
|
||||
.map(|c| code(c))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(match self.company.close_into.as_deref() {
|
||||
None | Some(ledger_core::RETAINED_EARNINGS_CODE) => {
|
||||
ledger_core::CloseTo::RetainedEarnings { sweep }
|
||||
}
|
||||
Some(c) => ledger_core::CloseTo::Account {
|
||||
into: code(c)?,
|
||||
sweep,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The fiscal year's earnings through `as_of`, for the balance sheet:
|
||||
/// net income (loss) year to date (closing entries excluded), and the
|
||||
/// part of it still unclosed (closing entries included, so months
|
||||
@@ -261,6 +316,32 @@ fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
|
||||
}
|
||||
|
||||
/// Resolve a class by id or name (case-insensitive).
|
||||
fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||
if let Ok(id) = vendor.parse::<u64>()
|
||||
&& store.ledger().get_vendor(id).is_some()
|
||||
{
|
||||
return Ok(id);
|
||||
}
|
||||
let matches: Vec<u64> = store
|
||||
.ledger()
|
||||
.list_vendors()
|
||||
.into_iter()
|
||||
.filter(|v| v.name.eq_ignore_ascii_case(vendor.trim()))
|
||||
.map(|v| v.id)
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[id] => Ok(*id),
|
||||
[] => Err(format!(
|
||||
"unknown vendor {vendor:?}: add it with books.add_vendor"
|
||||
)),
|
||||
_ => Err(format!("several vendors are named {vendor:?}: pass the id")),
|
||||
}
|
||||
}
|
||||
|
||||
fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
|
||||
form.map(|f| f.label().to_string())
|
||||
}
|
||||
|
||||
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||
if let Ok(id) = class.parse::<u32>()
|
||||
&& store.ledger().get_class(id).is_some()
|
||||
@@ -447,6 +528,48 @@ fn statement_range(
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> ScheduleOut {
|
||||
let code = |id| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
ScheduleOut {
|
||||
schedule_id: s.id,
|
||||
description: s.description.clone(),
|
||||
kind: match s.kind {
|
||||
ledger_core::ScheduleKind::PrepaidExpense => "prepaid_expense",
|
||||
ledger_core::ScheduleKind::DeferredRevenue => "deferred_revenue",
|
||||
}
|
||||
.to_string(),
|
||||
balance_account: code(s.balance_account),
|
||||
pl_account: code(s.pl_account),
|
||||
total: money(s.total.amount),
|
||||
remaining: money(s.remaining()),
|
||||
months: ledger_core::schedules::amortization_plan(s.total.amount, s.start, s.months)
|
||||
.into_iter()
|
||||
.map(|(d, a)| ScheduleMonthOut {
|
||||
date: d.to_string(),
|
||||
amount: money(a),
|
||||
posted: s.posted.contains(&d),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn recurring_out(r: &ledger_core::RecurringEntry) -> RecurringOut {
|
||||
RecurringOut {
|
||||
recurring_id: r.id,
|
||||
description: r.description.clone(),
|
||||
frequency: format!("{:?}", r.frequency).to_lowercase(),
|
||||
start: r.start.to_string(),
|
||||
end: r.end.map(|e| e.to_string()),
|
||||
generated: r.generated.iter().map(|d| d.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Profit and loss for a range (see [`statement_range`]), optionally one class.
|
||||
fn pnl_statement(
|
||||
ledger: &Ledger,
|
||||
@@ -512,6 +635,8 @@ impl BooksServer {
|
||||
} else {
|
||||
balance
|
||||
}),
|
||||
tax_category: a.tax_category.clone(),
|
||||
deductible_percent: a.deductible_percent.map(|p| p.to_string()),
|
||||
});
|
||||
}
|
||||
Ok(Json(ChartOut {
|
||||
@@ -715,9 +840,19 @@ impl BooksServer {
|
||||
) -> Result<Json<DraftEntryOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let vendor = p
|
||||
.vendor
|
||||
.as_deref()
|
||||
.map(|v| resolve_vendor(&store, v))
|
||||
.transpose()?;
|
||||
let id = store
|
||||
.create_entry(parse_date(&p.date)?, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if vendor.is_some() {
|
||||
store
|
||||
.set_entry_vendor(id, vendor)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(DraftEntryOut {
|
||||
entry_id: id,
|
||||
status: "draft".to_string(),
|
||||
@@ -1188,6 +1323,11 @@ impl BooksServer {
|
||||
lines,
|
||||
total: money(ReceiptPolicy::entry_debits(&entry)),
|
||||
receipts: preview_receipts(&store, p.entry_id)?,
|
||||
vendor: store
|
||||
.ledger()
|
||||
.entry_vendor(p.entry_id)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| v.name.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1359,6 +1499,519 @@ impl BooksServer {
|
||||
Ok(Json(ImportBankCsvOut { imported, count }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.create_schedule",
|
||||
description = "Spread a prepaid expense (asset expensed monthly, e.g. a year of insurance paid up front) or deferred revenue (liability earned monthly, e.g. an annual subscription collected up front) evenly over the months it covers, to the cent (the last month absorbs rounding). Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the monthly plan; post it with books.run_schedules."
|
||||
)]
|
||||
async fn create_schedule(
|
||||
&self,
|
||||
params: Parameters<CreateScheduleParams>,
|
||||
) -> Result<Json<ScheduleOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let kind = match p.kind.as_str() {
|
||||
"prepaid_expense" => ledger_core::ScheduleKind::PrepaidExpense,
|
||||
"deferred_revenue" => ledger_core::ScheduleKind::DeferredRevenue,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"kind {other:?}: expected prepaid_expense or deferred_revenue"
|
||||
));
|
||||
}
|
||||
};
|
||||
let currency = store.ledger().base_currency();
|
||||
let balance_account = resolve_account(&store, &p.balance_account)?;
|
||||
let pl_account = resolve_account(&store, &p.pl_account)?;
|
||||
let id = store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: p.description,
|
||||
kind,
|
||||
total: Money::new(parse_amount(&p.total)?, currency),
|
||||
start: parse_date(&p.start)?,
|
||||
months: p.months,
|
||||
balance_account,
|
||||
pl_account,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(schedule_out(
|
||||
store.ledger(),
|
||||
store.ledger().get_schedule(id).expect("registered"),
|
||||
)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_schedules",
|
||||
description = "Amortization schedules (prepaid expenses, deferred revenue): each month's amount, what has posted, and what remains.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn list_schedules(&self) -> Result<Json<ListSchedulesOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
Ok(Json(ListSchedulesOut {
|
||||
schedules: ledger
|
||||
.schedules()
|
||||
.map(|s| schedule_out(ledger, s))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.run_schedules",
|
||||
description = "Post every amortization schedule's months ending on or before `through` (default today) that haven't posted. Idempotent: a month posts once. Without confirmed=true, returns the postings it would make. Exempt from the receipt policy: the schedule is the evidence (the original payment carries the receipt)."
|
||||
)]
|
||||
async fn run_schedules(
|
||||
&self,
|
||||
params: Parameters<RunSchedulesParams>,
|
||||
) -> Result<Json<RunSchedulesOut>, String> {
|
||||
let p = params.0;
|
||||
let through = match &p.through {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let mut due: Vec<(NaiveDate, u64, Decimal)> = store
|
||||
.ledger()
|
||||
.schedules()
|
||||
.flat_map(|s| s.due(through).into_iter().map(move |(d, a)| (d, s.id, a)))
|
||||
.collect();
|
||||
due.sort();
|
||||
let describe = |store: &Store, id: u64, date: NaiveDate| -> Result<String, String> {
|
||||
Ok(store
|
||||
.ledger()
|
||||
.preview_schedule_month(id, date)
|
||||
.map_err(|e| e.to_string())?
|
||||
.1)
|
||||
};
|
||||
if !p.confirmed {
|
||||
let postings = due
|
||||
.into_iter()
|
||||
.map(|(date, id, amount)| {
|
||||
Ok(SchedulePostingOut {
|
||||
schedule_id: id,
|
||||
date: date.to_string(),
|
||||
description: describe(&store, id, date)?,
|
||||
amount: money(amount),
|
||||
entry_id: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
return Ok(Json(RunSchedulesOut {
|
||||
status: "preview".to_string(),
|
||||
postings,
|
||||
}));
|
||||
}
|
||||
let mut postings = Vec::new();
|
||||
for (date, id, amount) in due {
|
||||
let description = describe(&store, id, date)?;
|
||||
let entry = store
|
||||
.post_schedule_month(id, date)
|
||||
.map_err(|e| e.to_string())?;
|
||||
postings.push(SchedulePostingOut {
|
||||
schedule_id: id,
|
||||
date: date.to_string(),
|
||||
description,
|
||||
amount: money(amount),
|
||||
entry_id: Some(entry),
|
||||
});
|
||||
}
|
||||
Ok(Json(RunSchedulesOut {
|
||||
status: "posted".to_string(),
|
||||
postings,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.create_recurring",
|
||||
description = "A recurring entry (rent, a subscription, a loan payment): balanced lines and a frequency (weekly, monthly, quarterly, yearly) from a start date, optionally ending. books.run_recurring turns due occurrences into drafts, never posted entries: attach each actual receipt and post through the normal preview."
|
||||
)]
|
||||
async fn create_recurring(
|
||||
&self,
|
||||
params: Parameters<CreateRecurringParams>,
|
||||
) -> Result<Json<RecurringOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let currency = store.ledger().base_currency();
|
||||
let frequency = ledger_core::Frequency::parse(&p.frequency).ok_or_else(|| {
|
||||
format!(
|
||||
"frequency {:?}: expected weekly, monthly, quarterly, or yearly",
|
||||
p.frequency
|
||||
)
|
||||
})?;
|
||||
let lines = p
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Ok((
|
||||
resolve_account(&store, &l.account)?,
|
||||
parse_side(&l.side)?,
|
||||
Money::new(parse_amount(&l.amount)?, currency),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
let id = store
|
||||
.register_recurring(ledger_core::NewRecurring {
|
||||
description: p.description,
|
||||
lines,
|
||||
frequency,
|
||||
start: parse_date(&p.start)?,
|
||||
end: match &p.end {
|
||||
Some(e) => Some(parse_date(e)?),
|
||||
None => None,
|
||||
},
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let r = store
|
||||
.ledger()
|
||||
.recurring_entries()
|
||||
.find(|r| r.id == id)
|
||||
.expect("registered");
|
||||
Ok(Json(recurring_out(r)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_recurring",
|
||||
description = "Recurring entries and the occurrences generated so far.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn list_recurring(&self) -> Result<Json<ListRecurringOut>, String> {
|
||||
let store = self.lock()?;
|
||||
Ok(Json(ListRecurringOut {
|
||||
recurring: store
|
||||
.ledger()
|
||||
.recurring_entries()
|
||||
.map(recurring_out)
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.run_recurring",
|
||||
description = "Turn every recurring entry's occurrences on or before `through` (default today) into drafts. Idempotent: an occurrence generates once. Each draft then goes through the normal flow: attach the receipt, preview, post."
|
||||
)]
|
||||
async fn run_recurring(
|
||||
&self,
|
||||
params: Parameters<RunRecurringParams>,
|
||||
) -> Result<Json<RunRecurringOut>, String> {
|
||||
let through = match ¶ms.0.through {
|
||||
Some(s) => parse_date(s)?,
|
||||
None => today(),
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let drafts = store
|
||||
.run_recurring(through)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|(id, date, entry)| RecurringDraftOut {
|
||||
recurring_id: id,
|
||||
date: date.to_string(),
|
||||
entry_id: entry,
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(RunRecurringOut { drafts }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.record_payout",
|
||||
description = "Record a platform payout (app store, payment processor, marketplace) grossed up: Dr the deposit account the net deposit, Dr fee accounts the fees withheld, Dr/Cr adjustments (refunds, chargebacks, reserves, taxes withheld), Cr revenue the gross sales (several lines and classes allowed). Refuses unless gross - fees +/- adjustments equals the deposit to the cent, and says by how much it's off. Creates a draft: attach the payout statement as its receipt, preview, and post."
|
||||
)]
|
||||
async fn record_payout(
|
||||
&self,
|
||||
params: Parameters<RecordPayoutParams>,
|
||||
) -> Result<Json<RecordPayoutOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let class = |store: &Store, c: &Option<String>| -> Result<Option<u32>, String> {
|
||||
c.as_deref().map(|c| resolve_class(store, c)).transpose()
|
||||
};
|
||||
let lines = |store: &Store, ls: &[PayoutLineParam]| {
|
||||
ls.iter()
|
||||
.map(|l| {
|
||||
Ok(ledger_core::PayoutLine {
|
||||
account: resolve_account(store, &l.account)?,
|
||||
amount: parse_amount(&l.amount)?,
|
||||
class: class(store, &l.class)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
};
|
||||
let payout = ledger_core::Payout {
|
||||
deposit_account: resolve_account(&store, &p.deposit_account)?,
|
||||
deposit: parse_amount(&p.deposit)?,
|
||||
revenue: lines(&store, &p.revenue)?,
|
||||
fees: lines(&store, &p.fees)?,
|
||||
adjustments: p
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| {
|
||||
Ok(ledger_core::PayoutAdjustment {
|
||||
account: resolve_account(&store, &a.account)?,
|
||||
side: parse_side(&a.side)?,
|
||||
amount: parse_amount(&a.amount)?,
|
||||
class: class(&store, &a.class)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?,
|
||||
};
|
||||
let (plan, totals) =
|
||||
ledger_core::payout_plan(&payout, store.ledger().chart()).map_err(|e| e.to_string())?;
|
||||
let currency = store.base_currency();
|
||||
let entry_id = store
|
||||
.create_entry(parse_date(&p.date)?, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut described = Vec::new();
|
||||
for (index, (account, side, amount, class)) in plan.into_iter().enumerate() {
|
||||
store
|
||||
.add_line(entry_id, account, side, Money::new(amount, currency))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(class) = class {
|
||||
store
|
||||
.set_line_class(entry_id, index, Some(class))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let code = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default();
|
||||
described.push(format!("{side:?} {code} {}", money(amount)));
|
||||
}
|
||||
Ok(Json(RecordPayoutOut {
|
||||
entry_id,
|
||||
status: "draft".to_string(),
|
||||
gross: money(totals.gross),
|
||||
fees: money(totals.fees),
|
||||
adjustments: money(totals.adjustments),
|
||||
deposit: money(totals.deposit),
|
||||
lines: described,
|
||||
next: "Attach the payout statement (books.attach_receipt), then preview and post with books.post_entry.".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_vendors",
|
||||
description = "Vendors, with which 1099 each gets and whether its W-9 is on file.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn list_vendors(&self) -> Result<Json<ListVendorsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
Ok(Json(ListVendorsOut {
|
||||
vendors: store
|
||||
.ledger()
|
||||
.list_vendors()
|
||||
.into_iter()
|
||||
.map(|v| VendorOut {
|
||||
id: v.id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_vendor_1099",
|
||||
description = "Mark which information return a vendor gets: \"nec\" (1099-NEC: contractors, freelancers, attorneys), \"misc\" (1099-MISC: rent, royalties, prizes), or \"none\" (corporations generally, merchants, anyone paid only by card). Record whether a W-9 is on file; never store the TIN itself. Ask the human when unsure; the CPA confirms."
|
||||
)]
|
||||
async fn set_vendor_1099(
|
||||
&self,
|
||||
params: Parameters<SetVendor1099Params>,
|
||||
) -> Result<Json<VendorOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_vendor(&store, &p.vendor)?;
|
||||
let form = match p.form_1099.trim().to_lowercase().as_str() {
|
||||
"none" | "" => None,
|
||||
other => Some(
|
||||
ledger_core::Form1099::parse(other)
|
||||
.ok_or_else(|| format!("form_1099 {other:?}: expected nec, misc, or none"))?,
|
||||
),
|
||||
};
|
||||
store
|
||||
.set_vendor_1099(id, form, p.tin_on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let v = store.ledger().get_vendor(id).expect("exists");
|
||||
Ok(Json(VendorOut {
|
||||
id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_entry_vendor",
|
||||
description = "Tag an entry (draft or posted) with the vendor it paid, for 1099 tracking, or omit vendor to clear the tag. Reporting metadata only: amounts never change. books.pay_bill tags its payments automatically; books.draft_entry takes a vendor too."
|
||||
)]
|
||||
async fn set_entry_vendor(
|
||||
&self,
|
||||
params: Parameters<SetEntryVendorParams>,
|
||||
) -> Result<Json<GetEntryOut>, String> {
|
||||
let p = params.0;
|
||||
{
|
||||
let mut store = self.lock()?;
|
||||
let vendor = p
|
||||
.vendor
|
||||
.as_deref()
|
||||
.map(|v| resolve_vendor(&store, v))
|
||||
.transpose()?;
|
||||
store
|
||||
.set_entry_vendor(p.entry_id, vendor)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
self.get_entry(Parameters(GetEntryParams {
|
||||
entry_id: p.entry_id,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.vendor_1099",
|
||||
description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to asset accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 from 2026), and which are missing a W-9. For the CPA to confirm.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn vendor_1099(
|
||||
&self,
|
||||
params: Parameters<Vendor1099Params>,
|
||||
) -> Result<Json<Vendor1099Out>, String> {
|
||||
let p = params.0;
|
||||
let threshold = p.threshold.as_deref().map(parse_amount).transpose()?;
|
||||
let store = self.lock()?;
|
||||
let r = ledger_core::vendor_1099_report(store.ledger(), p.year, threshold);
|
||||
let missing_w9 = r
|
||||
.vendors
|
||||
.iter()
|
||||
.filter(|v| v.reportable && !v.tin_on_file)
|
||||
.map(|v| v.name.clone())
|
||||
.collect();
|
||||
let untagged = store
|
||||
.ledger()
|
||||
.posted_entries()
|
||||
.filter(|e| {
|
||||
chrono::Datelike::year(&e.date()) == p.year
|
||||
&& store.ledger().entry_vendor(e.id()).is_none()
|
||||
})
|
||||
.count();
|
||||
let mut notes = vec![
|
||||
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(),
|
||||
"Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.".to_string(),
|
||||
];
|
||||
if p.year > 2026 {
|
||||
notes.push(format!(
|
||||
"The $2,000 threshold is indexed for inflation after 2026; confirm the {} amount and pass threshold to override.",
|
||||
p.year
|
||||
));
|
||||
}
|
||||
if untagged > 0 {
|
||||
notes.push(format!(
|
||||
"{untagged} posted entries in {} have no vendor tag; review any that paid contractors or rent.",
|
||||
p.year
|
||||
));
|
||||
}
|
||||
Ok(Json(Vendor1099Out {
|
||||
year: r.year,
|
||||
threshold: money(r.threshold),
|
||||
vendors: r
|
||||
.vendors
|
||||
.into_iter()
|
||||
.map(|v| Vendor1099LineOut {
|
||||
vendor_id: v.vendor_id,
|
||||
name: v.name,
|
||||
form_1099: form_1099_label(v.form),
|
||||
tin_on_file: v.tin_on_file,
|
||||
paid: money(v.paid),
|
||||
reportable: v.reportable,
|
||||
})
|
||||
.collect(),
|
||||
missing_w9,
|
||||
notes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.chart_template",
|
||||
description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the wanderbooks.toml settings that go with it. A proposal: review it with the human, adjust, then books.import_chart_csv.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn chart_template(
|
||||
&self,
|
||||
params: Parameters<ChartTemplateParams>,
|
||||
) -> Result<Json<ChartTemplateOut>, String> {
|
||||
let p = params.0;
|
||||
let entity = ledger_import::EntityType::parse(&p.entity_type).ok_or_else(|| {
|
||||
format!(
|
||||
"entity_type {:?}: expected sole_proprietorship, partnership, s_corporation, or c_corporation (an LLC is one of these for tax purposes)",
|
||||
p.entity_type
|
||||
)
|
||||
})?;
|
||||
let t = ledger_import::chart_template(entity, &p.owners)?;
|
||||
Ok(Json(ChartTemplateOut {
|
||||
csv: t.csv(),
|
||||
settings_toml: t.settings_toml,
|
||||
notes: t.notes,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.tax_categories",
|
||||
description = "The tax categories an account can map to (where it lands on the income tax return), filtered to the business's return when wanderbooks.toml says which (tax_form or tax_classification). Each has a default deductible percent (meals 50, nondeductible 0) and guidance. Categories are stable keys, not form line numbers.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn tax_categories(&self) -> Result<Json<TaxCategoriesOut>, String> {
|
||||
let form = self.company.tax_form;
|
||||
let categories = ledger_core::tax::CATEGORIES
|
||||
.iter()
|
||||
.filter(|c| form.is_none_or(|f| c.forms.contains(&f)))
|
||||
.map(|c| TaxCategoryOut {
|
||||
key: c.key.to_string(),
|
||||
label: c.label.to_string(),
|
||||
kind: match c.kind {
|
||||
ledger_core::TaxKind::Income => "income",
|
||||
ledger_core::TaxKind::CostOfGoods => "cost_of_goods",
|
||||
ledger_core::TaxKind::Deduction => "deduction",
|
||||
}
|
||||
.to_string(),
|
||||
default_deductible_percent: c.default_deductible_percent,
|
||||
note: c.note.to_string(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(TaxCategoriesOut {
|
||||
form: form.map(|f| f.label().to_string()),
|
||||
categories,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_tax_category",
|
||||
description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it. Drives the tax_summary report."
|
||||
)]
|
||||
async fn set_tax_category(
|
||||
&self,
|
||||
params: Parameters<SetTaxCategoryParams>,
|
||||
) -> Result<Json<SetTaxCategoryOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_account(&store, &p.account)?;
|
||||
let pct = match &p.deductible_percent {
|
||||
Some(s) => Some(parse_amount(s)?),
|
||||
None => None,
|
||||
};
|
||||
if let Some(cat) = &p.category {
|
||||
let t = store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(id)
|
||||
.expect("resolved")
|
||||
.account_type;
|
||||
ledger_core::tax::check_category(cat, t, self.company.tax_form)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
store
|
||||
.set_account_tax(id, p.category.as_deref(), pct)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(SetTaxCategoryOut { ok: true }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.import_chart_csv",
|
||||
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported."
|
||||
@@ -1371,10 +2024,26 @@ impl BooksServer {
|
||||
let rows = ledger_import::parse_chart_csv(&p.csv_text).map_err(|e| e.to_string())?;
|
||||
let mut store = self.lock()?;
|
||||
// Validate all rows first: nothing is half-imported.
|
||||
let parse_type = |t: &str| match t {
|
||||
"asset" => Ok(AccountType::Asset),
|
||||
"liability" => Ok(AccountType::Liability),
|
||||
"equity" => Ok(AccountType::Equity),
|
||||
"revenue" => Ok(AccountType::Revenue),
|
||||
"expense" => Ok(AccountType::Expense),
|
||||
_ => Err(format!("bad account type: {t}")),
|
||||
};
|
||||
for row in &rows {
|
||||
if store.ledger().chart().find_by_code(&row.code).is_some() {
|
||||
return Err(format!("duplicate account code: {}", row.code));
|
||||
}
|
||||
if let Some(cat) = &row.tax_category {
|
||||
ledger_core::tax::check_category(
|
||||
cat,
|
||||
parse_type(&row.account_type)?,
|
||||
self.company.tax_form,
|
||||
)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
}
|
||||
}
|
||||
let mut created = Vec::new();
|
||||
for row in rows {
|
||||
@@ -1397,9 +2066,14 @@ impl BooksServer {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
store
|
||||
let id = store
|
||||
.add_account(&row.code, &row.name, account_type, parent, false)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(cat) = &row.tax_category {
|
||||
store
|
||||
.set_account_tax(id, Some(cat), None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
created.push(row.code.clone());
|
||||
}
|
||||
let count = created.len();
|
||||
@@ -1510,12 +2184,13 @@ impl BooksServer {
|
||||
"trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?,
|
||||
"general_ledger" => self.export_general_ledger(&format, &p)?,
|
||||
"members_equity" => self.export_members_equity(&format, &p)?,
|
||||
"tax_summary" => self.export_tax_summary(&format, &p)?,
|
||||
"profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?,
|
||||
"balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?,
|
||||
"cash_flow" => self.export_cash_flow(&format, &p)?,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, or members_equity",
|
||||
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, or tax_summary",
|
||||
p.report
|
||||
));
|
||||
}
|
||||
@@ -1566,6 +2241,13 @@ impl BooksServer {
|
||||
};
|
||||
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of)
|
||||
}
|
||||
"tax_summary" => {
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
report::tax_summary(
|
||||
&ledger_core::tax_summary(ledger, start, end, self.company.tax_form)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
"members_equity" => {
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
let me =
|
||||
@@ -1575,7 +2257,7 @@ impl BooksServer {
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, or members_equity"
|
||||
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary"
|
||||
));
|
||||
}
|
||||
})
|
||||
@@ -1981,18 +2663,7 @@ impl BooksServer {
|
||||
if store.ledger().get_period(p.period_id).is_none() {
|
||||
return Err(format!("unknown period {}", p.period_id));
|
||||
}
|
||||
// A partnership closes into its members' capital accounts by share;
|
||||
// everyone else into Retained Earnings.
|
||||
let to = if self.company.members.is_empty() {
|
||||
ledger_core::CloseTo::RetainedEarnings
|
||||
} else {
|
||||
ledger_core::CloseTo::Members(
|
||||
self.member_capitals(store.ledger())?
|
||||
.into_iter()
|
||||
.map(|m| (m.capital_account, m.share))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let to = self.close_target(store.ledger())?;
|
||||
let describe = |ledger: &Ledger, eid: Option<ledger_core::EntryId>| -> Vec<String> {
|
||||
let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else {
|
||||
return Vec::new();
|
||||
@@ -2684,6 +3355,13 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
statements.push(("members-equity", report::members_equity(&me)));
|
||||
}
|
||||
statements.push((
|
||||
"tax-summary",
|
||||
report::tax_summary(
|
||||
&ledger_core::tax_summary(ledger, fy, as_of, self.company.tax_form)
|
||||
.map_err(|e| e.to_string())?,
|
||||
),
|
||||
));
|
||||
statements.push((
|
||||
"trial-balance",
|
||||
report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of),
|
||||
@@ -2878,6 +3556,60 @@ impl BooksServer {
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_tax_summary(&self, format: &str, p: &ExportParams) -> Result<String, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
let ts = ledger_core::tax_summary(ledger, start, end, self.company.tax_form)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let line = |l: &ledger_core::TaxSummaryLine| {
|
||||
serde_json::json!({
|
||||
"category": l.category, "label": l.label, "accounts": l.accounts,
|
||||
"book": money(l.book), "tax": money(l.tax),
|
||||
})
|
||||
};
|
||||
if format == "json" {
|
||||
return serde_json::to_string_pretty(&serde_json::json!({
|
||||
"start": ts.start.to_string(), "end": ts.end.to_string(),
|
||||
"form": ts.form.map(|f| f.label()),
|
||||
"income": ts.income.iter().map(line).collect::<Vec<_>>(),
|
||||
"cost_of_goods": ts.cost_of_goods.iter().map(line).collect::<Vec<_>>(),
|
||||
"deductions": ts.deductions.iter().map(line).collect::<Vec<_>>(),
|
||||
"uncategorized": ts.uncategorized.iter().map(|u| serde_json::json!({
|
||||
"account": u.account, "book": money(u.book),
|
||||
})).collect::<Vec<_>>(),
|
||||
"book_net": money(ts.book_net()),
|
||||
"tax_net": money(ts.tax_net()),
|
||||
}))
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
let mut csv = String::from("section,category,label,accounts,book,tax\n");
|
||||
for (section, lines) in [
|
||||
("income", &ts.income),
|
||||
("cost_of_goods", &ts.cost_of_goods),
|
||||
("deductions", &ts.deductions),
|
||||
] {
|
||||
for l in lines {
|
||||
csv.push_str(&format!(
|
||||
"{section},{},{},{},{},{}\n",
|
||||
l.category,
|
||||
csv_escape(&l.label),
|
||||
csv_escape(&l.accounts.join("; ")),
|
||||
money(l.book),
|
||||
money(l.tax)
|
||||
));
|
||||
}
|
||||
}
|
||||
for u in &ts.uncategorized {
|
||||
csv.push_str(&format!(
|
||||
"uncategorized,,,{},{},\n",
|
||||
csv_escape(&u.account),
|
||||
money(u.book)
|
||||
));
|
||||
}
|
||||
Ok(csv)
|
||||
}
|
||||
|
||||
fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result<String, String> {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
|
||||
@@ -48,6 +48,399 @@ pub struct ChartAccount {
|
||||
/// Current balance in the account's normal side, as a decimal string.
|
||||
/// Positive means a normal balance.
|
||||
pub balance: String,
|
||||
/// Tax category key, if assigned.
|
||||
pub tax_category: Option<String>,
|
||||
/// Deductible percent override, if any.
|
||||
pub deductible_percent: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.create_schedule`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct CreateScheduleParams {
|
||||
/// "prepaid_expense" (asset expensed monthly) or "deferred_revenue"
|
||||
/// (liability earned monthly).
|
||||
pub kind: String,
|
||||
/// What it is, e.g. "2027 liability insurance".
|
||||
pub description: String,
|
||||
/// Amount to spread, decimal string.
|
||||
pub total: String,
|
||||
/// Any date in the first month covered, YYYY-MM-DD.
|
||||
pub start: String,
|
||||
/// Months covered.
|
||||
pub months: u32,
|
||||
/// Prepaid (asset) or deferred revenue (liability) account code.
|
||||
pub balance_account: String,
|
||||
/// Expense or revenue account code.
|
||||
pub pl_account: String,
|
||||
}
|
||||
|
||||
/// One month of a schedule.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ScheduleMonthOut {
|
||||
/// Month-end date the posting lands on.
|
||||
pub date: String,
|
||||
/// Amount, decimal string.
|
||||
pub amount: String,
|
||||
/// True once posted.
|
||||
pub posted: bool,
|
||||
}
|
||||
|
||||
/// An amortization schedule.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ScheduleOut {
|
||||
/// Schedule id.
|
||||
pub schedule_id: u64,
|
||||
/// Description.
|
||||
pub description: String,
|
||||
/// "prepaid_expense" or "deferred_revenue".
|
||||
pub kind: String,
|
||||
/// Balance-sheet account code.
|
||||
pub balance_account: String,
|
||||
/// P&L account code.
|
||||
pub pl_account: String,
|
||||
/// Total, decimal string.
|
||||
pub total: String,
|
||||
/// Not yet recognized, decimal string.
|
||||
pub remaining: String,
|
||||
/// Every month.
|
||||
pub months: Vec<ScheduleMonthOut>,
|
||||
}
|
||||
|
||||
/// Result of `books.list_schedules`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ListSchedulesOut {
|
||||
/// Schedules.
|
||||
pub schedules: Vec<ScheduleOut>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.run_schedules`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RunSchedulesParams {
|
||||
/// Post months ending on or before this date (default today).
|
||||
pub through: Option<String>,
|
||||
/// Without true, a preview: nothing posts.
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
/// One posting from a schedule run.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SchedulePostingOut {
|
||||
/// Schedule id.
|
||||
pub schedule_id: u64,
|
||||
/// Month-end date.
|
||||
pub date: String,
|
||||
/// Entry description.
|
||||
pub description: String,
|
||||
/// Amount, decimal string.
|
||||
pub amount: String,
|
||||
/// The posted entry (absent in a preview).
|
||||
pub entry_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Result of `books.run_schedules`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RunSchedulesOut {
|
||||
/// "preview" or "posted".
|
||||
pub status: String,
|
||||
/// Postings due (preview) or made.
|
||||
pub postings: Vec<SchedulePostingOut>,
|
||||
}
|
||||
|
||||
/// One line of a recurring entry.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RecurringLineParam {
|
||||
/// Account code.
|
||||
pub account: String,
|
||||
/// "debit" or "credit".
|
||||
pub side: String,
|
||||
/// Decimal string.
|
||||
pub amount: String,
|
||||
}
|
||||
|
||||
/// Parameters for `books.create_recurring`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct CreateRecurringParams {
|
||||
/// Description for each generated draft.
|
||||
pub description: String,
|
||||
/// "weekly", "monthly", "quarterly", or "yearly".
|
||||
pub frequency: String,
|
||||
/// First occurrence, YYYY-MM-DD (monthly and longer repeat on this day,
|
||||
/// clamped to month end).
|
||||
pub start: String,
|
||||
/// Last date an occurrence may fall on, YYYY-MM-DD; omit for none.
|
||||
pub end: Option<String>,
|
||||
/// Balanced lines.
|
||||
pub lines: Vec<RecurringLineParam>,
|
||||
}
|
||||
|
||||
/// A recurring entry.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RecurringOut {
|
||||
/// Recurring entry id.
|
||||
pub recurring_id: u64,
|
||||
/// Description.
|
||||
pub description: String,
|
||||
/// Frequency.
|
||||
pub frequency: String,
|
||||
/// First occurrence.
|
||||
pub start: String,
|
||||
/// Last date, if it ends.
|
||||
pub end: Option<String>,
|
||||
/// Occurrences generated so far.
|
||||
pub generated: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.list_recurring`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ListRecurringOut {
|
||||
/// Recurring entries.
|
||||
pub recurring: Vec<RecurringOut>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.run_recurring`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RunRecurringParams {
|
||||
/// Generate occurrences on or before this date (default today).
|
||||
pub through: Option<String>,
|
||||
}
|
||||
|
||||
/// One draft from a recurring run.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RecurringDraftOut {
|
||||
/// Recurring entry id.
|
||||
pub recurring_id: u64,
|
||||
/// Occurrence date.
|
||||
pub date: String,
|
||||
/// The draft entry to review, attach a receipt to, and post.
|
||||
pub entry_id: u64,
|
||||
}
|
||||
|
||||
/// Result of `books.run_recurring`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RunRecurringOut {
|
||||
/// Drafts created.
|
||||
pub drafts: Vec<RecurringDraftOut>,
|
||||
}
|
||||
|
||||
/// A revenue or fee line of a payout.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct PayoutLineParam {
|
||||
/// Account code or id.
|
||||
pub account: String,
|
||||
/// Positive decimal string.
|
||||
pub amount: String,
|
||||
/// Optional class (name or id), e.g. the app or product line.
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
/// An adjustment on a payout statement.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct PayoutAdjustmentParam {
|
||||
/// Account code or id.
|
||||
pub account: String,
|
||||
/// "debit" reduces the deposit (refund, chargeback, reserve held, tax
|
||||
/// withheld); "credit" increases it (reserve released, adjustment in
|
||||
/// your favor).
|
||||
pub side: String,
|
||||
/// Positive decimal string.
|
||||
pub amount: String,
|
||||
/// Optional class (name or id).
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.record_payout`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RecordPayoutParams {
|
||||
/// Deposit date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// E.g. "App Store payout, October 2026".
|
||||
pub description: String,
|
||||
/// Account the money landed in.
|
||||
pub deposit_account: String,
|
||||
/// Net deposit, decimal string; negative when the platform debited you.
|
||||
pub deposit: String,
|
||||
/// Gross revenue lines (credited).
|
||||
pub revenue: Vec<PayoutLineParam>,
|
||||
/// Fees withheld (debited).
|
||||
#[serde(default)]
|
||||
pub fees: Vec<PayoutLineParam>,
|
||||
/// Everything else on the statement.
|
||||
#[serde(default)]
|
||||
pub adjustments: Vec<PayoutAdjustmentParam>,
|
||||
}
|
||||
|
||||
/// Result of `books.record_payout`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RecordPayoutOut {
|
||||
/// The draft entry.
|
||||
pub entry_id: u64,
|
||||
/// "draft".
|
||||
pub status: String,
|
||||
/// Gross revenue.
|
||||
pub gross: String,
|
||||
/// Fees withheld.
|
||||
pub fees: String,
|
||||
/// Net effect of adjustments on the deposit.
|
||||
pub adjustments: String,
|
||||
/// The deposit.
|
||||
pub deposit: String,
|
||||
/// The entry's lines, e.g. "Debit 1000 1265.01".
|
||||
pub lines: Vec<String>,
|
||||
/// What to do next.
|
||||
pub next: String,
|
||||
}
|
||||
|
||||
/// A vendor.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct VendorOut {
|
||||
/// Vendor id.
|
||||
pub id: u64,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// "1099-NEC", "1099-MISC", or absent.
|
||||
pub form_1099: Option<String>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
}
|
||||
|
||||
/// Result of `books.list_vendors`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ListVendorsOut {
|
||||
/// Vendors, by id.
|
||||
pub vendors: Vec<VendorOut>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_vendor_1099`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetVendor1099Params {
|
||||
/// Vendor id or name.
|
||||
pub vendor: String,
|
||||
/// "nec" (contractors, attorneys), "misc" (rent, royalties, other), or
|
||||
/// "none" (corporations, most merchants).
|
||||
pub form_1099: String,
|
||||
/// A W-9 is on file (the TIN itself is never stored).
|
||||
#[serde(default)]
|
||||
pub tin_on_file: bool,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_vendor`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetEntryVendorParams {
|
||||
/// Entry id (draft or posted).
|
||||
pub entry_id: u64,
|
||||
/// Vendor id or name; omit to clear the tag.
|
||||
pub vendor: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.vendor_1099`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct Vendor1099Params {
|
||||
/// Calendar year of payment.
|
||||
pub year: i32,
|
||||
/// Override the reporting threshold, decimal string (default: $600
|
||||
/// through 2025, $2,000 from 2026).
|
||||
pub threshold: Option<String>,
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Vendor1099LineOut {
|
||||
/// Vendor id.
|
||||
pub vendor_id: u64,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// "1099-NEC", "1099-MISC", or absent.
|
||||
pub form_1099: Option<String>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// Cash paid in the year, decimal string.
|
||||
pub paid: String,
|
||||
/// Marked for a 1099 and paid at least the threshold.
|
||||
pub reportable: bool,
|
||||
}
|
||||
|
||||
/// Result of `books.vendor_1099`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Vendor1099Out {
|
||||
/// Calendar year.
|
||||
pub year: i32,
|
||||
/// Threshold applied, decimal string.
|
||||
pub threshold: String,
|
||||
/// Vendors marked for a 1099 or paid in the year.
|
||||
pub vendors: Vec<Vendor1099LineOut>,
|
||||
/// Reportable vendors without a W-9 on file: request one.
|
||||
pub missing_w9: Vec<String>,
|
||||
/// Caveats for the human and CPA.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.chart_template`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ChartTemplateParams {
|
||||
/// "sole_proprietorship" (incl. single-member LLC), "partnership"
|
||||
/// (incl. multi-member LLC), "s_corporation", or "c_corporation".
|
||||
pub entity_type: String,
|
||||
/// Owners' names: one capital and distributions account each for a
|
||||
/// partnership (at least two). Ignored otherwise.
|
||||
#[serde(default)]
|
||||
pub owners: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.chart_template`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ChartTemplateOut {
|
||||
/// Chart CSV (code,name,type,parent_code,tax_category) for
|
||||
/// `books.import_chart_csv`, after review.
|
||||
pub csv: String,
|
||||
/// The `wanderbooks.toml` settings that go with it: tax form, owners,
|
||||
/// where period closes go.
|
||||
pub settings_toml: String,
|
||||
/// What to decide or know.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_tax_category`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetTaxCategoryParams {
|
||||
/// Account code (or id).
|
||||
pub account: String,
|
||||
/// Tax category key from `books.tax_categories`; null clears it.
|
||||
pub category: Option<String>,
|
||||
/// Percent deductible, overriding the category default (decimal
|
||||
/// string, 0 to 100), e.g. "100" for a fully deductible company party.
|
||||
pub deductible_percent: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.set_tax_category`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SetTaxCategoryOut {
|
||||
/// True when set.
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// One tax category.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TaxCategoryOut {
|
||||
/// Stable key.
|
||||
pub key: String,
|
||||
/// Label.
|
||||
pub label: String,
|
||||
/// "income", "cost_of_goods", or "deduction".
|
||||
pub kind: String,
|
||||
/// Percent deductible by default.
|
||||
pub default_deductible_percent: u32,
|
||||
/// Guidance.
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// Result of `books.tax_categories`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TaxCategoriesOut {
|
||||
/// The return the categories are filtered to, if configured.
|
||||
pub form: Option<String>,
|
||||
/// Categories.
|
||||
pub categories: Vec<TaxCategoryOut>,
|
||||
}
|
||||
|
||||
/// Result of `books.chart`.
|
||||
@@ -329,6 +722,8 @@ pub struct DraftEntryParams {
|
||||
pub date: String,
|
||||
/// Description of what happened.
|
||||
pub description: String,
|
||||
/// The vendor this entry pays (id or name), for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.draft_entry`.
|
||||
@@ -687,6 +1082,8 @@ pub struct GetEntryOut {
|
||||
pub total: String,
|
||||
/// Attached receipts.
|
||||
pub receipts: Vec<PreviewReceipt>,
|
||||
/// The vendor the entry is tagged with, for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: 23 narrow tools, chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 50, "expected 50 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 64, "expected 64 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
@@ -882,6 +882,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
"general-ledger.xlsx",
|
||||
"profit-and-loss.pdf",
|
||||
"profit-and-loss.xlsx",
|
||||
"tax-summary.pdf",
|
||||
"tax-summary.xlsx",
|
||||
"trial-balance.pdf",
|
||||
"trial-balance.xlsx"
|
||||
]
|
||||
@@ -899,7 +901,7 @@ fn mcp_period_statements_over_stdio() {
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
|
||||
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let data = dir.join("books-data");
|
||||
@@ -1055,4 +1057,495 @@ fn mcp_period_statements_over_stdio() {
|
||||
assert_eq!(me["total"]["contributions"], "1000.00");
|
||||
assert_eq!(me["total"]["net_income"], "4.00");
|
||||
assert_eq!(me["total"]["ending"], "1004.00");
|
||||
|
||||
// Tax categories: filtered to the partnership return.
|
||||
let cats = mcp.call("books.tax_categories", json!({}));
|
||||
assert_eq!(cats["form"], "Form 1065");
|
||||
let keys: Vec<&str> = cats["categories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["key"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(keys.contains(&"guaranteed_payments"));
|
||||
assert!(
|
||||
!keys.contains(&"officer_compensation"),
|
||||
"a corporate category"
|
||||
);
|
||||
mcp.call(
|
||||
"books.set_tax_category",
|
||||
json!({"account": "4000", "category": "gross_receipts"}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.set_tax_category",
|
||||
json!({"account": "6000", "category": "other_deductions"}),
|
||||
);
|
||||
let err = mcp.call_err(
|
||||
"books.set_tax_category",
|
||||
json!({"account": "4000", "category": "meals"}),
|
||||
);
|
||||
assert!(err.contains("expense"), "{err}");
|
||||
let chart = mcp.call("books.chart", json!({}));
|
||||
let sales = chart["accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "4000")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(sales["tax_category"], "gross_receipts");
|
||||
// Jan-Feb: 6.50 of sales, 3.00 of software, all categorized.
|
||||
let ts = mcp.call(
|
||||
"books.export",
|
||||
json!({"format": "json", "report": "tax_summary", "start": "2026-01-01", "end": "2026-02-28"}),
|
||||
);
|
||||
let ts: serde_json::Value = serde_json::from_str(ts["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(ts["form"], "Form 1065");
|
||||
assert_eq!(ts["income"][0]["book"], "6.50");
|
||||
assert_eq!(ts["deductions"][0]["category"], "other_deductions");
|
||||
assert_eq!(ts["uncategorized"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(ts["tax_net"], "3.50");
|
||||
// A chart import validates categories before creating anything.
|
||||
let err = mcp.call_err(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n"}),
|
||||
);
|
||||
assert!(err.contains("4100"), "{err}");
|
||||
let chart = mcp.call("books.chart", json!({}));
|
||||
assert!(
|
||||
chart["accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|a| a["code"] != "6100")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_partnership_from_template_over_stdio() {
|
||||
let init = |mcp: &mut Mcp| {
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
};
|
||||
// 1. Ask for a template (any server will do).
|
||||
let dir = test_dir("template");
|
||||
let mut scratch = Mcp::spawn(&dir.join("scratch"));
|
||||
init(&mut scratch);
|
||||
let t = scratch.call(
|
||||
"books.chart_template",
|
||||
json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}),
|
||||
);
|
||||
let settings = t["settings_toml"].as_str().unwrap();
|
||||
assert!(settings.contains("distribution_account = \"3101\""));
|
||||
let err = scratch.call_err("books.chart_template", json!({"entity_type": "LLC"}));
|
||||
assert!(err.contains("entity_type"), "{err}");
|
||||
|
||||
// 2. Its settings become the company config; its chart, the chart.
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
init(&mut mcp);
|
||||
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
|
||||
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}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
};
|
||||
post(&mut mcp, "2026-01-02", "1000", "3000", "1000.00");
|
||||
post(&mut mcp, "2026-01-02", "1000", "3001", "1000.00");
|
||||
post(&mut mcp, "2026-01-10", "1000", "4000", "400.00");
|
||||
post(&mut mcp, "2026-01-20", "3101", "1000", "50.00");
|
||||
|
||||
// 3. Members' equity sees Bo's distribution in his distributions account.
|
||||
let me = mcp.call(
|
||||
"books.export",
|
||||
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
|
||||
);
|
||||
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(me["members"][1]["distributions"], "50.00");
|
||||
assert_eq!(me["members"][1]["ending"], "1150.00");
|
||||
|
||||
// 4. Closing January: 200 each to capital, Bo's distributions swept in.
|
||||
let jan = mcp.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
|
||||
);
|
||||
let closed = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": jan["period_id"], "confirmed": true}),
|
||||
);
|
||||
assert_eq!(
|
||||
closed["lines"],
|
||||
json!([
|
||||
"Debit 4000 400.00",
|
||||
"Credit 3000 200.00",
|
||||
"Credit 3001 200.00",
|
||||
"Credit 3101 50.00",
|
||||
"Debit 3001 50.00"
|
||||
])
|
||||
);
|
||||
let bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
|
||||
let equity: Vec<(String, String)> = bs["equity_accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a["code"].as_str().unwrap().to_string(),
|
||||
a["amount"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
equity,
|
||||
[
|
||||
("3000".into(), "1200.00".into()),
|
||||
("3001".into(), "1150.00".into())
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_schedules_and_recurring_over_stdio() {
|
||||
let dir = test_dir("schedules");
|
||||
let init = |mcp: &mut Mcp| {
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
};
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
init(&mut mcp);
|
||||
for (code, name, typ) in [
|
||||
("1000", "Bank", "asset"),
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
("6300", "Insurance", "expense"),
|
||||
("6600", "Rent", "expense"),
|
||||
] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
|
||||
// 1,000.00 of insurance over 3 months: 333.33, 333.33, 333.34.
|
||||
let s = mcp.call(
|
||||
"books.create_schedule",
|
||||
json!({
|
||||
"kind": "prepaid_expense",
|
||||
"description": "Liability insurance",
|
||||
"total": "1000.00",
|
||||
"start": "2026-11-15",
|
||||
"months": 3,
|
||||
"balance_account": "1300",
|
||||
"pl_account": "6300",
|
||||
}),
|
||||
);
|
||||
let amounts: Vec<&str> = s["months"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m["amount"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(amounts, ["333.33", "333.33", "333.34"]);
|
||||
let err = mcp.call_err(
|
||||
"books.create_schedule",
|
||||
json!({
|
||||
"kind": "prepaid_expense", "description": "x", "total": "10",
|
||||
"start": "2026-11-01", "months": 2,
|
||||
"balance_account": "6300", "pl_account": "6300",
|
||||
}),
|
||||
);
|
||||
assert!(err.contains("Asset"), "{err}");
|
||||
|
||||
// Preview posts nothing; confirmed posts November and December.
|
||||
let preview = mcp.call("books.run_schedules", json!({"through": "2026-12-31"}));
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(preview["postings"].as_array().unwrap().len(), 2);
|
||||
let pl = mcp.call(
|
||||
"books.profit_and_loss",
|
||||
json!({"start": "2026-11-01", "end": "2026-12-31"}),
|
||||
);
|
||||
assert_eq!(pl["expenses"], "0.00");
|
||||
let run = mcp.call(
|
||||
"books.run_schedules",
|
||||
json!({"through": "2026-12-31", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(run["status"], "posted");
|
||||
assert_eq!(run["postings"][1]["date"], "2026-12-31");
|
||||
let again = mcp.call(
|
||||
"books.run_schedules",
|
||||
json!({"through": "2026-12-31", "confirmed": true}),
|
||||
);
|
||||
assert!(
|
||||
again["postings"].as_array().unwrap().is_empty(),
|
||||
"idempotent"
|
||||
);
|
||||
|
||||
// Monthly rent: drafts only, never posted.
|
||||
mcp.call(
|
||||
"books.create_recurring",
|
||||
json!({
|
||||
"description": "Office rent",
|
||||
"frequency": "monthly",
|
||||
"start": "2026-11-01",
|
||||
"lines": [
|
||||
{"account": "6600", "side": "debit", "amount": "2000.00"},
|
||||
{"account": "1000", "side": "credit", "amount": "2000.00"},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let drafts = mcp.call("books.run_recurring", json!({"through": "2026-12-15"}));
|
||||
assert_eq!(drafts["drafts"].as_array().unwrap().len(), 2);
|
||||
let pl = mcp.call(
|
||||
"books.profit_and_loss",
|
||||
json!({"start": "2026-11-01", "end": "2026-12-31"}),
|
||||
);
|
||||
assert_eq!(pl["expenses"], "666.66", "rent drafts don't post");
|
||||
drop(mcp);
|
||||
|
||||
// Restart: everything replays from the journal.
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
init(&mut mcp);
|
||||
let list = mcp.call("books.list_schedules", json!({}));
|
||||
assert_eq!(list["schedules"][0]["remaining"], "333.34");
|
||||
let rec = mcp.call("books.list_recurring", json!({}));
|
||||
assert_eq!(
|
||||
rec["recurring"][0]["generated"],
|
||||
json!(["2026-11-01", "2026-12-01"])
|
||||
);
|
||||
let more = mcp.call("books.run_recurring", json!({"through": "2026-12-31"}));
|
||||
assert!(more["drafts"].as_array().unwrap().is_empty(), "idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_payout_gross_up_over_stdio() {
|
||||
let dir = test_dir("payout");
|
||||
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", "Bank", "asset"),
|
||||
("4000", "App Sales", "revenue"),
|
||||
("4100", "Refunds", "expense"),
|
||||
("6000", "Platform Fees", "expense"),
|
||||
] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
mcp.call("books.add_class", json!({"name": "App A"}));
|
||||
mcp.call("books.add_class", json!({"name": "App B"}));
|
||||
|
||||
// 1,000.00 + 500.00 gross, 225.00 commission, a 9.99 refund: 1,265.01.
|
||||
let mut payout = json!({
|
||||
"date": "2026-11-30",
|
||||
"description": "App Store payout, November 2026",
|
||||
"deposit_account": "1000",
|
||||
"deposit": "1265.00",
|
||||
"revenue": [
|
||||
{"account": "4000", "amount": "1000.00", "class": "App A"},
|
||||
{"account": "4000", "amount": "500.00", "class": "App B"},
|
||||
],
|
||||
"fees": [
|
||||
{"account": "6000", "amount": "150.00", "class": "App A"},
|
||||
{"account": "6000", "amount": "75.00", "class": "App B"},
|
||||
],
|
||||
"adjustments": [
|
||||
{"account": "4100", "side": "debit", "amount": "9.99", "class": "App A"},
|
||||
],
|
||||
});
|
||||
let err = mcp.call_err("books.record_payout", payout.clone());
|
||||
assert!(err.contains("off by -0.01"), "{err}");
|
||||
payout["deposit"] = json!("1265.01");
|
||||
let out = mcp.call("books.record_payout", payout);
|
||||
assert_eq!(out["status"], "draft");
|
||||
assert_eq!(out["gross"], "1500.00");
|
||||
assert_eq!(out["adjustments"], "-9.99");
|
||||
assert_eq!(
|
||||
out["lines"],
|
||||
json!([
|
||||
"Debit 1000 1265.01",
|
||||
"Debit 6000 150.00",
|
||||
"Debit 6000 75.00",
|
||||
"Debit 4100 9.99",
|
||||
"Credit 4000 1000.00",
|
||||
"Credit 4000 500.00"
|
||||
])
|
||||
);
|
||||
|
||||
// The statement is the receipt; then it posts like any entry.
|
||||
let entry_id = out["entry_id"].as_u64().unwrap();
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"file_name": "payout.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 statement"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": entry_id, "confirmed": true}),
|
||||
);
|
||||
let by_class = mcp.call(
|
||||
"books.profit_and_loss_by_class",
|
||||
json!({"start": "2026-11-01", "end": "2026-11-30"}),
|
||||
);
|
||||
let net: Vec<(String, String)> = by_class["classes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c["class"].as_str().unwrap_or("").to_string(),
|
||||
c["net_income"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
net,
|
||||
[
|
||||
("App A".into(), "840.01".into()),
|
||||
("App B".into(), "425.00".into())
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_vendor_1099_over_stdio() {
|
||||
let dir = test_dir("form1099");
|
||||
let init = |mcp: &mut Mcp| {
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
};
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
init(&mut mcp);
|
||||
for (code, name, typ) in [
|
||||
("1000", "Bank", "asset"),
|
||||
("6000", "Contract Labor", "expense"),
|
||||
] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
mcp.call("books.add_vendor", json!({"name": "Dana Designs"}));
|
||||
let v = mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "dana designs", "form_1099": "nec"}),
|
||||
);
|
||||
assert_eq!(v["form_1099"], "1099-NEC");
|
||||
let err = mcp.call_err(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "k"}),
|
||||
);
|
||||
assert!(err.contains("nec, misc, or none"), "{err}");
|
||||
|
||||
// A 2,500.00 check to Dana, tagged at draft time.
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-05-01", "description": "Logo", "vendor": "Dana Designs"}),
|
||||
);
|
||||
let id = e["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
mcp.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": "2500.00"}),
|
||||
);
|
||||
}
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({
|
||||
"entry_id": id,
|
||||
"file_name": "invoice.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 invoice"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let got = mcp.call("books.get_entry", json!({"entry_id": id}));
|
||||
assert_eq!(got["vendor"], "Dana Designs");
|
||||
|
||||
let r = mcp.call("books.vendor_1099", json!({"year": 2026}));
|
||||
assert_eq!(r["threshold"], "2000.00");
|
||||
assert_eq!(r["vendors"][0]["paid"], "2500.00");
|
||||
assert_eq!(r["vendors"][0]["reportable"], true);
|
||||
assert_eq!(r["missing_w9"], json!(["Dana Designs"]));
|
||||
// An override above what was paid: not reportable.
|
||||
let high = mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "threshold": "3000"}),
|
||||
);
|
||||
assert_eq!(high["vendors"][0]["reportable"], false);
|
||||
// W-9 arrives.
|
||||
mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "nec", "tin_on_file": true}),
|
||||
);
|
||||
drop(mcp);
|
||||
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
init(&mut mcp);
|
||||
let r = mcp.call("books.vendor_1099", json!({"year": 2026}));
|
||||
assert_eq!(r["vendors"][0]["paid"], "2500.00");
|
||||
assert_eq!(r["missing_w9"], json!([]));
|
||||
// Clearing the tag removes the payment.
|
||||
mcp.call("books.set_entry_vendor", json!({"entry_id": id}));
|
||||
let r = mcp.call("books.vendor_1099", json!({"year": 2026}));
|
||||
assert_eq!(r["vendors"][0]["paid"], "0.00");
|
||||
let vendors = mcp.call("books.list_vendors", json!({}));
|
||||
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 5;
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -179,5 +179,21 @@ ALTER TABLE lines ADD COLUMN class_id INTEGER;
|
||||
-- keeps the filing and the withdrawal.
|
||||
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
|
||||
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
|
||||
"#,
|
||||
r#"
|
||||
-- v6: tax categories on accounts (where each account lands on the return,
|
||||
-- and how much of it is deductible). Percent is TEXT, never REAL.
|
||||
ALTER TABLE accounts ADD COLUMN tax_category TEXT;
|
||||
ALTER TABLE accounts ADD COLUMN deductible_percent TEXT;
|
||||
"#,
|
||||
r#"
|
||||
-- v7: 1099 tracking: which return each vendor gets, whether its W-9 is on
|
||||
-- file (never the TIN itself), and the vendor each entry paid.
|
||||
ALTER TABLE vendors ADD COLUMN form_1099 TEXT;
|
||||
ALTER TABLE vendors ADD COLUMN tin_on_file INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE TABLE IF NOT EXISTS entry_vendors(
|
||||
entry_id INTEGER PRIMARY KEY,
|
||||
vendor_id INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -955,7 +955,7 @@ impl Store {
|
||||
/// re-runs the deterministic close logic and checks the closing entry
|
||||
/// id against the journal.
|
||||
pub fn close_period(&mut self, id: PeriodId) -> Result<Option<EntryId>, StoreError> {
|
||||
self.close_period_to(id, &CloseTo::RetainedEarnings)
|
||||
self.close_period_to(id, &CloseTo::RetainedEarnings { sweep: vec![] })
|
||||
}
|
||||
|
||||
/// Close a period with its net income going to `to`: Retained Earnings,
|
||||
@@ -969,8 +969,8 @@ impl Store {
|
||||
) -> Result<Option<EntryId>, StoreError> {
|
||||
// Pure pre-validation: a doomed close journals nothing, not even
|
||||
// the Retained Earnings account creation below.
|
||||
self.ledger.preview_close_period(id)?;
|
||||
if *to == CloseTo::RetainedEarnings {
|
||||
self.ledger.preview_close_period_to(id, to)?;
|
||||
if matches!(to, CloseTo::RetainedEarnings { .. }) {
|
||||
// Retained Earnings must exist before the close so its creation
|
||||
// (if needed) is journaled as account.added; the close's own
|
||||
// ensure then no-ops and replay stays deterministic.
|
||||
@@ -997,11 +997,17 @@ impl Store {
|
||||
None => None,
|
||||
};
|
||||
let close_to = match to {
|
||||
CloseTo::RetainedEarnings => json!("retained_earnings"),
|
||||
CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
|
||||
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
|
||||
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
|
||||
CloseTo::Members(members) => json!({
|
||||
"members": members
|
||||
.iter()
|
||||
.map(|(account, share)| json!({"account": account, "share": share.to_string()}))
|
||||
.map(|m| json!({
|
||||
"account": m.capital,
|
||||
"share": m.share.to_string(),
|
||||
"distributions": m.distributions,
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
};
|
||||
@@ -1789,6 +1795,208 @@ impl Store {
|
||||
Ok((id, sha, dedup))
|
||||
}
|
||||
|
||||
/// Register an amortization schedule. Journaled as `schedule.registered`.
|
||||
pub fn register_schedule(
|
||||
&mut self,
|
||||
new: ledger_core::NewSchedule,
|
||||
) -> Result<ledger_core::ScheduleId, StoreError> {
|
||||
ledger_core::schedules::validate_schedule(&new, self.ledger.chart())?;
|
||||
let id = self.ledger.next_schedule_id();
|
||||
let line = self.append_journal(
|
||||
"schedule.registered",
|
||||
json!({
|
||||
"id": id,
|
||||
"description": new.description,
|
||||
"kind": serde_json::to_value(new.kind)?,
|
||||
"total": {"amount": new.total.amount.to_string(), "currency": new.total.currency.code()},
|
||||
"start": new.start.to_string(),
|
||||
"months": new.months,
|
||||
"balance_account": new.balance_account,
|
||||
"pl_account": new.pl_account,
|
||||
}),
|
||||
)?;
|
||||
let got = self.ledger.register_schedule(new)?;
|
||||
debug_assert_eq!(got, id);
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Post one month of a schedule. Journaled as `schedule.posted` with the
|
||||
/// whole entry, so a month is never half-posted or posted twice; replay
|
||||
/// re-derives the entry and checks it line by line.
|
||||
pub fn post_schedule_month(
|
||||
&mut self,
|
||||
id: ledger_core::ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
// Pure pre-validation, so the mutation below cannot fail.
|
||||
let (date, _, _) = self.ledger.preview_schedule_month(id, month_end)?;
|
||||
if let Some(p) = self.ledger.closed_period_containing(date) {
|
||||
return Err(LedgerError::ClosedPeriod(p, date.to_string()).into());
|
||||
}
|
||||
let entry = self.ledger.post_schedule_month(id, month_end)?;
|
||||
let line = self.append_journal(
|
||||
"schedule.posted",
|
||||
json!({
|
||||
"schedule_id": id,
|
||||
"month": date.to_string(),
|
||||
"entry": entry_detail_json(&self.ledger, entry),
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Post every schedule's due months through `through`, oldest first.
|
||||
/// Returns (schedule, month, entry) for each.
|
||||
pub fn run_schedules(
|
||||
&mut self,
|
||||
through: NaiveDate,
|
||||
) -> Result<Vec<(ledger_core::ScheduleId, NaiveDate, EntryId)>, StoreError> {
|
||||
let mut due: Vec<(NaiveDate, ledger_core::ScheduleId)> = self
|
||||
.ledger
|
||||
.schedules()
|
||||
.flat_map(|s| s.due(through).into_iter().map(move |(d, _)| (d, s.id)))
|
||||
.collect();
|
||||
due.sort();
|
||||
let mut out = Vec::with_capacity(due.len());
|
||||
for (month, id) in due {
|
||||
out.push((id, month, self.post_schedule_month(id, month)?));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Register a recurring entry. Journaled as `recurring.registered`.
|
||||
pub fn register_recurring(
|
||||
&mut self,
|
||||
new: ledger_core::NewRecurring,
|
||||
) -> Result<ledger_core::RecurringId, StoreError> {
|
||||
ledger_core::schedules::validate_recurring(&new, self.ledger.chart())?;
|
||||
let id = self.ledger.next_recurring_id();
|
||||
let lines: Vec<Value> = new
|
||||
.lines
|
||||
.iter()
|
||||
.map(|(account, side, money)| {
|
||||
json!({
|
||||
"account": account,
|
||||
"side": if *side == Side::Debit { "debit" } else { "credit" },
|
||||
"amount": money.amount.to_string(),
|
||||
"currency": money.currency.code(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let line = self.append_journal(
|
||||
"recurring.registered",
|
||||
json!({
|
||||
"id": id,
|
||||
"description": new.description,
|
||||
"frequency": serde_json::to_value(new.frequency)?,
|
||||
"start": new.start.to_string(),
|
||||
"end": new.end.map(|e| e.to_string()),
|
||||
"lines": lines,
|
||||
}),
|
||||
)?;
|
||||
let got = self.ledger.register_recurring(new)?;
|
||||
debug_assert_eq!(got, id);
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Generate every recurring entry's due occurrences through `through`
|
||||
/// as drafts. Each journaled as `recurring.generated`. Returns
|
||||
/// (recurring entry, date, draft) for each.
|
||||
pub fn run_recurring(
|
||||
&mut self,
|
||||
through: NaiveDate,
|
||||
) -> Result<Vec<(ledger_core::RecurringId, NaiveDate, EntryId)>, StoreError> {
|
||||
let mut due: Vec<(NaiveDate, ledger_core::RecurringId)> = self
|
||||
.ledger
|
||||
.recurring_entries()
|
||||
.flat_map(|r| r.due(through).into_iter().map(move |d| (d, r.id)))
|
||||
.collect();
|
||||
due.sort();
|
||||
let mut out = Vec::with_capacity(due.len());
|
||||
for (date, id) in due {
|
||||
let entry = self.ledger.generate_recurring(id, date)?;
|
||||
let line = self.append_journal(
|
||||
"recurring.generated",
|
||||
json!({"recurring_id": id, "date": date.to_string(), "entry_id": entry}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
out.push((id, date, entry));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set (or clear) an account's tax category and deductible percent.
|
||||
/// Journaled as `account.tax_set`.
|
||||
pub fn set_account_tax(
|
||||
&mut self,
|
||||
account: AccountId,
|
||||
category: Option<&str>,
|
||||
deductible_percent: Option<Decimal>,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger
|
||||
.check_account_tax(account, category, deductible_percent)?;
|
||||
let line = self.append_journal(
|
||||
"account.tax_set",
|
||||
json!({
|
||||
"account_id": account,
|
||||
"tax_category": category,
|
||||
"deductible_percent": deductible_percent.map(|p| p.to_string()),
|
||||
}),
|
||||
)?;
|
||||
self.ledger
|
||||
.set_account_tax(account, category, deductible_percent)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set which 1099 a vendor gets (or none) and whether its W-9 is on
|
||||
/// file. Journaled as `vendor.1099_set`.
|
||||
pub fn set_vendor_1099(
|
||||
&mut self,
|
||||
vendor: VendorId,
|
||||
form: Option<ledger_core::Form1099>,
|
||||
tin_on_file: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger
|
||||
.get_vendor(vendor)
|
||||
.ok_or(LedgerError::UnknownVendor(vendor))?;
|
||||
let line = self.append_journal(
|
||||
"vendor.1099_set",
|
||||
json!({ "vendor_id": vendor, "form_1099": form, "tin_on_file": tin_on_file }),
|
||||
)?;
|
||||
self.ledger.set_vendor_1099(vendor, form, tin_on_file)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tag an entry with the vendor it paid, or clear the tag. Journaled as
|
||||
/// `entry.vendor_set`.
|
||||
pub fn set_entry_vendor(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
vendor: Option<VendorId>,
|
||||
) -> Result<(), StoreError> {
|
||||
// Validate first, so nothing invalid is journaled.
|
||||
self.ledger
|
||||
.get_entry(entry)
|
||||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||||
if let Some(v) = vendor {
|
||||
self.ledger
|
||||
.get_vendor(v)
|
||||
.ok_or(LedgerError::UnknownVendor(v))?;
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"entry.vendor_set",
|
||||
json!({ "entry_id": entry, "vendor_id": vendor }),
|
||||
)?;
|
||||
self.ledger.set_entry_vendor(entry, vendor)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -2712,6 +2920,113 @@ fn replay_line_into_ledger(
|
||||
)));
|
||||
}
|
||||
}
|
||||
"schedule.registered" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
let got = ledger.register_schedule(ledger_core::NewSchedule {
|
||||
description: get_str(p, "description")?.to_string(),
|
||||
kind: serde_json::from_value(p["kind"].clone())?,
|
||||
total: parse_money(p, "total")?,
|
||||
start: parse_date(get_str(p, "start")?)?,
|
||||
months: get_u64(p, "months")? as u32,
|
||||
balance_account: get_u64(p, "balance_account")?,
|
||||
pl_account: get_u64(p, "pl_account")?,
|
||||
})?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"schedule id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"schedule.posted" => {
|
||||
let got = ledger.post_schedule_month(
|
||||
get_u64(p, "schedule_id")?,
|
||||
parse_date(get_str(p, "month")?)?,
|
||||
)?;
|
||||
check_replayed_entry(ledger, got, &p["entry"])?;
|
||||
}
|
||||
"recurring.registered" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
let lines = p["lines"]
|
||||
.as_array()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("recurring lines".into()))?
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Ok((
|
||||
get_u64(l, "account")?,
|
||||
parse_side(get_str(l, "side")?)?,
|
||||
Money::new(
|
||||
get_str(l, "amount")?.parse().map_err(|_| {
|
||||
StoreError::JournalCorrupt("recurring amount".into())
|
||||
})?,
|
||||
Currency::new(get_str(l, "currency")?).map_err(|_| {
|
||||
StoreError::JournalCorrupt("recurring currency".into())
|
||||
})?,
|
||||
),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, StoreError>>()?;
|
||||
let got = ledger.register_recurring(ledger_core::NewRecurring {
|
||||
description: get_str(p, "description")?.to_string(),
|
||||
lines,
|
||||
frequency: serde_json::from_value(p["frequency"].clone())?,
|
||||
start: parse_date(get_str(p, "start")?)?,
|
||||
end: match p.get("end").and_then(|v| v.as_str()) {
|
||||
Some(e) => Some(parse_date(e)?),
|
||||
None => None,
|
||||
},
|
||||
})?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"recurring id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"recurring.generated" => {
|
||||
let want = get_u64(p, "entry_id")?;
|
||||
let got = ledger.generate_recurring(
|
||||
get_u64(p, "recurring_id")?,
|
||||
parse_date(get_str(p, "date")?)?,
|
||||
)?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"recurring draft: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"vendor.1099_set" => {
|
||||
let form = match p.get("form_1099").and_then(|v| v.as_str()) {
|
||||
Some(s) => Some(ledger_core::Form1099::parse(s).ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("vendor.1099_set form_1099".into())
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
ledger.set_vendor_1099(
|
||||
get_u64(p, "vendor_id")?,
|
||||
form,
|
||||
p.get("tin_on_file")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
)?;
|
||||
}
|
||||
"entry.vendor_set" => {
|
||||
ledger.set_entry_vendor(
|
||||
get_u64(p, "entry_id")?,
|
||||
p.get("vendor_id").and_then(|v| v.as_u64()),
|
||||
)?;
|
||||
}
|
||||
"account.tax_set" => {
|
||||
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
|
||||
Some(s) => Some(s.parse().map_err(|_| {
|
||||
StoreError::JournalCorrupt("account.tax_set deductible_percent".into())
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
ledger.set_account_tax(
|
||||
get_u64(p, "account_id")?,
|
||||
p.get("tax_category").and_then(|v| v.as_str()),
|
||||
pct,
|
||||
)?;
|
||||
}
|
||||
"receipt.attached"
|
||||
| "receipt.linked"
|
||||
| "receipt.unlinked"
|
||||
@@ -2768,22 +3083,7 @@ fn replay_line_into_ledger(
|
||||
// Deterministic: the close re-derives the closing entry from
|
||||
// the replayed state and the journaled target (absent: Retained
|
||||
// Earnings), so it must match the journal's id and every line.
|
||||
let to = match p.get("close_to").and_then(|v| v.get("members")) {
|
||||
None => ledger_core::CloseTo::RetainedEarnings,
|
||||
Some(members) => ledger_core::CloseTo::Members(
|
||||
members
|
||||
.as_array()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("close_to.members".into()))?
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let share = get_str(m, "share")?.parse().map_err(|_| {
|
||||
StoreError::JournalCorrupt("close_to member share".into())
|
||||
})?;
|
||||
Ok((get_u64(m, "account")?, share))
|
||||
})
|
||||
.collect::<Result<_, StoreError>>()?,
|
||||
),
|
||||
};
|
||||
let to = close_to_from_journal(p.get("close_to"))?;
|
||||
let got = ledger.close_period_to(id, &to)?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
@@ -2960,6 +3260,122 @@ fn apply_document_payment(
|
||||
/// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs
|
||||
/// for lifecycle changes (receipt linking, disposal, period close,
|
||||
/// cleared flags, document payments, fx rate updates).
|
||||
/// The close target recorded in a `period.closed` line. Absent or
|
||||
/// `"retained_earnings"`: Retained Earnings with no sweeps (every journal
|
||||
/// written before targets existed).
|
||||
fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
||||
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
|
||||
let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
|
||||
v.as_array()
|
||||
.ok_or_else(|| corrupt("sweep"))?
|
||||
.iter()
|
||||
.map(|x| x.as_u64().ok_or_else(|| corrupt("sweep id")))
|
||||
.collect()
|
||||
};
|
||||
let Some(v) = v.filter(|v| !v.is_null() && v.as_str() != Some("retained_earnings")) else {
|
||||
return Ok(CloseTo::RetainedEarnings { sweep: vec![] });
|
||||
};
|
||||
if let Some(re) = v.get("retained_earnings") {
|
||||
return Ok(CloseTo::RetainedEarnings {
|
||||
sweep: ids(&re["sweep"])?,
|
||||
});
|
||||
}
|
||||
if let Some(a) = v.get("account") {
|
||||
return Ok(CloseTo::Account {
|
||||
into: get_u64(a, "into")?,
|
||||
sweep: ids(&a["sweep"])?,
|
||||
});
|
||||
}
|
||||
let members = v
|
||||
.get("members")
|
||||
.and_then(|m| m.as_array())
|
||||
.ok_or_else(|| corrupt("unknown target"))?;
|
||||
Ok(CloseTo::Members(
|
||||
members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
Ok(ledger_core::MemberClose {
|
||||
capital: get_u64(m, "account")?,
|
||||
share: get_str(m, "share")?
|
||||
.parse()
|
||||
.map_err(|_| corrupt("member share"))?,
|
||||
distributions: m.get("distributions").and_then(|d| d.as_u64()),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, StoreError>>()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Record a posted entry that rides inside another event's payload (a
|
||||
/// closing entry, a schedule posting) in the projection, like any posted
|
||||
/// entry.
|
||||
fn project_posted_entry(conn: &Connection, e: &Value) -> Result<(), StoreError> {
|
||||
let eid = get_u64(e, "id")? as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
|
||||
params![eid, get_str(e, "date")?, get_str(e, "description")?],
|
||||
)?;
|
||||
for l in e
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("posted entry without lines".into()))?
|
||||
{
|
||||
conn.execute(
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
eid,
|
||||
get_u64(l, "account")? as i64,
|
||||
get_str(l, "side")?,
|
||||
get_str(l, "amount")?,
|
||||
get_str(l, "currency")?,
|
||||
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full detail of a posted entry, for events that carry one.
|
||||
fn entry_detail_json(ledger: &Ledger, id: EntryId) -> Value {
|
||||
let e = ledger.get_entry(id).expect("entry just posted");
|
||||
json!({
|
||||
"id": id,
|
||||
"date": e.date().to_string(),
|
||||
"description": e.description(),
|
||||
"lines": e.lines().iter().map(journal_line_json).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// On replay, an entry re-derived from master data must match the journal
|
||||
/// line for line.
|
||||
fn check_replayed_entry(
|
||||
ledger: &Ledger,
|
||||
got: EntryId,
|
||||
journaled: &Value,
|
||||
) -> Result<(), StoreError> {
|
||||
let want = get_u64(journaled, "id")?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"entry id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
let lines: Vec<Value> = ledger
|
||||
.get_entry(got)
|
||||
.expect("replay posted it")
|
||||
.lines()
|
||||
.iter()
|
||||
.map(journal_line_json)
|
||||
.collect();
|
||||
if journaled["lines"].as_array() != Some(&lines) {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"entry {got}: replayed lines differ from the journal"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
||||
let p = &line.payload;
|
||||
match line.line_type.as_str() {
|
||||
@@ -3013,6 +3429,12 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
}
|
||||
}
|
||||
"entry.reversed" => {
|
||||
// A reversed payment un-pays the vendor: carry the tag.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
|
||||
SELECT ?, vendor_id FROM entry_vendors WHERE entry_id = ?",
|
||||
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
|
||||
params![
|
||||
@@ -3074,6 +3496,46 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"vendor.1099_set" => {
|
||||
conn.execute(
|
||||
"UPDATE vendors SET form_1099 = ?, tin_on_file = ? WHERE id = ?",
|
||||
params![
|
||||
p.get("form_1099").and_then(|v| v.as_str()),
|
||||
p.get("tin_on_file")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
get_u64(p, "vendor_id")? as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"entry.vendor_set" => match p.get("vendor_id").and_then(|v| v.as_u64()) {
|
||||
Some(v) => {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) VALUES (?, ?)",
|
||||
params![get_u64(p, "entry_id")? as i64, v as i64],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"DELETE FROM entry_vendors WHERE entry_id = ?",
|
||||
params![get_u64(p, "entry_id")? as i64],
|
||||
)?;
|
||||
}
|
||||
},
|
||||
"account.tax_set" => {
|
||||
conn.execute(
|
||||
"UPDATE accounts SET tax_category = ?, deductible_percent = ? WHERE id = ?",
|
||||
params![
|
||||
p.get("tax_category").and_then(|v| v.as_str()),
|
||||
p.get("deductible_percent").and_then(|v| v.as_str()),
|
||||
get_u64(p, "account_id")? as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"schedule.registered" | "recurring.registered" | "recurring.generated" => {
|
||||
// Master data and drafts: the ledger holds them; no report rows.
|
||||
}
|
||||
"schedule.posted" => project_posted_entry(conn, &p["entry"])?,
|
||||
"receipt.unlinked" => {
|
||||
conn.execute(
|
||||
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
|
||||
@@ -3153,29 +3615,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
// posted entry so the projection stays complete, then mark the
|
||||
// period row closed.
|
||||
if !p.get("entry").map(|v| v.is_null()).unwrap_or(true) {
|
||||
let e = &p["entry"];
|
||||
let eid = get_u64(e, "id")? as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
|
||||
params![eid, get_str(e, "date")?, get_str(e, "description")?],
|
||||
)?;
|
||||
for l in e.get("lines").and_then(|v| v.as_array()).ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("period.closed entry without lines".into())
|
||||
})? {
|
||||
conn.execute(
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
eid,
|
||||
get_u64(l, "account")? as i64,
|
||||
get_str(l, "side")?,
|
||||
get_str(l, "amount")?,
|
||||
get_str(l, "currency")?,
|
||||
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
project_posted_entry(conn, &p["entry"])?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE periods SET status = 'closed', closing_entry = ? WHERE id = ?",
|
||||
@@ -3249,6 +3689,13 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
get_u64(p, "entry_id")? as i64,
|
||||
],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) VALUES (?, ?)",
|
||||
params![
|
||||
get_u64(p, "entry_id")? as i64,
|
||||
get_u64(p, "vendor_id")? as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"invoice.payment_received" => {
|
||||
let amount: Decimal = get_str(p, "amount")?
|
||||
@@ -3261,6 +3708,14 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
|
||||
apply_document_payment(conn, "bills", get_u64(p, "bill_id")?, amount)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
|
||||
SELECT ?, vendor_id FROM bills WHERE id = ?",
|
||||
params![
|
||||
get_u64(p, "entry_id")? as i64,
|
||||
get_u64(p, "bill_id")? as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"fx.rate_set" => {
|
||||
conn.execute(
|
||||
|
||||
@@ -504,7 +504,18 @@ fn a_close_into_members_capital_replays_exactly() {
|
||||
store
|
||||
.close_period_to(
|
||||
jan,
|
||||
&ledger_core::CloseTo::Members(vec![(alice, half), (bob, half)]),
|
||||
&ledger_core::CloseTo::Members(vec![
|
||||
ledger_core::MemberClose {
|
||||
capital: alice,
|
||||
share: half,
|
||||
distributions: None,
|
||||
},
|
||||
ledger_core::MemberClose {
|
||||
capital: bob,
|
||||
share: half,
|
||||
distributions: None,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
// 100.01 splits 50.01 / 50.00 into the capital accounts.
|
||||
@@ -526,3 +537,149 @@ fn a_close_into_members_capital_replays_exactly() {
|
||||
assert_eq!(balances(&store), want);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_and_recurring_entries_replay_exactly() {
|
||||
let dir = test_dir("schedules");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let prepaid = store
|
||||
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let ins = store
|
||||
.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = store
|
||||
.add_account("6600", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||
let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
|
||||
store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: usd_s("1000"),
|
||||
start: date(1, 1),
|
||||
months: 3,
|
||||
balance_account: prepaid,
|
||||
pl_account: ins,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.register_recurring(ledger_core::NewRecurring {
|
||||
description: "Rent".into(),
|
||||
lines: vec![
|
||||
(rent, Side::Debit, usd_s("500")),
|
||||
(cash, Side::Credit, usd_s("500")),
|
||||
],
|
||||
frequency: ledger_core::Frequency::Monthly,
|
||||
start: date(1, 1),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
// 333.33, 333.33 through February; rent drafts for Jan 1 and Feb 1.
|
||||
let posted = store.run_schedules(date(2, 28)).unwrap();
|
||||
assert_eq!(posted.len(), 2);
|
||||
let drafts = store.run_recurring(date(2, 28)).unwrap();
|
||||
assert_eq!(
|
||||
drafts.iter().map(|d| d.1).collect::<Vec<_>>(),
|
||||
[date(1, 1), date(2, 1)]
|
||||
);
|
||||
store.post_entry(drafts[0].2).unwrap();
|
||||
let state = |s: &Store| {
|
||||
(
|
||||
s.ledger().account_balance(ins).unwrap(),
|
||||
s.ledger().account_balance(rent).unwrap(),
|
||||
s.ledger().get_entry(drafts[1].2).unwrap().status(),
|
||||
)
|
||||
};
|
||||
let want = (
|
||||
Decimal::new(66666, 2),
|
||||
Decimal::new(500, 0),
|
||||
ledger_core::EntryStatus::Draft,
|
||||
);
|
||||
assert_eq!(state(&store), want);
|
||||
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
assert_eq!(state(&store), want);
|
||||
store.rebuild().unwrap();
|
||||
assert_eq!(state(&store), want);
|
||||
store.verify().unwrap();
|
||||
// Idempotent after replay: nothing new is due through February.
|
||||
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
|
||||
assert!(store.run_recurring(date(2, 28)).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_1099_tags_replay_and_project() {
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{Form1099, vendor_1099_report};
|
||||
let dir = test_dir("form1099");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let labor = store
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = store.add_vendor("Dana").unwrap();
|
||||
store
|
||||
.set_vendor_1099(dana, Some(Form1099::Nec), true)
|
||||
.unwrap();
|
||||
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||
let bill = store
|
||||
.create_bill(
|
||||
dana,
|
||||
d(3, 1),
|
||||
d(3, 31),
|
||||
vec![DocumentLine {
|
||||
description: "Design".into(),
|
||||
amount: Decimal::from(1500),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.pay_bill(bill, d(3, 20), Decimal::from(1500), cash)
|
||||
.unwrap();
|
||||
let e = store.create_entry(d(6, 1), "Dana, June").unwrap();
|
||||
store.add_line(e, labor, Side::Debit, usd(700)).unwrap();
|
||||
store.add_line(e, cash, Side::Credit, usd(700)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
store.set_entry_vendor(e, Some(dana)).unwrap();
|
||||
let dup = store.create_entry(d(6, 2), "Dana, duplicate").unwrap();
|
||||
store.add_line(dup, labor, Side::Debit, usd(700)).unwrap();
|
||||
store.add_line(dup, cash, Side::Credit, usd(700)).unwrap();
|
||||
store.post_entry(dup).unwrap();
|
||||
store.set_entry_vendor(dup, Some(dana)).unwrap();
|
||||
store.reverse_entry(dup, d(6, 3), "duplicate").unwrap();
|
||||
let before = vendor_1099_report(store.ledger(), 2026, None);
|
||||
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
|
||||
assert!(before.vendors[0].reportable);
|
||||
drop(store);
|
||||
|
||||
// Reopen (replay), then rebuild the projection from scratch.
|
||||
for rebuild in [false, true] {
|
||||
if rebuild {
|
||||
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
}
|
||||
let store = Store::open(&dir).unwrap();
|
||||
assert_eq!(vendor_1099_report(store.ledger(), 2026, None), before);
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let tagged: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entry_vendors WHERE vendor_id = ?",
|
||||
[dana as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
// Bill, its payment, June, the duplicate, and its reversal.
|
||||
assert_eq!(tagged, 5);
|
||||
let form: String = conn
|
||||
.query_row("SELECT form_1099 FROM vendors", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(form, "nec");
|
||||
}
|
||||
}
|
||||
+100
-1
@@ -32,7 +32,10 @@ CPA package and the balance sheet's net income line, `[[company.members]]`
|
||||
to 100%) drives the statement of members' equity, `[books]
|
||||
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
||||
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
||||
`config.json` sets `receipt_policy`.
|
||||
`config.json` sets `receipt_policy`. Members may name a
|
||||
`distribution_account`; single-owner businesses may set `[books] close_into`
|
||||
(the equity account a close puts net income in; default Retained Earnings)
|
||||
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
|
||||
|
||||
## Transports
|
||||
|
||||
@@ -130,6 +133,71 @@ so the lifetime total is exactly (cost − salvage).
|
||||
posts the balanced disposal entry. Subject to receipt policy (bill of
|
||||
sale); monthly depreciation is exempt as an allocation.
|
||||
|
||||
## Tools — schedules and recurring entries
|
||||
|
||||
Amortization spreads a prepaid expense (asset → expense) or deferred
|
||||
revenue (liability → revenue) evenly over the months it covers, posting on
|
||||
each month end, to the cent (the last month absorbs rounding). Like
|
||||
depreciation, the schedule is the evidence: its postings are exempt from
|
||||
the receipt policy (the original payment carries the receipt). Recurring
|
||||
entries never post: they generate drafts, so every real receipt still goes
|
||||
through the policy.
|
||||
|
||||
- `books.create_schedule { kind: "prepaid_expense" | "deferred_revenue",
|
||||
description, total, start, months, balance_account, pl_account }` →
|
||||
`{ schedule_id, ..., total, remaining, months: [{ date, amount, posted }] }`.
|
||||
Prepaid needs an asset and an expense account; deferred revenue a
|
||||
liability and a revenue account.
|
||||
- `books.list_schedules {}` → `{ schedules: [...] }`, same shape.
|
||||
- `books.run_schedules { through?, confirmed? }` → `{ status: "preview" |
|
||||
"posted", postings: [{ schedule_id, date, description, amount,
|
||||
entry_id? }] }`: every unposted month ending on or before `through`
|
||||
(default today). Idempotent; closed periods are refused.
|
||||
- `books.create_recurring { description, frequency: "weekly" | "monthly" |
|
||||
"quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }`
|
||||
→ `{ recurring_id, ..., generated: [] }`. Lines must balance. Monthly and
|
||||
longer occurrences are computed from the start date each time, so a
|
||||
month-end start stays on month ends (Jan 31, Feb 28, Mar 31).
|
||||
- `books.list_recurring {}` → `{ recurring: [...] }`.
|
||||
- `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date,
|
||||
entry_id }] }`: one draft per occurrence on or before `through`, once.
|
||||
|
||||
## Tools — payouts
|
||||
|
||||
- `books.record_payout { date, description, deposit_account, deposit,
|
||||
revenue: [{ account, amount, class? }], fees?: [{ account, amount,
|
||||
class? }], adjustments?: [{ account, side, amount, class? }] }` →
|
||||
`{ entry_id, status: "draft", gross, fees, adjustments, deposit, lines,
|
||||
next }`. Builds the grossed-up entry — Dr deposit (net; Cr when negative),
|
||||
Dr fees, Dr/Cr adjustments (a debit reduces the deposit: refunds,
|
||||
chargebacks, reserves held, tax withheld), Cr revenue (gross; revenue or
|
||||
liability accounts, e.g. deferred revenue or sales tax collected) — and
|
||||
refuses unless gross − fees ± adjustments equals the deposit to the cent,
|
||||
naming the difference. A draft: attach the payout statement, preview,
|
||||
post. Platform-agnostic; format-specific statement parsers can feed it.
|
||||
|
||||
## Tools — 1099 tracking
|
||||
|
||||
Payments are counted as cash: credits minus debits to asset accounts on
|
||||
entries tagged with the vendor. Card payments move no asset (the card
|
||||
processor's 1099-K covers them), so they drop out; reversals of tagged
|
||||
entries carry the tag and net out. Thresholds: $600 for payments through
|
||||
2025, $2,000 from 2026 (indexed for inflation after 2026); always
|
||||
overridable, always for the CPA to confirm. TINs are never stored, only
|
||||
whether a W-9 is on file.
|
||||
|
||||
- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?,
|
||||
tin_on_file }] }`.
|
||||
- `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none",
|
||||
tin_on_file? }` → the vendor. `vendor` is an id or a name.
|
||||
- `books.set_entry_vendor { entry_id, vendor? }` → the entry (as
|
||||
`books.get_entry`, which now shows `vendor`). Omitting `vendor` clears
|
||||
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
|
||||
takes `vendor` too; bills and their payments are tagged automatically.
|
||||
- `books.vendor_1099 { year, threshold? }` → `{ year, threshold, vendors:
|
||||
[{ vendor_id, name, form_1099?, tin_on_file, paid, reportable }],
|
||||
missing_w9, notes }`.
|
||||
|
||||
## Tools — receipts & documents
|
||||
|
||||
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
|
||||
@@ -218,6 +286,37 @@ posting.
|
||||
get "Members' equity" with members named; the balance sheet separates
|
||||
retained earnings from the fiscal year's net income (loss).
|
||||
|
||||
## Tools — chart templates
|
||||
|
||||
- `books.chart_template { entity_type, owners? }` → `{ csv, settings_toml,
|
||||
notes }`: a starter chart for a sole proprietorship (incl. single-member
|
||||
LLC), partnership (incl. multi-member LLC), S corporation, or C
|
||||
corporation — the equity and owner-pay accounts that entity needs (owner's
|
||||
capital and draws; per-partner capital and distributions plus guaranteed
|
||||
payments; officer compensation and shareholder distributions; dividends
|
||||
and income tax expense), common income and expense accounts with tax
|
||||
categories, and the `wanderbooks.toml` settings (tax form, members, close
|
||||
target). A proposal: review with the human, then `books.import_chart_csv`.
|
||||
|
||||
## Tools — tax categories
|
||||
|
||||
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
|
||||
default_deductible_percent, note }] }`: where an account can land on the
|
||||
income tax return, filtered to the business's return when known
|
||||
(`[company] tax_form`, or derived from `tax_classification`). Stable
|
||||
semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...),
|
||||
never form line numbers, which change by year.
|
||||
- `books.set_tax_category { account, category?, deductible_percent? }`:
|
||||
map a revenue or expense account to a category (null clears),
|
||||
optionally overriding its deductible percent. Journaled as
|
||||
`account.tax_set`.
|
||||
- `books.export { report: "tax_summary" }`: book vs. tax amounts by
|
||||
category, the difference where they disagree (meals 50%, penalties 0%),
|
||||
and every P&L account still uncategorized. In the CPA package for the
|
||||
fiscal year to date.
|
||||
- `books.import_chart_csv` accepts an optional fifth `tax_category` column,
|
||||
validated before anything is created.
|
||||
|
||||
## Tools — import
|
||||
|
||||
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code,
|
||||
|
||||
+5
-2
@@ -24,11 +24,11 @@ No other statuses.
|
||||
| General ledger by account (running balance, split) | Reports → General Ledger | partial | hand-verified fixture, ties to trial balance; no golden file yet |
|
||||
| Trial balance as of a date | Reports → Trial Balance (as of) | partial | hand-verified fixture; no golden file yet |
|
||||
| Statement of changes in members' equity | Reports → Statement of Changes in Equity | partial | hand-verified two-member fixture incl. rounding and a close; no golden file yet |
|
||||
| Tax line mapping + tax summary | Accounts → Tax line mapping; Tax reports | partial | hand-verified fixture (50% meals, nondeductible, contra income); no golden file yet |
|
||||
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
||||
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
||||
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
|
||||
| Cash flow statement (indirect) | Reports → Cash Flow | done | R-FUTURE-3 |
|
||||
| Period close / retained earnings | Close books | todo | R-FUTURE-2 |
|
||||
| Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 |
|
||||
| Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
|
||||
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
|
||||
@@ -40,7 +40,10 @@ No other statuses.
|
||||
| Tracking classes (divisional P&L) | Class tracking | done | — |
|
||||
| CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — |
|
||||
| Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) |
|
||||
| Scheduled transactions | Scheduled txns | todo | R-FUTURE-6 |
|
||||
| Scheduled transactions (recurring entries → drafts) | Scheduled txns / Recurring transactions | partial | hand-verified fixture incl. month-end dates; drafts only, by design |
|
||||
| Payout gross-up (deposit → revenue, fees, adjustments) | Bank deposit with fee lines; Xero/QBO Stripe & Shopify integrations | partial | hand-verified fixture incl. classes and a negative payout; no statement parsers yet |
|
||||
| 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet |
|
||||
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
|
||||
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
|
||||
|
||||
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
||||
|
||||
+33
-4
@@ -23,7 +23,8 @@ requirement conflict, the requirement wins.
|
||||
organize the chart and reject postings.
|
||||
- **R-5 — Single currency per ledger (day one).** Every account and every
|
||||
journal line uses the ledger's base currency. Anything else is rejected,
|
||||
not converted. Multi-currency with translation is R-FUTURE-1.
|
||||
not converted, unless the line records its foreign currency, amount, and
|
||||
rate (R-FUTURE-1, done: see below).
|
||||
- **R-6 — Line hygiene.** Zero-amount lines are rejected, not ignored.
|
||||
Unknown accounts are rejected. Single-line entries are rejected.
|
||||
|
||||
@@ -65,6 +66,14 @@ requirement conflict, the requirement wins.
|
||||
to the cent, the last member absorbing rounding, so every row's total is
|
||||
exact. Any equity change the roll-forward doesn't explain is shown as
|
||||
"other", never hidden.
|
||||
- **R-9d — Tax categories.** Revenue and expense accounts map to stable
|
||||
tax categories (`ledger_core::tax::CATEGORIES`: key, label, income /
|
||||
cost of goods / deduction, the returns it appears on — Schedule C, 1065,
|
||||
1120-S, 1120 — default deductible percent, guidance), with an optional
|
||||
per-account deductible percent. `tax_summary(start, end, form)` groups
|
||||
the P&L by category with book and deductible amounts and lists
|
||||
uncategorized accounts. Book basis only: no tax depreciation, basis, or
|
||||
filing; not tax advice.
|
||||
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
|
||||
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
||||
keys). Two engines fed the same books must produce identical output. This
|
||||
@@ -96,8 +105,8 @@ requirement conflict, the requirement wins.
|
||||
|
||||
## Explicitly future work
|
||||
|
||||
- **R-FUTURE-6 — Budgets and scheduled transactions.** (Budgets explicitly
|
||||
declined as bloat; scheduled transactions remain future.)
|
||||
- **R-FUTURE-6 — Budgets.** Explicitly declined as bloat. (Scheduled
|
||||
transactions, the other half of R-FUTURE-6, are done: see below.)
|
||||
|
||||
## Promoted from future (done)
|
||||
|
||||
@@ -118,9 +127,29 @@ requirement conflict, the requirement wins.
|
||||
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
||||
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
||||
account by share, to the cent, the last absorbing rounding: the
|
||||
partnership convention). Periods close in order (`CloseOutOfOrder`). The
|
||||
partnership convention), or `CloseTo::Account` (any equity account, e.g.
|
||||
a sole proprietor's capital). Every target can sweep owner-pay equity
|
||||
accounts — draws, distributions, dividends — into it: a member's
|
||||
distributions account into their own capital. The members' equity
|
||||
statement counts a member's distributions account as part of their
|
||||
capital. Periods close in order (`CloseOutOfOrder`). The
|
||||
journal records the target, and replay re-derives the closing entry and
|
||||
checks every line against the journal.
|
||||
- **R-? — Prepaid expenses, deferred revenue, and recurring entries.** Was
|
||||
part of R-FUTURE-6 ("scheduled transactions"). Amortization schedules
|
||||
post monthly, equal to the cent, idempotently, honoring closed periods;
|
||||
the journal records each posting with its full entry and replay checks
|
||||
it. Recurring entries generate drafts only, never posts, so the receipt
|
||||
policy always applies.
|
||||
- **R-? — Payout gross-up.** Platform deposits are recorded gross:
|
||||
revenue, fees, and adjustments, reconciled to the deposit to the cent,
|
||||
as a draft that carries the payout statement as its receipt.
|
||||
- **R-? — 1099 tracking.** Vendors carry the information return they get
|
||||
and whether a W-9 is on file (never the TIN). Entries carry the vendor
|
||||
they paid; bills, bill payments, and reversals carry it automatically.
|
||||
The year's worksheet counts cash paid (asset-account credits minus
|
||||
debits), so card payments are excluded, against a year-dependent,
|
||||
overridable threshold. Schema v7.
|
||||
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
|
||||
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
|
||||
AR/AP aging. MCP tools for the full workflow.
|
||||
|
||||
+35
-1
@@ -214,4 +214,38 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
||||
accounts carry allocated earnings; the members'-equity statement treats
|
||||
those closes as income, not contributions, and the balance sheet shows
|
||||
only unallocated earnings. Replay now checks closing lines, not just ids.
|
||||
|
||||
- 2026-10-11: Tax categories (Stephen: wanderbooks is a general MCP-first
|
||||
accounting application, so features ship for every small business, not
|
||||
when Wanderland needs them). Accounts map to stable categories rather than
|
||||
form line numbers; each category knows its returns and default
|
||||
deductible percent; the tax summary shows book vs. tax by category and
|
||||
every uncategorized account. tax_form comes from config or the tax
|
||||
classification. Schema v6.
|
||||
- 2026-10-11: Owner equity for every entity type. Chart templates for sole
|
||||
proprietorships, partnerships/multi-member LLCs, S corps, and C corps
|
||||
(equity and owner-pay accounts, tax categories, matching config). Closes
|
||||
sweep draws, distributions, and dividends into their target; partners'
|
||||
distribution accounts count as capital on the members' equity statement
|
||||
and close into it. CloseTo became RetainedEarnings { sweep } / Account
|
||||
{ into, sweep } / Members([MemberClose]); journals without a target, or
|
||||
with the old plain "retained_earnings", still replay.
|
||||
- 2026-10-11: Schedules. Prepaid expenses and deferred revenue amortize
|
||||
monthly on month ends, to the cent; runs are previewed, idempotent, and
|
||||
exempt from the receipt policy (the schedule is the evidence). Recurring
|
||||
entries generate drafts, never posts. Journal events schedule.registered,
|
||||
schedule.posted (with the full entry, checked on replay),
|
||||
recurring.registered, recurring.generated. No schema change. 59 tools.
|
||||
- 2026-10-11: Payouts. books.record_payout grosses a platform deposit up
|
||||
into revenue (by class), fees, and adjustments, refusing unless it
|
||||
reconciles to the cent; it creates a draft so the payout statement is
|
||||
attached as the receipt. Pure core logic (payouts.rs), no new journal
|
||||
events (it journals as a draft and lines). 60 tools. Statement parsers
|
||||
(Apple, Google, Stripe) wait for sample files.
|
||||
- 2026-10-11: 1099 tracking. Vendors get form_1099 (NEC/MISC) and
|
||||
tin_on_file (never the TIN); entries get an optional vendor tag (bills,
|
||||
bill payments, and reversals inherit it). books.vendor_1099 counts cash
|
||||
paid per vendor per calendar year against $600 (≤2025) / $2,000 (2026+,
|
||||
OBBBA) or an override, and lists reportable vendors missing a W-9.
|
||||
Journal events vendor.1099_set and entry.vendor_set. Schema v7. 64 tools.
|
||||
Also: requirements and parity no longer list finished R-FUTURE work as
|
||||
future (only budgets remain, declined).
|
||||
Reference in new issue
Block a user