Files
wanderbooks/crates/ledger-core/src/parties.rs
T
swaitsandClaude Opus 5.5 df3fc3f4e4
check / check (push) Waiting to run
Party money counts cash paying down a liability (reimbursements, card payments)
ledger_core::parties::entry_flow: cash lines count; liability lines count
except the part paired against cash in the same entry, which counts once
as paid to the liability's creditor (or received from a lender). A
reimbursement to the members no longer nets to zero. What-for breakdown
comes from the same function and sums to the net exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 16:07:23 -06:00

515 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (Stephen,
//! 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 accounts [`entry_flow`] reads money from: the cash accounts (every
/// asset account but receivables when `cash` is empty), and the
/// liabilities other than payables and the payroll withholdings (cards,
/// what members paid for the company and are owed back, loans).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MoneyAccounts {
/// Cash: bank, savings, cash on hand.
pub cash: std::collections::HashSet<crate::AccountId>,
/// Liabilities that pay for the company or are paid down.
pub liabilities: std::collections::HashSet<crate::AccountId>,
}
/// The [`MoneyAccounts`] of `ledger`, with `cash` the books' cash accounts.
pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> MoneyAccounts {
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();
let mut out = MoneyAccounts::default();
for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) {
match a.account_type {
AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => {
out.cash.insert(a.id);
}
AccountType::Liability => {
out.liabilities.insert(a.id);
}
_ => {}
}
}
out
}
/// What one entry moved with its counterparty ([`entry_flow`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EntryFlow {
/// Paid to the counterparty (positive) or received from it (negative).
pub net: rust_decimal::Decimal,
/// What for, by account (debits − credits): positive is what the money
/// paid for, negative where money received came from. Sums to `net`.
pub what_for: Vec<(crate::AccountId, rust_decimal::Decimal)>,
}
/// The money one entry moved with its counterparty, and what for.
///
/// ```text
/// GOAL: "how much did we pay them" right however it was paid: from the
/// bank, on a card, by a member for the company, or by paying down
/// what the company owes them (a reimbursement, a card payment).
/// SOURCE: DECISION (Stephen, 2026-10-15), after a reimbursement to the
/// members netted to zero under the earlier rule (cash and
/// liabilities alike as money).
/// DEFINITIONS (over the entry's lines, in `money`):
/// cash_cr, cash_dr: credits and debits on cash accounts;
/// liab_cr, liab_dr: credits and debits on money liabilities;
/// down = min(liab_dr, cash_cr): cash paying down a liability (its
/// creditor is paid; the liability is what for);
/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the
/// lender pays the company).
/// INVARIANTS:
/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up.
/// 2. what_for: every line not on a money account (debits − credits),
/// plus `down` on the debited liabilities and −`up` on the credited
/// ones (in line order), zero sums left out, accounts merged; its sum
/// is net exactly, because the entry balances.
/// SCENARIOS:
/// 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.
/// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 /
/// Cr 1000): net is the amount, what for is the liability.
/// 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.
/// 5. A transfer between the company's own cash accounts: zero.
/// 6. A member contributing property (Dr equipment / Cr capital): zero.
/// ```
pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFlow {
use crate::Side;
use rust_decimal::Decimal;
let (mut cash_cr, mut cash_dr, mut liab_cr, mut liab_dr) =
(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
for l in entry.lines() {
let a = l.amount.amount;
match (
money.cash.contains(&l.account),
money.liabilities.contains(&l.account),
l.side,
) {
(true, _, Side::Credit) => cash_cr += a,
(true, _, Side::Debit) => cash_dr += a,
(false, true, Side::Credit) => liab_cr += a,
(false, true, Side::Debit) => liab_dr += a,
_ => {}
}
}
let down = liab_dr.min(cash_cr);
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 down_left, mut up_left) = (down, up);
let mut order: Vec<crate::AccountId> = Vec::new();
for l in entry.lines() {
let a = l.amount.amount;
let part = if money.cash.contains(&l.account) {
Decimal::ZERO
} else if money.liabilities.contains(&l.account) {
match l.side {
Side::Debit => {
let take = a.min(down_left);
down_left -= take;
take
}
Side::Credit => {
let take = a.min(up_left);
up_left -= take;
-take
}
}
} else {
match l.side {
Side::Debit => a,
Side::Credit => -a,
}
};
if !part.is_zero() {
if !by.contains_key(&l.account) {
order.push(l.account);
}
*by.entry(l.account).or_default() += part;
}
}
EntryFlow {
net,
what_for: order
.into_iter()
.map(|a| (a, by[&a]))
.filter(|(_, v)| !v.is_zero())
.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 (Stephen,
/// 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 counts its entry_flow net:
/// 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 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| entry_flow(e, &money).net;
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,
}
}