Audit fixes: party money, reconciliations, require policy, K-1 property
An independent recompute of the real books found no numeric disagreements; review found 15 defects, all fixed with spec-first regression tests: - k1: a prepaid credited straight to capital is property; a member paying a vendor through 2100 is cash. Docs and B-13 match. - parties: a reversal of a reversal counts on its root's side; liability-to-liability payments (member pays the card) count as paid. - viewer: party money uses the server's cash accounts and full calendar year; per-party "What for" scales multi-party entries. - reconciliations: refuse a withdrawn statement before journaling; new books.void_statement; reconciled lines can't be un-cleared; one shared reconcilable-accounts rule for digest and viewer. - CPA package includes receipts linked to entries; a file can't be both filed on and linked to one entry. - parties = "require" covers payroll, tax remittance (agency) and disposal (buyer); no-party marks follow reversal chains and clear when a vendor is tagged. - derived member shares are frozen to exact amounts when posted, so a settings change never re-splits history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
e1b2509c22
commit
8498e6cfab
22 files changed
+1802
-343
No files matched your search
@@ -81,18 +81,26 @@ impl Default for Company {
|
|||||||
impl Company {
|
impl Company {
|
||||||
/// The members as party shares see them ([`ledger_core::PartyMember`]):
|
/// The members as party shares see them ([`ledger_core::PartyMember`]):
|
||||||
/// each member whose name (ignoring case and surrounding space) is a
|
/// each member whose name (ignoring case and surrounding space) is a
|
||||||
/// party with the member role not archived, and whose capital account
|
/// party with the member role, and whose capital account is in the
|
||||||
/// is in the chart. Members missing either are left out, so their
|
/// chart. The party not archived of that name when there is one, else
|
||||||
/// shares can't be derived (and the viewer shows no capital
|
/// the most recent archived one: archiving a member ("no longer dealt
|
||||||
/// statement for them).
|
/// with; history stays") never takes their history away. Members
|
||||||
|
/// missing either are left out, so their shares can't be derived (and
|
||||||
|
/// the viewer shows no capital statement for them).
|
||||||
pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> {
|
pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> {
|
||||||
|
let is_member = |p: &&ledger_core::Party| p.roles.contains(&ledger_core::Role::Member);
|
||||||
self.members
|
self.members
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|m| {
|
.filter_map(|m| {
|
||||||
let party = ledger.party_by_name(&m.name)?;
|
let key = ledger_core::parties::name_key(&m.name);
|
||||||
if !party.roles.contains(&ledger_core::Role::Member) {
|
let party = match ledger.party_by_name(&m.name) {
|
||||||
return None;
|
Some(p) => Some(p).filter(is_member)?,
|
||||||
}
|
None => ledger
|
||||||
|
.parties()
|
||||||
|
.filter(|p| p.archived && ledger_core::parties::name_key(&p.name) == key)
|
||||||
|
.filter(is_member)
|
||||||
|
.max_by_key(|p| p.id)?,
|
||||||
|
};
|
||||||
Some(ledger_core::PartyMember {
|
Some(ledger_core::PartyMember {
|
||||||
party: party.id,
|
party: party.id,
|
||||||
share: m.share,
|
share: m.share,
|
||||||
|
|||||||
+104
-59
@@ -17,18 +17,19 @@
|
|||||||
//! 2. contributions_cash + contributions_property = contributions,
|
//! 2. contributions_cash + contributions_property = contributions,
|
||||||
//! exactly, per member.
|
//! exactly, per member.
|
||||||
//! 3. Property is what an entry crediting a member's capital account
|
//! 3. Property is what an entry crediting a member's capital account
|
||||||
//! debits to asset accounts that are neither cash (`cash`),
|
//! debits to asset accounts that are neither cash (`cash`) nor
|
||||||
//! receivables, nor prepaid: equipment, a vehicle, software a member
|
//! receivables: equipment, a vehicle, software, or the unexpired part
|
||||||
//! hands the company. The rest of a contribution is cash: deposits,
|
//! of a service contract (AppleCare+, a prepaid subscription) that a
|
||||||
//! and what a member paid for the company (an expense, a debt they
|
//! member owned and hands the company, each with its own basis
|
||||||
//! took on, e.g. Dr 2100 / Cr capital, or a service paid in advance).
|
//! (§723). The rest of a contribution is cash: deposits, and what a
|
||||||
//! A prepaid account is the balance account of a prepaid-expense
|
//! member paid for the company (an expense booked straight to
|
||||||
//! schedule, or an asset account whose name says "prepaid" (1300
|
//! capital, or a debt they took on, e.g. Dr 2100 / Cr capital). A
|
||||||
//! Prepaid Expenses before its schedule exists): a member paying the
|
//! member who pays a vendor for the company's prepaid service is
|
||||||
//! company's service in advance paid cash on its behalf, and it reads
|
//! booked through Due to Shareholders (Dr prepaid / Cr 2100), then
|
||||||
//! the same booked to capital directly or through 2100. Item L
|
//! converted (Dr 2100 / Cr capital): cash. A prepaid credited
|
||||||
//! property is property the member owned, with its own basis
|
//! straight to capital is a contract they handed over: property.
|
||||||
//! (§723), never cash they spent for the company.
|
//! (DECISION, owner, 2026-10-15, after the AppleCare+ contributed
|
||||||
|
//! with the MacBook read as cash.)
|
||||||
//! 4. Within one entry, the property (at most the members' credits in
|
//! 4. Within one entry, the property (at most the members' credits in
|
||||||
//! it) is shared among the members credited in proportion to their
|
//! it) is shared among the members credited in proportion to their
|
||||||
//! credits, to the cent, leftover cents by largest remainder
|
//! credits, to the cent, leftover cents by largest remainder
|
||||||
@@ -52,12 +53,16 @@
|
|||||||
//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001
|
//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001
|
||||||
//! 500) reversed the next day: each member 0.00 contributed, 0.00
|
//! 500) reversed the next day: each member 0.00 contributed, 0.00
|
||||||
//! distributed.
|
//! distributed.
|
||||||
//! 2. Members prepay an annual service from joint funds straight to
|
//! 2. Members pay a vendor ahead for the company's annual service from
|
||||||
//! capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr 3001 1,500):
|
//! joint funds, through Due to Shareholders (Dr 1300 Prepaid 3,000 /
|
||||||
//! 1,500.00 cash each, no property.
|
//! Cr 2100 3,000), later converted to capital (Dr 2100 3,000 / Cr
|
||||||
|
//! 3000 1,500, Cr 3001 1,500): 1,500.00 cash each, no property.
|
||||||
//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000
|
//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000
|
||||||
//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
|
//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
|
||||||
//! whichever member is listed first.
|
//! whichever member is listed first.
|
||||||
|
//! 4. Members hand the company a service contract they already owned,
|
||||||
|
//! straight to capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr
|
||||||
|
//! 3001 1,500): 1,500.00 property each, no cash.
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::reports::{MemberCapital, members_equity};
|
use crate::reports::{MemberCapital, members_equity};
|
||||||
@@ -165,18 +170,13 @@ pub fn k1_detail(
|
|||||||
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
||||||
let closings = ledger.closing_entry_ids();
|
let closings = ledger.closing_entry_ids();
|
||||||
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||||
let prepaid: Vec<AccountId> = ledger
|
|
||||||
.schedules()
|
|
||||||
.filter(|s| s.kind == crate::schedules::ScheduleKind::PrepaidExpense)
|
|
||||||
.map(|s| s.balance_account)
|
|
||||||
.collect();
|
|
||||||
let property_account = |id: AccountId| {
|
let property_account = |id: AccountId| {
|
||||||
!cash.contains(&id)
|
!cash.contains(&id)
|
||||||
&& Some(id) != receivables
|
&& Some(id) != receivables
|
||||||
&& !prepaid.contains(&id)
|
&& ledger
|
||||||
&& ledger.chart().get(id).is_some_and(|a| {
|
.chart()
|
||||||
a.account_type == AccountType::Asset && !a.name.to_lowercase().contains("prepaid")
|
.get(id)
|
||||||
})
|
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||||
};
|
};
|
||||||
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
|
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
|
||||||
e.lines()
|
e.lines()
|
||||||
@@ -701,13 +701,13 @@ mod tests {
|
|||||||
assert_eq!(split(&bk, &ms), vec![back, back]);
|
assert_eq!(split(&bk, &ms), vec![back, back]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// INVARIANT 3 and SCENARIO 2, by hand: members prepay a 3,000 annual
|
/// INVARIANT 3, by hand: a contract the members hand over (Dr 1300
|
||||||
/// service straight to capital (Dr 1300 Prepaid / Cr 3000 1,500, Cr
|
/// Prepaid 25 / Cr 3000 12.50, Cr 3001 12.50, the AppleCare+ with the
|
||||||
/// 3001 1,500): 1,500 cash each, no property, as when routed through
|
/// MacBook) is property, 12.50 each. Members paying a 3,000 annual
|
||||||
/// 2100. A prepaid-expense schedule's account is prepaid whatever its
|
/// service for the company go through 2100: Dr 1300 / Cr 2100 3,000,
|
||||||
/// name.
|
/// then Dr 2100 / Cr 3000 1,500, Cr 3001 1,500: 1,500 cash each.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_prepaid_service_paid_by_members_is_cash() {
|
fn a_contract_handed_over_is_property_a_service_paid_for_is_cash() {
|
||||||
let mut bk = books();
|
let mut bk = books();
|
||||||
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
|
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
|
||||||
post(
|
post(
|
||||||
@@ -715,45 +715,90 @@ mod tests {
|
|||||||
1,
|
1,
|
||||||
5,
|
5,
|
||||||
&[
|
&[
|
||||||
(prepaid, Side::Debit, "3000"),
|
(prepaid, Side::Debit, "25"),
|
||||||
|
(a, Side::Credit, "12.50"),
|
||||||
|
(b, Side::Credit, "12.50"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let ms = members(&bk);
|
||||||
|
let handed = (d("0"), d("12.50"), d("0"));
|
||||||
|
assert_eq!(split(&bk, &ms), vec![handed, handed]);
|
||||||
|
let due =
|
||||||
|
bk.l.add_account(
|
||||||
|
"2100",
|
||||||
|
"Due to Members",
|
||||||
|
AccountType::Liability,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
|
||||||
|
);
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
&[
|
||||||
|
(due, Side::Debit, "3000"),
|
||||||
|
(a, Side::Credit, "1500"),
|
||||||
|
(b, Side::Credit, "1500"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let each = (d("1500"), d("12.50"), d("0"));
|
||||||
|
assert_eq!(split(&bk, &ms), vec![each, each]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIOS 2 and 4 exactly as the module doc states them: 3,000 of
|
||||||
|
/// prepaid service through 2100 is 1,500.00 cash each; the same 3,000
|
||||||
|
/// straight to capital is 1,500.00 property each.
|
||||||
|
#[test]
|
||||||
|
fn scenarios_2_and_4_prepaid_through_2100_is_cash_straight_to_capital_is_property() {
|
||||||
|
let mut bk = books();
|
||||||
|
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
|
||||||
|
let due =
|
||||||
|
bk.l.add_account(
|
||||||
|
"2100",
|
||||||
|
"Due to Shareholders",
|
||||||
|
AccountType::Liability,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
|
||||||
|
);
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
1,
|
||||||
|
6,
|
||||||
|
&[
|
||||||
|
(due, Side::Debit, "3000"),
|
||||||
(a, Side::Credit, "1500"),
|
(a, Side::Credit, "1500"),
|
||||||
(b, Side::Credit, "1500"),
|
(b, Side::Credit, "1500"),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let ms = members(&bk);
|
let ms = members(&bk);
|
||||||
let each = (d("1500"), d("0"), d("0"));
|
let cash = (d("1500"), d("0"), d("0"));
|
||||||
assert_eq!(split(&bk, &ms), vec![each, each]);
|
assert_eq!(split(&bk, &ms), vec![cash, cash]);
|
||||||
// An account named otherwise, backing a prepaid schedule.
|
|
||||||
let ins =
|
|
||||||
bk.l.add_account(
|
|
||||||
"1310",
|
|
||||||
"Insurance paid ahead",
|
|
||||||
AccountType::Asset,
|
|
||||||
None,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let exp =
|
|
||||||
bk.l.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
|
||||||
.unwrap();
|
|
||||||
post(
|
post(
|
||||||
&mut bk.l,
|
&mut bk.l,
|
||||||
2,
|
2,
|
||||||
1,
|
1,
|
||||||
&[(ins, Side::Debit, "1200"), (a, Side::Credit, "1200")],
|
&[
|
||||||
|
(prepaid, Side::Debit, "3000"),
|
||||||
|
(a, Side::Credit, "1500"),
|
||||||
|
(b, Side::Credit, "1500"),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert_eq!(split(&bk, &ms)[0].1, d("1200"), "no schedule yet: property");
|
let both = (d("1500"), d("1500"), d("0"));
|
||||||
bk.l.register_schedule(crate::schedules::NewSchedule {
|
assert_eq!(split(&bk, &ms), vec![both, both]);
|
||||||
description: "2026 insurance".into(),
|
|
||||||
kind: crate::schedules::ScheduleKind::PrepaidExpense,
|
|
||||||
total: Money::new(d("1200"), Currency::USD),
|
|
||||||
start: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
|
||||||
months: 12,
|
|
||||||
balance_account: ins,
|
|
||||||
pl_account: exp,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(split(&bk, &ms)[0], (d("2700"), d("0"), d("0")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 /
|
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 /
|
||||||
|
|||||||
@@ -1386,6 +1386,11 @@ impl Ledger {
|
|||||||
if let Some(p) = self.entry_parties.get(&entry).cloned() {
|
if let Some(p) = self.entry_parties.get(&entry).cloned() {
|
||||||
self.entry_parties.insert(id, p);
|
self.entry_parties.insert(id, p);
|
||||||
}
|
}
|
||||||
|
// An entry with no counterparty is reversed with none either
|
||||||
|
// (set_entry_no_party INVARIANT 1).
|
||||||
|
if let Some(why) = self.entry_no_party.get(&entry).cloned() {
|
||||||
|
self.entry_no_party.insert(id, why);
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2461,8 +2466,10 @@ impl Ledger {
|
|||||||
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
|
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
|
||||||
/// entries).
|
/// entries).
|
||||||
/// 3. On an entry naming no party or one: Some(v) makes v's party the
|
/// 3. On an entry naming no party or one: Some(v) makes v's party the
|
||||||
/// entry's one party (all of it); None clears the party too when
|
/// entry's one party (all of it) and clears a no-party mark
|
||||||
/// it was the vendor's.
|
/// (set_entry_parties INVARIANT 3: an entry never names a party
|
||||||
|
/// and carries the mark at once); None clears the party too when
|
||||||
|
/// it was the vendor's, and leaves no mark.
|
||||||
/// 4. On an entry naming several parties, Some(v) with v's party
|
/// 4. On an entry naming several parties, Some(v) with v's party
|
||||||
/// among them sets the tag and keeps every party and share as
|
/// among them sets the tag and keeps every party and share as
|
||||||
/// they are. IF v's party is not among them (or v has none):
|
/// they are. IF v's party is not among them (or v has none):
|
||||||
@@ -2542,6 +2549,9 @@ impl Ledger {
|
|||||||
self.entry_parties
|
self.entry_parties
|
||||||
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
||||||
}
|
}
|
||||||
|
if self.entry_parties.contains_key(&entry) {
|
||||||
|
self.entry_no_party.remove(&entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// The party tag goes too only when it was the vendor's.
|
// The party tag goes too only when it was the vendor's.
|
||||||
@@ -3175,14 +3185,25 @@ impl Ledger {
|
|||||||
/// ```text
|
/// ```text
|
||||||
/// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION
|
/// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION
|
||||||
/// (owner, 2026-10-15).
|
/// (owner, 2026-10-15).
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. An entry and its reversal (and theirs: Self::reversal_chain)
|
||||||
|
/// carry the same mark, as they name the same parties
|
||||||
|
/// (set_entry_parties INVARIANT 5): marking any one marks the
|
||||||
|
/// chain, and reverse_entry copies the mark to the reversal.
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF the entry is unknown, already names a party, or the reason
|
/// 1. IF the entry is unknown, already names a party, or the reason
|
||||||
/// is blank or fails the party text rules: SHOULD refuse.
|
/// is blank or fails the party text rules: SHOULD refuse.
|
||||||
/// 2. Tagging the entry with a party later clears the mark.
|
/// 2. Tagging the entry with a party later clears the mark
|
||||||
|
/// (set_entry_parties, set_entry_party, set_entry_vendor), on the
|
||||||
|
/// whole chain.
|
||||||
|
/// 3. A transfer between the company's own accounts marked, then
|
||||||
|
/// reversed: the reversal is marked with the same reason.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
|
pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
|
||||||
self.check_set_entry_no_party(entry, reason)?;
|
self.check_set_entry_no_party(entry, reason)?;
|
||||||
self.entry_no_party.insert(entry, reason.trim().to_string());
|
for e in self.reversal_chain(entry) {
|
||||||
|
self.entry_no_party.insert(e, reason.trim().to_string());
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3195,10 +3216,16 @@ impl Ledger {
|
|||||||
if !self.entries.contains_key(&entry) {
|
if !self.entries.contains_key(&entry) {
|
||||||
return Err(LedgerError::UnknownEntry(entry));
|
return Err(LedgerError::UnknownEntry(entry));
|
||||||
}
|
}
|
||||||
if self.entry_parties.contains_key(&entry) {
|
if let Some(e) = self
|
||||||
return Err(LedgerError::InvalidParty(format!(
|
.reversal_chain(entry)
|
||||||
"entry {entry} names a party; clear it first"
|
.into_iter()
|
||||||
)));
|
.find(|e| self.entry_parties.contains_key(e))
|
||||||
|
{
|
||||||
|
return Err(LedgerError::InvalidParty(if e == entry {
|
||||||
|
format!("entry {entry} names a party; clear it first")
|
||||||
|
} else {
|
||||||
|
format!("entry {entry}'s reversal chain entry {e} names a party; clear it first")
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if reason.trim().is_empty() {
|
if reason.trim().is_empty() {
|
||||||
return Err(LedgerError::InvalidParty(
|
return Err(LedgerError::InvalidParty(
|
||||||
@@ -4102,9 +4129,18 @@ impl Ledger {
|
|||||||
/// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash
|
/// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash
|
||||||
/// net pay.
|
/// net pay.
|
||||||
/// 2. Net pay MUST equal gross minus the employee's withholdings.
|
/// 2. Net pay MUST equal gross minus the employee's withholdings.
|
||||||
|
/// 3. When every line with net pay names its employee party, the entry
|
||||||
|
/// names those parties (Self::entry_parties), each with its net pay
|
||||||
|
/// (summed over its lines): the whole of it for one employee, else
|
||||||
|
/// an exact amount each, so each employee's books.party counts what
|
||||||
|
/// they were paid. Lines named by employee_name only (or shares
|
||||||
|
/// Self::set_entry_parties would refuse) leave the entry naming no
|
||||||
|
/// party (the agent tags it).
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF any amount is negative, gross is zero, or withholdings exceed
|
/// 1. IF any amount is negative, gross is zero, or withholdings exceed
|
||||||
/// gross: SHOULD refuse.
|
/// gross: SHOULD refuse.
|
||||||
|
/// 2. Pat (net 1,500.00) and Sam (net 900.00), both parties: the entry
|
||||||
|
/// names Pat 1,500.00 and Sam 900.00.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn run_payroll(
|
pub fn run_payroll(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -4131,6 +4167,7 @@ impl Ledger {
|
|||||||
self.add_line(entry, account, side, amount)?;
|
self.add_line(entry, account, side, amount)?;
|
||||||
}
|
}
|
||||||
self.post_entry(entry)?;
|
self.post_entry(entry)?;
|
||||||
|
self.name_payroll_parties(entry, &lines)?;
|
||||||
let id = self.next_payroll_run_id;
|
let id = self.next_payroll_run_id;
|
||||||
self.next_payroll_run_id += 1;
|
self.next_payroll_run_id += 1;
|
||||||
self.payroll_runs.insert(
|
self.payroll_runs.insert(
|
||||||
@@ -4145,6 +4182,45 @@ impl Ledger {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Self::run_payroll`] INVARIANT 3: name the employees a payroll
|
||||||
|
/// entry paid as its parties, each with their net pay. Live and on
|
||||||
|
/// replay ([`Self::replay_payroll_run`]) alike.
|
||||||
|
fn name_payroll_parties(
|
||||||
|
&mut self,
|
||||||
|
entry: EntryId,
|
||||||
|
lines: &[PayrollLine],
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
let mut paid: Vec<(crate::PartyId, Decimal)> = Vec::new();
|
||||||
|
let mut unnamed = false;
|
||||||
|
for l in lines.iter().filter(|l| !l.net_pay().is_zero()) {
|
||||||
|
match l.employee {
|
||||||
|
Some(p) => match paid.iter_mut().find(|(x, _)| *x == p) {
|
||||||
|
Some((_, v)) => *v += l.net_pay(),
|
||||||
|
None => paid.push((p, l.net_pay())),
|
||||||
|
},
|
||||||
|
None => unnamed = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !unnamed && !paid.is_empty() {
|
||||||
|
let shares: Vec<crate::PartyShare> = match paid.as_slice() {
|
||||||
|
[(p, _)] => vec![crate::PartyShare::whole(*p)],
|
||||||
|
_ => paid
|
||||||
|
.iter()
|
||||||
|
.map(|&(party, v)| crate::PartyShare {
|
||||||
|
party,
|
||||||
|
share: crate::Share::Amount(v),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
// Shares set_entry_parties would refuse (two employees with
|
||||||
|
// vendor records) leave the entry for the agent to tag.
|
||||||
|
if self.check_set_entry_parties(entry, &shares).is_ok() {
|
||||||
|
self.set_entry_parties(entry, &shares)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Current balances of the payroll tax payable accounts (`2110`–`2150`),
|
/// Current balances of the payroll tax payable accounts (`2110`–`2150`),
|
||||||
/// in code order. Accounts that don't exist yet count as zero and are
|
/// in code order. Accounts that don't exist yet count as zero and are
|
||||||
/// skipped, as are zero balances. Balances are normal-side: positive
|
/// skipped, as are zero balances. Balances are normal-side: positive
|
||||||
@@ -4245,6 +4321,9 @@ impl Ledger {
|
|||||||
return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id));
|
return Err(LedgerError::ReplayIdMismatch(id, self.next_payroll_run_id));
|
||||||
}
|
}
|
||||||
self.next_payroll_run_id += 1;
|
self.next_payroll_run_id += 1;
|
||||||
|
if self.entries.contains_key(&entry_id) {
|
||||||
|
self.name_payroll_parties(entry_id, &lines)?;
|
||||||
|
}
|
||||||
self.payroll_runs.insert(
|
self.payroll_runs.insert(
|
||||||
id,
|
id,
|
||||||
PayrollRun {
|
PayrollRun {
|
||||||
|
|||||||
@@ -514,22 +514,38 @@ pub struct EntryFlow {
|
|||||||
/// down = min(liab_dr, cash_cr): cash paying down a liability (its
|
/// down = min(liab_dr, cash_cr): cash paying down a liability (its
|
||||||
/// creditor is paid; the liability is what for);
|
/// creditor is paid; the liability is what for);
|
||||||
/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the
|
/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the
|
||||||
/// lender pays the company).
|
/// lender pays the company);
|
||||||
|
/// left(a), for each money liability a: its debits less what of them
|
||||||
|
/// `down` takes, less its credits less what of them `up` takes (`down`
|
||||||
|
/// and `up` taken from its lines in line order), so one account's
|
||||||
|
/// debits and credits offset first;
|
||||||
|
/// across = min(Σ left(a) > 0, Σ −left(a) < 0): one liability paying
|
||||||
|
/// down another (a member paying the card from their own funds: Dr
|
||||||
|
/// 2050 / Cr 2100; a loan paying off a card): the debited liability's
|
||||||
|
/// creditor is paid, by the credited one's.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up.
|
/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up
|
||||||
|
/// + across.
|
||||||
/// 2. what_for: every line not on a money account (debits − credits),
|
/// 2. what_for: every line not on a money account (debits − credits),
|
||||||
/// plus `down` on the debited liabilities and −`up` on the credited
|
/// plus `down` on the debited liabilities and −`up` on the credited
|
||||||
/// ones (in line order), zero sums left out, accounts merged; its sum
|
/// ones (in line order), plus `across` on the liabilities with
|
||||||
/// is net exactly, because the entry balances.
|
/// left(a) > 0 (in order of first appearance), zero sums left out,
|
||||||
|
/// accounts merged; its sum is net exactly, because the entry
|
||||||
|
/// balances.
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. Expense from the bank, on a card, or paid by a member for the
|
/// 1. Expense from the bank, on a card, or paid by a member for the
|
||||||
/// company (Dr expense / Cr 1000, 2050, or 2100): net is the amount.
|
/// company (Dr expense / Cr 1000, 2050, or 2100): net is the amount.
|
||||||
/// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 /
|
/// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 /
|
||||||
/// Cr 1000): net is the amount, what for is the liability.
|
/// Cr 1000): net is the amount, what for is the liability.
|
||||||
/// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received).
|
/// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received).
|
||||||
/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net.
|
/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net. A charge
|
||||||
|
/// and a refund on the same card in one entry net on that card: no
|
||||||
|
/// card paid down.
|
||||||
/// 5. A transfer between the company's own cash accounts: zero.
|
/// 5. A transfer between the company's own cash accounts: zero.
|
||||||
/// 6. A member contributing property (Dr equipment / Cr capital): zero.
|
/// 6. A member contributing property (Dr equipment / Cr capital): zero.
|
||||||
|
/// 7. A member paying the card bill from their own funds (Dr 2050 / Cr
|
||||||
|
/// 2100), or a loan paying it off (Dr 2050 / Cr loan): net is the
|
||||||
|
/// amount, what for is the card, as if paid from the bank.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow {
|
pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow {
|
||||||
use crate::Side;
|
use crate::Side;
|
||||||
@@ -552,40 +568,72 @@ pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFl
|
|||||||
}
|
}
|
||||||
let down = liab_dr.min(cash_cr);
|
let down = liab_dr.min(cash_cr);
|
||||||
let up = liab_cr.min(cash_dr);
|
let up = liab_cr.min(cash_dr);
|
||||||
let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up;
|
|
||||||
let mut by: std::collections::BTreeMap<crate::AccountId, Decimal> = Default::default();
|
let mut by: std::collections::BTreeMap<crate::AccountId, Decimal> = Default::default();
|
||||||
let (mut down_left, mut up_left) = (down, up);
|
let (mut down_left, mut up_left) = (down, up);
|
||||||
let mut order: Vec<crate::AccountId> = Vec::new();
|
let mut order: Vec<crate::AccountId> = Vec::new();
|
||||||
|
// left(a) per money liability, in order of first appearance.
|
||||||
|
let mut left: Vec<(crate::AccountId, Decimal)> = Vec::new();
|
||||||
|
let add = |by: &mut std::collections::BTreeMap<crate::AccountId, Decimal>,
|
||||||
|
order: &mut Vec<crate::AccountId>,
|
||||||
|
account: crate::AccountId,
|
||||||
|
part: Decimal| {
|
||||||
|
if !part.is_zero() {
|
||||||
|
if !by.contains_key(&account) {
|
||||||
|
order.push(account);
|
||||||
|
}
|
||||||
|
*by.entry(account).or_default() += part;
|
||||||
|
}
|
||||||
|
};
|
||||||
for l in entry.lines() {
|
for l in entry.lines() {
|
||||||
let a = l.amount.amount;
|
let a = l.amount.amount;
|
||||||
let part = if money.cash.contains(&l.account) {
|
let part = if money.cash.contains(&l.account) {
|
||||||
Decimal::ZERO
|
Decimal::ZERO
|
||||||
} else if money.liabilities.contains(&l.account) {
|
} else if money.liabilities.contains(&l.account) {
|
||||||
match l.side {
|
let (part, rest) = match l.side {
|
||||||
Side::Debit => {
|
Side::Debit => {
|
||||||
let take = a.min(down_left);
|
let take = a.min(down_left);
|
||||||
down_left -= take;
|
down_left -= take;
|
||||||
take
|
(take, a - take)
|
||||||
}
|
}
|
||||||
Side::Credit => {
|
Side::Credit => {
|
||||||
let take = a.min(up_left);
|
let take = a.min(up_left);
|
||||||
up_left -= take;
|
up_left -= take;
|
||||||
-take
|
(-take, -(a - take))
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
match left.iter_mut().find(|(x, _)| *x == l.account) {
|
||||||
|
Some((_, v)) => *v += rest,
|
||||||
|
None => left.push((l.account, rest)),
|
||||||
}
|
}
|
||||||
|
part
|
||||||
} else {
|
} else {
|
||||||
match l.side {
|
match l.side {
|
||||||
Side::Debit => a,
|
Side::Debit => a,
|
||||||
Side::Credit => -a,
|
Side::Credit => -a,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !part.is_zero() {
|
add(&mut by, &mut order, l.account, part);
|
||||||
if !by.contains_key(&l.account) {
|
}
|
||||||
order.push(l.account);
|
let owed_down: Decimal = left
|
||||||
}
|
.iter()
|
||||||
*by.entry(l.account).or_default() += part;
|
.map(|(_, v)| *v)
|
||||||
|
.filter(|v| *v > Decimal::ZERO)
|
||||||
|
.sum();
|
||||||
|
let owed_up: Decimal = -left
|
||||||
|
.iter()
|
||||||
|
.map(|(_, v)| *v)
|
||||||
|
.filter(|v| *v < Decimal::ZERO)
|
||||||
|
.sum::<Decimal>();
|
||||||
|
let across = owed_down.min(owed_up);
|
||||||
|
let mut across_left = across;
|
||||||
|
for (account, v) in left {
|
||||||
|
if v > Decimal::ZERO && !across_left.is_zero() {
|
||||||
|
let take = v.min(across_left);
|
||||||
|
across_left -= take;
|
||||||
|
add(&mut by, &mut order, account, take);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let net = (cash_cr - cash_dr) + (liab_cr - liab_dr) + down - up + across;
|
||||||
EntryFlow {
|
EntryFlow {
|
||||||
net,
|
net,
|
||||||
what_for: order
|
what_for: order
|
||||||
@@ -625,7 +673,9 @@ pub fn activity(
|
|||||||
/// zero counts the party's resolved share of it
|
/// zero counts the party's resolved share of it
|
||||||
/// (crate::shares::resolve with `members`; the whole net for a sole
|
/// (crate::shares::resolve with `members`; the whole net for a sole
|
||||||
/// party): positive is a payment, negative a receipt. A reversal
|
/// party): positive is a payment, negative a receipt. A reversal
|
||||||
/// counts on its original's side, so it subtracts.
|
/// counts on its root original's side (the first entry of
|
||||||
|
/// Ledger::reversal_chain), so a reversal subtracts and a reversal
|
||||||
|
/// of that reversal adds back, on the same side.
|
||||||
/// 3. An entry moving no money (net zero, like property contributed)
|
/// 3. An entry moving no money (net zero, like property contributed)
|
||||||
/// counts nothing. An entry whose shares don't resolve counts
|
/// counts nothing. An entry whose shares don't resolve counts
|
||||||
/// nothing and is listed in `unresolved`.
|
/// nothing and is listed in `unresolved`.
|
||||||
@@ -672,8 +722,12 @@ pub fn activity_with(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) {
|
let side = match e.reversal_of() {
|
||||||
Some(original) => net_out(original),
|
Some(_) => ledger
|
||||||
|
.reversal_chain(id)
|
||||||
|
.first()
|
||||||
|
.and_then(|&root| ledger.get_entry(root))
|
||||||
|
.map_or(net, net_out),
|
||||||
None => net,
|
None => net,
|
||||||
};
|
};
|
||||||
if side > Decimal::ZERO {
|
if side > Decimal::ZERO {
|
||||||
|
|||||||
@@ -1252,6 +1252,40 @@ fn entry_flow_scenarios() {
|
|||||||
// Split payment: part bank, part card.
|
// Split payment: part bank, part card.
|
||||||
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
|
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
|
||||||
assert_eq!(f.net, dec("100.00"));
|
assert_eq!(f.net, dec("100.00"));
|
||||||
|
// 7. A member pays the card bill from their own funds, or a loan pays
|
||||||
|
// it off: the issuer is paid, for the card, as from the bank.
|
||||||
|
for funder in [members, loan] {
|
||||||
|
let f = case(&[(card, D, "300.00"), (funder, C, "300.00")]);
|
||||||
|
assert_eq!(f.net, dec("300.00"));
|
||||||
|
assert_eq!(f.what_for, vec![(card, dec("300.00"))]);
|
||||||
|
}
|
||||||
|
// Part bank, part a member.
|
||||||
|
let f = case(&[
|
||||||
|
(card, D, "300.00"),
|
||||||
|
(bank, C, "200.00"),
|
||||||
|
(members, C, "100.00"),
|
||||||
|
]);
|
||||||
|
assert_eq!(f.net, dec("300.00"));
|
||||||
|
assert_eq!(f.what_for, vec![(card, dec("300.00"))]);
|
||||||
|
// 4. A charge and its refund on one card offset there: no card paid
|
||||||
|
// down, 80.00 paid by card.
|
||||||
|
let f = case(&[
|
||||||
|
(exp, D, "100.00"),
|
||||||
|
(card, C, "100.00"),
|
||||||
|
(card, D, "20.00"),
|
||||||
|
(exp, C, "20.00"),
|
||||||
|
]);
|
||||||
|
assert_eq!(f.net, dec("80.00"));
|
||||||
|
assert_eq!(f.what_for, vec![(exp, dec("80.00"))]);
|
||||||
|
// Paying the card from the bank while the issuer charges a fee on it:
|
||||||
|
// 300.00 cash plus the 50.00 fee by card.
|
||||||
|
let f = case(&[
|
||||||
|
(card, D, "300.00"),
|
||||||
|
(bank, C, "300.00"),
|
||||||
|
(exp, D, "50.00"),
|
||||||
|
(card, C, "50.00"),
|
||||||
|
]);
|
||||||
|
assert_eq!(f.net, dec("350.00"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------- party shares
|
// ------------------------------------------------------- party shares
|
||||||
@@ -1698,6 +1732,62 @@ mod shares {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// set_entry_no_party INVARIANT 1 and SCENARIO 3: a marked transfer's
|
||||||
|
/// reversal carries the mark; marking a reversed entry marks the
|
||||||
|
/// whole chain; naming a party on any of them clears it on all.
|
||||||
|
#[test]
|
||||||
|
fn a_no_party_mark_follows_the_reversal_chain() {
|
||||||
|
let mut b = books();
|
||||||
|
let own = |b: &mut Books| {
|
||||||
|
let (due, bank) = (b.due, b.bank);
|
||||||
|
post(
|
||||||
|
&mut b.l,
|
||||||
|
&[(due, Side::Debit, "10.00"), (bank, Side::Credit, "10.00")],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let e = own(&mut b);
|
||||||
|
b.l.set_entry_no_party(e, "transfer between our own accounts")
|
||||||
|
.unwrap();
|
||||||
|
let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
b.l.entry_no_party(r),
|
||||||
|
Some("transfer between our own accounts")
|
||||||
|
);
|
||||||
|
// Marked after it was reversed: the whole chain.
|
||||||
|
let e2 = own(&mut b);
|
||||||
|
let r2 = b.l.reverse_entry(e2, date(2026, 5, 2), "undo").unwrap();
|
||||||
|
b.l.set_entry_no_party(r2, "accrual").unwrap();
|
||||||
|
assert_eq!(b.l.entry_no_party(e2), Some("accrual"));
|
||||||
|
assert_eq!(b.l.entry_no_party(r2), Some("accrual"));
|
||||||
|
// A party named on the original clears both.
|
||||||
|
b.l.set_entry_party(e2, Some(b.acme)).unwrap();
|
||||||
|
assert_eq!(b.l.entry_no_party(e2), None);
|
||||||
|
assert_eq!(b.l.entry_no_party(r2), None);
|
||||||
|
refused(&mut b.l, "chain names a party", |l| {
|
||||||
|
l.set_entry_no_party(r2, "none")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// set_entry_vendor SCENARIO 3: tagging a vendor names its party and
|
||||||
|
/// clears a no-party mark, so the entry never carries both; clearing
|
||||||
|
/// the vendor afterwards leaves no stale mark to satisfy "require".
|
||||||
|
#[test]
|
||||||
|
fn tagging_a_vendor_clears_a_no_party_mark() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = b.l.try_create_entry(date(2026, 5, 1), "hosting").unwrap();
|
||||||
|
b.l.add_line(e, b.exp, Side::Debit, usd("12.00")).unwrap();
|
||||||
|
b.l.add_line(e, b.bank, Side::Credit, usd("12.00")).unwrap();
|
||||||
|
b.l.set_entry_no_party(e, "none really").unwrap();
|
||||||
|
let v = b.l.add_vendor("Namecheap").unwrap();
|
||||||
|
let pv = b.l.party_of_vendor(v).unwrap();
|
||||||
|
b.l.set_entry_vendor(e, Some(v)).unwrap();
|
||||||
|
assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(pv)]);
|
||||||
|
assert_eq!(b.l.entry_no_party(e), None);
|
||||||
|
b.l.set_entry_vendor(e, None).unwrap();
|
||||||
|
assert!(b.l.entry_parties(e).is_empty());
|
||||||
|
assert_eq!(b.l.entry_no_party(e), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// Reversals carry every share; the pair nets to zero for each party.
|
/// Reversals carry every share; the pair nets to zero for each party.
|
||||||
#[test]
|
#[test]
|
||||||
fn reversal_carries_all_shares() {
|
fn reversal_carries_all_shares() {
|
||||||
@@ -1739,6 +1829,33 @@ mod shares {
|
|||||||
assert!(a.entries.contains(&e));
|
assert!(a.entries.contains(&e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// activity_with INVARIANT 2: a reversal of a reversal reinstates the
|
||||||
|
/// payment on its root original's side: paid, never a negative
|
||||||
|
/// received.
|
||||||
|
#[test]
|
||||||
|
fn reversing_a_reversal_reinstates_the_payment_as_paid() {
|
||||||
|
let mut b = books();
|
||||||
|
let a = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "100.00"),
|
||||||
|
(b.bank, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_party(a, Some(b.acme)).unwrap();
|
||||||
|
let r1 = b.l.reverse_entry(a, date(2026, 5, 2), "undo").unwrap();
|
||||||
|
let r2 = b.l.reverse_entry(r1, date(2026, 5, 3), "redo").unwrap();
|
||||||
|
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
|
||||||
|
assert_eq!(act.entries, std::collections::BTreeSet::from([a, r1, r2]));
|
||||||
|
assert_eq!((act.paid, act.received), (d("100.00"), Decimal::ZERO));
|
||||||
|
// A third level takes it back out again, still on the paid side.
|
||||||
|
let _r3 =
|
||||||
|
b.l.reverse_entry(r2, date(2026, 5, 4), "undo again")
|
||||||
|
.unwrap();
|
||||||
|
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
|
||||||
|
assert_eq!((act.paid, act.received), (Decimal::ZERO, Decimal::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
fn cents() -> impl Strategy<Value = Decimal> {
|
fn cents() -> impl Strategy<Value = Decimal> {
|
||||||
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
|
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-109
@@ -63,8 +63,9 @@ pub struct EntryInfo {
|
|||||||
/// One party an entry names, with its share.
|
/// One party an entry names, with its share.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// SOURCE: ledger_core::shares (resolve), with Books::cash_accounts and
|
/// SOURCE: ledger_core::shares (resolve), with Books::money_cash_accounts
|
||||||
/// Books::members, as the server's books.get_entry resolves them.
|
/// and Books::members, as the server's books.get_entry resolves
|
||||||
|
/// them.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. resolved: the party's part from ledger_core::shares::resolve, None
|
/// 1. resolved: the party's part from ledger_core::shares::resolve, None
|
||||||
/// when the entry's shares don't resolve; over an entry's parties the
|
/// when the entry's shares don't resolve; over an entry's parties the
|
||||||
@@ -124,11 +125,12 @@ impl AssetTrail {
|
|||||||
/// The party's entries: Ledger::entry_parties names it, alone or among
|
/// The party's entries: Ledger::entry_parties names it, alone or among
|
||||||
/// others (core tags hand tags, vendor tags, bills, invoices, their
|
/// others (core tags hand tags, vendor tags, bills, invoices, their
|
||||||
/// payments, reversals).
|
/// payments, reversals).
|
||||||
/// The year: the calendar year of `today`, Jan 1 through today (the
|
/// The year: the calendar year of `today`, Jan 1 through Dec 31 (the
|
||||||
/// year 1099s are counted in).
|
/// year 1099s are counted in, and books.party's year: a posted entry,
|
||||||
|
/// invoice, or bill dated later this year counts).
|
||||||
/// Money: Books::activity (ledger_core::parties::activity_with with
|
/// Money: Books::activity (ledger_core::parties::activity_with with
|
||||||
/// Books::cash_accounts and Books::members: a party's share of an
|
/// Books::money_cash_accounts and Books::members: a party's share of
|
||||||
/// entry naming several; the same numbers books.party reports).
|
/// an entry naming several; the same numbers books.party reports).
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. entries: every entry that is the party's, drafts included, in
|
/// 1. entries: every entry that is the party's, drafts included, in
|
||||||
/// Books::entries order (newest first).
|
/// Books::entries order (newest first).
|
||||||
@@ -233,17 +235,19 @@ pub struct Books {
|
|||||||
pub members: Vec<ledger_core::PartyMember>,
|
pub members: Vec<ledger_core::PartyMember>,
|
||||||
/// `[books] financing_accounts`, resolved (empty when not set).
|
/// `[books] financing_accounts`, resolved (empty when not set).
|
||||||
pub financing_accounts: Vec<AccountId>,
|
pub financing_accounts: Vec<AccountId>,
|
||||||
/// The cash accounts the member capital statement hands
|
/// The cash accounts every number the server also reports reads
|
||||||
/// ledger_core::k1 to tell cash from property: `[books]
|
/// money with: `[books] cash_accounts`, resolved; when that is empty,
|
||||||
/// cash_accounts`, resolved; when that is empty, core's fallback
|
/// core's fallback (ledger_core::parties::money_accounts: every asset
|
||||||
/// (ledger_core::parties::money_accounts: every asset account but
|
/// account but receivables), exactly as the server passes them. Party
|
||||||
/// receivables), so the viewer reads money as core does. Without the
|
/// money (Books::activity, shares, what for), the party 1099 line, and
|
||||||
/// setting every contribution therefore reads as cash: property can't
|
/// the member capital statement (ledger_core::k1, to tell cash from
|
||||||
/// be told from cash without knowing which accounts hold cash.
|
/// property) all use it, so the viewer shows books.party's,
|
||||||
/// Kept apart from `cash_accounts`, whose name-based guess the Cash
|
/// books.parties' and the K-1 export's numbers. Without the setting
|
||||||
/// page, dashboard and charts keep (changing those is out of scope:
|
/// every contribution therefore reads as cash: property can't be told
|
||||||
/// "every asset" would put equipment on the Cash page).
|
/// from cash without knowing which accounts hold cash. Kept apart from
|
||||||
pub k1_cash_accounts: Vec<AccountId>,
|
/// `cash_accounts`, whose name-based guess the Cash page, dashboard and
|
||||||
|
/// charts keep ("every asset" would put equipment on the Cash page).
|
||||||
|
pub money_cash_accounts: Vec<AccountId>,
|
||||||
/// `[books] investing_accounts`, resolved (empty when not set).
|
/// `[books] investing_accounts`, resolved (empty when not set).
|
||||||
pub investing_accounts: Vec<AccountId>,
|
pub investing_accounts: Vec<AccountId>,
|
||||||
pub card_accounts: Vec<AccountId>,
|
pub card_accounts: Vec<AccountId>,
|
||||||
@@ -292,8 +296,8 @@ pub enum PartyPeriod {
|
|||||||
/// get back?" (owner, 2026-10-15) with the books' own numbers.
|
/// get back?" (owner, 2026-10-15) with the books' own numbers.
|
||||||
/// SOURCE: ledger_core::parties::activity_with (paid, received) and
|
/// SOURCE: ledger_core::parties::activity_with (paid, received) and
|
||||||
/// ledger_core::parties::entry_flow (what for), with
|
/// ledger_core::parties::entry_flow (what for), with
|
||||||
/// Books::cash_accounts and Books::members, as PartyInfo uses
|
/// Books::money_cash_accounts and Books::members, as PartyInfo
|
||||||
/// them.
|
/// uses them.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. paid, received: Books::activity(party, span)'s, exactly.
|
/// 1. paid, received: Books::activity(party, span)'s, exactly.
|
||||||
/// 2. net() = paid − received.
|
/// 2. net() = paid − received.
|
||||||
@@ -364,11 +368,11 @@ pub struct CapitalLine {
|
|||||||
/// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward,
|
/// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward,
|
||||||
/// split into cash and property); ledger_config::Company::members
|
/// split into cash and property); ledger_config::Company::members
|
||||||
/// ([[company.members]], all of them, in order, as the CPA
|
/// ([[company.members]], all of them, in order, as the CPA
|
||||||
/// package builds its MemberCapital list); Books::k1_cash_accounts;
|
/// package builds its MemberCapital list); Books::money_cash_accounts;
|
||||||
/// Books::financing_accounts; DECISION (the due-to accounts, and
|
/// Books::financing_accounts; DECISION (the due-to accounts, and
|
||||||
/// the cash fallback, below).
|
/// the cash fallback, below).
|
||||||
/// DEFINITIONS:
|
/// DEFINITIONS:
|
||||||
/// row: k1_detail(span, members, k1_cash_accounts).rows[i], i this
|
/// row: k1_detail(span, members, money_cash_accounts).rows[i], i this
|
||||||
/// member's position in [[company.members]] (matched by capital
|
/// member's position in [[company.members]] (matched by capital
|
||||||
/// account).
|
/// account).
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
@@ -538,14 +542,13 @@ impl Books {
|
|||||||
let receipt_threshold = policy.threshold();
|
let receipt_threshold = policy.threshold();
|
||||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||||
let members = settings.company.party_members(&ledger);
|
let members = settings.company.party_members(&ledger);
|
||||||
let share_money = ledger_core::parties::money_accounts(&ledger, &cash_accounts);
|
|
||||||
let codes = |cs: &[String]| -> Vec<AccountId> {
|
let codes = |cs: &[String]| -> Vec<AccountId> {
|
||||||
cs.iter()
|
cs.iter()
|
||||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let financing_accounts = codes(&settings.financing_accounts);
|
let financing_accounts = codes(&settings.financing_accounts);
|
||||||
let k1_cash_accounts = {
|
let money_cash_accounts = {
|
||||||
let configured = codes(&settings.cash_accounts);
|
let configured = codes(&settings.cash_accounts);
|
||||||
if configured.is_empty() {
|
if configured.is_empty() {
|
||||||
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[])
|
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[])
|
||||||
@@ -558,15 +561,9 @@ impl Books {
|
|||||||
configured
|
configured
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let share_money = ledger_core::parties::money_accounts(&ledger, &money_cash_accounts);
|
||||||
let investing_accounts = codes(&settings.investing_accounts);
|
let investing_accounts = codes(&settings.investing_accounts);
|
||||||
let card_accounts: Vec<AccountId> = ledger
|
let card_accounts = ledger_store::reconcilable::card_accounts(&ledger);
|
||||||
.chart()
|
|
||||||
.all()
|
|
||||||
.filter(|a| {
|
|
||||||
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
|
||||||
})
|
|
||||||
.map(|a| a.id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Journal events per entry.
|
// Journal events per entry.
|
||||||
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
||||||
@@ -755,7 +752,7 @@ impl Books {
|
|||||||
cash_accounts,
|
cash_accounts,
|
||||||
members,
|
members,
|
||||||
financing_accounts,
|
financing_accounts,
|
||||||
k1_cash_accounts,
|
money_cash_accounts,
|
||||||
investing_accounts,
|
investing_accounts,
|
||||||
card_accounts,
|
card_accounts,
|
||||||
fresh,
|
fresh,
|
||||||
@@ -962,8 +959,7 @@ impl Books {
|
|||||||
fn party_infos(&self) -> BTreeMap<PartyId, PartyInfo> {
|
fn party_infos(&self) -> BTreeMap<PartyId, PartyInfo> {
|
||||||
let l = &self.ledger;
|
let l = &self.ledger;
|
||||||
let year = self.today.year();
|
let year = self.today.year();
|
||||||
let year_start = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid");
|
let in_year = |d: NaiveDate| d.year() == year;
|
||||||
let in_year = |d: NaiveDate| d >= year_start && d <= self.today;
|
|
||||||
let mut out: BTreeMap<PartyId, PartyInfo> = l
|
let mut out: BTreeMap<PartyId, PartyInfo> = l
|
||||||
.parties()
|
.parties()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
@@ -977,7 +973,7 @@ impl Books {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for (id, info) in out.iter_mut() {
|
for (id, info) in out.iter_mut() {
|
||||||
let a = self.activity(*id, (year_start, self.today));
|
let a = self.activity(*id, self.party_span(PartyPeriod::ThisYear));
|
||||||
info.entries = self
|
info.entries = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1281,15 +1277,15 @@ impl Books {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The accounts the books expect to reconcile monthly: the cash
|
/// The accounts the books expect to reconcile monthly: the cash
|
||||||
/// accounts, then the card accounts, each once.
|
/// accounts, then the card accounts, then any other liability with a
|
||||||
|
/// recorded statement, each once (ledger_store::reconcilable, the
|
||||||
|
/// server's digest rule).
|
||||||
pub fn reconcilable_accounts(&self) -> Vec<AccountId> {
|
pub fn reconcilable_accounts(&self) -> Vec<AccountId> {
|
||||||
let mut out = self.cash_accounts.clone();
|
ledger_store::reconcilable::reconcilable_accounts(
|
||||||
for a in &self.card_accounts {
|
&self.ledger,
|
||||||
if !out.contains(a) {
|
&self.cash_accounts,
|
||||||
out.push(*a);
|
&self.statements,
|
||||||
}
|
)
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When an account is due a reconciliation: the date it should be
|
/// When an account is due a reconciliation: the date it should be
|
||||||
@@ -1301,7 +1297,9 @@ impl Books {
|
|||||||
/// attachments.md, Reconciliations: "an account with activity
|
/// attachments.md, Reconciliations: "an account with activity
|
||||||
/// not reconciled through last month's end").
|
/// not reconciled through last month's end").
|
||||||
/// SOURCE: that rule; CONVENTION (a bank or card statement closes
|
/// SOURCE: that rule; CONVENTION (a bank or card statement closes
|
||||||
/// monthly; AGENTS.md's monthly close reconciles the bank).
|
/// monthly; AGENTS.md's monthly close reconciles the bank);
|
||||||
|
/// ledger_store::reconcilable (the server's digest reads the
|
||||||
|
/// same function).
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. Some(last_month_end()) iff the account has a posted line
|
/// 1. Some(last_month_end()) iff the account has a posted line
|
||||||
/// dated on or before last_month_end() (activity to reconcile)
|
/// dated on or before last_month_end() (activity to reconcile)
|
||||||
@@ -1310,28 +1308,48 @@ impl Books {
|
|||||||
/// 2. None otherwise (including activity only this month).
|
/// 2. None otherwise (including activity only this month).
|
||||||
/// ```
|
/// ```
|
||||||
pub fn reconciliation_due(&self, account: AccountId) -> Option<NaiveDate> {
|
pub fn reconciliation_due(&self, account: AccountId) -> Option<NaiveDate> {
|
||||||
let through = self.last_month_end();
|
ledger_store::reconcilable::reconciliation_due(
|
||||||
let active = self
|
&self.ledger,
|
||||||
.ledger
|
&self.reconciliations,
|
||||||
.posted_entries()
|
account,
|
||||||
.any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account));
|
self.today,
|
||||||
(active && self.reconciled_through(account).is_none_or(|d| d < through)).then_some(through)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// A party's money over a span
|
// A party's money over a span
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
/// The dates a [`PartyPeriod`] covers: a year is January 1 to
|
/// The dates a [`PartyPeriod`] covers.
|
||||||
/// December 31, cut at today; all time starts at the earliest entry
|
///
|
||||||
/// in the books (today when there is none).
|
/// ```text
|
||||||
|
/// GOAL: the viewer's totals are books.party's for the same year
|
||||||
|
/// (PartyTotalsOut: "the viewer shows the same numbers").
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. A year is January 1 to December 31, cut at the later of today
|
||||||
|
/// and its last posted entry: no posted entry of the year falls
|
||||||
|
/// outside it (so its sums are the calendar year's exactly), and
|
||||||
|
/// months after today with nothing posted aren't shown.
|
||||||
|
/// 2. All time starts at the earliest entry in the books (today when
|
||||||
|
/// there is none) and ends at the later of today and the last
|
||||||
|
/// posted entry.
|
||||||
|
/// ```
|
||||||
pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) {
|
pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) {
|
||||||
|
let last_posted = |within: Option<i32>| {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.status == EntryStatus::Posted)
|
||||||
|
.filter(|e| within.is_none_or(|y| e.date.year() == y))
|
||||||
|
.map(|e| e.date)
|
||||||
|
.max()
|
||||||
|
.map_or(self.today, |d| d.max(self.today))
|
||||||
|
};
|
||||||
let year = |y: i32| {
|
let year = |y: i32| {
|
||||||
(
|
(
|
||||||
NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"),
|
NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"),
|
||||||
NaiveDate::from_ymd_opt(y, 12, 31)
|
NaiveDate::from_ymd_opt(y, 12, 31)
|
||||||
.expect("valid")
|
.expect("valid")
|
||||||
.min(self.today),
|
.min(last_posted(Some(y))),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
match period {
|
match period {
|
||||||
@@ -1346,7 +1364,7 @@ impl Books {
|
|||||||
.min()
|
.min()
|
||||||
.unwrap_or(self.today)
|
.unwrap_or(self.today)
|
||||||
.min(self.today);
|
.min(self.today);
|
||||||
(first, self.today)
|
(first, last_posted(None))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1419,29 +1437,7 @@ impl Books {
|
|||||||
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
|
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
|
||||||
for e in &posted {
|
for e in &posted {
|
||||||
let flow = ledger_core::parties::entry_flow(e, &money);
|
let flow = ledger_core::parties::entry_flow(e, &money);
|
||||||
let share = if l.entry_parties(e.id()).len() == 1 {
|
let parts = self.party_what_for(e, party, &flow);
|
||||||
flow.net
|
|
||||||
} else {
|
|
||||||
self.party_share(e, party)
|
|
||||||
};
|
|
||||||
// A member's own capital lines are what their share was
|
|
||||||
// for, when they account for it exactly.
|
|
||||||
let own: Vec<(AccountId, Decimal)> = self
|
|
||||||
.member(party)
|
|
||||||
.map(|m| {
|
|
||||||
flow.what_for
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|(a, _)| *a == m.capital || Some(*a) == m.distributions)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let parts =
|
|
||||||
if !own.is_empty() && own.iter().map(|(_, v)| *v).sum::<Decimal>() == share {
|
|
||||||
own
|
|
||||||
} else {
|
|
||||||
share_of_what_for(&flow, share)
|
|
||||||
};
|
|
||||||
for (account, amount) in parts {
|
for (account, amount) in parts {
|
||||||
*by.entry(account).or_default() += amount;
|
*by.entry(account).or_default() += amount;
|
||||||
}
|
}
|
||||||
@@ -1457,8 +1453,9 @@ impl Books {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ledger_core::parties::activity_with over `span`, with the books'
|
/// ledger_core::parties::activity_with over `span`, with
|
||||||
/// cash accounts and members: the one source of a party's money.
|
/// Books::money_cash_accounts and the members: the one source of a
|
||||||
|
/// party's money, the server's own (books.party, books.parties).
|
||||||
pub fn activity(
|
pub fn activity(
|
||||||
&self,
|
&self,
|
||||||
party: PartyId,
|
party: PartyId,
|
||||||
@@ -1468,11 +1465,84 @@ impl Books {
|
|||||||
&self.ledger,
|
&self.ledger,
|
||||||
party,
|
party,
|
||||||
span,
|
span,
|
||||||
&self.cash_accounts,
|
&self.money_cash_accounts,
|
||||||
&self.members,
|
&self.members,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT
|
||||||
|
/// 3): its fraction of the entry's entry_flow what_for, summing to its
|
||||||
|
/// money share exactly (zero when the entry moves no money, like a
|
||||||
|
/// bill or a contribution of property).
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// The fraction: 1 for an entry with one party; else its resolved
|
||||||
|
/// share over what the shares split (the net, or the entry's total
|
||||||
|
/// debits when the net is zero).
|
||||||
|
/// A member's own capital and distribution lines are theirs in full;
|
||||||
|
/// another member's are never theirs. Every other line is scaled by
|
||||||
|
/// the fraction (to the cent, toward zero), and the first scaled line
|
||||||
|
/// takes the leftover so the parts sum to the money share.
|
||||||
|
/// ```
|
||||||
|
pub fn party_what_for(
|
||||||
|
&self,
|
||||||
|
e: &ledger_core::JournalEntry,
|
||||||
|
party: PartyId,
|
||||||
|
flow: &ledger_core::parties::EntryFlow,
|
||||||
|
) -> Vec<(AccountId, Decimal)> {
|
||||||
|
use rust_decimal::RoundingStrategy::ToZero;
|
||||||
|
let parties = self.ledger.entry_parties(e.id()).len();
|
||||||
|
if parties <= 1 {
|
||||||
|
return flow.what_for.clone();
|
||||||
|
}
|
||||||
|
let resolved = self.party_share(e, party);
|
||||||
|
let target = if flow.net.is_zero() {
|
||||||
|
e.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == Side::Debit)
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum::<Decimal>()
|
||||||
|
} else {
|
||||||
|
flow.net
|
||||||
|
};
|
||||||
|
if target.is_zero() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let fraction = resolved / target;
|
||||||
|
let money_share = if flow.net.is_zero() {
|
||||||
|
Decimal::ZERO
|
||||||
|
} else {
|
||||||
|
resolved
|
||||||
|
};
|
||||||
|
let me = self.member(party).copied();
|
||||||
|
let member_line = |a: AccountId| {
|
||||||
|
self.members
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.capital == a || m.distributions == Some(a))
|
||||||
|
};
|
||||||
|
let mut own: Vec<(AccountId, Decimal)> = Vec::new();
|
||||||
|
let mut scaled: Vec<(AccountId, Decimal)> = Vec::new();
|
||||||
|
for &(a, v) in &flow.what_for {
|
||||||
|
if me.is_some_and(|m| m.capital == a || m.distributions == Some(a)) {
|
||||||
|
own.push((a, v));
|
||||||
|
} else if !member_line(a) {
|
||||||
|
scaled.push((a, (v * fraction).round_dp_with_strategy(2, ToZero)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let have: Decimal = own.iter().chain(&scaled).map(|(_, v)| *v).sum();
|
||||||
|
let left = money_share - have;
|
||||||
|
if !left.is_zero() {
|
||||||
|
match scaled.first_mut() {
|
||||||
|
Some(first) => first.1 += left,
|
||||||
|
None => own.push((e.lines()[0].account, left)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
own.into_iter()
|
||||||
|
.chain(scaled)
|
||||||
|
.filter(|(_, v)| !v.is_zero())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// A party's resolved share of an entry's flow (its entry_flow net
|
/// A party's resolved share of an entry's flow (its entry_flow net
|
||||||
/// for a sole party; zero when the shares don't resolve or it isn't
|
/// for a sole party; zero when the shares don't resolve or it isn't
|
||||||
/// named).
|
/// named).
|
||||||
@@ -1499,7 +1569,8 @@ impl Books {
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|x| x.capital_account == m.capital)?;
|
.position(|x| x.capital_account == m.capital)?;
|
||||||
let detail =
|
let detail =
|
||||||
ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.k1_cash_accounts).ok()?;
|
ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.money_cash_accounts)
|
||||||
|
.ok()?;
|
||||||
let row = detail.rows.get(i)?;
|
let row = detail.rows.get(i)?;
|
||||||
let lines: Vec<CapitalLine> = detail
|
let lines: Vec<CapitalLine> = detail
|
||||||
.lines
|
.lines
|
||||||
@@ -1605,18 +1676,21 @@ impl Books {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A party's 1099 worksheet line for a calendar year and that year's
|
/// A party's 1099 worksheet line for a calendar year and that year's
|
||||||
/// threshold: ledger-core's vendor_1099_report_with (cash and
|
/// threshold: ledger-core's vendor_1099_report_with_members line for
|
||||||
/// financing accounts) line for its vendor record; None without a
|
/// its vendor record, with Books::money_cash_accounts, the financing
|
||||||
/// vendor record in the report, or when the year's threshold isn't
|
/// accounts and the members (the server's books.vendor_1099 and
|
||||||
/// known.
|
/// books.party amount_1099); None without a vendor record in the
|
||||||
|
/// report, or when the year's threshold isn't known.
|
||||||
pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> {
|
pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> {
|
||||||
let l = &self.ledger;
|
let l = &self.ledger;
|
||||||
let vendor = l.get_party(party)?.vendor?;
|
let vendor = l.get_party(party)?.vendor?;
|
||||||
let sources = ledger_core::PaymentAccounts {
|
let sources = ledger_core::PaymentAccounts {
|
||||||
cash: self.cash_accounts.clone(),
|
cash: self.money_cash_accounts.clone(),
|
||||||
financing: self.financing_accounts.clone(),
|
financing: self.financing_accounts.clone(),
|
||||||
};
|
};
|
||||||
let r = ledger_core::vendor_1099_report_with(l, year, None, &sources).ok()?;
|
let r =
|
||||||
|
ledger_core::vendor_1099_report_with_members(l, year, None, &sources, &self.members)
|
||||||
|
.ok()?;
|
||||||
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
|
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
|
||||||
Some((line, r.threshold))
|
Some((line, r.threshold))
|
||||||
}
|
}
|
||||||
@@ -1816,26 +1890,8 @@ fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose
|
/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose
|
||||||
/// names say they hold cash.
|
/// names say they hold cash (ledger_store::reconcilable::cash_accounts,
|
||||||
|
/// as the server's digest reads them).
|
||||||
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
|
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
|
||||||
if !settings.cash_accounts.is_empty() {
|
ledger_store::reconcilable::cash_accounts(ledger, &settings.cash_accounts)
|
||||||
return settings
|
|
||||||
.cash_accounts
|
|
||||||
.iter()
|
|
||||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
ledger
|
|
||||||
.chart()
|
|
||||||
.all()
|
|
||||||
.filter(|a| {
|
|
||||||
let n = a.name.to_lowercase();
|
|
||||||
a.account_type == AccountType::Asset
|
|
||||||
&& !a.placeholder
|
|
||||||
&& ["cash", "bank", "checking", "savings", "money market"]
|
|
||||||
.iter()
|
|
||||||
.any(|w| n.contains(w))
|
|
||||||
})
|
|
||||||
.map(|a| a.id)
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
@@ -282,6 +282,16 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
|||||||
also: account.map(Route::Account),
|
also: account.map(Route::Account),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
"statement.voided" => Some(Event {
|
||||||
|
icon: icon::PROHIBIT,
|
||||||
|
text: format!(
|
||||||
|
"Voided the statement recorded on {}: {}",
|
||||||
|
attachment_name(b, att),
|
||||||
|
s(p, "reason")
|
||||||
|
),
|
||||||
|
route: Some(Route::Document(att)),
|
||||||
|
also: None,
|
||||||
|
}),
|
||||||
"reconciliation.completed" => {
|
"reconciliation.completed" => {
|
||||||
let account = p.get("account_id").and_then(|v| v.as_u64());
|
let account = p.get("account_id").and_then(|v| v.as_u64());
|
||||||
let balance = s(p, "statement_balance")
|
let balance = s(p, "statement_balance")
|
||||||
|
|||||||
@@ -603,3 +603,49 @@ fn books_show_opens_a_reconciliation() {
|
|||||||
h.state_mut().show(ViewerTarget::Reconciliation { id: 999 });
|
h.state_mut().show(ViewerTarget::Reconciliation { id: 999 });
|
||||||
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
|
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ledger_store::reconcilable INVARIANT 1, the digest's rule: a liability
|
||||||
|
/// not named for a card (an Amex) joins the accounts to reconcile once it
|
||||||
|
/// has a recorded statement, so Needs attention asks for it as the digest
|
||||||
|
/// does (it used to be listed by the digest only).
|
||||||
|
#[test]
|
||||||
|
fn a_liability_with_a_statement_needs_reconciling_whatever_its_name() {
|
||||||
|
let mut f = fixture("amex");
|
||||||
|
let amex = f
|
||||||
|
.store
|
||||||
|
.add_account("2060", "Amex", AccountType::Liability, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let software = f.store.ledger().chart().find_by_code("6300").unwrap().id;
|
||||||
|
let e = f.store.create_entry(d(2026, 9, 3), "Amex charge").unwrap();
|
||||||
|
f.store
|
||||||
|
.add_line(e, software, Side::Debit, usd(1_000))
|
||||||
|
.unwrap();
|
||||||
|
f.store.add_line(e, amex, Side::Credit, usd(1_000)).unwrap();
|
||||||
|
f.store.post_entry(e).unwrap();
|
||||||
|
let b = load(&f.dir.0);
|
||||||
|
assert!(!b.reconcilable_accounts().contains(&amex));
|
||||||
|
let (att, _, _) = f
|
||||||
|
.store
|
||||||
|
.file_document(
|
||||||
|
"statement",
|
||||||
|
"amex-aug.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
b"amex aug",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
f.store
|
||||||
|
.record_statement(Statement {
|
||||||
|
attachment_id: att,
|
||||||
|
account_id: amex,
|
||||||
|
period_start: d(2026, 8, 1),
|
||||||
|
period_end: d(2026, 8, 31),
|
||||||
|
opening_balance: Decimal::ZERO,
|
||||||
|
closing_balance: Decimal::ZERO,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let b = load(&f.dir.0);
|
||||||
|
assert!(b.reconcilable_accounts().contains(&amex));
|
||||||
|
assert_eq!(b.reconciliation_due(amex), Some(d(2026, 9, 30)));
|
||||||
|
assert!(reconcile_items(&b).contains(&"Reconcile Amex through Sep 30, 2026".to_string()));
|
||||||
|
}
|
||||||
@@ -362,11 +362,14 @@ fn each_partys_totals_are_the_books_numbers() {
|
|||||||
assert!(out > Decimal::ZERO);
|
assert!(out > Decimal::ZERO);
|
||||||
assert_eq!(info(f.irs).paid_ytd, out);
|
assert_eq!(info(f.irs).paid_ytd, out);
|
||||||
|
|
||||||
// Pat: both runs (one by party, one by name), no tagged entries.
|
// Pat: both runs (one by party, one by name). The run by party names
|
||||||
|
// Pat on its entry (Ledger::run_payroll INVARIANT 3): its net pay,
|
||||||
|
// 1,000.00 − 100.00 − 30.00 − 62.00 − 14.50 = 793.50, paid to Pat;
|
||||||
|
// the run by name only names no one.
|
||||||
let pat = info(f.pat);
|
let pat = info(f.pat);
|
||||||
assert_eq!(pat.payroll_runs.len(), 2);
|
assert_eq!(pat.payroll_runs.len(), 2);
|
||||||
assert!(pat.entries.is_empty());
|
assert_eq!(pat.entries.len(), 1);
|
||||||
assert_eq!(pat.paid_ytd, Decimal::ZERO);
|
assert_eq!(pat.paid_ytd, cents(79_350));
|
||||||
|
|
||||||
// Every entry's party is the one whose list holds it.
|
// Every entry's party is the one whose list holds it.
|
||||||
for e in &b.entries {
|
for e in &b.entries {
|
||||||
@@ -1437,7 +1440,7 @@ fn owed_to_members_reads_the_financing_accounts_first() {
|
|||||||
|
|
||||||
/// Without [books] cash_accounts the capital statement reads money as
|
/// Without [books] cash_accounts the capital statement reads money as
|
||||||
/// core does (every asset account but receivables: Books::
|
/// core does (every asset account but receivables: Books::
|
||||||
/// k1_cash_accounts), so the laptop on 1500 reads as cash: Tobin's 2026
|
/// money_cash_accounts), so the laptop on 1500 reads as cash: Tobin's 2026
|
||||||
/// contributions are $600.00 cash, none property. The Cash page keeps its
|
/// contributions are $600.00 cash, none property. The Cash page keeps its
|
||||||
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
|
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1446,7 +1449,7 @@ fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
|||||||
write_member_toml(&m.dir.0, "");
|
write_member_toml(&m.dir.0, "");
|
||||||
let b = load_with_toml(&m.dir.0);
|
let b = load_with_toml(&m.dir.0);
|
||||||
let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id;
|
let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id;
|
||||||
assert_eq!(b.k1_cash_accounts, vec![id("1000"), id("1500")]);
|
assert_eq!(b.money_cash_accounts, vec![id("1000"), id("1500")]);
|
||||||
assert!(
|
assert!(
|
||||||
b.cash_accounts.is_empty(),
|
b.cash_accounts.is_empty(),
|
||||||
"the Cash page's guess is unchanged"
|
"the Cash page's guess is unchanged"
|
||||||
@@ -1471,3 +1474,76 @@ fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
|||||||
(cents(160_000), Decimal::ZERO)
|
(cents(160_000), Decimal::ZERO)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PartyTotalsOut's promise ("the viewer shows the same numbers"): with no
|
||||||
|
/// `[books] cash_accounts`, party money reads cash as core does (every
|
||||||
|
/// asset account but receivables, as the server passes it), and the year
|
||||||
|
/// is the calendar year, so a posted entry dated after today counts.
|
||||||
|
/// Equipment bought from Checking (an asset for an asset: no money moved,
|
||||||
|
/// under core's every-asset fallback) and software dated next month, both
|
||||||
|
/// with Apple: 99.00 paid, in the viewer and the server alike (the viewer
|
||||||
|
/// used to say 2,000.00: its name-based cash guess, cut at today).
|
||||||
|
#[test]
|
||||||
|
fn party_money_is_the_servers_without_cash_accounts_and_after_today() {
|
||||||
|
use ledger_gui::books::PartyPeriod;
|
||||||
|
let dir = TempDir::new("server-numbers");
|
||||||
|
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||||
|
let checking = s
|
||||||
|
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let equipment = s
|
||||||
|
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let capital = s
|
||||||
|
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let software = s
|
||||||
|
.add_account("6200", "Software", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let e = s.create_entry(d(2026, 1, 1), "Opening").unwrap();
|
||||||
|
s.add_line(e, checking, Side::Debit, usd(1_000_000))
|
||||||
|
.unwrap();
|
||||||
|
s.add_line(e, capital, Side::Credit, usd(1_000_000))
|
||||||
|
.unwrap();
|
||||||
|
s.post_entry(e).unwrap();
|
||||||
|
let apple_v = s.add_vendor("Apple").unwrap();
|
||||||
|
let apple = s.ledger().party_of_vendor(apple_v).unwrap();
|
||||||
|
for (date, account, c) in [
|
||||||
|
(d(2026, 5, 1), equipment, 200_000),
|
||||||
|
(d(2026, 11, 20), software, 9_900),
|
||||||
|
] {
|
||||||
|
let e = s.create_entry(date, "Apple").unwrap();
|
||||||
|
s.add_line(e, account, Side::Debit, usd(c)).unwrap();
|
||||||
|
s.add_line(e, checking, Side::Credit, usd(c)).unwrap();
|
||||||
|
s.post_entry(e).unwrap();
|
||||||
|
s.set_entry_party(e, Some(apple)).unwrap();
|
||||||
|
}
|
||||||
|
let oracle = s.ledger().clone();
|
||||||
|
drop(s);
|
||||||
|
let b = load(&dir.0);
|
||||||
|
// What books.party computes: activity_with over the calendar year
|
||||||
|
// with the (empty) cash_accounts setting.
|
||||||
|
let server = ledger_core::parties::activity_with(
|
||||||
|
&oracle,
|
||||||
|
apple,
|
||||||
|
(d(2026, 1, 1), d(2026, 12, 31)),
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(server.paid, server.received),
|
||||||
|
(cents(9_900), Decimal::ZERO)
|
||||||
|
);
|
||||||
|
let span = b.party_span(PartyPeriod::ThisYear);
|
||||||
|
assert_eq!(span, (d(2026, 1, 1), d(2026, 11, 20)));
|
||||||
|
let flow = b.party_flow(apple, span, true);
|
||||||
|
assert_eq!((flow.paid, flow.received), (server.paid, server.received));
|
||||||
|
assert_eq!(b.party(apple).unwrap().paid_ytd, cents(9_900));
|
||||||
|
let months = b.party_months(apple, span);
|
||||||
|
assert_eq!(months.len(), 11);
|
||||||
|
assert_eq!(months.iter().map(|m| m.paid).sum::<Decimal>(), cents(9_900));
|
||||||
|
assert_eq!(
|
||||||
|
b.party_span(PartyPeriod::AllTime),
|
||||||
|
(d(2026, 1, 1), d(2026, 11, 20))
|
||||||
|
);
|
||||||
|
}
|
||||||
+224
-77
@@ -54,7 +54,7 @@ use rust_decimal::Decimal;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// The agent operating contract, delivered via initialize.
|
/// The agent operating contract, delivered via initialize.
|
||||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- 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- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\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. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\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- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platfo
Line truncated
|
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- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake; books.void_statement (with a reason) a mis-recorded statement, to record it again. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\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. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\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- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory
Line truncated
|
||||||
|
|
||||||
/// Max receipt/document upload: 25 MB.
|
/// Max receipt/document upload: 25 MB.
|
||||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||||
@@ -461,13 +461,17 @@ impl BooksServer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cash and card accounts due for reconciliation: the `[books]
|
/// Cash and card accounts due for reconciliation
|
||||||
/// cash_accounts` and every liability with a recorded statement, with
|
/// (ledger_store::reconcilable, the viewer's Needs-attention rule):
|
||||||
/// posted activity through the end of last month, not reconciled
|
/// the cash accounts (`[books] cash_accounts`, else assets named for
|
||||||
/// through it.
|
/// cash), the card accounts (liabilities named "card", before their
|
||||||
|
/// first statement), and every other liability with a recorded
|
||||||
|
/// statement, with posted activity through the end of last month, not
|
||||||
|
/// reconciled through it.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: the monthly close's reconciliation step can't be forgotten.
|
/// GOAL: the monthly close's reconciliation step can't be forgotten,
|
||||||
|
/// and the digest and the viewer agree on what is due.
|
||||||
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
|
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
|
||||||
/// attention: an account with activity not reconciled through
|
/// attention: an account with activity not reconciled through
|
||||||
/// last month's end).
|
/// last month's end).
|
||||||
@@ -477,40 +481,17 @@ impl BooksServer {
|
|||||||
store: &Store,
|
store: &Store,
|
||||||
today: NaiveDate,
|
today: NaiveDate,
|
||||||
) -> Result<Vec<UnreconciledAccountOut>, String> {
|
) -> Result<Vec<UnreconciledAccountOut>, String> {
|
||||||
|
use ledger_store::reconcilable;
|
||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
let due = chrono::Datelike::with_day(&today, 1)
|
let cash = reconcilable::cash_accounts(ledger, &self.settings().roles.cash);
|
||||||
.and_then(|d| d.pred_opt())
|
let statements = store.statements(None).map_err(|e| e.to_string())?;
|
||||||
.expect("a month before today");
|
let reconciliations = store.reconciliations(None).map_err(|e| e.to_string())?;
|
||||||
let mut accounts: Vec<AccountId> = self
|
|
||||||
.settings()
|
|
||||||
.roles
|
|
||||||
.cash
|
|
||||||
.iter()
|
|
||||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
|
||||||
.collect();
|
|
||||||
for st in store.statements(None).map_err(|e| e.to_string())? {
|
|
||||||
if ledger
|
|
||||||
.chart()
|
|
||||||
.get(st.account_id)
|
|
||||||
.is_some_and(|a| a.account_type == AccountType::Liability)
|
|
||||||
{
|
|
||||||
accounts.push(st.account_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accounts.sort_unstable();
|
|
||||||
accounts.dedup();
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for id in accounts {
|
for id in reconcilable::reconcilable_accounts(ledger, &cash, &statements) {
|
||||||
let active = ledger
|
let Some(due) = reconcilable::reconciliation_due(ledger, &reconciliations, id, today)
|
||||||
.posted_entries()
|
else {
|
||||||
.any(|e| e.date() <= due && e.lines().iter().any(|l| l.account == id));
|
|
||||||
if !active {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let through = reconciled_through(store, id)?;
|
|
||||||
if through.is_some_and(|t| t >= due) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (code, name) = code_name(store, id);
|
let (code, name) = code_name(store, id);
|
||||||
out.push(UnreconciledAccountOut {
|
out.push(UnreconciledAccountOut {
|
||||||
next_step: format!(
|
next_step: format!(
|
||||||
@@ -518,7 +499,8 @@ impl BooksServer {
|
|||||||
),
|
),
|
||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
reconciled_through: through.map(|d| d.to_string()),
|
reconciled_through: reconcilable::reconciled_through(&reconciliations, id)
|
||||||
|
.map(|d| d.to_string()),
|
||||||
due_through: due.to_string(),
|
due_through: due.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1565,6 +1547,19 @@ fn party_activity(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `party_required` refusal for an engine-posted entry (payroll, a
|
||||||
|
/// remittance, a disposal) that names no counterparty under `[books]
|
||||||
|
/// parties = "require"`: `what` says what to pass.
|
||||||
|
fn party_required(what: &str) -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"code": "party_required",
|
||||||
|
"message": format!(
|
||||||
|
"these books require a counterparty on every entry ([books] parties = \"require\"): {what} (add the party with books.add_party first if it's new)"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// The chart's ids for these account codes (codes it lacks are skipped).
|
/// The chart's ids for these account codes (codes it lacks are skipped).
|
||||||
fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
|
fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
|
||||||
codes
|
codes
|
||||||
@@ -1650,6 +1645,58 @@ fn check_shares_resolve(
|
|||||||
ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ())
|
ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fix a posted entry's derived split among several parties: journal the
|
||||||
|
/// exact amounts they resolve to now, so a later change to
|
||||||
|
/// `[[company.members]]` (a new income split, a member archived) never
|
||||||
|
/// re-splits posted history.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: posted is forever, the split between its parties included.
|
||||||
|
/// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md
|
||||||
|
/// "Shares"; ledger_core::shares INVARIANT 10 (derived several:
|
||||||
|
/// the members' capital lines, else their income shares, both read
|
||||||
|
/// from the settings).
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. Only a posted entry naming two or more parties with derived shares
|
||||||
|
/// changes: its shares become Amount(|part|) for each party, in
|
||||||
|
/// order, journaled as entry.parties_set (the reversal chain too), so
|
||||||
|
/// each party's resolved part is exactly what it was.
|
||||||
|
/// 2. Nothing changes when the shares don't resolve, or a part is zero
|
||||||
|
/// (an amount must be positive): the entry keeps its derived shares.
|
||||||
|
/// ```
|
||||||
|
fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) -> Result<(), String> {
|
||||||
|
let ledger = store.ledger();
|
||||||
|
let Some(entry) = ledger.get_entry(entry_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let shares = ledger.entry_parties(entry_id);
|
||||||
|
if entry.status() != ledger_core::EntryStatus::Posted
|
||||||
|
|| shares.len() < 2
|
||||||
|
|| !shares
|
||||||
|
.iter()
|
||||||
|
.any(|s| matches!(s.share, ledger_core::Share::Derived))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (money, members) = share_context(ledger, settings);
|
||||||
|
let Ok(parts) = ledger_core::shares::resolve(entry, shares, &money, &members) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if parts.iter().any(|(_, v)| v.is_zero()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let fixed: Vec<ledger_core::PartyShare> = parts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(party, v)| ledger_core::PartyShare {
|
||||||
|
party,
|
||||||
|
share: ledger_core::Share::Amount(v.abs()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
store
|
||||||
|
.set_entry_parties(entry_id, &fixed)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// An entry's parties for output, each share resolved, and why they don't
|
/// An entry's parties for output, each share resolved, and why they don't
|
||||||
/// resolve when they don't.
|
/// resolve when they don't.
|
||||||
fn entry_parties_out(
|
fn entry_parties_out(
|
||||||
@@ -2482,6 +2529,7 @@ impl BooksServer {
|
|||||||
.to_tool_error());
|
.to_tool_error());
|
||||||
}
|
}
|
||||||
store.post_entry(p.entry_id).map_err(|e| e.to_string())?;
|
store.post_entry(p.entry_id).map_err(|e| e.to_string())?;
|
||||||
|
fix_derived_shares(&mut store, &self.settings(), p.entry_id)?;
|
||||||
let mut posted = out;
|
let mut posted = out;
|
||||||
posted.status = "posted".to_string();
|
posted.status = "posted".to_string();
|
||||||
Ok(Json(posted))
|
Ok(Json(posted))
|
||||||
@@ -2957,6 +3005,10 @@ impl BooksServer {
|
|||||||
.map(|p| p.label().to_string()),
|
.map(|p| p.label().to_string()),
|
||||||
parties,
|
parties,
|
||||||
parties_error,
|
parties_error,
|
||||||
|
no_party_reason: store
|
||||||
|
.ledger()
|
||||||
|
.entry_no_party(p.entry_id)
|
||||||
|
.map(str::to_string),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4971,7 +5023,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.dispose_asset",
|
name = "books.dispose_asset",
|
||||||
description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids."
|
description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids. Name the buyer (buyer: a party id or name): it is the entry's party, and [books] parties = \"require\" refuses a sale without one; a retirement with no proceeds and no buyer is marked as having no counterparty."
|
||||||
)]
|
)]
|
||||||
async fn dispose_asset(
|
async fn dispose_asset(
|
||||||
&self,
|
&self,
|
||||||
@@ -4985,6 +5037,11 @@ impl BooksServer {
|
|||||||
let proceeds_account = resolve_account(&store, &p.proceeds_account)?;
|
let proceeds_account = resolve_account(&store, &p.proceeds_account)?;
|
||||||
let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?;
|
let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?;
|
||||||
let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?;
|
let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?;
|
||||||
|
let buyer = p
|
||||||
|
.buyer
|
||||||
|
.as_deref()
|
||||||
|
.map(|r| resolve_party(&store, r))
|
||||||
|
.transpose()?;
|
||||||
let asset = store
|
let asset = store
|
||||||
.ledger()
|
.ledger()
|
||||||
.get_asset(asset_id)
|
.get_asset(asset_id)
|
||||||
@@ -5076,6 +5133,14 @@ impl BooksServer {
|
|||||||
for aid in &attachment_ids {
|
for aid in &attachment_ids {
|
||||||
store.check_linkable(*aid).map_err(|e| e.to_string())?;
|
store.check_linkable(*aid).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
if buyer.is_none()
|
||||||
|
&& !proceeds.amount.is_zero()
|
||||||
|
&& self.settings().parties == ledger_config::PartyPolicy::Require
|
||||||
|
{
|
||||||
|
return Err(party_required(
|
||||||
|
"pass buyer: the party the asset was sold to",
|
||||||
|
));
|
||||||
|
}
|
||||||
let entry_id = store
|
let entry_id = store
|
||||||
.dispose_asset(
|
.dispose_asset(
|
||||||
asset_id,
|
asset_id,
|
||||||
@@ -5090,6 +5155,14 @@ impl BooksServer {
|
|||||||
.link_receipt(*aid, entry_id)
|
.link_receipt(*aid, entry_id)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
match buyer {
|
||||||
|
Some(b) => store.set_entry_party(entry_id, Some(b)),
|
||||||
|
None if proceeds.amount.is_zero() => {
|
||||||
|
store.set_entry_no_party(entry_id, "asset retired with no proceeds")
|
||||||
|
}
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let mut posted = out;
|
let mut posted = out;
|
||||||
posted.status = "posted".to_string();
|
posted.status = "posted".to_string();
|
||||||
posted.entry_id = entry_id;
|
posted.entry_id = entry_id;
|
||||||
@@ -5235,7 +5308,8 @@ impl BooksServer {
|
|||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF statement_attachment is given: the account, date, and
|
/// 1. IF statement_attachment is given: the account, date, and
|
||||||
/// balance are the statement's; any given that disagree: SHOULD
|
/// balance are the statement's; any given that disagree: SHOULD
|
||||||
/// refuse.
|
/// refuse. IF its attachment is withdrawn (the statement retired)
|
||||||
|
/// or its statement voided: SHOULD refuse, preview or not.
|
||||||
/// 2. IF it isn't: account, statement_date, and statement_balance are
|
/// 2. IF it isn't: account, statement_date, and statement_balance are
|
||||||
/// required, and confirmed=true SHOULD refuse.
|
/// required, and confirmed=true SHOULD refuse.
|
||||||
/// 3. IF confirmed=true and problems is not empty: SHOULD refuse,
|
/// 3. IF confirmed=true and problems is not empty: SHOULD refuse,
|
||||||
@@ -5265,7 +5339,11 @@ impl BooksServer {
|
|||||||
.transpose()?;
|
.transpose()?;
|
||||||
let (account, date, balance) = match p.statement_attachment {
|
let (account, date, balance) = match p.statement_attachment {
|
||||||
Some(att) => {
|
Some(att) => {
|
||||||
find_attachment(&store, att)?;
|
if find_attachment(&store, att)?.withdrawn_at.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"attachment {att} is withdrawn, so its statement is retired: file the statement again and record it (books.record_statement)"
|
||||||
|
));
|
||||||
|
}
|
||||||
let st = store
|
let st = store
|
||||||
.statement(att)
|
.statement(att)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -5481,6 +5559,38 @@ impl BooksServer {
|
|||||||
Ok(Json(ReconciliationsOut { reconciliations }))
|
Ok(Json(ReconciliationsOut { reconciliations }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: correct a mis-recorded statement on the record.
|
||||||
|
/// SOURCE: Store::void_statement.
|
||||||
|
/// SCENARIOS:
|
||||||
|
/// 1. IF the attachment records no statement, a reconciliation not
|
||||||
|
/// voided rests on it, or the reason is blank: SHOULD refuse,
|
||||||
|
/// journaling nothing.
|
||||||
|
/// 2. Otherwise: journaled as `statement.voided`; books.statements no
|
||||||
|
/// longer lists it, and the same attachment can record the
|
||||||
|
/// statement again (books.record_statement).
|
||||||
|
/// ```
|
||||||
|
#[tool(
|
||||||
|
name = "books.void_statement",
|
||||||
|
description = "Void a recorded statement that was recorded wrong (a mistyped balance or period), with the reason recorded in the journal, so the same filed statement can be recorded again with books.record_statement. Refused while a reconciliation not voided rests on it (books.void_reconciliation first). Nothing is erased: the journal keeps what it said. Journaled as statement.voided. Returns the statement as it was recorded."
|
||||||
|
)]
|
||||||
|
async fn void_statement(
|
||||||
|
&self,
|
||||||
|
params: Parameters<VoidStatementParams>,
|
||||||
|
) -> Result<Json<StatementOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let st = store
|
||||||
|
.statement(p.attachment_id)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("attachment {} records no statement", p.attachment_id))?;
|
||||||
|
let out = statement_out(&store, &st)?;
|
||||||
|
store
|
||||||
|
.void_statement(p.attachment_id, &p.reason)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: undo a mistaken reconciliation on the record, to redo it.
|
/// GOAL: undo a mistaken reconciliation on the record, to redo it.
|
||||||
/// SOURCE: Store::void_reconciliation.
|
/// SOURCE: Store::void_reconciliation.
|
||||||
@@ -6356,7 +6466,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.set_entry_party",
|
name = "books.set_entry_party",
|
||||||
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved."
|
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved."
|
||||||
)]
|
)]
|
||||||
async fn set_entry_party(
|
async fn set_entry_party(
|
||||||
&self,
|
&self,
|
||||||
@@ -6418,6 +6528,7 @@ impl BooksServer {
|
|||||||
store
|
store
|
||||||
.set_entry_parties(p.entry_id, &sh)
|
.set_entry_parties(p.entry_id, &sh)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
fix_derived_shares(&mut store, &settings, p.entry_id)?;
|
||||||
}
|
}
|
||||||
(party, None, None) => {
|
(party, None, None) => {
|
||||||
store
|
store
|
||||||
@@ -6705,6 +6816,15 @@ impl BooksServer {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Result<_, String>>()?;
|
.collect::<Result<_, String>>()?;
|
||||||
|
if self.settings().parties == ledger_config::PartyPolicy::Require
|
||||||
|
&& lines
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.employee.is_none() && !l.net_pay().is_zero())
|
||||||
|
{
|
||||||
|
return Err(party_required(
|
||||||
|
"name each payroll line's employee party (employee), so the entry names who was paid",
|
||||||
|
));
|
||||||
|
}
|
||||||
let run_id = store
|
let run_id = store
|
||||||
.run_payroll(parse_date(&p.date)?, lines, cash)
|
.run_payroll(parse_date(&p.date)?, lines, cash)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -6724,7 +6844,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.remit_payroll_taxes",
|
name = "books.remit_payroll_taxes",
|
||||||
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry."
|
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it."
|
||||||
)]
|
)]
|
||||||
async fn remit_payroll_taxes(
|
async fn remit_payroll_taxes(
|
||||||
&self,
|
&self,
|
||||||
@@ -6733,6 +6853,14 @@ impl BooksServer {
|
|||||||
let p = params.0;
|
let p = params.0;
|
||||||
let mut store = self.lock()?;
|
let mut store = self.lock()?;
|
||||||
let cash = resolve_account(&store, &p.cash_account)?;
|
let cash = resolve_account(&store, &p.cash_account)?;
|
||||||
|
let agency = p
|
||||||
|
.agency
|
||||||
|
.as_deref()
|
||||||
|
.map(|r| resolve_party(&store, r))
|
||||||
|
.transpose()?;
|
||||||
|
if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require {
|
||||||
|
return Err(party_required("pass agency: the tax agency paid"));
|
||||||
|
}
|
||||||
// Total before remitting, for the response.
|
// Total before remitting, for the response.
|
||||||
let total: Decimal = store
|
let total: Decimal = store
|
||||||
.ledger()
|
.ledger()
|
||||||
@@ -6743,6 +6871,11 @@ impl BooksServer {
|
|||||||
let entry_id = store
|
let entry_id = store
|
||||||
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(a) = agency {
|
||||||
|
store
|
||||||
|
.set_entry_party(entry_id, Some(a))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
Ok(Json(RemitPayrollTaxesOut {
|
Ok(Json(RemitPayrollTaxesOut {
|
||||||
entry_id,
|
entry_id,
|
||||||
total: money(total),
|
total: money(total),
|
||||||
@@ -6937,7 +7070,7 @@ impl BooksServer {
|
|||||||
/// ```
|
/// ```
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export_cpa_package",
|
name = "books.export_cpa_package",
|
||||||
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
|
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
|
||||||
annotations(read_only_hint = true)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn export_cpa_package(
|
async fn export_cpa_package(
|
||||||
@@ -7252,8 +7385,11 @@ impl BooksServer {
|
|||||||
format!("{e}\n").into_bytes(),
|
format!("{e}\n").into_bytes(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
// The evidence: every current receipt on the year's posted entries,
|
// The evidence: every current file behind the year's posted
|
||||||
// and the company's documents, with an index tying them together.
|
// entries, filed on them or linked to them (a file linked to an
|
||||||
|
// entry counts as its receipt), one copy per entry with a row in
|
||||||
|
// the index; and the company's documents, the files behind no
|
||||||
|
// entry.
|
||||||
let mut receipt_count = 0;
|
let mut receipt_count = 0;
|
||||||
if p.include_receipts {
|
if p.include_receipts {
|
||||||
let clean = |s: &str| -> String {
|
let clean = |s: &str| -> String {
|
||||||
@@ -7272,44 +7408,55 @@ impl BooksServer {
|
|||||||
if a.withdrawn_at.is_some() {
|
if a.withdrawn_at.is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?;
|
let mut behind: Vec<u64> = a.entry_id.into_iter().collect();
|
||||||
match a.entry_id.and_then(|e| ledger.get_entry(e)) {
|
for sub in store.attachment_links(a.id).map_err(|e| e.to_string())? {
|
||||||
Some(e)
|
if let ledger_store::Subject::Entry(e) = sub
|
||||||
if e.status() == ledger_core::EntryStatus::Posted
|
&& !behind.contains(&e)
|
||||||
&& e.date() >= fy
|
|
||||||
&& e.date() <= as_of =>
|
|
||||||
{
|
{
|
||||||
let name = format!(
|
behind.push(e);
|
||||||
"receipts/{}-entry-{}-{}",
|
|
||||||
e.date(),
|
|
||||||
e.id(),
|
|
||||||
clean(&a.file_name)
|
|
||||||
);
|
|
||||||
let amount: Decimal = e
|
|
||||||
.lines()
|
|
||||||
.iter()
|
|
||||||
.filter(|l| l.side == Side::Debit)
|
|
||||||
.map(|l| l.amount.amount)
|
|
||||||
.sum();
|
|
||||||
index.push_str(&format!(
|
|
||||||
"{},{},\"{}\",{},{},{}\n",
|
|
||||||
e.id(),
|
|
||||||
e.date(),
|
|
||||||
e.description().replace('"', "\"\""),
|
|
||||||
money(amount),
|
|
||||||
name,
|
|
||||||
a.blob_sha
|
|
||||||
));
|
|
||||||
files.push((name, bytes));
|
|
||||||
receipt_count += 1;
|
|
||||||
}
|
}
|
||||||
None if a.entry_id.is_none() && a.category != "receipts" => {
|
}
|
||||||
|
behind.sort_unstable();
|
||||||
|
let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?;
|
||||||
|
if behind.is_empty() {
|
||||||
|
if a.category != "receipts" {
|
||||||
files.push((
|
files.push((
|
||||||
format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)),
|
format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)),
|
||||||
bytes,
|
bytes,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => {}
|
continue;
|
||||||
|
}
|
||||||
|
for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) {
|
||||||
|
if e.status() != ledger_core::EntryStatus::Posted
|
||||||
|
|| e.date() < fy
|
||||||
|
|| e.date() > as_of
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = format!(
|
||||||
|
"receipts/{}-entry-{}-{}",
|
||||||
|
e.date(),
|
||||||
|
e.id(),
|
||||||
|
clean(&a.file_name)
|
||||||
|
);
|
||||||
|
let amount: Decimal = e
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == Side::Debit)
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum();
|
||||||
|
index.push_str(&format!(
|
||||||
|
"{},{},\"{}\",{},{},{}\n",
|
||||||
|
e.id(),
|
||||||
|
e.date(),
|
||||||
|
e.description().replace('"', "\"\""),
|
||||||
|
money(amount),
|
||||||
|
name,
|
||||||
|
a.blob_sha
|
||||||
|
));
|
||||||
|
files.push((name, bytes.clone()));
|
||||||
|
receipt_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
files.push(("receipts/index.csv".into(), index.into_bytes()));
|
files.push(("receipts/index.csv".into(), index.into_bytes()));
|
||||||
|
|||||||
@@ -1463,6 +1463,9 @@ pub struct GetEntryOut {
|
|||||||
pub parties: Vec<EntryPartyOut>,
|
pub parties: Vec<EntryPartyOut>,
|
||||||
/// Why the shares don't resolve, when they don't.
|
/// Why the shares don't resolve, when they don't.
|
||||||
pub parties_error: Option<String>,
|
pub parties_error: Option<String>,
|
||||||
|
/// Why it has no counterparty, when it is marked so (a reversal
|
||||||
|
/// carries its original's mark).
|
||||||
|
pub no_party_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1989,6 +1992,12 @@ pub struct DisposeAssetParams {
|
|||||||
/// sale) to link to the disposal entry on posting. Required when the
|
/// sale) to link to the disposal entry on posting. Required when the
|
||||||
/// receipt policy applies.
|
/// receipt policy applies.
|
||||||
pub receipt_attachment_ids: Option<Vec<u64>>,
|
pub receipt_attachment_ids: Option<Vec<u64>>,
|
||||||
|
/// The buyer (party id or name), named on the disposal entry. Under
|
||||||
|
/// `[books] parties = "require"` a sale with proceeds needs one; a
|
||||||
|
/// retirement with no proceeds and no buyer is marked as having no
|
||||||
|
/// counterparty.
|
||||||
|
#[serde(default)]
|
||||||
|
pub buyer: Option<String>,
|
||||||
/// Confirm the disposal. Without confirmed=true returns a preview and
|
/// Confirm the disposal. Without confirmed=true returns a preview and
|
||||||
/// posts nothing.
|
/// posts nothing.
|
||||||
pub confirmed: Option<bool>,
|
pub confirmed: Option<bool>,
|
||||||
@@ -2416,6 +2425,10 @@ pub struct RemitPayrollTaxesParams {
|
|||||||
pub date: String,
|
pub date: String,
|
||||||
/// Cash account id or code the remittance comes from.
|
/// Cash account id or code the remittance comes from.
|
||||||
pub cash_account: String,
|
pub cash_account: String,
|
||||||
|
/// The tax agency paid (party id or name, e.g. the IRS), named on the
|
||||||
|
/// remittance entry. Required under `[books] parties = "require"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub agency: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of `books.remit_payroll_taxes`.
|
/// Output of `books.remit_payroll_taxes`.
|
||||||
@@ -2530,8 +2543,10 @@ pub struct ExportCpaPackageParams {
|
|||||||
/// `[books] investing_accounts`.
|
/// `[books] investing_accounts`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub investing_accounts: Vec<String>,
|
pub investing_accounts: Vec<String>,
|
||||||
/// Include every receipt for the year's entries and the company's
|
/// Include every receipt for the year's entries (every current file
|
||||||
/// documents (default true).
|
/// filed on or linked to each posted entry, one copy per entry, each
|
||||||
|
/// with a row in receipts/index.csv) and the company's documents (the
|
||||||
|
/// files behind no entry) (default true).
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub include_receipts: bool,
|
pub include_receipts: bool,
|
||||||
/// Carry every member's and 1099 payee's full TIN from the TIN vault
|
/// Carry every member's and 1099 payee's full TIN from the TIN vault
|
||||||
@@ -2986,10 +3001,12 @@ pub struct ListPartiesOut {
|
|||||||
/// liability (a card, a member paying for it, a loan; not payables or
|
/// liability (a card, a member paying for it, a loan; not payables or
|
||||||
/// payroll withholdings), with cash that pays a liability down counting
|
/// payroll withholdings), with cash that pays a liability down counting
|
||||||
/// once, as paid to that liability's creditor (a reimbursement to the
|
/// once, as paid to that liability's creditor (a reimbursement to the
|
||||||
/// members, a card payment to the issuer), and cash raised by a liability
|
/// members, a card payment to the issuer), a liability paid down by
|
||||||
/// counting as received from the lender. Positive is paid to the party,
|
/// another counting the same way (a member paying the card from their own
|
||||||
/// negative received from it; a reversal counts against its original's
|
/// funds, a loan paying it off), and cash raised by a liability counting
|
||||||
/// side. A transfer between the company's own cash accounts nets to zero.
|
/// as received from the lender. Positive is paid to the party, negative
|
||||||
|
/// received from it; a reversal counts against its root original's side
|
||||||
|
/// (reversing the reversal counts on that side again). A transfer between the company's own cash accounts nets to zero.
|
||||||
/// A bill is not paid until its payment is.
|
/// A bill is not paid until its payment is.
|
||||||
/// An entry naming several parties counts each one's share of that money
|
/// An entry naming several parties counts each one's share of that money
|
||||||
/// (its amount, its percent, or its derived share: see
|
/// (its amount, its percent, or its derived share: see
|
||||||
@@ -3271,6 +3288,15 @@ pub struct ReconciliationOut {
|
|||||||
pub voided_reason: Option<String>,
|
pub voided_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parameters for `books.void_statement`.
|
||||||
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
|
pub struct VoidStatementParams {
|
||||||
|
/// The attachment whose recorded statement to void.
|
||||||
|
pub attachment_id: u64,
|
||||||
|
/// Why (what was recorded wrong), recorded in the journal.
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.void_reconciliation`.
|
/// Parameters for `books.void_reconciliation`.
|
||||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
pub struct VoidReconciliationParams {
|
pub struct VoidReconciliationParams {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
// Tool listing: the narrow tools; chart is read-only.
|
// Tool listing: the narrow tools; chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
let tools = list["result"]["tools"].as_array().unwrap();
|
||||||
assert_eq!(tools.len(), 92, "expected 92 tools, got {}", tools.len());
|
assert_eq!(tools.len(), 93, "expected 93 tools, got {}", tools.len());
|
||||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1148,6 +1148,155 @@ fn void_reconciliation_lets_it_be_redone() {
|
|||||||
assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid));
|
assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// books.reconcile SCENARIO 1: a statement whose file was withdrawn is
|
||||||
|
/// retired; the preview and the confirmation are refused, and nothing is
|
||||||
|
/// journaled (it used to return an error after journaling
|
||||||
|
/// reconciliation.completed). books.statements no longer lists it.
|
||||||
|
#[test]
|
||||||
|
fn a_withdrawn_statement_is_retired_and_reconciles_nothing() {
|
||||||
|
let mut m = books("withdrawn-statement");
|
||||||
|
let (dep, pay) = january(&mut m);
|
||||||
|
m.clear(dep, 0);
|
||||||
|
m.clear(pay, 1);
|
||||||
|
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
|
||||||
|
m.ok(
|
||||||
|
"books.unlink_attachment",
|
||||||
|
json!({"attachment_id": st, "subject": {"kind": "account", "id": 1}, "reason": "wrong file"}),
|
||||||
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.withdraw_attachment",
|
||||||
|
json!({"attachment_id": st, "reason": "wrong file"}),
|
||||||
|
);
|
||||||
|
let why = m.refuse("books.reconcile", json!({"statement_attachment": st}));
|
||||||
|
assert!(why.contains("withdrawn"), "{why}");
|
||||||
|
m.refuse(
|
||||||
|
"books.reconcile",
|
||||||
|
json!({"statement_attachment": st, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.count("reconciliation.completed"), 0);
|
||||||
|
assert!(
|
||||||
|
m.read_only("books.reconciliations", json!({}))["reconciliations"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
m.read_only("books.statements", json!({}))["statements"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// books.void_statement SCENARIOS 1–2: a statement recorded with the wrong
|
||||||
|
/// closing balance is voided and recorded again on the same file, then
|
||||||
|
/// reconciles; voiding it while that reconciliation stands is refused.
|
||||||
|
#[test]
|
||||||
|
fn a_mis_recorded_statement_is_voided_and_recorded_again() {
|
||||||
|
let mut m = books("void-statement");
|
||||||
|
let (dep, pay) = january(&mut m);
|
||||||
|
m.clear(dep, 0);
|
||||||
|
m.clear(pay, 1);
|
||||||
|
let st = m.statement("1000", "2026-01-01", "2026-01-31", "880.00");
|
||||||
|
m.refuse(
|
||||||
|
"books.void_statement",
|
||||||
|
json!({"attachment_id": st, "reason": " "}),
|
||||||
|
);
|
||||||
|
let v = m.ok(
|
||||||
|
"books.void_statement",
|
||||||
|
json!({"attachment_id": st, "reason": "closing balance mistyped"}),
|
||||||
|
);
|
||||||
|
assert_eq!(v["closing_balance"], "880.00");
|
||||||
|
assert_eq!(m.count("statement.voided"), 1);
|
||||||
|
m.refuse(
|
||||||
|
"books.void_statement",
|
||||||
|
json!({"attachment_id": st, "reason": "again"}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
m.read_only("books.statements", json!({}))["statements"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.record_statement",
|
||||||
|
json!({"attachment_id": st, "account": "1000", "period_start": "2026-01-01",
|
||||||
|
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "800.00"}),
|
||||||
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.reconcile",
|
||||||
|
json!({"statement_attachment": st, "confirmed": true}),
|
||||||
|
);
|
||||||
|
m.refuse(
|
||||||
|
"books.void_statement",
|
||||||
|
json!({"attachment_id": st, "reason": "why"}),
|
||||||
|
);
|
||||||
|
// A line the reconciliation cleared stays cleared while it stands.
|
||||||
|
m.refuse(
|
||||||
|
"books.mark_cleared",
|
||||||
|
json!({"entry_id": pay, "line_index": 1, "cleared": false}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// books.export_cpa_package `include_receipts`: receipts/ holds every
|
||||||
|
/// current file behind each posted entry, filed on it or linked to it (a
|
||||||
|
/// linked file counts as the entry's receipt, so the receipt policy let
|
||||||
|
/// the entry post), one copy per entry with an index row; documents/ holds
|
||||||
|
/// the files behind no entry. A linked-only receipt used to be left out
|
||||||
|
/// of the package entirely.
|
||||||
|
#[test]
|
||||||
|
fn the_cpa_package_carries_receipts_linked_to_entries() {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let mut m = books_at("cpa-linked", "0.00", "");
|
||||||
|
let receipt = m.receipt(None, b"%PDF linked receipt");
|
||||||
|
let e = m.draft(
|
||||||
|
"2026-03-01",
|
||||||
|
json!({}),
|
||||||
|
&[("6999", "debit", "40.00"), ("1000", "credit", "40.00")],
|
||||||
|
);
|
||||||
|
m.link(receipt, json!({"kind": "entry", "id": e}));
|
||||||
|
// A contract linked to the same entry is evidence for it too.
|
||||||
|
m.doc(
|
||||||
|
"contract",
|
||||||
|
b"%PDF contract",
|
||||||
|
json!([{"kind": "entry", "id": e}]),
|
||||||
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.post_entry",
|
||||||
|
json!({"entry_id": e, "confirmed": true}),
|
||||||
|
);
|
||||||
|
let pkg = m.ok(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||||
|
);
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(pkg["content_base64"].as_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||||
|
let names: Vec<String> = (0..zip.len())
|
||||||
|
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
let mut index = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut zip.by_name("receipts/index.csv").unwrap(), &mut index)
|
||||||
|
.unwrap();
|
||||||
|
let rows: Vec<&str> = index.lines().skip(1).collect();
|
||||||
|
let for_e: Vec<&&str> = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.starts_with(&format!("{e},")))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(for_e.len(), 2, "the linked receipt and contract: {index}");
|
||||||
|
for r in for_e {
|
||||||
|
let file = r.split(',').nth(4).unwrap();
|
||||||
|
assert!(names.iter().any(|n| n == file), "{file} in {names:?}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!names.iter().any(|n| n.starts_with("documents/contract")),
|
||||||
|
"the contract is behind an entry, filed with it: {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------- import sources
|
// ------------------------------------------------- import sources
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1243,9 +1392,12 @@ fn unreconciled(m: &mut Mcp) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cash accounts (`[books] cash_accounts`) and card accounts with
|
/// Cash accounts (`[books] cash_accounts`), card accounts (liabilities
|
||||||
/// statements are listed until reconciled through the end of last month;
|
/// named "card", before their first statement, so the first
|
||||||
/// accounts without activity by then aren't.
|
/// reconciliation is asked for), and any other liability once it has a
|
||||||
|
/// statement are listed until reconciled through the end of last month;
|
||||||
|
/// accounts without activity by then aren't (ledger_store::reconcilable,
|
||||||
|
/// the viewer's rule too).
|
||||||
#[test]
|
#[test]
|
||||||
fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
||||||
let mut m = books("digest");
|
let mut m = books("digest");
|
||||||
@@ -1263,10 +1415,15 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
|||||||
json!({}),
|
json!({}),
|
||||||
&[("6999", "debit", "40.00"), ("2050", "credit", "40.00")],
|
&[("6999", "debit", "40.00"), ("2050", "credit", "40.00")],
|
||||||
);
|
);
|
||||||
|
let owed = m.post(
|
||||||
|
&day,
|
||||||
|
json!({}),
|
||||||
|
&[("6999", "debit", "15.00"), ("2100", "credit", "15.00")],
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unreconciled(&mut m),
|
unreconciled(&mut m),
|
||||||
["1000"],
|
["1000", "2050"],
|
||||||
"the card has no statement yet"
|
"the card is due before its first statement; 2100 isn't a card"
|
||||||
);
|
);
|
||||||
let d = m.read_only("books.digest", json!({"since": "2020-01-01"}));
|
let d = m.read_only("books.digest", json!({"since": "2020-01-01"}));
|
||||||
let row = &d["unreconciled_accounts"][0];
|
let row = &d["unreconciled_accounts"][0];
|
||||||
@@ -1278,15 +1435,21 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("books.record_statement")
|
.contains("books.record_statement")
|
||||||
);
|
);
|
||||||
// The card's statement makes it a card account to reconcile.
|
|
||||||
let card_st = m.statement("2050", &start, &day, "40.00");
|
let card_st = m.statement("2050", &start, &day, "40.00");
|
||||||
assert_eq!(unreconciled(&mut m), ["1000", "2050"]);
|
|
||||||
m.clear(card, 1);
|
m.clear(card, 1);
|
||||||
m.ok(
|
m.ok(
|
||||||
"books.reconcile",
|
"books.reconcile",
|
||||||
json!({"statement_attachment": card_st, "confirmed": true}),
|
json!({"statement_attachment": card_st, "confirmed": true}),
|
||||||
);
|
);
|
||||||
assert_eq!(unreconciled(&mut m), ["1000"]);
|
assert_eq!(unreconciled(&mut m), ["1000"]);
|
||||||
|
// A liability not named for a card joins with its first statement.
|
||||||
|
let owed_st = m.statement("2100", &start, &day, "15.00");
|
||||||
|
assert_eq!(unreconciled(&mut m), ["1000", "2100"]);
|
||||||
|
m.clear(owed, 1);
|
||||||
|
m.ok(
|
||||||
|
"books.reconcile",
|
||||||
|
json!({"statement_attachment": owed_st, "confirmed": true}),
|
||||||
|
);
|
||||||
m.clear(dep, 0);
|
m.clear(dep, 0);
|
||||||
let st = m.statement("1000", &start, &day, "100.00");
|
let st = m.statement("1000", &start, &day, "100.00");
|
||||||
m.ok(
|
m.ok(
|
||||||
|
|||||||
@@ -997,6 +997,11 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
"books.void_reconciliation",
|
"books.void_reconciliation",
|
||||||
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
|
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
|
||||||
);
|
);
|
||||||
|
// Voided, its statement can be voided too, to record it again.
|
||||||
|
m.ok(
|
||||||
|
"books.void_statement",
|
||||||
|
json!({"attachment_id": stmt, "reason": "closing balance mistyped"}),
|
||||||
|
);
|
||||||
|
|
||||||
// Periods.
|
// Periods.
|
||||||
let p = m.ok(
|
let p = m.ok(
|
||||||
|
|||||||
@@ -1383,7 +1383,8 @@ fn a_joint_reimbursement_names_both_members_and_splits_by_share() {
|
|||||||
"books.post_entry",
|
"books.post_entry",
|
||||||
json!({"entry_id": id, "confirmed": true}),
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
);
|
);
|
||||||
assert_eq!(m.count("entry.parties_set"), 1);
|
// Named derived on the draft, then fixed at the amounts it posted at.
|
||||||
|
assert_eq!(m.count("entry.parties_set"), 2);
|
||||||
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
||||||
let t = m.party(who, 2026)["totals"].clone();
|
let t = m.party(who, 2026)["totals"].clone();
|
||||||
assert_eq!(t["paid"], "50.00", "{who}'s half");
|
assert_eq!(t["paid"], "50.00", "{who}'s half");
|
||||||
@@ -1563,3 +1564,155 @@ fn under_require_several_parties_satisfy_the_policy() {
|
|||||||
);
|
);
|
||||||
m.read_only("books.verify", json!({}));
|
m.read_only("books.verify", json!({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// docs/parties-and-attachments.md "Counterparties" (require): the
|
||||||
|
/// engine's own posting paths name their counterparty or are refused
|
||||||
|
/// before anything posts. books.run_payroll names the employee party on
|
||||||
|
/// the entry (so their books.party counts the net pay); a line by name
|
||||||
|
/// only is refused. books.remit_payroll_taxes needs `agency`;
|
||||||
|
/// books.dispose_asset needs `buyer` for a sale, and marks a retirement
|
||||||
|
/// with no proceeds as having none.
|
||||||
|
#[test]
|
||||||
|
fn under_require_engine_postings_name_their_counterparty() {
|
||||||
|
let mut m = books_with("require-engine", "parties = \"require\"\n");
|
||||||
|
m.accounts(&[
|
||||||
|
("1500", "Equipment", "asset"),
|
||||||
|
("1510", "Accumulated Depreciation", "asset"),
|
||||||
|
("6500", "Depreciation", "expense"),
|
||||||
|
("7100", "Gain or Loss on Disposal", "revenue"),
|
||||||
|
]);
|
||||||
|
let pat = m.add_party("Pat Doe", &["employee"]);
|
||||||
|
let irs = m.add_party("IRS", &["tax_agency"]);
|
||||||
|
let buyer = m.add_party("Buyer Co", &[]);
|
||||||
|
let mut by_name = pat_line("Pat Doe");
|
||||||
|
by_name.as_object_mut().unwrap().remove("employee");
|
||||||
|
by_name["employee_name"] = json!("Pat Doe");
|
||||||
|
let e = m.refuse(
|
||||||
|
"books.run_payroll",
|
||||||
|
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [by_name]}),
|
||||||
|
);
|
||||||
|
assert!(e.contains("party_required"), "{e}");
|
||||||
|
let run = m.ok(
|
||||||
|
"books.run_payroll",
|
||||||
|
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("Pat Doe")]}),
|
||||||
|
);
|
||||||
|
let entry = m.read_only("books.get_entry", json!({"entry_id": run["entry_id"]}));
|
||||||
|
assert_eq!(entry["parties"][0]["party_id"], json!(pat), "{entry}");
|
||||||
|
assert_eq!(m.party("Pat Doe", 2026)["totals"]["paid"], "773.50");
|
||||||
|
let e = m.refuse(
|
||||||
|
"books.remit_payroll_taxes",
|
||||||
|
json!({"date": "2026-03-01", "cash_account": "1000"}),
|
||||||
|
);
|
||||||
|
assert!(e.contains("party_required"), "{e}");
|
||||||
|
let r = m.ok(
|
||||||
|
"books.remit_payroll_taxes",
|
||||||
|
json!({"date": "2026-03-01", "cash_account": "1000", "agency": "IRS"}),
|
||||||
|
);
|
||||||
|
let entry = m.read_only("books.get_entry", json!({"entry_id": r["entry_id"]}));
|
||||||
|
assert_eq!(entry["parties"][0]["party_id"], json!(irs), "{entry}");
|
||||||
|
// Assets: one sold, one retired.
|
||||||
|
let reg = |m: &mut Mcp, tag: &str| {
|
||||||
|
m.ok(
|
||||||
|
"books.register_asset",
|
||||||
|
json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "1200.00",
|
||||||
|
"salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500",
|
||||||
|
"accum_depr_account": "1510", "expense_account": "6500", "tag": tag,
|
||||||
|
"tax_basis_rule": "cost"}),
|
||||||
|
)["asset_id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let (sold, retired) = (reg(&mut m, "LAP-1"), reg(&mut m, "LAP-2"));
|
||||||
|
let sale = json!({"asset_id": sold, "date": "2026-06-30", "proceeds": "500.00",
|
||||||
|
"proceeds_account": "1000", "gain_loss_account": "7100",
|
||||||
|
"confirmed": true});
|
||||||
|
let before = m.journal();
|
||||||
|
let e = m.refuse("books.dispose_asset", sale.clone());
|
||||||
|
assert!(e.contains("party_required"), "{e}");
|
||||||
|
assert!(m.journal() == before, "refused, nothing journaled");
|
||||||
|
let mut with_buyer = sale;
|
||||||
|
with_buyer["buyer"] = json!("Buyer Co");
|
||||||
|
let d = m.ok("books.dispose_asset", with_buyer);
|
||||||
|
let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]}));
|
||||||
|
assert_eq!(entry["parties"][0]["party_id"], json!(buyer), "{entry}");
|
||||||
|
let d = m.ok(
|
||||||
|
"books.dispose_asset",
|
||||||
|
json!({"asset_id": retired, "date": "2026-06-30", "proceeds": "0.00",
|
||||||
|
"proceeds_account": "1000", "gain_loss_account": "7100", "confirmed": true}),
|
||||||
|
);
|
||||||
|
let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]}));
|
||||||
|
assert!(entry["no_party_reason"].is_string(), "{entry}");
|
||||||
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fix_derived_shares INVARIANT 1 (docs/parties-and-attachments.md
|
||||||
|
/// "Shares": posted is forever, the split included): a joint
|
||||||
|
/// reimbursement posted with derived shares at 50/50 keeps 50.00 each when
|
||||||
|
/// the members' shares later become 60/40, and when a member is archived;
|
||||||
|
/// a draft still follows the settings until it posts.
|
||||||
|
#[test]
|
||||||
|
fn a_posted_derived_split_survives_settings_changes_and_archiving() {
|
||||||
|
let mut m = member_books("shares-fixed", "");
|
||||||
|
let posted = m.post(
|
||||||
|
"2026-03-01",
|
||||||
|
both(),
|
||||||
|
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||||
|
);
|
||||||
|
let e = m.read_only("books.get_entry", json!({"entry_id": posted}));
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&e),
|
||||||
|
[
|
||||||
|
("Tobin Harlow".to_string(), "50.00".to_string()),
|
||||||
|
("Morgan Ellis".to_string(), "50.00".to_string())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(e["parties"][0]["share"], "amount", "fixed when posted: {e}");
|
||||||
|
assert_eq!(e["parties"][0]["amount"], "50.00", "{e}");
|
||||||
|
let draft = m.draft(
|
||||||
|
"2026-03-02",
|
||||||
|
both(),
|
||||||
|
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||||
|
);
|
||||||
|
// The members' split changes to 60/40.
|
||||||
|
let toml = m.data.parent().unwrap().join("wanderbooks.toml");
|
||||||
|
let text = std::fs::read_to_string(&toml).unwrap();
|
||||||
|
let changed = text
|
||||||
|
.replacen("share = \"50%\"", "share = \"60%\"", 1)
|
||||||
|
.replacen("share = \"50%\"", "share = \"40%\"", 1);
|
||||||
|
std::fs::write(&toml, changed).unwrap();
|
||||||
|
// The server reloads it on change: wait until the draft reads 60/40.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||||
|
let d = loop {
|
||||||
|
let d = m.ok("books.post_entry", json!({"entry_id": draft}));
|
||||||
|
if resolved(&d)[0].1 == "60.00" || std::time::Instant::now() > deadline {
|
||||||
|
break d;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&d),
|
||||||
|
[
|
||||||
|
("Tobin Harlow".to_string(), "60.00".to_string()),
|
||||||
|
("Morgan Ellis".to_string(), "40.00".to_string())
|
||||||
|
],
|
||||||
|
"a draft follows the settings"
|
||||||
|
);
|
||||||
|
let e = m.read_only("books.get_entry", json!({"entry_id": posted}));
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&e),
|
||||||
|
[
|
||||||
|
("Tobin Harlow".to_string(), "50.00".to_string()),
|
||||||
|
("Morgan Ellis".to_string(), "50.00".to_string())
|
||||||
|
],
|
||||||
|
"posted history keeps its split"
|
||||||
|
);
|
||||||
|
// Morgan archived: her history stays, and Tobin's.
|
||||||
|
let mo = e["parties"][1]["party_id"].as_u64().unwrap();
|
||||||
|
m.ok(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": mo.to_string(), "archived": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.party("Tobin Harlow", 2026)["totals"]["paid"], "50.00");
|
||||||
|
assert_eq!(m.party(&mo.to_string(), 2026)["totals"]["paid"], "50.00");
|
||||||
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
pub mod blob;
|
pub mod blob;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
|
pub mod reconcilable;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//! Which accounts the monthly close reconciles, and when one is due: one
|
||||||
|
//! rule, read by the server's `books.digest` (`unreconciled_accounts`) and
|
||||||
|
//! the viewer's Needs attention and account pages alike.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GOAL: the monthly close's reconciliation step can't be forgotten, and
|
||||||
|
//! the digest and the viewer never disagree on what is due.
|
||||||
|
//! SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
|
||||||
|
//! attention: an account with activity not reconciled through last
|
||||||
|
//! month's end); CONVENTION (bank and card statements close
|
||||||
|
//! monthly); AGENTS.md's monthly close.
|
||||||
|
//! DEFINITIONS:
|
||||||
|
//! cash accounts: `[books] cash_accounts`, resolved; when none are set,
|
||||||
|
//! the asset accounts (not placeholders) whose names say they hold
|
||||||
|
//! cash ("cash", "bank", "checking", "savings", "money market").
|
||||||
|
//! card accounts: liability accounts whose names contain "card".
|
||||||
|
//! INVARIANTS:
|
||||||
|
//! 1. reconcilable_accounts: the cash accounts, then the card accounts,
|
||||||
|
//! then every other liability with a recorded statement, each once.
|
||||||
|
//! A card is expected before its first statement, so its first
|
||||||
|
//! reconciliation is asked for; a liability named otherwise (an Amex)
|
||||||
|
//! joins once its first statement is recorded.
|
||||||
|
//! 2. reconciliation_due(account) = Some(through), through the last day
|
||||||
|
//! of the month before today's, iff the account has a posted line
|
||||||
|
//! dated on or before it and no reconciliation not voided reaches it;
|
||||||
|
//! None otherwise (including activity only this month).
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::{Reconciliation, Statement};
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use ledger_core::{AccountId, AccountType, Ledger};
|
||||||
|
|
||||||
|
/// The words an asset account's name uses to say it holds cash.
|
||||||
|
pub const CASH_WORDS: [&str; 5] = ["cash", "bank", "checking", "savings", "money market"];
|
||||||
|
|
||||||
|
/// The cash accounts: `configured` (account codes) resolved, else the
|
||||||
|
/// asset accounts whose names say they hold cash.
|
||||||
|
pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
|
||||||
|
if !configured.is_empty() {
|
||||||
|
return configured
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
ledger
|
||||||
|
.chart()
|
||||||
|
.all()
|
||||||
|
.filter(|a| {
|
||||||
|
let n = a.name.to_lowercase();
|
||||||
|
a.account_type == AccountType::Asset
|
||||||
|
&& !a.placeholder
|
||||||
|
&& CASH_WORDS.iter().any(|w| n.contains(w))
|
||||||
|
})
|
||||||
|
.map(|a| a.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card accounts: liabilities whose names contain "card".
|
||||||
|
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
|
||||||
|
ledger
|
||||||
|
.chart()
|
||||||
|
.all()
|
||||||
|
.filter(|a| {
|
||||||
|
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
||||||
|
})
|
||||||
|
.map(|a| a.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accounts the books reconcile monthly (INVARIANT 1).
|
||||||
|
pub fn reconcilable_accounts(
|
||||||
|
ledger: &Ledger,
|
||||||
|
cash: &[AccountId],
|
||||||
|
statements: &[Statement],
|
||||||
|
) -> Vec<AccountId> {
|
||||||
|
let mut out: Vec<AccountId> = Vec::new();
|
||||||
|
let with_statement = statements.iter().map(|s| s.account_id).filter(|&a| {
|
||||||
|
ledger
|
||||||
|
.chart()
|
||||||
|
.get(a)
|
||||||
|
.is_some_and(|x| x.account_type == AccountType::Liability)
|
||||||
|
});
|
||||||
|
for a in cash
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.chain(card_accounts(ledger))
|
||||||
|
.chain(with_statement)
|
||||||
|
{
|
||||||
|
if !out.contains(&a) {
|
||||||
|
out.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last day of the month before `today`'s.
|
||||||
|
pub fn last_month_end(today: NaiveDate) -> NaiveDate {
|
||||||
|
today
|
||||||
|
.with_day(1)
|
||||||
|
.and_then(|d| d.pred_opt())
|
||||||
|
.expect("a month before today")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest statement date `account` is reconciled through: its newest
|
||||||
|
/// reconciliation not voided.
|
||||||
|
pub fn reconciled_through(
|
||||||
|
reconciliations: &[Reconciliation],
|
||||||
|
account: AccountId,
|
||||||
|
) -> Option<NaiveDate> {
|
||||||
|
reconciliations
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.account_id == account && r.voided.is_none())
|
||||||
|
.map(|r| r.statement_date)
|
||||||
|
.max()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `account` is due a reconciliation (INVARIANT 2).
|
||||||
|
pub fn reconciliation_due(
|
||||||
|
ledger: &Ledger,
|
||||||
|
reconciliations: &[Reconciliation],
|
||||||
|
account: AccountId,
|
||||||
|
today: NaiveDate,
|
||||||
|
) -> Option<NaiveDate> {
|
||||||
|
let through = last_month_end(today);
|
||||||
|
let active = ledger
|
||||||
|
.posted_entries()
|
||||||
|
.any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account));
|
||||||
|
(active && reconciled_through(reconciliations, account).is_none_or(|d| d < through))
|
||||||
|
.then_some(through)
|
||||||
|
}
|
||||||
@@ -1473,12 +1473,39 @@ impl Store {
|
|||||||
|
|
||||||
/// Mark a posted entry's line cleared (or uncleared) for bank
|
/// Mark a posted entry's line cleared (or uncleared) for bank
|
||||||
/// reconciliation. Journaled as `line.cleared`.
|
/// reconciliation. Journaled as `line.cleared`.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: a recorded reconciliation stays proof: the lines it cleared
|
||||||
|
/// stay cleared while it stands.
|
||||||
|
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (never
|
||||||
|
/// edited; void to redo).
|
||||||
|
/// SCENARIOS:
|
||||||
|
/// 1. IF the line is one a reconciliation not voided recorded as
|
||||||
|
/// cleared and `cleared` is false: SHOULD refuse
|
||||||
|
/// (ReconciliationRefused, naming it: void it first), journaling
|
||||||
|
/// nothing.
|
||||||
|
/// 2. Clearing a line dated on or before a reconciliation (an
|
||||||
|
/// outstanding check that clears next month) is allowed: the
|
||||||
|
/// later statement shows it.
|
||||||
|
/// 3. Otherwise as Ledger::mark_cleared.
|
||||||
|
/// ```
|
||||||
pub fn mark_cleared(
|
pub fn mark_cleared(
|
||||||
&mut self,
|
&mut self,
|
||||||
entry_id: EntryId,
|
entry_id: EntryId,
|
||||||
line_index: usize,
|
line_index: usize,
|
||||||
cleared: bool,
|
cleared: bool,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
|
if !cleared
|
||||||
|
&& let Some(r) = self
|
||||||
|
.reconciliations(None)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|r| r.voided.is_none() && r.cleared.contains(&(entry_id, line_index)))
|
||||||
|
{
|
||||||
|
return Err(StoreError::ReconciliationRefused(format!(
|
||||||
|
"entry {entry_id} line {line_index} is cleared by reconciliation {} (account {} through {}): void that reconciliation (with a reason) to un-clear it",
|
||||||
|
r.id, r.account_id, r.statement_date
|
||||||
|
)));
|
||||||
|
}
|
||||||
let mut next = self.ledger.clone();
|
let mut next = self.ledger.clone();
|
||||||
next.mark_cleared(entry_id, line_index, cleared)?;
|
next.mark_cleared(entry_id, line_index, cleared)?;
|
||||||
let line = self.append_journal(
|
let line = self.append_journal(
|
||||||
@@ -2477,7 +2504,12 @@ impl Store {
|
|||||||
/// 2. IF it is withdrawn: SHOULD refuse (AttachmentWithdrawn).
|
/// 2. IF it is withdrawn: SHOULD refuse (AttachmentWithdrawn).
|
||||||
/// 3. IF it is linked (to any entry): SHOULD refuse (AttachmentLinked).
|
/// 3. IF it is linked (to any entry): SHOULD refuse (AttachmentLinked).
|
||||||
/// 4. IF the entry is unknown: SHOULD refuse.
|
/// 4. IF the entry is unknown: SHOULD refuse.
|
||||||
/// 5. Otherwise: SHOULD journal `receipt.linked`.
|
/// 5. IF it is already linked to that entry (link_attachment): SHOULD
|
||||||
|
/// refuse (AlreadyLinked) BECAUSE filed on and linked to the same
|
||||||
|
/// entry is one receipt counted once, which neither unlink_receipt
|
||||||
|
/// nor unlink_attachment could then take off (link_attachment
|
||||||
|
/// SCENARIO 2, the other way round).
|
||||||
|
/// 6. Otherwise: SHOULD journal `receipt.linked`.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn link_receipt(
|
pub fn link_receipt(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -2488,6 +2520,10 @@ impl Store {
|
|||||||
.get_entry(entry_id)
|
.get_entry(entry_id)
|
||||||
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
.ok_or(LedgerError::UnknownEntry(entry_id))?;
|
||||||
self.check_linkable(attachment_id)?;
|
self.check_linkable(attachment_id)?;
|
||||||
|
let subject = Subject::Entry(entry_id);
|
||||||
|
if self.attachment_links(attachment_id)?.contains(&subject) {
|
||||||
|
return Err(StoreError::AlreadyLinked(attachment_id, subject.describe()));
|
||||||
|
}
|
||||||
let line = self.append_journal(
|
let line = self.append_journal(
|
||||||
"receipt.linked",
|
"receipt.linked",
|
||||||
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
json!({ "attachment_id": attachment_id, "entry_id": entry_id }),
|
||||||
@@ -2763,10 +2799,12 @@ impl Store {
|
|||||||
/// then `attachment.linked` to the account.
|
/// then `attachment.linked` to the account.
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF the attachment is unknown or withdrawn, already records a
|
/// 1. IF the attachment is unknown or withdrawn, already records a
|
||||||
/// statement, the account is unknown or not an asset or liability
|
/// statement (not voided), the account is unknown or not an asset
|
||||||
/// (a bank, cash, or card account), the period runs backwards, or
|
/// or liability (a bank, cash, or card account), the period runs
|
||||||
/// a balance has more than two decimal places: SHOULD refuse,
|
/// backwards, or a balance has more than two decimal places:
|
||||||
/// journaling nothing.
|
/// SHOULD refuse, journaling nothing.
|
||||||
|
/// 2. IF its statement was voided (void_statement): records it
|
||||||
|
/// afresh, the correction of a mis-recorded statement.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> {
|
pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> {
|
||||||
let (_, withdrawn) = self.attachment_state(st.attachment_id)?;
|
let (_, withdrawn) = self.attachment_state(st.attachment_id)?;
|
||||||
@@ -2818,7 +2856,57 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The statement an attachment records, if any.
|
/// Void a recorded statement, with the reason: what it says was
|
||||||
|
/// recorded wrong (a mistyped balance or period), or it shouldn't have
|
||||||
|
/// been recorded. Journaled as `statement.voided {attachment_id,
|
||||||
|
/// reason}`.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: a mis-recorded statement can be corrected on the record,
|
||||||
|
/// nothing erased (the journal keeps what it said and why it was
|
||||||
|
/// voided).
|
||||||
|
/// SOURCE: docs/parties-and-attachments.md "Reconciliations"; R-13a.
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. After it, the statement is no longer listed (statements,
|
||||||
|
/// statement), backs no reconciliation, and the same attachment
|
||||||
|
/// may record a statement again (record_statement).
|
||||||
|
/// SCENARIOS:
|
||||||
|
/// 1. IF the reason is blank: SHOULD refuse (ReasonRequired).
|
||||||
|
/// 2. IF the attachment records no statement: SHOULD refuse.
|
||||||
|
/// 3. IF a reconciliation not voided names it: SHOULD refuse
|
||||||
|
/// (ReconciliationRefused: void that reconciliation first).
|
||||||
|
/// 4. Every refusal journals nothing.
|
||||||
|
/// ```
|
||||||
|
pub fn void_statement(&mut self, attachment_id: u64, reason: &str) -> Result<(), StoreError> {
|
||||||
|
let reason = reason.trim();
|
||||||
|
if reason.is_empty() {
|
||||||
|
return Err(StoreError::ReasonRequired);
|
||||||
|
}
|
||||||
|
if self.statement(attachment_id)?.is_none() {
|
||||||
|
return Err(StoreError::InvalidStatement(format!(
|
||||||
|
"attachment {attachment_id} records no statement"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(r) = self
|
||||||
|
.reconciliations(None)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|r| r.voided.is_none() && r.attachment_id == attachment_id)
|
||||||
|
{
|
||||||
|
return Err(StoreError::ReconciliationRefused(format!(
|
||||||
|
"statement {attachment_id} backs reconciliation {}: void that reconciliation first",
|
||||||
|
r.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let line = self.append_journal(
|
||||||
|
"statement.voided",
|
||||||
|
json!({"attachment_id": attachment_id, "reason": reason}),
|
||||||
|
)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The statement an attachment records, if any (not voided, its file
|
||||||
|
/// not withdrawn).
|
||||||
pub fn statement(&self, attachment_id: u64) -> Result<Option<Statement>, StoreError> {
|
pub fn statement(&self, attachment_id: u64) -> Result<Option<Statement>, StoreError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.statements(None)?
|
.statements(None)?
|
||||||
@@ -2826,7 +2914,9 @@ impl Store {
|
|||||||
.find(|s| s.attachment_id == attachment_id))
|
.find(|s| s.attachment_id == attachment_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recorded statements, optionally one account's, by period end.
|
/// Recorded statements, optionally one account's, by period end: those
|
||||||
|
/// not voided whose file isn't withdrawn (a withdrawn file retires its
|
||||||
|
/// statement).
|
||||||
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
|
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
|
||||||
query_statements(&self.conn, account)
|
query_statements(&self.conn, account)
|
||||||
}
|
}
|
||||||
@@ -2851,16 +2941,23 @@ impl Store {
|
|||||||
/// 4. Journaled as `reconciliation.completed`; never edited (void and
|
/// 4. Journaled as `reconciliation.completed`; never edited (void and
|
||||||
/// redo).
|
/// redo).
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF the attachment records no statement: SHOULD refuse.
|
/// 1. IF the attachment is withdrawn: SHOULD refuse
|
||||||
|
/// (AttachmentWithdrawn). IF it records no statement (or only a
|
||||||
|
/// voided one): SHOULD refuse.
|
||||||
/// 2. IF the books differ from the statement: SHOULD refuse with
|
/// 2. IF the books differ from the statement: SHOULD refuse with
|
||||||
/// NotReconciled naming the difference.
|
/// NotReconciled naming the difference.
|
||||||
/// 3. IF a reconciliation not voided is dated on or after it: SHOULD
|
/// 3. IF a reconciliation not voided is dated on or after it: SHOULD
|
||||||
/// refuse.
|
/// refuse.
|
||||||
|
/// 4. Every refusal comes before anything is journaled.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn complete_reconciliation(
|
pub fn complete_reconciliation(
|
||||||
&mut self,
|
&mut self,
|
||||||
statement_attachment: u64,
|
statement_attachment: u64,
|
||||||
) -> Result<Reconciliation, StoreError> {
|
) -> Result<Reconciliation, StoreError> {
|
||||||
|
let (_, withdrawn) = self.attachment_state(statement_attachment)?;
|
||||||
|
if withdrawn {
|
||||||
|
return Err(StoreError::AttachmentWithdrawn(statement_attachment));
|
||||||
|
}
|
||||||
let st = self.statement(statement_attachment)?.ok_or_else(|| {
|
let st = self.statement(statement_attachment)?.ok_or_else(|| {
|
||||||
StoreError::InvalidStatement(format!(
|
StoreError::InvalidStatement(format!(
|
||||||
"attachment {statement_attachment} records no statement (record_statement first)"
|
"attachment {statement_attachment} records no statement (record_statement first)"
|
||||||
@@ -4029,6 +4126,7 @@ pub(crate) fn replay_line_into_ledger(
|
|||||||
"attachment.linked"
|
"attachment.linked"
|
||||||
| "attachment.unlinked"
|
| "attachment.unlinked"
|
||||||
| "statement.recorded"
|
| "statement.recorded"
|
||||||
|
| "statement.voided"
|
||||||
| "reconciliation.completed"
|
| "reconciliation.completed"
|
||||||
| "reconciliation.voided" => {
|
| "reconciliation.voided" => {
|
||||||
// Evidence, not accounting state: the projection records them.
|
// Evidence, not accounting state: the projection records them.
|
||||||
@@ -4668,6 +4766,14 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
"statement.voided" => {
|
||||||
|
// The journal keeps what it said; the projection lists only
|
||||||
|
// statements that stand.
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM statements WHERE attachment_id = ?",
|
||||||
|
params![get_u64(p, "attachment_id")? as i64],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
"reconciliation.completed" => {
|
"reconciliation.completed" => {
|
||||||
let id = get_u64(p, "id")? as i64;
|
let id = get_u64(p, "id")? as i64;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -5326,13 +5432,14 @@ pub struct Reconciliation {
|
|||||||
|
|
||||||
/// Recorded statements in the projection, optionally one account's, by
|
/// Recorded statements in the projection, optionally one account's, by
|
||||||
/// period end (shared by [`Store::statements`] and the read-only
|
/// period end (shared by [`Store::statements`] and the read-only
|
||||||
/// snapshot).
|
/// snapshot): those not voided, leaving out any whose attachment is
|
||||||
|
/// withdrawn.
|
||||||
pub(crate) fn query_statements(
|
pub(crate) fn query_statements(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
account: Option<AccountId>,
|
account: Option<AccountId>,
|
||||||
) -> Result<Vec<Statement>, StoreError> {
|
) -> Result<Vec<Statement>, StoreError> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id",
|
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements WHERE attachment_id NOT IN (SELECT id FROM attachments WHERE withdrawn_at IS NOT NULL) ORDER BY period_end, attachment_id",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
|
|||||||
@@ -1273,3 +1273,151 @@ fn everything_survives_replay_and_verifies() {
|
|||||||
assert_eq!(view(&s, &subjects), before, "full replay");
|
assert_eq!(view(&s, &subjects), before, "full replay");
|
||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------- withdrawn and voided statements
|
||||||
|
|
||||||
|
/// Oracle: complete_reconciliation SCENARIOS 1 and 4 (a withdrawn
|
||||||
|
/// statement file: AttachmentWithdrawn, before anything is journaled);
|
||||||
|
/// statements ("a withdrawn file retires its statement"). The statement's
|
||||||
|
/// account link is taken off and the file withdrawn; confirming then must
|
||||||
|
/// record no reconciliation, and the statement is no longer listed.
|
||||||
|
#[test]
|
||||||
|
fn a_withdrawn_statement_records_no_reconciliation() {
|
||||||
|
let dir = test_dir("withdrawn-statement");
|
||||||
|
let (mut s, fx) = fixture(&dir);
|
||||||
|
let a = jan_cash(&mut s, &fx, "jan.pdf");
|
||||||
|
clear_jan(&mut s, &fx);
|
||||||
|
s.unlink_attachment(a, Subject::Account(fx.cash), "filed wrong")
|
||||||
|
.unwrap();
|
||||||
|
s.withdraw_attachment(a, "filed wrong").unwrap();
|
||||||
|
let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| s.complete_reconciliation(a));
|
||||||
|
assert!(
|
||||||
|
matches!(e, StoreError::AttachmentWithdrawn(x) if x == a),
|
||||||
|
"{e:?}"
|
||||||
|
);
|
||||||
|
assert!(s.reconciliations(None).unwrap().is_empty());
|
||||||
|
assert_eq!(s.statement(a).unwrap(), None);
|
||||||
|
assert!(s.statements(Some(fx.cash)).unwrap().is_empty());
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: void_statement SCENARIOS 1–4 and INVARIANT 1; record_statement
|
||||||
|
/// SCENARIO 2 (a voided statement is recorded afresh on the same file);
|
||||||
|
/// the docs' `statement.voided {attachment_id, reason}`. A January
|
||||||
|
/// statement mistyped at 830.00 is voided and recorded again at 380.00,
|
||||||
|
/// then reconciled; voiding it while that reconciliation stands is
|
||||||
|
/// refused. Reopening and a full replay agree.
|
||||||
|
#[test]
|
||||||
|
fn a_mis_recorded_statement_is_voided_and_recorded_again() {
|
||||||
|
let dir = test_dir("void-statement");
|
||||||
|
let (mut s, fx) = fixture(&dir);
|
||||||
|
let a = doc(&mut s, "jan.pdf");
|
||||||
|
let typo = stmt(
|
||||||
|
a,
|
||||||
|
fx.cash,
|
||||||
|
date(2026, 1, 1),
|
||||||
|
date(2026, 1, 31),
|
||||||
|
"0.00",
|
||||||
|
"830.00",
|
||||||
|
);
|
||||||
|
s.record_statement(typo).unwrap();
|
||||||
|
for blank in ["", " "] {
|
||||||
|
let e = refused_nothing_journaled(&mut s, &dir, "blank", |s| s.void_statement(a, blank));
|
||||||
|
assert!(matches!(e, StoreError::ReasonRequired), "{e:?}");
|
||||||
|
}
|
||||||
|
let plain = doc(&mut s, "plain.pdf");
|
||||||
|
let e = refused_nothing_journaled(&mut s, &dir, "no statement", |s| {
|
||||||
|
s.void_statement(plain, "why")
|
||||||
|
});
|
||||||
|
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
|
||||||
|
s.void_statement(a, "closing balance mistyped").unwrap();
|
||||||
|
let (kind, payload) = journal(&dir).last().cloned().unwrap();
|
||||||
|
assert_eq!(kind, "statement.voided");
|
||||||
|
assert_eq!(
|
||||||
|
payload,
|
||||||
|
json!({"attachment_id": a, "reason": "closing balance mistyped"})
|
||||||
|
);
|
||||||
|
assert_eq!(s.statement(a).unwrap(), None);
|
||||||
|
assert!(s.statements(None).unwrap().is_empty());
|
||||||
|
let e = refused_nothing_journaled(&mut s, &dir, "voided twice", |s| {
|
||||||
|
s.void_statement(a, "again")
|
||||||
|
});
|
||||||
|
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
|
||||||
|
let right = stmt(
|
||||||
|
a,
|
||||||
|
fx.cash,
|
||||||
|
date(2026, 1, 1),
|
||||||
|
date(2026, 1, 31),
|
||||||
|
"0.00",
|
||||||
|
"380.00",
|
||||||
|
);
|
||||||
|
s.record_statement(right.clone()).unwrap();
|
||||||
|
assert_eq!(s.statements(None).unwrap(), vec![right.clone()]);
|
||||||
|
clear_jan(&mut s, &fx);
|
||||||
|
let rec = s.complete_reconciliation(a).unwrap();
|
||||||
|
let e = refused_nothing_journaled(&mut s, &dir, "backs a reconciliation", |s| {
|
||||||
|
s.void_statement(a, "why")
|
||||||
|
});
|
||||||
|
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
|
||||||
|
s.verify().unwrap();
|
||||||
|
drop(s);
|
||||||
|
delete_db(&dir);
|
||||||
|
let s = Store::open(&dir).unwrap();
|
||||||
|
assert_eq!(s.statements(None).unwrap(), vec![right]);
|
||||||
|
assert_eq!(s.reconciliations(None).unwrap(), vec![rec]);
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: mark_cleared SCENARIOS 1–3. A line January's reconciliation
|
||||||
|
/// recorded as cleared can't be un-cleared while it stands; once it is
|
||||||
|
/// voided it can. A line dated in January but cleared later (an
|
||||||
|
/// outstanding item) may still be cleared.
|
||||||
|
#[test]
|
||||||
|
fn a_reconciled_line_stays_cleared_until_the_reconciliation_is_voided() {
|
||||||
|
let dir = test_dir("reconciled-line");
|
||||||
|
let (mut s, fx) = fixture(&dir);
|
||||||
|
let a = jan_cash(&mut s, &fx, "jan.pdf");
|
||||||
|
clear_jan(&mut s, &fx);
|
||||||
|
let rec = s.complete_reconciliation(a).unwrap();
|
||||||
|
let e = refused_nothing_journaled(&mut s, &dir, "un-clear", |s| {
|
||||||
|
s.mark_cleared(fx.e2, 1, false)
|
||||||
|
});
|
||||||
|
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
|
||||||
|
assert!(s.ledger().is_cleared(fx.e2, 1));
|
||||||
|
// An outstanding January item cleared afterwards: allowed.
|
||||||
|
let late = post(
|
||||||
|
&mut s,
|
||||||
|
date(2026, 1, 30),
|
||||||
|
"Check 101",
|
||||||
|
fx.expense,
|
||||||
|
fx.cash,
|
||||||
|
"10.00",
|
||||||
|
);
|
||||||
|
s.mark_cleared(late, 1, true).unwrap();
|
||||||
|
s.mark_cleared(late, 1, false).unwrap();
|
||||||
|
s.void_reconciliation(rec.id, "redo").unwrap();
|
||||||
|
s.mark_cleared(fx.e2, 1, false).unwrap();
|
||||||
|
assert!(!s.ledger().is_cleared(fx.e2, 1));
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: link_receipt SCENARIO 5 (already linked to that entry: refuse,
|
||||||
|
/// AlreadyLinked, journaling nothing), the mirror of link_attachment
|
||||||
|
/// SCENARIO 2. A file linked to an entry can't then be filed on it too.
|
||||||
|
#[test]
|
||||||
|
fn a_file_linked_to_an_entry_cannot_also_be_filed_on_it() {
|
||||||
|
let dir = test_dir("linked-then-filed");
|
||||||
|
let (mut s, fx) = fixture(&dir);
|
||||||
|
let (a, _, _) = s
|
||||||
|
.attach_receipt(None, "r.pdf", "application/pdf", b"%PDF r", None)
|
||||||
|
.unwrap();
|
||||||
|
s.link_attachment(a, Subject::Entry(fx.e2)).unwrap();
|
||||||
|
let e = refused_nothing_journaled(&mut s, &dir, "filed on too", |s| s.link_receipt(a, fx.e2));
|
||||||
|
assert!(
|
||||||
|
matches!(e, StoreError::AlreadyLinked(x, _) if x == a),
|
||||||
|
"{e:?}"
|
||||||
|
);
|
||||||
|
// Another entry is fine: filed on one, linked to another.
|
||||||
|
s.link_receipt(a, fx.e3).unwrap();
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
+73
-28
@@ -138,8 +138,9 @@ below.
|
|||||||
preview/confirm pattern as post.
|
preview/confirm pattern as post.
|
||||||
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
|
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
|
||||||
its `vendor`, `party_id`, and `party` (the party's label, when it names
|
its `vendor`, `party_id`, and `party` (the party's label, when it names
|
||||||
exactly one) tags, and `parties` / `parties_error` as in the
|
exactly one) tags, `parties` / `parties_error` as in the `post_entry`
|
||||||
`post_entry` preview.
|
preview, and `no_party_reason` when it is marked as having no
|
||||||
|
counterparty.
|
||||||
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
||||||
→ entries with lines, newest first. `limit` caps at 100.
|
→ entries with lines, newest first. `limit` caps at 100.
|
||||||
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
||||||
@@ -165,10 +166,13 @@ below.
|
|||||||
ask the human, then update it upstream (pull request or issue at
|
ask the human, then update it upstream (pull request or issue at
|
||||||
github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash
|
github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash
|
||||||
and card accounts due for reconciliation (`{ code, name,
|
and card accounts due for reconciliation (`{ code, name,
|
||||||
reconciled_through?, due_through, next_step }`): every `[books]
|
reconciled_through?, due_through, next_step }`): the cash accounts
|
||||||
cash_accounts` account and every liability with a recorded statement
|
(`[books] cash_accounts`, else asset accounts named for cash: cash,
|
||||||
that has posted activity through the end of last month but no
|
bank, checking, savings, money market), the card accounts (liabilities
|
||||||
reconciliation (not voided) through it.
|
named "card", before their first statement), and every other liability
|
||||||
|
with a recorded statement, that have posted activity through the end of
|
||||||
|
last month but no reconciliation (not voided) through it. The viewer's
|
||||||
|
Needs attention uses the same rule (`ledger_store::reconcilable`).
|
||||||
|
|
||||||
## Tools — fixed assets
|
## Tools — fixed assets
|
||||||
|
|
||||||
@@ -197,11 +201,16 @@ so the lifetime total is exactly (cost − salvage).
|
|||||||
→ per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for
|
→ per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for
|
||||||
assets outside their useful life. Idempotent per asset and period.
|
assets outside their useful life. Idempotent per asset and period.
|
||||||
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
|
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
|
||||||
gain_loss_account, confirmed?, receipt_attachment_ids? }` → without
|
gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` →
|
||||||
|
without
|
||||||
confirmation: `{ status: "preview", ... }` and posts nothing; with
|
confirmation: `{ status: "preview", ... }` and posts nothing; with
|
||||||
`confirmed: true`: auto-posts missing final-period depreciation, then
|
`confirmed: true`: auto-posts missing final-period depreciation, then
|
||||||
posts the balanced disposal entry. Subject to receipt policy (bill of
|
posts the balanced disposal entry. Subject to receipt policy (bill of
|
||||||
sale); monthly depreciation is exempt as an allocation.
|
sale); monthly depreciation is exempt as an allocation. `buyer` (a
|
||||||
|
party) is the disposal entry's party; under `[books] parties =
|
||||||
|
"require"` a sale with proceeds without one is refused before anything
|
||||||
|
posts (`party_required`); a retirement with no proceeds and no buyer is
|
||||||
|
marked as having no counterparty.
|
||||||
- `books.export { report: "fixed_asset_register", format: json | csv,
|
- `books.export { report: "fixed_asset_register", format: json | csv,
|
||||||
as_of? }`: every asset registered by the date (tag, serial, cost,
|
as_of? }`: every asset registered by the date (tag, serial, cost,
|
||||||
depreciation by schedule, book value, disposal), totals, and the
|
depreciation by schedule, book value, disposal), totals, and the
|
||||||
@@ -447,12 +456,19 @@ include vendor and customer when it has those records.
|
|||||||
payables and payroll withholding roles (`books.roles`). A liability
|
payables and payroll withholding roles (`books.roles`). A liability
|
||||||
counts as how the company paid or was paid: a card, a member paying for
|
counts as how the company paid or was paid: a card, a member paying for
|
||||||
it, a loan.
|
it, a loan.
|
||||||
- `paid` / `received`: each entry's money lines, credits minus debits; a
|
- `paid` / `received`: each entry's money lines, credits minus debits
|
||||||
positive net was paid to the party, a negative one received from it; a
|
(`ledger_core::parties::entry_flow`), with a liability paid down
|
||||||
reversal counts against its original's side. A bill isn't paid until its
|
counting once, as paid to its creditor: by cash (a reimbursement to the
|
||||||
payment is; an entry only moving money between money accounts nets to
|
members, a card payment) or by another liability (a member paying the
|
||||||
zero. An entry naming several parties counts this party's resolved
|
card from their own funds, a loan paying it off); cash raised by a
|
||||||
share only (an entry whose shares don't resolve counts for none).
|
liability is received from the lender. A positive net was paid to the
|
||||||
|
party, a negative one received from it; a reversal counts against its
|
||||||
|
root original's side (reversing the reversal counts on that side
|
||||||
|
again). A bill isn't paid until its payment is; a transfer between the
|
||||||
|
company's own cash accounts nets to zero. An entry naming several
|
||||||
|
parties counts this party's resolved share only (an entry whose shares
|
||||||
|
don't resolve counts for none). The year is the whole calendar year;
|
||||||
|
the viewer's party page and directory show these same numbers.
|
||||||
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
|
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
|
||||||
`owed` / `receivable`: its open bills' and invoices' balances now (every
|
`owed` / `receivable`: its open bills' and invoices' balances now (every
|
||||||
year).
|
year).
|
||||||
@@ -565,7 +581,8 @@ the receipt policy counts it.
|
|||||||
exists. `link_to` as in "Tools — attachments".
|
exists. `link_to` as in "Tools — attachments".
|
||||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||||
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||||
must be unlinked first.
|
must be unlinked first; one already linked to that entry
|
||||||
|
(`books.link_attachment`) is refused, as filing it there twice.
|
||||||
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
|
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
|
||||||
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
|
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
|
||||||
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
|
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
|
||||||
@@ -782,10 +799,17 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
|||||||
Records what a filed statement says (balances as printed, in the
|
Records what a filed statement says (balances as printed, in the
|
||||||
account's normal direction, cents at most) and links it to the account.
|
account's normal direction, cents at most) and links it to the account.
|
||||||
Journaled as `statement.recorded` then `attachment.linked`. Refused for
|
Journaled as `statement.recorded` then `attachment.linked`. Refused for
|
||||||
an unknown or withdrawn attachment, one already recording a statement,
|
an unknown or withdrawn attachment, one already recording a statement
|
||||||
an account that isn't an asset or liability, or a period that runs
|
(not voided), an account that isn't an asset or liability, or a period
|
||||||
backwards.
|
that runs backwards.
|
||||||
- `books.statements { account? }` → `{ statements }`, by statement date.
|
- `books.statements { account? }` → `{ statements }`, by statement date:
|
||||||
|
those not voided whose file isn't withdrawn (withdrawing a statement's
|
||||||
|
file retires it).
|
||||||
|
- `books.void_statement { attachment_id, reason }` → the statement as it
|
||||||
|
was recorded. For a statement recorded wrong: journaled as
|
||||||
|
`statement.voided`; it is no longer listed, backs no reconciliation, and
|
||||||
|
the same attachment can record it again. Refused while a reconciliation
|
||||||
|
not voided rests on it.
|
||||||
- `books.reconcile { statement_attachment? | account, statement_date,
|
- `books.reconcile { statement_attachment? | account, statement_date,
|
||||||
statement_balance, confirmed? }` → `{ status: "preview" | "reconciled",
|
statement_balance, confirmed? }` → `{ status: "preview" | "reconciled",
|
||||||
account_code, statement_date, statement_attachment?, book_balance,
|
account_code, statement_date, statement_attachment?, book_balance,
|
||||||
@@ -796,8 +820,9 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
|||||||
account, date, and balance are the recorded statement's (any given must
|
account, date, and balance are the recorded statement's (any given must
|
||||||
agree). Without `confirmed: true` it is a preview that records nothing;
|
agree). Without `confirmed: true` it is a preview that records nothing;
|
||||||
`problems` lists why a confirmation would be refused. Confirmed, it
|
`problems` lists why a confirmation would be refused. Confirmed, it
|
||||||
needs a recorded statement, a difference of exactly zero, and a date
|
needs a recorded statement (its file not withdrawn: refused before
|
||||||
after the account's last reconciliation not voided; it then journals
|
anything is journaled), a difference of exactly zero, and a date after
|
||||||
|
the account's last reconciliation not voided; it then journals
|
||||||
`reconciliation.completed` (the cleared lines, the statement) and links
|
`reconciliation.completed` (the cleared lines, the statement) and links
|
||||||
the statement to it, returning `reconciliation`.
|
the statement to it, returning `reconciliation`.
|
||||||
- `books.reconciliations { account? }` → `{ reconciliations: [{
|
- `books.reconciliations { account? }` → `{ reconciliations: [{
|
||||||
@@ -807,6 +832,10 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
|||||||
- `books.void_reconciliation { reconciliation_id, reason }` → the
|
- `books.void_reconciliation { reconciliation_id, reason }` → the
|
||||||
reconciliation, voided. Journaled as `reconciliation.voided`; the
|
reconciliation, voided. Journaled as `reconciliation.voided`; the
|
||||||
account can be reconciled through that date again.
|
account can be reconciled through that date again.
|
||||||
|
- `books.mark_cleared` refuses to un-clear a line a reconciliation not
|
||||||
|
voided recorded as cleared: void the reconciliation first. Clearing a
|
||||||
|
line dated on or before a reconciliation (an outstanding check that
|
||||||
|
clears on a later statement) is allowed.
|
||||||
|
|
||||||
## Tools — A/R and A/P
|
## Tools — A/R and A/P
|
||||||
|
|
||||||
@@ -851,9 +880,15 @@ entries.
|
|||||||
`employee` names the employee's party (id or name; it must have the
|
`employee` names the employee's party (id or name; it must have the
|
||||||
employee role, else nothing is written); new runs should name it, and
|
employee role, else nothing is written); new runs should name it, and
|
||||||
`employee_name` then defaults to the party's name. A line needs one or
|
`employee_name` then defaults to the party's name. A line needs one or
|
||||||
the other.
|
the other. When every line with net pay names its party, the entry
|
||||||
- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`.
|
names those parties, each with its net pay, so each employee's
|
||||||
Clears every payroll payable to cash in one entry.
|
`books.party` counts it; under `[books] parties = "require"` a line
|
||||||
|
with net pay and no party is refused before anything posts
|
||||||
|
(`party_required`).
|
||||||
|
- `books.remit_payroll_taxes { date, cash_account, agency? }` → `{
|
||||||
|
entry_id, total }`. Clears every payroll payable to cash in one entry,
|
||||||
|
naming `agency` (the tax agency's party) on it; required under
|
||||||
|
`[books] parties = "require"` (`party_required`, nothing posted).
|
||||||
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
|
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
|
||||||
total_gross, entry_id }] }`.
|
total_gross, entry_id }] }`.
|
||||||
|
|
||||||
@@ -874,6 +909,12 @@ entries.
|
|||||||
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
|
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
|
||||||
investing_accounts?, include_receipts?, include_tins?, save_to? }` →
|
investing_accounts?, include_receipts?, include_tins?, save_to? }` →
|
||||||
`{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`.
|
`{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`.
|
||||||
|
**Receipts** (`include_receipts`, default true): `receipts/` holds every
|
||||||
|
current file behind each posted entry of the fiscal year to date, filed
|
||||||
|
on it or linked to it (`books.link_attachment`; a linked file counts as
|
||||||
|
the entry's receipt), one copy per entry, each with a row in
|
||||||
|
`receipts/index.csv`; `documents/<category>/` holds the files behind no
|
||||||
|
entry, receipts excepted.
|
||||||
**TINs** (`include_tins`, default true; DECISION, the owner, 2026-10-15):
|
**TINs** (`include_tins`, default true; DECISION, the owner, 2026-10-15):
|
||||||
the K-1 worksheet and the 1099 worksheet carry every member's and
|
the K-1 worksheet and the 1099 worksheet carry every member's and
|
||||||
payee's full TIN from the vault, so the zip is written to `save_to` (a
|
payee's full TIN from the vault, so the zip is written to `save_to` (a
|
||||||
@@ -892,10 +933,14 @@ entries.
|
|||||||
contributions_cash, contributions_property, net_income, distributions,
|
contributions_cash, contributions_property, net_income, distributions,
|
||||||
other, ending_capital` for the fiscal year to date: the statement of
|
other, ending_capital` for the fiscal year to date: the statement of
|
||||||
members' equity's figures (`ledger_core::k1`), contributions split into
|
members' equity's figures (`ledger_core::k1`), contributions split into
|
||||||
property (what a contribution debits to asset accounts other than cash,
|
property (what an entry crediting a member's capital debits to asset
|
||||||
receivables, and prepaid accounts: a prepaid schedule's account, or one
|
accounts other than cash and receivables: equipment, software, or a
|
||||||
named "prepaid") and cash (the rest, including a service the members
|
prepaid service contract the members owned and handed over straight to
|
||||||
paid ahead). Property in an entry crediting several members is shared
|
capital, e.g. AppleCare+ contributed with a laptop) and cash (the rest:
|
||||||
|
deposits, and what the members paid for the company, including a
|
||||||
|
service they paid a vendor ahead for, booked through Due to
|
||||||
|
Shareholders and then converted to capital; DECISION, the owner,
|
||||||
|
2026-10-15). Property in an entry crediting several members is shared
|
||||||
in proportion to their credits. An entry and its reversal both in the
|
in proportion to their credits. An entry and its reversal both in the
|
||||||
year cancel: neither is a contribution or a distribution. A member's
|
year cancel: neither is a contribution or a distribution. A member's
|
||||||
party is the party with the member's name (not a display name) and the
|
party is the party with the member's name (not a display name) and the
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ derived. Spec: `ledger_core::shares`.
|
|||||||
member gets what their own lines say (and those must account for all
|
member gets what their own lines say (and those must account for all
|
||||||
it moved); otherwise each gets their income share, rescaled to the
|
it moved); otherwise each gets their income share, rescaled to the
|
||||||
members named, rounded as percents are.
|
members named, rounded as percents are.
|
||||||
|
- **Posted is forever, the split included:** when an entry naming
|
||||||
|
several parties with derived shares posts (or a posted entry is tagged
|
||||||
|
so), the server records the exact amounts they resolve to
|
||||||
|
(`entry.parties_set` with amounts), so a later change to
|
||||||
|
`[[company.members]]` (a new income split, a member archived) never
|
||||||
|
re-splits posted history. A draft's derived shares follow the settings
|
||||||
|
until it posts. An archived member stays a member for their history
|
||||||
|
(`Company::party_members` falls back to the archived party of that
|
||||||
|
name). Entries posted with derived shares before this rule keep them
|
||||||
|
until re-tagged with `books.set_entry_party`.
|
||||||
- **Refused** (journaling nothing): a party named twice, an unknown
|
- **Refused** (journaling nothing): a party named twice, an unknown
|
||||||
party, mixed kinds of share, percents not summing to 100, an amount
|
party, mixed kinds of share, percents not summing to 100, an amount
|
||||||
finer than a cent, and two parties with vendor records. A 1099 counts
|
finer than a cent, and two parties with vendor records. A 1099 counts
|
||||||
@@ -126,6 +136,14 @@ derived. Spec: `ledger_core::shares`.
|
|||||||
every share, so a reversal subtracts each party's part; re-tagging
|
every share, so a reversal subtracts each party's part; re-tagging
|
||||||
either one (parties, shares, or vendor) re-tags the other, so the pair
|
either one (parties, shares, or vendor) re-tags the other, so the pair
|
||||||
always nets to zero.
|
always nets to zero.
|
||||||
|
A reversal counts on its root original's side, so reversing the
|
||||||
|
reversal reinstates the payment as paid (never a negative received).
|
||||||
|
- **One number everywhere:** `books.party`, `books.parties`, and the
|
||||||
|
viewer's party page and directory read cash as the server does
|
||||||
|
(`[books] cash_accounts`, else every asset account but receivables)
|
||||||
|
and count the whole calendar year, so a posted entry dated later this
|
||||||
|
year counts in both. The viewer shows the year up to the later of today
|
||||||
|
and its last posted entry.
|
||||||
- **For the agent:** a joint member transaction (a reimbursement on Due
|
- **For the agent:** a joint member transaction (a reimbursement on Due
|
||||||
to Shareholders, a contribution or distribution from joint funds)
|
to Shareholders, a contribution or distribution from joint funds)
|
||||||
names both members with no shares and lets them derive; an uneven
|
names both members with no shares and lets them derive; an uneven
|
||||||
@@ -315,17 +333,26 @@ statement_balance, cleared: [(entry_id, line)], attachment_id}`. It
|
|||||||
refuses unless the difference is exactly zero and a statement for that
|
refuses unless the difference is exactly zero and a statement for that
|
||||||
account and date is attached (the receipt rule's twin). A reconciliation
|
account and date is attached (the receipt rule's twin). A reconciliation
|
||||||
is never edited; a later one supersedes it; `reconciliation.voided
|
is never edited; a later one supersedes it; `reconciliation.voided
|
||||||
{id, reason}` exists for mistakes. Viewer: per-account reconciliation
|
{id, reason}` exists for mistakes. While it stands, the lines it
|
||||||
|
recorded as cleared can't be un-cleared (void it first), and nothing is
|
||||||
|
journaled when it is refused (a withdrawn statement file included). A
|
||||||
|
statement recorded wrong is voided (`statement.voided {attachment_id,
|
||||||
|
reason}`, refused while a reconciliation rests on it) and recorded again
|
||||||
|
on the same file; withdrawing a statement's file retires its statement. Viewer: per-account reconciliation
|
||||||
history with its statement; Needs-attention: an account with activity not
|
history with its statement; Needs-attention: an account with activity not
|
||||||
reconciled through last month's end.
|
reconciled through last month's end.
|
||||||
|
|
||||||
Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"):
|
Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"):
|
||||||
`books.record_statement`, `books.statements`, `books.reconcile` (preview
|
`books.record_statement`, `books.statements`, `books.reconcile` (preview
|
||||||
by default; `statement_attachment` + `confirmed: true` records it),
|
by default; `statement_attachment` + `confirmed: true` records it),
|
||||||
`books.reconciliations`, `books.void_reconciliation`; `books.show
|
`books.reconciliations`, `books.void_reconciliation`,
|
||||||
reconciliation`; `books.digest` `unreconciled_accounts` (the `[books]
|
`books.void_statement`; `books.show
|
||||||
cash_accounts` and liabilities with statements, not reconciled through
|
reconciliation`; `books.digest` `unreconciled_accounts` and the
|
||||||
last month's end). The statement of a reconciliation not voided can't be
|
viewer's Needs attention, one rule (`ledger_store::reconcilable`): the
|
||||||
|
cash accounts (`[books] cash_accounts`, else assets named for cash), the
|
||||||
|
card accounts (liabilities named "card", before their first statement),
|
||||||
|
and other liabilities with statements, not reconciled through last
|
||||||
|
month's end). The statement of a reconciliation not voided can't be
|
||||||
unlinked from it.
|
unlinked from it.
|
||||||
|
|
||||||
### Import sources
|
### Import sources
|
||||||
@@ -378,6 +405,18 @@ without forcing one style. `[books] parties = "off" | "suggest" |
|
|||||||
- `require`: posting is refused until the entry names a party or is
|
- `require`: posting is refused until the entry names a party or is
|
||||||
marked as having none (transfers, depreciation, amortization, closes
|
marked as having none (transfers, depreciation, amortization, closes
|
||||||
are exempt by kind).
|
are exempt by kind).
|
||||||
|
The engine's own posting paths name their counterparty or are refused
|
||||||
|
first, so nothing posts around the rule: `books.run_payroll` names each
|
||||||
|
line's employee party on the entry (each with their net pay) and, under
|
||||||
|
`require`, refuses a line without one; `books.remit_payroll_taxes`
|
||||||
|
takes `agency` (required under `require`); `books.dispose_asset` takes
|
||||||
|
`buyer` (required under `require` for a sale with proceeds; a
|
||||||
|
retirement with no proceeds and no buyer is marked as having none).
|
||||||
|
- **Marks follow the chain:** a reversal carries its original's parties,
|
||||||
|
shares, vendor tag, and no-party mark; marking or tagging any entry of a
|
||||||
|
reversal chain marks or tags them all. Tagging a vendor (or any party)
|
||||||
|
clears a no-party mark, so an entry never names a party and carries the
|
||||||
|
mark at once.
|
||||||
|
|
||||||
Also: memorized matches (journaled rules like description contains
|
Also: memorized matches (journaled rules like description contains
|
||||||
"OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts,
|
"OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts,
|
||||||
|
|||||||
+6
-2
@@ -238,8 +238,12 @@ closed period).
|
|||||||
both in a statement's span are neither contributions nor
|
both in a statement's span are neither contributions nor
|
||||||
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
|
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
|
||||||
the same). The K-1 worksheet's contributed property (Item L) is what
|
the same). The K-1 worksheet's contributed property (Item L) is what
|
||||||
the member owned and handed over, with its own basis (§723): never a
|
the member owned and handed over, with its own basis (§723): a prepaid
|
||||||
prepaid service they paid for the company (cash), and shared among
|
service contract credited straight to capital is property (a contract
|
||||||
|
handed over), while a prepaid service they paid a vendor for on the
|
||||||
|
company's behalf, booked through Due to Shareholders (Dr prepaid / Cr
|
||||||
|
2100, then Dr 2100 / Cr capital), is cash (DECISION, the owner,
|
||||||
|
2026-10-15); and it is shared among
|
||||||
members credited in one entry in proportion to their credits
|
members credited in one entry in proportion to their credits
|
||||||
(`k1` INVARIANTS 3, 4, 6).
|
(`k1` INVARIANTS 3, 4, 6).
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user