Parties, attachment links, statements, reconciliations (store), TIN vault crate

Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 14:43:40 -06:00
1 parent 2a188bd039
commit a299077991
54 files changed
+10961 -178

No files matched your search

+9
View File
@@ -152,6 +152,15 @@ pub enum LedgerError {
#[error("bad tax basis: {0}")]
InvalidTaxBasis(String),
/// No party has this id.
#[error("unknown party {0}")]
UnknownParty(u64),
/// A party command that breaks a rule of the directory
/// (`crate::parties`).
#[error("{0}")]
InvalidParty(String),
/// A serial number of the wrong form (see `assets::check_serial`).
#[error("bad serial number: {0}")]
InvalidSerial(String),
+69 -12
View File
@@ -3,11 +3,17 @@
//! A vendor is marked with the information return it gets (1099-NEC for
//! nonemployee compensation, 1099-MISC for rents and the like) and whether
//! its W-9 is on file. Entries are tagged with the vendor they paid
//! (`pay_bill` tags automatically). The amount paid is the cash that left
//! asset accounts on the vendor's tagged entries: credits minus debits to
//! asset accounts. A card payment (Dr expense, Cr credit card) moves no
//! asset, so it is excluded, as it should be: card payments are reported by
//! the card processor on 1099-K, not by the payer.
//! (`pay_bill` tags automatically). The amount paid is what left the
//! company's payment sources on the vendor's tagged entries: credits minus
//! debits to them ([`PaymentAccounts`]). The sources are the cash accounts
//! (bank, cash on hand) and the financing liabilities (what owners or
//! lenders paid on the company's behalf, e.g. Due to Shareholders): the
//! company is the payer either way. A card payment (Dr expense, Cr credit
//! card) leaves neither, so it is excluded, as it should be: card payments
//! are reported by the card processor on 1099-K, not by the payer. A bill
//! (Cr payables) is not a payment until it is paid, and a prepayment
//! (Dr prepaid, Cr cash) counts in full when made: §6041 reports payments
//! made in the year.
//!
//! Thresholds are set by law and change; [`default_threshold`] encodes
//! them and the report takes an override. Confirm with the CPA.
@@ -117,6 +123,57 @@ pub fn vendor_1099_report(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
) -> Result<Vendor1099Report, LedgerError> {
vendor_1099_report_with(ledger, year, threshold, &PaymentAccounts::default())
}
/// The accounts payments to vendors come out of, for
/// [`vendor_1099_report_with`]: the books' `[books] cash_accounts` and
/// `[books] financing_accounts`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PaymentAccounts {
/// Cash accounts: bank, savings, cash on hand.
pub cash: Vec<crate::AccountId>,
/// Financing liabilities: owner advances, loans (paid on the company's
/// behalf). Never a payment card.
pub financing: Vec<crate::AccountId>,
}
impl PaymentAccounts {
/// Whether `account` is a payment source. With no cash accounts given,
/// every asset account is one (books that haven't named their cash).
fn includes(&self, ledger: &Ledger, account: crate::AccountId) -> bool {
if self.cash.is_empty() {
ledger
.chart()
.get(account)
.is_some_and(|a| a.account_type == AccountType::Asset)
|| self.financing.contains(&account)
} else {
self.cash.contains(&account) || self.financing.contains(&account)
}
}
}
/// [`vendor_1099_report`], counting payments out of `sources`.
///
/// ```text
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
/// the year; card payments go on 1099-K).
/// INVARIANTS:
/// 1. A vendor's amount is credits minus debits to `sources` on its
/// tagged posted entries dated in `year`.
/// SCENARIOS:
/// 1. Paid from cash, or by a member through a financing liability:
/// counts. Charged to a card, or billed and unpaid: doesn't.
/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to
/// cash and a reversal subtract.
/// ```
pub fn vendor_1099_report_with(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
sources: &PaymentAccounts,
) -> Result<Vendor1099Report, LedgerError> {
let threshold = match threshold {
Some(t) => t,
@@ -133,12 +190,7 @@ pub fn vendor_1099_report(
let cash: Decimal = entry
.lines()
.iter()
.filter(|l| {
ledger
.chart()
.get(l.account)
.is_some_and(|a| a.account_type == AccountType::Asset)
})
.filter(|l| sources.includes(ledger, l.account))
.map(|l| match l.side {
crate::Side::Credit => l.amount.amount,
crate::Side::Debit => -l.amount.amount,
@@ -156,7 +208,12 @@ pub fn vendor_1099_report(
vendor_id: v.id,
name: v.name.clone(),
form: v.form_1099,
tin_on_file: v.tin_on_file,
// On file by the vendor's flag or its party's W-9 facts.
tin_on_file: v.tin_on_file
|| ledger
.party_of_vendor(v.id)
.and_then(|p| ledger.get_party(p))
.is_some_and(|p| p.w9.is_some()),
paid: amount,
reportable: v.form_1099.is_some() && amount >= threshold,
}
+505 -5
View File
@@ -261,6 +261,11 @@ pub struct Ledger {
/// Every asset tag ever held ([`assets::tag_key`] → the asset that
/// held it): a tag never moves to another asset.
asset_tags: std::collections::BTreeMap<String, AssetId>,
/// The party directory ([`crate::parties`]).
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
next_party_id: crate::PartyId,
/// The party each entry is with (the counterparty).
entry_parties: HashMap<EntryId, crate::PartyId>,
}
impl Ledger {
@@ -300,6 +305,9 @@ impl Ledger {
bill_payments: HashMap::new(),
roles: std::collections::BTreeMap::new(),
asset_tags: std::collections::BTreeMap::new(),
parties: std::collections::BTreeMap::new(),
next_party_id: 1,
entry_parties: HashMap::new(),
}
}
@@ -594,6 +602,9 @@ impl Ledger {
("bill_payments", self.bill_payments == other.bill_payments),
("roles", self.roles == other.roles),
("asset_tags", self.asset_tags == other.asset_tags),
("parties", self.parties == other.parties),
("next_party_id", self.next_party_id == other.next_party_id),
("entry_parties", self.entry_parties == other.entry_parties),
]
.into_iter()
.filter(|(_, same)| !same)
@@ -1359,10 +1370,13 @@ impl Ledger {
.ok_or(LedgerError::UnknownEntry(id))?
.mark_reversal_of(entry);
self.post_entry(id)?;
// A reversed payment un-pays the vendor: carry the tag.
// A reversed payment un-pays the vendor: carry the tags.
if let Some(v) = self.entry_vendor(entry) {
self.entry_vendors.insert(id, v);
}
if let Some(p) = self.entry_party(entry) {
self.entry_parties.insert(id, p);
}
Ok(id)
}
@@ -2311,6 +2325,9 @@ impl Ledger {
if name.trim().is_empty() {
return Err(LedgerError::EmptyAssetDescription);
}
// Its party takes this name, so the name obeys party rules (no
// full TIN; parties INVARIANT 1).
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
let id = self.next_customer_id;
self.next_customer_id += 1;
self.customers.insert(
@@ -2320,6 +2337,7 @@ impl Ledger {
name: name.to_string(),
},
);
self.link_record_party(name, false, id);
Ok(id)
}
@@ -2328,6 +2346,9 @@ impl Ledger {
if name.trim().is_empty() {
return Err(LedgerError::EmptyAssetDescription);
}
// Its party takes this name, so the name obeys party rules (no
// full TIN; parties INVARIANT 1).
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
let id = self.next_vendor_id;
self.next_vendor_id += 1;
self.vendors.insert(
@@ -2339,6 +2360,7 @@ impl Ledger {
tin_on_file: false,
},
);
self.link_record_party(name, true, id);
Ok(id)
}
@@ -2395,8 +2417,444 @@ impl Ledger {
return Err(LedgerError::UnknownVendor(v));
}
self.entry_vendors.insert(entry, v);
if let Some(p) = self.party_of_vendor(v) {
self.entry_parties.insert(entry, p);
}
}
None => {
// The party tag goes too only when it was the vendor's.
if let Some(v) = self.entry_vendors.remove(&entry)
&& self.entry_parties.get(&entry).copied() == self.party_of_vendor(v)
{
self.entry_parties.remove(&entry);
}
}
}
Ok(())
}
/// Tag an entry with a vendor and that vendor's party (bills, bill
/// payments): one counterparty, both tags.
fn tag_vendor(&mut self, entry: EntryId, vendor: VendorId) {
self.entry_vendors.insert(entry, vendor);
if let Some(p) = self.party_of_vendor(vendor) {
self.entry_parties.insert(entry, p);
}
}
/// Tag an entry with a customer's party (invoices, invoice payments).
fn tag_customer(&mut self, entry: EntryId, customer: CustomerId) {
if let Some(p) = self.party_of_customer(customer) {
self.entry_parties.insert(entry, p);
}
}
/// Link a new vendor or customer record to its party: the party not
/// archived with that name (ignoring case and surrounding space) that
/// has no such record yet, else a new party (named "Name (2)", "(3)",
/// ... when a live party already has the name). Deterministic, so replay
/// rebuilds the same directory from the same records.
fn link_record_party(&mut self, name: &str, vendor: bool, record: u64) {
let key = crate::parties::name_key(name);
let slot = |p: &crate::Party| if vendor { p.vendor } else { p.customer };
let found = self
.parties
.values()
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key && slot(p).is_none())
.map(|p| p.id);
let id = match found {
Some(id) => id,
None => {
let id = self.next_party_id;
self.next_party_id += 1;
// Names are unique among live parties (parties INVARIANT 3):
// a second record of the same name gets "Name (2)", and so on.
let base = name.trim();
let taken = |n: &str| {
let k = crate::parties::name_key(n);
self.parties
.values()
.any(|p| !p.archived && crate::parties::name_key(&p.name) == k)
};
let mut party_name = base.to_string();
let mut n = 2;
while taken(&party_name) {
party_name = format!("{base} ({n})");
n += 1;
}
self.parties.insert(
id,
crate::Party {
id,
name: party_name,
display_name: None,
roles: std::collections::BTreeSet::new(),
website: None,
address: None,
notes: None,
archived: false,
w9: None,
w4: None,
vendor: None,
customer: None,
},
);
id
}
};
let p = self.parties.get_mut(&id).expect("just found or made");
if vendor {
p.vendor = Some(record);
} else {
p.customer = Some(record);
}
}
/// The party a vendor record belongs to.
pub fn party_of_vendor(&self, vendor: VendorId) -> Option<crate::PartyId> {
self.parties
.values()
.find(|p| p.vendor == Some(vendor))
.map(|p| p.id)
}
/// The party a customer record belongs to.
pub fn party_of_customer(&self, customer: CustomerId) -> Option<crate::PartyId> {
self.parties
.values()
.find(|p| p.customer == Some(customer))
.map(|p| p.id)
}
/// One party.
pub fn get_party(&self, id: crate::PartyId) -> Option<&crate::Party> {
self.parties.get(&id)
}
/// Every party, in id order.
pub fn parties(&self) -> impl Iterator<Item = &crate::Party> + '_ {
self.parties.values()
}
/// The party (not archived) named `name`, ignoring case and surrounding
/// space.
pub fn party_by_name(&self, name: &str) -> Option<&crate::Party> {
let key = crate::parties::name_key(name);
self.parties
.values()
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key)
}
/// The party an entry is with, if tagged.
pub fn entry_party(&self, entry: EntryId) -> Option<crate::PartyId> {
self.entry_parties.get(&entry).copied()
}
/// The next party id the directory will assign.
pub fn next_party_id(&self) -> crate::PartyId {
self.next_party_id
}
/// Add a party.
///
/// ```text
/// GOAL: a record for anyone the company deals with.
/// SOURCE: crate::parties (INVARIANTS 1, 3); DECISION (owner,
/// 2026-10-15).
/// SCENARIOS:
/// 1. IF the name is blank, holds a full TIN, or names a party not
/// archived: SHOULD refuse, changing nothing.
/// 2. IF a role is vendor or customer: SHOULD refuse (those follow
/// their records; add_vendor / add_customer make them).
/// ```
pub fn add_party(
&mut self,
name: &str,
roles: &[crate::Role],
) -> Result<crate::PartyId, LedgerError> {
self.check_add_party(name, roles)?;
let id = self.next_party_id;
self.next_party_id += 1;
self.parties.insert(
id,
crate::Party {
id,
name: name.trim().to_string(),
display_name: None,
roles: roles.iter().copied().collect(),
website: None,
address: None,
notes: None,
archived: false,
w9: None,
w4: None,
vendor: None,
customer: None,
},
);
Ok(id)
}
/// The validation behind [`Self::add_party`].
pub fn check_add_party(&self, name: &str, roles: &[crate::Role]) -> Result<(), LedgerError> {
let bad = |why: String| Err(LedgerError::InvalidParty(why));
if name.trim().is_empty() {
return bad("a party needs a name".into());
}
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
if self.party_by_name(name).is_some() {
return bad(format!("a party named {:?} already exists", name.trim()));
}
if roles
.iter()
.any(|r| matches!(r, crate::Role::Vendor | crate::Role::Customer))
{
return bad(
"vendor and customer roles come with their records: add_vendor or add_customer"
.into(),
);
}
Ok(())
}
/// Change one of a party's facts.
///
/// ```text
/// SOURCE: crate::parties (INVARIANTS 1, 3).
/// SCENARIOS:
/// 1. IF the party is unknown, a text holds a full TIN or is too long,
/// or a new name is blank or taken: SHOULD refuse, changing
/// nothing.
/// ```
pub fn update_party(
&mut self,
id: crate::PartyId,
field: crate::PartyField,
) -> Result<(), LedgerError> {
self.check_update_party(id, &field)?;
let p = self.parties.get_mut(&id).expect("checked");
use crate::PartyField::*;
match field {
Name(n) => {
// Its records carry the same name: aging, bills, and the
// 1099 worksheet show what the party is called now.
p.name = n.trim().to_string();
let (name, vendor, customer) = (p.name.clone(), p.vendor, p.customer);
if let Some(v) = vendor.and_then(|v| self.vendors.get_mut(&v)) {
v.name = name.clone();
}
if let Some(c) = customer.and_then(|c| self.customers.get_mut(&c)) {
c.name = name;
}
}
DisplayName(v) => p.display_name = v,
Website(v) => p.website = v,
Address(v) => p.address = v,
Notes(v) => p.notes = v,
Archived(b) => p.archived = b,
}
Ok(())
}
/// The validation behind [`Self::update_party`].
pub fn check_update_party(
&self,
id: crate::PartyId,
field: &crate::PartyField,
) -> Result<(), LedgerError> {
use crate::PartyField::*;
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
let text = |f: &str, v: &Option<String>| match v {
Some(t) => crate::parties::check_text(f, t).map_err(LedgerError::InvalidParty),
None => Ok(()),
};
match field {
Name(n) => {
if n.trim().is_empty() {
return Err(LedgerError::InvalidParty("a party needs a name".into()));
}
crate::parties::check_text("name", n).map_err(LedgerError::InvalidParty)?;
if let Some(other) = self.party_by_name(n)
&& other.id != id
{
return Err(LedgerError::InvalidParty(format!(
"a party named {:?} already exists",
n.trim()
)));
}
}
DisplayName(v) => text("display_name", v)?,
Website(v) => text("website", v)?,
Address(v) => text("address", v)?,
Notes(v) => text("notes", v)?,
Archived(false) => {
if let Some(other) = self.party_by_name(&p.name)
&& other.id != id
{
return Err(LedgerError::InvalidParty(format!(
"another party is named {:?}; rename one first",
p.name
)));
}
}
Archived(true) => {}
}
Ok(())
}
/// Give a party a role, or take one away.
///
/// ```text
/// SOURCE: crate::parties (INVARIANT 2).
/// SCENARIOS:
/// 1. IF the role is vendor or customer: SHOULD refuse (they follow
/// the records).
/// 2. IF taking away a 1099 role leaves W-9 facts with no 1099 role,
/// or taking away employee leaves W-4 facts: SHOULD refuse (clear
/// the facts first).
/// ```
pub fn set_party_role(
&mut self,
id: crate::PartyId,
role: crate::Role,
on: bool,
) -> Result<(), LedgerError> {
self.check_set_party_role(id, role, on)?;
let p = self.parties.get_mut(&id).expect("checked");
if on {
p.roles.insert(role);
} else {
p.roles.remove(&role);
}
Ok(())
}
/// The validation behind [`Self::set_party_role`].
pub fn check_set_party_role(
&self,
id: crate::PartyId,
role: crate::Role,
on: bool,
) -> Result<(), LedgerError> {
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
if matches!(role, crate::Role::Vendor | crate::Role::Customer) {
return Err(LedgerError::InvalidParty(
"vendor and customer roles follow their records".into(),
));
}
if !on {
let mut left = p.roles.clone();
left.remove(&role);
if p.w9.is_some() && !left.iter().any(|r| r.gets_1099()) {
return Err(LedgerError::InvalidParty(
"clear the W-9 facts before removing the last 1099 role".into(),
));
}
if p.w4.is_some() && role == crate::Role::Employee {
return Err(LedgerError::InvalidParty(
"clear the W-4 facts before removing the employee role".into(),
));
}
}
Ok(())
}
/// Record (or clear) a party's W-9 facts.
///
/// ```text
/// SOURCE: crate::parties (INVARIANTS 1, 2).
/// SCENARIOS:
/// 1. IF the party has no 1099 role (contractor, landlord), or the
/// facts' form is wrong (W9Facts::check): SHOULD refuse.
/// ```
pub fn set_party_w9(
&mut self,
id: crate::PartyId,
facts: Option<crate::W9Facts>,
) -> Result<(), LedgerError> {
self.check_set_party_w9(id, facts.as_ref())?;
self.parties.get_mut(&id).expect("checked").w9 = facts;
Ok(())
}
/// The validation behind [`Self::set_party_w9`].
pub fn check_set_party_w9(
&self,
id: crate::PartyId,
facts: Option<&crate::W9Facts>,
) -> Result<(), LedgerError> {
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
if let Some(f) = facts {
if !p.roles.iter().any(|r| r.gets_1099()) {
return Err(LedgerError::InvalidParty(
"W-9 facts need a 1099 role (contractor or landlord)".into(),
));
}
f.check().map_err(LedgerError::InvalidParty)?;
}
Ok(())
}
/// Record (or clear) a party's W-4 facts.
pub fn set_party_w4(
&mut self,
id: crate::PartyId,
facts: Option<crate::W4Facts>,
) -> Result<(), LedgerError> {
self.check_set_party_w4(id, facts.as_ref())?;
self.parties.get_mut(&id).expect("checked").w4 = facts;
Ok(())
}
/// The validation behind [`Self::set_party_w4`].
pub fn check_set_party_w4(
&self,
id: crate::PartyId,
facts: Option<&crate::W4Facts>,
) -> Result<(), LedgerError> {
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
if let Some(f) = facts {
if !p.roles.contains(&crate::Role::Employee) {
return Err(LedgerError::InvalidParty(
"W-4 facts need the employee role".into(),
));
}
f.check().map_err(LedgerError::InvalidParty)?;
}
Ok(())
}
/// Tag an entry with the party it was with, or clear the tag. A party
/// with a vendor record also tags the entry's vendor (for 1099s), as
/// [`Self::set_entry_vendor`] does the other way round. Metadata: no
/// amount changes, so posted entries may be tagged.
pub fn set_entry_party(
&mut self,
entry: EntryId,
party: Option<crate::PartyId>,
) -> Result<(), LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
match party {
Some(p) => {
let vendor = self
.parties
.get(&p)
.ok_or(LedgerError::UnknownParty(p))?
.vendor;
self.entry_parties.insert(entry, p);
match vendor {
Some(v) => {
self.entry_vendors.insert(entry, v);
}
None => {
self.entry_vendors.remove(&entry);
}
}
}
None => {
self.entry_parties.remove(&entry);
self.entry_vendors.remove(&entry);
}
}
@@ -2562,6 +3020,7 @@ impl Ledger {
entry_id: entry,
},
);
self.tag_customer(entry, customer_id);
Ok(id)
}
@@ -2633,7 +3092,7 @@ impl Ledger {
entry_id: entry,
},
);
self.entry_vendors.insert(entry, vendor_id);
self.tag_vendor(entry, vendor_id);
Ok(id)
}
@@ -2647,6 +3106,30 @@ impl Ledger {
self.bills.get(&id)
}
/// Every invoice, by id.
pub fn invoices(&self) -> Vec<&Invoice> {
let mut v: Vec<&Invoice> = self.invoices.values().collect();
v.sort_by_key(|i| i.id);
v
}
/// Every bill, by id.
pub fn bills(&self) -> Vec<&Bill> {
let mut v: Vec<&Bill> = self.bills.values().collect();
v.sort_by_key(|b| b.id);
v
}
/// An invoice's payments: `(payment entry, amount)`, in order received.
pub fn invoice_payments(&self, id: InvoiceId) -> &[(EntryId, Decimal)] {
self.invoice_payments.get(&id).map_or(&[], Vec::as_slice)
}
/// A bill's payments: `(payment entry, amount)`, in order paid.
pub fn bill_payments(&self, id: BillId) -> &[(EntryId, Decimal)] {
self.bill_payments.get(&id).map_or(&[], Vec::as_slice)
}
/// Record a customer payment against an invoice. Posts: Dr Cash,
/// Cr A/R. Updates the invoice's paid amount and status.
///
@@ -2714,10 +3197,12 @@ impl Ledger {
let inv = self.invoices.get_mut(&invoice_id).expect("invoice exists");
inv.amount_paid += amount;
inv.status = arap::derive_status(inv.amount_paid, inv.total());
let customer = inv.customer_id;
self.invoice_payments
.entry(invoice_id)
.or_default()
.push((entry, amount));
self.tag_customer(entry, customer);
Ok(entry)
}
@@ -2792,7 +3277,7 @@ impl Ledger {
.entry(bill_id)
.or_default()
.push((entry, amount));
self.entry_vendors.insert(entry, bill.vendor_id);
self.tag_vendor(entry, bill.vendor_id);
Ok(entry)
}
@@ -2828,6 +3313,7 @@ impl Ledger {
entry_id,
},
);
self.tag_customer(entry_id, customer_id);
Ok(())
}
@@ -2860,7 +3346,7 @@ impl Ledger {
entry_id,
},
);
self.entry_vendors.insert(entry_id, vendor_id);
self.tag_vendor(entry_id, vendor_id);
Ok(())
}
@@ -2886,6 +3372,8 @@ impl Ledger {
.or_default()
.push((entry_id, amount));
inv.status = arap::derive_status(inv.amount_paid, inv.total());
let customer = inv.customer_id;
self.tag_customer(entry_id, customer);
Ok(())
}
@@ -2909,7 +3397,7 @@ impl Ledger {
.or_default()
.push((entry_id, amount));
let vendor = bill.vendor_id;
self.entry_vendors.insert(entry_id, vendor);
self.tag_vendor(entry_id, vendor);
Ok(())
}
@@ -3205,6 +3693,18 @@ impl Ledger {
cash_account: AccountId,
) -> Result<(), LedgerError> {
validate_payroll_lines(lines)?;
// A line that names its employee names a party with that role.
for line in lines {
if let Some(id) = line.employee {
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
if !p.roles.contains(&crate::Role::Employee) {
return Err(LedgerError::InvalidParty(format!(
"{} isn't an employee (give the party the employee role first)",
p.label()
)));
}
}
}
self.check_controls(&PAYROLL)?;
self.chart
.get(cash_account)
+6 -1
View File
@@ -27,6 +27,7 @@ pub mod journal;
pub mod kernel;
pub mod ledger;
pub mod money;
pub mod parties;
pub mod payouts;
pub mod payroll;
pub mod periods;
@@ -49,13 +50,17 @@ pub use assets::{
};
pub use classes::{Class, ClassId};
pub use error::LedgerError;
pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
pub use form1099::{
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
vendor_1099_report_with,
};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use ledger::{
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
};
pub use money::{Currency, Money};
pub use parties::{Party, PartyField, PartyId, Role, W4Facts, W9Facts};
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{
+409
View File
@@ -0,0 +1,409 @@
//! Parties: everyone the company deals with, in one directory.
//!
//! A party is a vendor, a contractor, a customer, an employee, a member, a
//! payout platform, a tax agency, a bank or card issuer, a landlord, or
//! several of these. Vendor and customer *records* (bills, invoices,
//! payments, aging) stay where they are and link to their party; a party
//! adds who they are (names, facts) and links entries to them.
//!
//! ```text
//! GOAL: answer "who is this, and what has the company done with them?"
//! for anyone the books touch, without a CRM's sprawl.
//! SOURCE: docs/parties-and-attachments.md; DECISION (owner,
//! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4.
//! INVARIANTS:
//! 1. No party field holds a full taxpayer identification number: any
//! run of nine digits (single spaces or hyphens between them allowed)
//! is refused. A W-9's TIN is kept as its last four digits only.
//! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need
//! the employee role.
//! 3. Names are unique, ignoring case and surrounding space, among parties
//! not archived.
//! 4. Every vendor and customer record links to exactly one party.
//! ```
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
/// Opaque party handle.
pub type PartyId = u64;
/// What a party is to the company.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
/// Sells to the company (has a vendor record: bills, payments).
Vendor,
/// A vendor paid for services who gets a 1099-NEC.
Contractor,
/// Buys from the company (has a customer record: invoices).
Customer,
/// Works for the company on payroll.
Employee,
/// A member (owner) of the company, as in wanderbooks.toml.
Member,
/// Pays the company out of sales (an app store, a payment processor).
PayoutPlatform,
/// Taxes the company (the IRS, a state).
TaxAgency,
/// Holds the company's money or credit (a bank, a card issuer).
FinancialInstitution,
/// Rents property to the company (gets a 1099-MISC for rent).
Landlord,
}
impl Role {
/// Every role, in display order.
pub const ALL: [Role; 9] = [
Role::Vendor,
Role::Contractor,
Role::Customer,
Role::Employee,
Role::Member,
Role::PayoutPlatform,
Role::TaxAgency,
Role::FinancialInstitution,
Role::Landlord,
];
/// Its wire name, e.g. `payout_platform`.
pub fn key(self) -> &'static str {
match self {
Role::Vendor => "vendor",
Role::Contractor => "contractor",
Role::Customer => "customer",
Role::Employee => "employee",
Role::Member => "member",
Role::PayoutPlatform => "payout_platform",
Role::TaxAgency => "tax_agency",
Role::FinancialInstitution => "financial_institution",
Role::Landlord => "landlord",
}
}
/// The role named `key`.
pub fn parse(key: &str) -> Option<Role> {
Role::ALL.into_iter().find(|r| r.key() == key)
}
/// Whether this role files a 1099 (and so may carry W-9 facts).
pub fn gets_1099(self) -> bool {
matches!(self, Role::Contractor | Role::Landlord)
}
}
/// What a W-9 said, without the number itself.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct W9Facts {
/// When it was received.
pub received: NaiveDate,
/// Line 3 federal tax classification, e.g. "individual/sole proprietor".
pub entity_type: String,
/// Line 4 exempt payee code, if any.
#[serde(default)]
pub exempt_payee_code: Option<String>,
/// Line 4 FATCA exemption code, if any.
#[serde(default)]
pub fatca_code: Option<String>,
/// The TIN's last four digits (exactly four digits).
pub tin_last4: String,
/// Where the form itself is kept, e.g. "wanderbooks vault" or "CPA portal".
pub kept_at: String,
/// SHA-256 of the vault record holding the full TIN, when one exists:
/// proves the vault held it without the books holding it.
#[serde(default)]
pub vault_record: Option<String>,
}
/// What a W-4 said, without the SSN.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct W4Facts {
/// When it was received.
pub received: NaiveDate,
/// The work state, two letters.
pub state: String,
/// Where the form itself is kept.
pub kept_at: String,
}
/// One party.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Party {
/// Opaque handle.
pub id: PartyId,
/// Legal or trade name, as on documents.
pub name: String,
/// A shorter name to show, if any.
#[serde(default)]
pub display_name: Option<String>,
/// Roles set by hand (vendor and customer also follow their records).
pub roles: BTreeSet<Role>,
/// Website.
#[serde(default)]
pub website: Option<String>,
/// Business address.
#[serde(default)]
pub address: Option<String>,
/// Notes.
#[serde(default)]
pub notes: Option<String>,
/// No longer dealt with; history stays.
#[serde(default)]
pub archived: bool,
/// W-9 facts (contractor, landlord).
#[serde(default)]
pub w9: Option<W9Facts>,
/// W-4 facts (employee).
#[serde(default)]
pub w4: Option<W4Facts>,
/// Its vendor record, if it bills the company.
#[serde(default)]
pub vendor: Option<crate::VendorId>,
/// Its customer record, if the company invoices it.
#[serde(default)]
pub customer: Option<crate::CustomerId>,
}
impl Party {
/// The name to show: the display name, else the name.
pub fn label(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
/// Its roles: the ones set by hand, plus vendor and customer when it
/// has those records.
pub fn all_roles(&self) -> BTreeSet<Role> {
let mut r = self.roles.clone();
if self.vendor.is_some() {
r.insert(Role::Vendor);
}
if self.customer.is_some() {
r.insert(Role::Customer);
}
r
}
}
/// The key names are compared by: case and surrounding space ignored.
pub fn name_key(name: &str) -> String {
name.trim().to_lowercase()
}
/// Whether `text` holds a run of nine digits, with at most one space or
/// hyphen between neighbors (an SSN, EIN, or ITIN however it's written).
/// Any Unicode numeral counts as a digit (full-width, Arabic-Indic, ...),
/// so a number can't slip through in another script.
pub fn has_full_tin(text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].is_numeric() {
let (mut digits, mut j) = (1, i + 1);
while j < chars.len() && digits < 9 {
if chars[j].is_numeric() {
digits += 1;
j += 1;
} else if matches!(chars[j], ' ' | '-')
&& chars.get(j + 1).is_some_and(|c| c.is_numeric())
{
j += 1;
} else {
break;
}
}
if digits >= 9 {
return true;
}
}
i += 1;
}
false
}
/// Check one party text field: not a full TIN, at most 500 characters, no
/// control characters except newline (addresses and notes run several
/// lines). Returns why not.
pub fn check_text(field: &str, text: &str) -> Result<(), String> {
if has_full_tin(text) {
return Err(format!(
"{field} looks like it holds a full taxpayer identification number; \
the books keep only its last four digits (the full number belongs in the vault)"
));
}
if text.chars().count() > 500 {
return Err(format!("{field} is at most 500 characters"));
}
if text.chars().any(|c| c.is_control() && c != '\n') {
return Err(format!("{field} can't contain control characters"));
}
Ok(())
}
impl W9Facts {
/// Check the facts' form. Returns why not.
pub fn check(&self) -> Result<(), String> {
if self.tin_last4.len() != 4 || !self.tin_last4.chars().all(|c| c.is_ascii_digit()) {
return Err("tin_last4 is exactly four digits".into());
}
if self.entity_type.trim().is_empty() {
return Err("a W-9's entity type (line 3) is required".into());
}
if self.kept_at.trim().is_empty() {
return Err("say where the W-9 itself is kept".into());
}
for (f, t) in [
("entity_type", Some(self.entity_type.as_str())),
("exempt_payee_code", self.exempt_payee_code.as_deref()),
("fatca_code", self.fatca_code.as_deref()),
("kept_at", Some(self.kept_at.as_str())),
] {
if let Some(t) = t {
check_text(f, t)?;
}
}
if let Some(h) = &self.vault_record
&& (h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()))
{
return Err("vault_record is a SHA-256 in hex".into());
}
Ok(())
}
}
impl W4Facts {
/// Check the facts' form. Returns why not.
pub fn check(&self) -> Result<(), String> {
if self.state.len() != 2 || !self.state.chars().all(|c| c.is_ascii_uppercase()) {
return Err("state is two capital letters, e.g. UT".into());
}
if self.kept_at.trim().is_empty() {
return Err("say where the W-4 itself is kept".into());
}
check_text("kept_at", &self.kept_at)
}
}
/// One fact of a party to change ([`crate::Ledger::update_party`]).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "field", content = "value")]
pub enum PartyField {
/// The legal or trade name.
Name(String),
/// The display name (`None` clears it).
DisplayName(Option<String>),
/// The website.
Website(Option<String>),
/// The business address.
Address(Option<String>),
/// Notes.
Notes(Option<String>),
/// Archived (no longer dealt with) or not.
Archived(bool),
}
/// What the books did with one party over a span of dates
/// ([`activity`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Activity {
/// The party's entries, drafts included, by id.
pub entries: BTreeSet<crate::EntryId>,
/// Paid to it in the span (net of refunds and reversals of payments).
pub paid: rust_decimal::Decimal,
/// Received from it in the span (net of reversals of receipts).
pub received: rust_decimal::Decimal,
}
/// The money accounts [`activity`] counts: the cash accounts (every asset
/// account but receivables when `cash` is empty), and every liability
/// account but payables and the payroll withholdings (cards, and what
/// members paid for the company and are owed back).
pub fn money_accounts(
ledger: &crate::Ledger,
cash: &[crate::AccountId],
) -> std::collections::HashSet<crate::AccountId> {
use crate::AccountType;
let not_money: std::collections::HashSet<crate::AccountId> = crate::PAYROLL[2..]
.iter()
.chain([&crate::PAYABLES, &crate::RECEIVABLES])
.filter_map(|c| ledger.role_account(c))
.collect();
ledger
.chart()
.all()
.filter(|a| !not_money.contains(&a.id))
.filter(|a| match a.account_type {
AccountType::Liability => true,
AccountType::Asset => cash.is_empty() || cash.contains(&a.id),
_ => false,
})
.map(|a| a.id)
.collect()
}
/// One party's entries, and the money paid to and received from it in
/// `start..=end`.
///
/// ```text
/// GOAL: one answer to "what did we pay them this year?" for the MCP
/// server and the viewer alike.
/// SOURCE: docs/parties-and-attachments.md (Parties); DECISION (owner,
/// 2026-10-15).
/// INVARIANTS:
/// 1. entries: every entry Ledger::entry_party names the party
/// (hand tags, vendor tags, bills and their payments, invoices and
/// their payments, and reversals of any of these carry it).
/// 2. Each posted entry dated in the span nets (credits − debits) on
/// money_accounts: positive is a payment, negative a receipt. A
/// reversal counts on its original's side, so it subtracts.
/// 3. A card charge counts as paid (the company paid them, by card); a
/// bill doesn't until it is paid. Not the 1099 amount: that is
/// form1099::vendor_1099_report_with.
/// ```
pub fn activity(
ledger: &crate::Ledger,
party: PartyId,
(start, end): (NaiveDate, NaiveDate),
cash: &[crate::AccountId],
) -> Activity {
use crate::Side;
use rust_decimal::Decimal;
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
.filter(|&id| ledger.entry_party(id) == Some(party))
.collect();
let money = money_accounts(ledger, cash);
let net_out = |e: &crate::JournalEntry| -> Decimal {
e.lines()
.iter()
.filter(|l| money.contains(&l.account))
.map(|l| match l.side {
Side::Credit => l.amount.amount,
Side::Debit => -l.amount.amount,
})
.sum()
};
let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO);
for &id in &entries {
let Some(e) = ledger.get_entry(id) else {
continue;
};
if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end {
continue;
}
let net = net_out(e);
let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) {
Some(original) => net_out(original),
None => net,
};
if side > Decimal::ZERO {
paid += net;
} else {
received -= net;
}
}
Activity {
entries,
paid,
received,
}
}
+5
View File
@@ -24,6 +24,10 @@ pub type PayrollRunId = u64;
pub struct PayrollLine {
/// Employee name, for the run record.
pub employee_name: String,
/// The employee's party, if named (new runs should; runs from before
/// the party directory carry the name only).
#[serde(default)]
pub employee: Option<crate::PartyId>,
/// Gross pay for the period.
pub gross_pay: Decimal,
/// Federal income tax withheld.
@@ -194,6 +198,7 @@ mod tests {
fn line(name: &str) -> PayrollLine {
PayrollLine {
employee_name: name.to_string(),
employee: None,
gross_pay: d(500_000), // 5000.00
federal_withholding: d(75_000), // 750.00
state_withholding: d(25_000), // 250.00