Parties, attachment links, statements, reconciliations (store), TIN vault crate
check / check (push) Waiting to run
check / check (push) Waiting to run
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:
1 parent
0a62353767
commit
e265ed4339
54 files changed
+10961
-178
No files matched your search
Generated
+65
@@ -123,6 +123,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
@@ -906,10 +916,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chinese-number"
|
||||
version = "0.7.8"
|
||||
@@ -975,6 +998,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
@@ -1312,7 +1336,9 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hybrid-array",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3748,6 +3774,22 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ledger-vault"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.5"
|
||||
@@ -4946,6 +4988,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
|
||||
dependencies = [
|
||||
"cpufeatures 0.3.1",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polycool"
|
||||
version = "0.4.0"
|
||||
@@ -7209,6 +7261,16 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
@@ -8585,6 +8647,9 @@ name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"]
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -322,6 +322,13 @@ receipt_threshold = "25.00"
|
||||
# by books.roles. Until assigned, each uses its default code (1200, 2000,
|
||||
# 4000, 6999, 3999, 6100, 6110, 2110-2150).
|
||||
|
||||
[vault]
|
||||
# The TIN vault (full taxpayer identification numbers and W-9s, encrypted,
|
||||
# in vault/ beside the books, which git ignores) is opened with this key
|
||||
# file, kept OUTSIDE the books. Keep a copy in your password manager:
|
||||
# without it the vault can't be read. Unset: ~/.config/wanderbooks/vault.key
|
||||
# key_file = "~/.config/wanderbooks/vault.key"
|
||||
|
||||
[viewer]
|
||||
# When a session starts and the viewer isn't open (`just gui-status`), the
|
||||
# agent: "ask" offers to open it, "always" opens it (`just gui`), "never"
|
||||
@@ -332,6 +339,8 @@ open_at_start = "ask"
|
||||
const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl.
|
||||
books-data/wanderbooks.db
|
||||
books-data/wanderbooks.db-*
|
||||
# The TIN vault: encrypted, but never in git (it must be purgeable).
|
||||
vault/
|
||||
# Copies of a database that disagreed with the journal, kept when it is
|
||||
# rebuilt; disposable like the database itself.
|
||||
books-data/wanderbooks.mismatch.*
|
||||
|
||||
@@ -368,11 +368,12 @@ fn the_toml_shows_every_setting_the_config_reads() {
|
||||
// Read from the config crate's serde structs, so a new setting there
|
||||
// fails here until the template shows it.
|
||||
let src = include_str!("../../ledger-config/src/lib.rs");
|
||||
assert_eq!(fields(src, "BooksToml"), ["company", "books"]);
|
||||
assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]);
|
||||
for (strukt, sect) in [
|
||||
("CompanyToml", "company"),
|
||||
("MemberToml", "company.members"),
|
||||
("BooksSection", "books"),
|
||||
("VaultSection", "vault"),
|
||||
] {
|
||||
for key in fields(src, strukt) {
|
||||
if strukt == "CompanyToml" && key == "members" {
|
||||
|
||||
@@ -112,6 +112,9 @@ pub struct BooksSettings {
|
||||
/// ("WL" for "WL-0001"). Unset: [`BooksSettings::asset_tag_prefix`]
|
||||
/// derives it from the company name.
|
||||
pub asset_tag_prefix: Option<String>,
|
||||
/// `[vault] key_file`: the TIN vault's key file. Unset: the vault's
|
||||
/// default (`~/.config/wanderbooks/vault.key`).
|
||||
pub vault_key_file: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl BooksSettings {
|
||||
@@ -168,6 +171,13 @@ struct BooksToml {
|
||||
company: CompanyToml,
|
||||
#[serde(default)]
|
||||
books: BooksSection,
|
||||
#[serde(default)]
|
||||
vault: VaultSection,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct VaultSection {
|
||||
key_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
@@ -497,6 +507,16 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
|
||||
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
|
||||
investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
|
||||
vault_key_file: match raw.vault.key_file {
|
||||
Some(p) if p.trim().is_empty() => {
|
||||
return Err("[vault] key_file is empty".into());
|
||||
}
|
||||
Some(p) => Some(std::path::PathBuf::from(match p.strip_prefix("~/") {
|
||||
Some(rest) => format!("{}/{rest}", std::env::var("HOME").unwrap_or_default()),
|
||||
None => p,
|
||||
})),
|
||||
None => None,
|
||||
},
|
||||
asset_tag_prefix: match raw.books.asset_tag_prefix {
|
||||
Some(p)
|
||||
if !p.is_empty()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 (Stephen,
|
||||
/// 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)
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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 (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 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 (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 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -213,3 +213,52 @@ fn forms_have_their_irs_names() {
|
||||
assert_eq!(Form1099::parse(f.label()), Some(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// vendor_1099_report_with SCENARIOS 1–2, worked by hand. Payment sources:
|
||||
/// cash 1000 and the members' financing liability 2200. Kim (1099-MISC,
|
||||
/// landlord) in 2026:
|
||||
/// - 1,000 rent from the bank (Dr rent, Cr 1000): +1,000
|
||||
/// - 2,400 prepaid rent from the bank (Dr 1300, Cr 1000): +2,400 (paid
|
||||
/// when made; the prepaid asset is not a source, so it doesn't offset)
|
||||
/// - 800 rent paid by a member (Dr rent, Cr 2200): +800
|
||||
/// - 500 rent on the card (Dr rent, Cr 2100): 0 (1099-K)
|
||||
/// - 300 refunded to the bank (Dr 1000, Cr rent): −300
|
||||
///
|
||||
/// Total 3,900. The plain report (every asset a source, no financing)
|
||||
/// sees 1,000 + 0 + 0 + 0 − 300 = 700: the understatement this fixes.
|
||||
#[test]
|
||||
fn payments_come_from_cash_and_financing_only() {
|
||||
use ledger_core::{PaymentAccounts, vendor_1099_report_with};
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let acct = |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||
let bank = acct(&mut l, "1000", AccountType::Asset);
|
||||
let prepaid = acct(&mut l, "1300", AccountType::Asset);
|
||||
let card = acct(&mut l, "2100", AccountType::Liability);
|
||||
let members = acct(&mut l, "2200", AccountType::Liability);
|
||||
let rent = acct(&mut l, "6600", AccountType::Expense);
|
||||
let kim = l.add_vendor("Kim").unwrap();
|
||||
l.set_vendor_1099(kim, Some(Form1099::Misc), true).unwrap();
|
||||
for (dr, cr, amt) in [
|
||||
(rent, bank, "1000"),
|
||||
(prepaid, bank, "2400"),
|
||||
(rent, members, "800"),
|
||||
(rent, card, "500"),
|
||||
(bank, rent, "300"),
|
||||
] {
|
||||
let e = l.create_entry(d(2026, 5, 1), "rent");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l.set_entry_vendor(e, Some(kim)).unwrap();
|
||||
}
|
||||
let sources = PaymentAccounts {
|
||||
cash: vec![bank],
|
||||
financing: vec![members],
|
||||
};
|
||||
let r = vendor_1099_report_with(&l, 2026, None, &sources).unwrap();
|
||||
assert_eq!(r.vendors[0].paid, dec("3900"));
|
||||
assert!(r.vendors[0].reportable, "3,900 is over 2,000");
|
||||
let plain = vendor_1099_report(&l, 2026, None).unwrap();
|
||||
assert_eq!(plain.vendors[0].paid, dec("700"));
|
||||
}
|
||||
@@ -196,6 +196,7 @@ fn pline(
|
||||
) -> PayrollLine {
|
||||
PayrollLine {
|
||||
employee_name: name.to_string(),
|
||||
employee: None,
|
||||
gross_pay: d(gross),
|
||||
federal_withholding: d(fed),
|
||||
state_withholding: d(state),
|
||||
@@ -1208,6 +1209,7 @@ fn arb_line() -> impl Strategy<Value = PayrollLine> {
|
||||
let z = |(v, keep): (i64, bool)| Decimal::new(if keep { v } else { 0 }, 2);
|
||||
PayrollLine {
|
||||
employee_name: "E".to_string(),
|
||||
employee: None,
|
||||
gross_pay: Decimal::new(gross, 2),
|
||||
federal_withholding: z(w[0]),
|
||||
state_withholding: z(w[1]),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -224,6 +224,7 @@ fn refuses(l: &mut Ledger, key: &str, account: Option<AccountId>) -> LedgerError
|
||||
fn alice() -> PayrollLine {
|
||||
PayrollLine {
|
||||
employee_name: "Alice".to_string(),
|
||||
employee: None,
|
||||
gross_pay: dec("5000.00"),
|
||||
federal_withholding: dec("600.00"),
|
||||
state_withholding: dec("250.00"),
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct PageState {
|
||||
pub reports: pages::reports::State,
|
||||
pub accounts: pages::accounts::State,
|
||||
pub assets: pages::assets::State,
|
||||
pub parties: pages::parties::State,
|
||||
pub documents: pages::documents::State,
|
||||
pub journal: pages::journal::State,
|
||||
}
|
||||
@@ -123,6 +124,7 @@ impl App {
|
||||
"reports" => Route::Reports(ReportKind::ProfitAndLoss),
|
||||
"accounts" => Route::Accounts,
|
||||
"assets" => Route::Assets,
|
||||
"parties" => Route::Parties,
|
||||
"documents" => Route::Documents,
|
||||
"company" => Route::Company,
|
||||
"journal" => Route::Journal,
|
||||
@@ -191,6 +193,12 @@ impl App {
|
||||
}
|
||||
Route::Asset(asset_id)
|
||||
}
|
||||
T::Party { party_id } => {
|
||||
if b.ledger.get_party(party_id).is_none() {
|
||||
return;
|
||||
}
|
||||
Route::Party(party_id)
|
||||
}
|
||||
T::Search { query } => {
|
||||
let st = &mut self.state.transactions;
|
||||
st.query = query;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! and recurring entries due, open past periods, uncategorized tax lines,
|
||||
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
|
||||
//! owners, fixed asset depreciation not posted, assets without a tag or a
|
||||
//! tax basis, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that
|
||||
//! tax basis, contractors and landlords paid without W-9 facts, and legal
|
||||
//! rules (like the 1099 threshold) due for an update. Each item says
|
||||
//! what's wrong, where to look, and the prompt that
|
||||
//! asks the agent to fix it (the GUI never changes the books itself).
|
||||
|
||||
use crate::books::{Attention, Books, Severity};
|
||||
@@ -38,7 +40,14 @@ use rust_decimal::Decimal;
|
||||
/// disagreeing today (unregistered_cost != 0: Problem); an active
|
||||
/// asset in service with no tag (Todo, one per asset); an active
|
||||
/// asset in service with no tax basis (Asset::tax_basis is None:
|
||||
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis).
|
||||
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis);
|
||||
/// a party not archived whose W-9 is Missing (pages::parties::
|
||||
/// w9_status: it files a 1099, has no W-9 facts, and its vendor
|
||||
/// record doesn't say one is on file) and that was paid this year
|
||||
/// (PartyInfo::paid_ytd > 0),
|
||||
/// unless its vendor is already a 1099 vendor without a W-9 above
|
||||
/// (Todo, one per party, its prompt from
|
||||
/// pages::parties::collect_w9_prompt, naming books.set_w9).
|
||||
/// 3. Nothing else: books with none of those yield no items.
|
||||
/// 4. Every item's prompt starts "In wanderbooks," or names
|
||||
/// wanderbooks, and its amounts are the books' exact amounts.
|
||||
@@ -229,6 +238,8 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
if today.month() == 1 {
|
||||
years.insert(0, today.year() - 1);
|
||||
}
|
||||
// Vendors the "needs a W-9" item already names this year.
|
||||
let mut asked_w9 = std::collections::HashSet::new();
|
||||
for year in years {
|
||||
// A year whose threshold isn't known is flagged by the stale-rule
|
||||
// item above; never guess it here.
|
||||
@@ -236,6 +247,10 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
continue;
|
||||
};
|
||||
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
|
||||
if year == today.year() {
|
||||
asked_w9.insert(v.vendor_id);
|
||||
}
|
||||
let party = b.ledger.party_of_vendor(v.vendor_id);
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} needs a W-9", v.name),
|
||||
@@ -244,7 +259,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
fmt::dollars(v.paid),
|
||||
fmt::dollars(r.threshold)
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
target: Some(party.map_or(Route::Company, Route::Party)),
|
||||
prompt: format!(
|
||||
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
|
||||
v.name
|
||||
@@ -253,6 +268,34 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
}
|
||||
}
|
||||
|
||||
for p in b.ledger.parties().filter(|p| {
|
||||
!p.archived
|
||||
&& crate::pages::parties::w9_status(b, p) == crate::pages::parties::W9Status::Missing
|
||||
&& !p.vendor.is_some_and(|v| asked_w9.contains(&v))
|
||||
}) {
|
||||
let Some(info) = b.party(p.id).filter(|i| i.paid_ytd > Decimal::ZERO) else {
|
||||
continue;
|
||||
};
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Collect a W-9 from {}", p.label()),
|
||||
detail: format!(
|
||||
"Paid {} in {} as {}, with no W-9 facts recorded: collect a W-9 before the next payment.",
|
||||
fmt::dollars(info.paid_ytd),
|
||||
today.year(),
|
||||
if p.roles.contains(&ledger_core::Role::Landlord)
|
||||
&& !p.roles.contains(&ledger_core::Role::Contractor)
|
||||
{
|
||||
"a landlord"
|
||||
} else {
|
||||
"a contractor"
|
||||
}
|
||||
),
|
||||
target: Some(Route::Party(p.id)),
|
||||
prompt: crate::pages::parties::collect_w9_prompt(b, p),
|
||||
});
|
||||
}
|
||||
|
||||
let overdue = |rows: Vec<ledger_core::AgingRow>| -> (usize, Decimal) {
|
||||
let late: Vec<Decimal> = rows
|
||||
.iter()
|
||||
|
||||
+215
-36
@@ -8,8 +8,8 @@ use ledger_config::BooksSettings;
|
||||
use ledger_core::analysis::{balance_history, balance_series};
|
||||
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, AssetRegister, AssetStatus, EntryId, EntryStatus, Ledger,
|
||||
Side, fixed_asset_register,
|
||||
AccountId, AccountType, AssetId, AssetRegister, AssetStatus, BillId, EntryId, EntryStatus,
|
||||
InvoiceId, Ledger, Party, PartyId, PayrollRunId, Side, Vendor1099Line, fixed_asset_register,
|
||||
};
|
||||
use ledger_store::{Attachment, JournalLine, Snapshot};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -42,8 +42,12 @@ pub struct EntryInfo {
|
||||
/// Net effect on cash accounts: positive in, negative out, zero if the
|
||||
/// entry doesn't touch cash.
|
||||
pub cash_effect: Decimal,
|
||||
/// The customer or vendor, from invoices, bills, and vendor tags.
|
||||
/// The counterparty's name: its directory party's label (see
|
||||
/// [`PartyInfo`] for which entries are a party's), else its vendor's
|
||||
/// or invoice customer's name.
|
||||
pub party: Option<String>,
|
||||
/// The directory party the entry is with (see [`PartyInfo`]).
|
||||
pub party_id: Option<PartyId>,
|
||||
/// Lower-cased text for search: description, accounts, vendor, amount.
|
||||
pub haystack: String,
|
||||
}
|
||||
@@ -81,6 +85,62 @@ impl AssetTrail {
|
||||
}
|
||||
}
|
||||
|
||||
/// One party's dealings with the company: its entries, documents, and
|
||||
/// this year's totals, computed once per refresh.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: answer "who is this, and what has the company done with them?"
|
||||
/// with the books' own numbers.
|
||||
/// SOURCE: docs/parties-and-attachments.md (Parties → Viewer); the
|
||||
/// module's rule (every number from ledger-core; this indexes and
|
||||
/// sums); DECISION (the year and the payment accounts, below).
|
||||
/// DEFINITIONS:
|
||||
/// The party's entries: Ledger::entry_party names it (core tags hand
|
||||
/// tags, vendor tags, bills, invoices, their payments, reversals).
|
||||
/// The year: the calendar year of `today`, Jan 1 through today (the
|
||||
/// year 1099s are counted in).
|
||||
/// Money: ledger_core::parties::activity with Books::cash_accounts
|
||||
/// (the same numbers books.party reports).
|
||||
/// INVARIANTS:
|
||||
/// 1. entries: every entry that is the party's, drafts included, in
|
||||
/// Books::entries order (newest first).
|
||||
/// 2. paid_ytd = activity's paid − received over the year, exactly.
|
||||
/// Positive: paid to them, net; negative: received from them, net.
|
||||
/// 3. invoiced_ytd = Σ Invoice::total() over its customer record's
|
||||
/// invoices dated in the year; billed_ytd the same for its vendor
|
||||
/// record's bills.
|
||||
/// 4. open_invoices = Σ Invoice::balance() over its customer record's
|
||||
/// invoices; open_bills = Σ Bill::balance() over its vendor record's
|
||||
/// bills (any date: what is still owed today).
|
||||
/// 5. form_1099: its vendor record's line in
|
||||
/// vendor_1099_report_with(ledger, year, None, cash and financing
|
||||
/// accounts), when it has a vendor
|
||||
/// record in that report and the report knows the year's threshold
|
||||
/// (threshold_1099 is then that threshold).
|
||||
/// 6. payroll_runs: the runs with a line whose employee is the party,
|
||||
/// or (older runs, no party on the line) whose employee_name is the
|
||||
/// party's name ignoring case and surrounding space.
|
||||
/// 7. events: indexes into Books::journal of party.* events with its
|
||||
/// id, entry.party_set naming it, and its records' vendor.added,
|
||||
/// customer.added, vendor.1099_set, and entry.vendor_set events.
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PartyInfo {
|
||||
pub id: PartyId,
|
||||
pub entries: Vec<EntryId>,
|
||||
pub paid_ytd: Decimal,
|
||||
pub invoiced_ytd: Decimal,
|
||||
pub billed_ytd: Decimal,
|
||||
pub open_invoices: Decimal,
|
||||
pub open_bills: Decimal,
|
||||
pub invoices: Vec<InvoiceId>,
|
||||
pub bills: Vec<BillId>,
|
||||
pub payroll_runs: Vec<PayrollRunId>,
|
||||
pub form_1099: Option<Vendor1099Line>,
|
||||
pub threshold_1099: Option<Decimal>,
|
||||
pub events: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Depreciation due and not yet in the books.
|
||||
///
|
||||
/// ```text
|
||||
@@ -154,6 +214,8 @@ pub struct Books {
|
||||
pub asset_trails: HashMap<AssetId, AssetTrail>,
|
||||
/// The asset each depreciation, disposal, or purchase entry is about.
|
||||
pub entry_assets: HashMap<EntryId, AssetId>,
|
||||
/// Every party's dealings with the company, by party id.
|
||||
pub parties: BTreeMap<PartyId, PartyInfo>,
|
||||
}
|
||||
|
||||
/// Whether what's on disk is what the journal says.
|
||||
@@ -288,7 +350,6 @@ impl Books {
|
||||
}
|
||||
}
|
||||
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
|
||||
let parties = parties(&ledger, &journal);
|
||||
let mut fresh = HashSet::new();
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let mut entries = Vec::new();
|
||||
@@ -345,7 +406,13 @@ impl Books {
|
||||
Side::Credit => -l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
let party = vendor.clone().or_else(|| parties.get(&id).cloned());
|
||||
// Core tags every entry with its counterparty (hand tags,
|
||||
// vendor tags, bills, invoices, their payments, reversals).
|
||||
let party_id = ledger.entry_party(id);
|
||||
let party = party_id
|
||||
.and_then(|p| ledger.get_party(p))
|
||||
.map(|p| p.label().to_string())
|
||||
.or_else(|| vendor.clone());
|
||||
let mut haystack = format!(
|
||||
"{} {} #{} {}",
|
||||
e.description(),
|
||||
@@ -362,6 +429,12 @@ impl Books {
|
||||
haystack.push(' ');
|
||||
haystack.push_str(v);
|
||||
}
|
||||
if let Some(p) = party_id.and_then(|p| ledger.get_party(p))
|
||||
&& p.display_name.is_some()
|
||||
{
|
||||
haystack.push(' ');
|
||||
haystack.push_str(&p.name);
|
||||
}
|
||||
entries.push(EntryInfo {
|
||||
id,
|
||||
date: e.date(),
|
||||
@@ -378,6 +451,7 @@ impl Books {
|
||||
missing_receipt,
|
||||
cash_effect,
|
||||
party,
|
||||
party_id,
|
||||
haystack: haystack.to_lowercase(),
|
||||
});
|
||||
}
|
||||
@@ -413,7 +487,9 @@ impl Books {
|
||||
integrity,
|
||||
asset_trails,
|
||||
entry_assets,
|
||||
parties: BTreeMap::new(),
|
||||
};
|
||||
books.parties = books.party_infos();
|
||||
books.kpis = books.compute_kpis();
|
||||
books.attention = crate::attention::find(&books);
|
||||
Ok(books)
|
||||
@@ -578,6 +654,140 @@ impl Books {
|
||||
}
|
||||
}
|
||||
|
||||
/// One party's dealings (None for an unknown party).
|
||||
pub fn party(&self, id: PartyId) -> Option<&PartyInfo> {
|
||||
self.parties.get(&id)
|
||||
}
|
||||
|
||||
/// A party's label (its display name, else its name).
|
||||
pub fn party_label(&self, id: PartyId) -> String {
|
||||
self.ledger
|
||||
.get_party(id)
|
||||
.map(|p| p.label().to_string())
|
||||
.unwrap_or_else(|| format!("party {id}"))
|
||||
}
|
||||
|
||||
/// Whether a party files a 1099: it has a 1099 role (contractor,
|
||||
/// landlord), or its vendor record is marked for a 1099.
|
||||
pub fn files_1099(&self, p: &Party) -> bool {
|
||||
p.roles.iter().any(|r| r.gets_1099())
|
||||
|| p.vendor
|
||||
.and_then(|v| self.ledger.get_vendor(v))
|
||||
.is_some_and(|v| v.form_1099.is_some())
|
||||
}
|
||||
|
||||
/// Every party's [`PartyInfo`].
|
||||
fn party_infos(&self) -> BTreeMap<PartyId, PartyInfo> {
|
||||
let l = &self.ledger;
|
||||
let year = self.today.year();
|
||||
let year_start = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid");
|
||||
let in_year = |d: NaiveDate| d >= year_start && d <= self.today;
|
||||
let mut out: BTreeMap<PartyId, PartyInfo> = l
|
||||
.parties()
|
||||
.map(|p| {
|
||||
(
|
||||
p.id,
|
||||
PartyInfo {
|
||||
id: p.id,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (id, info) in out.iter_mut() {
|
||||
let a = ledger_core::parties::activity(
|
||||
l,
|
||||
*id,
|
||||
(year_start, self.today),
|
||||
&self.cash_accounts,
|
||||
);
|
||||
info.entries = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| a.entries.contains(&e.id))
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
info.paid_ytd = a.paid - a.received;
|
||||
}
|
||||
for inv in l.invoices() {
|
||||
let id = inv.id;
|
||||
if let Some(info) = l
|
||||
.party_of_customer(inv.customer_id)
|
||||
.and_then(|p| out.get_mut(&p))
|
||||
{
|
||||
info.invoices.push(id);
|
||||
info.open_invoices += inv.balance();
|
||||
if in_year(inv.date) {
|
||||
info.invoiced_ytd += inv.total();
|
||||
}
|
||||
}
|
||||
}
|
||||
for bill in l.bills() {
|
||||
let id = bill.id;
|
||||
if let Some(info) = l
|
||||
.party_of_vendor(bill.vendor_id)
|
||||
.and_then(|p| out.get_mut(&p))
|
||||
{
|
||||
info.bills.push(id);
|
||||
info.open_bills += bill.balance();
|
||||
if in_year(bill.date) {
|
||||
info.billed_ytd += bill.total();
|
||||
}
|
||||
}
|
||||
}
|
||||
let sources = ledger_core::PaymentAccounts {
|
||||
cash: self.cash_accounts.clone(),
|
||||
financing: self.financing_accounts.clone(),
|
||||
};
|
||||
if let Ok(r) = ledger_core::vendor_1099_report_with(l, year, None, &sources) {
|
||||
for v in &r.vendors {
|
||||
if let Some(info) = l.party_of_vendor(v.vendor_id).and_then(|p| out.get_mut(&p)) {
|
||||
info.form_1099 = Some(v.clone());
|
||||
info.threshold_1099 = Some(r.threshold);
|
||||
}
|
||||
}
|
||||
}
|
||||
for run in l.list_payroll_runs() {
|
||||
for p in l.parties() {
|
||||
let names = |x: &ledger_core::PayrollLine| match x.employee {
|
||||
Some(e) => e == p.id,
|
||||
None => {
|
||||
ledger_core::parties::name_key(&x.employee_name)
|
||||
== ledger_core::parties::name_key(&p.name)
|
||||
}
|
||||
};
|
||||
if run.lines.iter().any(names)
|
||||
&& let Some(info) = out.get_mut(&p.id)
|
||||
&& !info.payroll_runs.contains(&run.id)
|
||||
{
|
||||
info.payroll_runs.push(run.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let u = |v: &Value, k: &str| v.get(k).and_then(|v| v.as_u64());
|
||||
for (i, line) in self.journal.iter().enumerate() {
|
||||
let p = &line.payload;
|
||||
let t = line.line_type.as_str();
|
||||
let party = if t.starts_with("party.") {
|
||||
u(p, "id")
|
||||
} else {
|
||||
match t {
|
||||
"entry.party_set" => u(p, "party_id"),
|
||||
"vendor.added" => u(p, "id").and_then(|v| l.party_of_vendor(v)),
|
||||
"customer.added" => u(p, "id").and_then(|c| l.party_of_customer(c)),
|
||||
"vendor.1099_set" | "entry.vendor_set" => {
|
||||
u(p, "vendor_id").and_then(|v| l.party_of_vendor(v))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(info) = party.and_then(|p| out.get_mut(&p)) {
|
||||
info.events.push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Depreciation due through the last finished month on or before
|
||||
/// `as_of` and not yet posted (see [`DepreciationDue`]).
|
||||
pub fn depreciation_due(&self, as_of: NaiveDate) -> DepreciationDue {
|
||||
@@ -617,37 +827,6 @@ impl Books {
|
||||
}
|
||||
}
|
||||
|
||||
/// Customer names for entries created by invoices and their payments,
|
||||
/// from the journal. (Bills and bill payments need nothing here: the
|
||||
/// ledger tags their entries with the vendor, which takes precedence.)
|
||||
fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<EntryId, String> {
|
||||
let mut out = HashMap::new();
|
||||
let customer = |id: Option<u64>| {
|
||||
id.and_then(|c| ledger.get_customer(c))
|
||||
.map(|c| c.name.clone())
|
||||
};
|
||||
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
|
||||
for line in journal {
|
||||
let p = &line.payload;
|
||||
let Some(entry) = u(p, "entry_id") else {
|
||||
continue;
|
||||
};
|
||||
let name = match line.line_type.as_str() {
|
||||
"invoice.created" => customer(u(p, "customer_id")),
|
||||
"invoice.payment_received" => customer(
|
||||
u(p, "invoice_id")
|
||||
.and_then(|i| ledger.get_invoice(i))
|
||||
.map(|i| i.customer_id),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(n) = name {
|
||||
out.insert(entry, n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every registered asset's trail, from the journal (depreciation,
|
||||
/// disposal, its own events) and the entries (the purchase).
|
||||
fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, AssetTrail> {
|
||||
|
||||
@@ -250,6 +250,94 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
.map(|n| s.add_customer(n))
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
// The party directory: who they are, beyond the vendor and customer
|
||||
// records (roles, W-9 facts with only the TIN's last four, sites).
|
||||
{
|
||||
use ledger_core::{PartyField, Role, W9Facts};
|
||||
let party_of = |s: &Store, v: u64| s.ledger().party_of_vendor(v).expect("linked");
|
||||
let sam_p = party_of(&s, sam);
|
||||
s.set_party_role(sam_p, Role::Contractor, true)?;
|
||||
s.set_party_w9(
|
||||
sam_p,
|
||||
Some(W9Facts {
|
||||
received: start + Days::new(20),
|
||||
entity_type: "Individual/sole proprietor".into(),
|
||||
exempt_payee_code: None,
|
||||
fatca_code: None,
|
||||
tin_last4: "4821".into(),
|
||||
kept_at: "wanderbooks vault".into(),
|
||||
vault_record: None,
|
||||
}),
|
||||
)?;
|
||||
s.update_party(
|
||||
sam_p,
|
||||
PartyField::Notes(Some(
|
||||
"Illustrator for client projects; invoices after each delivery.".into(),
|
||||
)),
|
||||
)?;
|
||||
let riley_p = party_of(&s, riley);
|
||||
s.set_party_role(riley_p, Role::Contractor, true)?;
|
||||
s.update_party(riley_p, PartyField::Website(Some("rileychen.dev".into())))?;
|
||||
let lofts = party_of(&s, landlord);
|
||||
s.set_party_role(lofts, Role::Landlord, true)?;
|
||||
s.set_party_w9(
|
||||
lofts,
|
||||
Some(W9Facts {
|
||||
received: start + Days::new(3),
|
||||
entity_type: "LLC taxed as a partnership".into(),
|
||||
exempt_payee_code: None,
|
||||
fatca_code: None,
|
||||
tin_last4: "3307".into(),
|
||||
kept_at: "CPA portal".into(),
|
||||
vault_record: None,
|
||||
}),
|
||||
)?;
|
||||
s.update_party(
|
||||
lofts,
|
||||
PartyField::Address(Some("1200 NW Everett St, Portland, OR 97209".into())),
|
||||
)?;
|
||||
s.update_party(lofts, PartyField::DisplayName(Some("Everett Lofts".into())))?;
|
||||
if let Some(fern) = s.ledger().party_of_customer(customers[0]) {
|
||||
s.update_party(
|
||||
fern,
|
||||
PartyField::Website(Some("fernandfield.coffee".into())),
|
||||
)?;
|
||||
s.update_party(
|
||||
fern,
|
||||
PartyField::Address(Some("88 SE Division St, Portland, OR 97202".into())),
|
||||
)?;
|
||||
}
|
||||
let irs = s.add_party("Internal Revenue Service", &[Role::TaxAgency])?;
|
||||
s.update_party(irs, PartyField::DisplayName(Some("IRS".into())))?;
|
||||
s.update_party(irs, PartyField::Website(Some("irs.gov".into())))?;
|
||||
let odr = s.add_party("Oregon Department of Revenue", &[Role::TaxAgency])?;
|
||||
s.update_party(odr, PartyField::Website(Some("oregon.gov/dor".into())))?;
|
||||
let old = s.add_party("Etsy, Inc.", &[Role::PayoutPlatform])?;
|
||||
s.update_party(
|
||||
old,
|
||||
PartyField::Notes(Some("Sold templates here before moving to Gumroad.".into())),
|
||||
)?;
|
||||
s.update_party(old, PartyField::Archived(true))?;
|
||||
}
|
||||
let gumroad = s.add_party("Gumroad, Inc.", &[ledger_core::Role::PayoutPlatform])?;
|
||||
s.update_party(
|
||||
gumroad,
|
||||
ledger_core::PartyField::DisplayName(Some("Gumroad".into())),
|
||||
)?;
|
||||
s.update_party(
|
||||
gumroad,
|
||||
ledger_core::PartyField::Website(Some("gumroad.com".into())),
|
||||
)?;
|
||||
let chase = s.add_party(
|
||||
"JPMorgan Chase Bank, N.A.",
|
||||
&[ledger_core::Role::FinancialInstitution],
|
||||
)?;
|
||||
s.update_party(
|
||||
chase,
|
||||
ledger_core::PartyField::DisplayName(Some("Chase".into())),
|
||||
)?;
|
||||
let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?;
|
||||
|
||||
// Formation documents.
|
||||
s.file_document(
|
||||
"formation",
|
||||
@@ -562,7 +650,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
// Pay the card in full on the 25th.
|
||||
let date = day(m, 25);
|
||||
if in_range(date) && card_balance > Decimal::ZERO {
|
||||
post(
|
||||
let paid = post(
|
||||
&mut s,
|
||||
date,
|
||||
"Credit card payment",
|
||||
@@ -582,6 +670,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
s.set_entry_party(paid, Some(chase))?;
|
||||
card_balance = Decimal::ZERO;
|
||||
}
|
||||
// Client invoices: two or three a month, net 30.
|
||||
@@ -657,6 +746,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
None,
|
||||
)?;
|
||||
s.post_entry(e)?;
|
||||
s.set_entry_party(e, Some(gumroad))?;
|
||||
}
|
||||
}
|
||||
// Contractors.
|
||||
@@ -715,7 +805,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
if month_index >= 3 {
|
||||
let date = day(m, 28);
|
||||
if in_range(date) {
|
||||
post(
|
||||
let draw = post(
|
||||
&mut s,
|
||||
date,
|
||||
"Owner draw",
|
||||
@@ -726,6 +816,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
s.set_entry_party(draw, Some(alex))?;
|
||||
}
|
||||
}
|
||||
if month_index % 3 == 2 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Where you are and how you got there: a browser-style history.
|
||||
|
||||
use ledger_core::{AccountId, AssetId, EntryId};
|
||||
use ledger_core::{AccountId, AssetId, EntryId, PartyId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ReportKind {
|
||||
@@ -63,6 +63,10 @@ pub enum Route {
|
||||
Assets,
|
||||
/// One fixed asset.
|
||||
Asset(AssetId),
|
||||
/// The party directory.
|
||||
Parties,
|
||||
/// One party.
|
||||
Party(PartyId),
|
||||
Documents,
|
||||
Document(u64),
|
||||
Company,
|
||||
@@ -81,6 +85,7 @@ impl Route {
|
||||
Route::Reports(_) => Section::Reports,
|
||||
Route::Accounts | Route::Account(_) => Section::Accounts,
|
||||
Route::Assets | Route::Asset(_) => Section::Assets,
|
||||
Route::Parties | Route::Party(_) => Section::Parties,
|
||||
Route::Documents | Route::Document(_) => Section::Documents,
|
||||
Route::Company => Section::Company,
|
||||
Route::Journal | Route::JournalEvent(_) => Section::Journal,
|
||||
@@ -97,13 +102,14 @@ pub enum Section {
|
||||
Reports,
|
||||
Accounts,
|
||||
Assets,
|
||||
Parties,
|
||||
Documents,
|
||||
Company,
|
||||
Journal,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
pub const ALL: [Section; 10] = [
|
||||
pub const ALL: [Section; 11] = [
|
||||
Section::Overview,
|
||||
Section::Cash,
|
||||
Section::Profit,
|
||||
@@ -111,6 +117,7 @@ impl Section {
|
||||
Section::Reports,
|
||||
Section::Accounts,
|
||||
Section::Assets,
|
||||
Section::Parties,
|
||||
Section::Documents,
|
||||
Section::Company,
|
||||
Section::Journal,
|
||||
@@ -125,6 +132,7 @@ impl Section {
|
||||
Section::Reports => "Reports",
|
||||
Section::Accounts => "Accounts",
|
||||
Section::Assets => "Assets",
|
||||
Section::Parties => "Parties",
|
||||
Section::Documents => "Documents",
|
||||
Section::Company => "Company",
|
||||
Section::Journal => "Journal",
|
||||
@@ -141,6 +149,7 @@ impl Section {
|
||||
Section::Reports => i::FILE_TEXT,
|
||||
Section::Accounts => i::TREE_STRUCTURE,
|
||||
Section::Assets => i::PACKAGE,
|
||||
Section::Parties => i::ADDRESS_BOOK,
|
||||
Section::Documents => i::FILES,
|
||||
Section::Company => i::BUILDINGS,
|
||||
Section::Journal => i::SCROLL,
|
||||
@@ -156,6 +165,7 @@ impl Section {
|
||||
Section::Reports => Route::Reports(ReportKind::ProfitAndLoss),
|
||||
Section::Accounts => Route::Accounts,
|
||||
Section::Assets => Route::Assets,
|
||||
Section::Parties => Route::Parties,
|
||||
Section::Documents => Route::Documents,
|
||||
Section::Company => Route::Company,
|
||||
Section::Journal => Route::Journal,
|
||||
|
||||
@@ -281,7 +281,7 @@ pub fn status_text(disposed: Option<(NaiveDate, Decimal)>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_box(ui: &mut Ui, n: &Notice) {
|
||||
pub(crate) fn notice_box(ui: &mut Ui, n: &Notice) {
|
||||
let p = pal(ui.ctx());
|
||||
let (fg, bg) = if n.problem {
|
||||
(p.warning, p.warning_soft)
|
||||
@@ -310,7 +310,7 @@ fn notice_box(ui: &mut Ui, n: &Notice) {
|
||||
/// One painted table row: `cells` are (text, width, right-aligned,
|
||||
/// color); the cell whose width is 0 takes what's left. A second line
|
||||
/// (`sub`) goes under the flexible cell.
|
||||
fn row(
|
||||
pub(crate) fn row(
|
||||
ui: &mut Ui,
|
||||
w: f32,
|
||||
cells: &[(String, f32, bool, Color32)],
|
||||
@@ -1345,7 +1345,7 @@ fn tax_card(ui: &mut Ui, a: &Asset) {
|
||||
}
|
||||
|
||||
/// A two-column grid of facts; `add` gets the label painter.
|
||||
fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) {
|
||||
pub(crate) fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) {
|
||||
let p = pal(ui.ctx());
|
||||
egui::Grid::new(ui.next_auto_id())
|
||||
.num_columns(2)
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
assets(ui, b, shell);
|
||||
schedules(ui, b);
|
||||
recurring(ui, b);
|
||||
parties(ui, b);
|
||||
parties(ui, b, shell);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -421,8 +421,18 @@ fn recurring(ui: &mut Ui, b: &Books) {
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn parties(ui: &mut Ui, b: &Books) {
|
||||
fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let mut go: Option<crate::nav::Route> = None;
|
||||
// A record's name opens its party.
|
||||
let mut name = |ui: &mut Ui, text: &str, party: Option<u64>| {
|
||||
let r = widgets::link(ui, RichText::new(text).font(theme::semibold(13.0)));
|
||||
if let Some(id) = party
|
||||
&& r.on_hover_text("Open this party").clicked()
|
||||
{
|
||||
go = Some(crate::nav::Route::Party(id));
|
||||
}
|
||||
};
|
||||
let year = chrono::Datelike::year(&b.today);
|
||||
let r1099 = vendor_1099_report(&b.ledger, year, None).ok();
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
@@ -444,7 +454,7 @@ fn parties(ui: &mut Ui, b: &Books) {
|
||||
.map(|x| x.paid)
|
||||
.unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
|
||||
name(ui, &v.name, b.ledger.party_of_vendor(v.id));
|
||||
if let Some(f) = v.form_1099 {
|
||||
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
|
||||
if v.tin_on_file {
|
||||
@@ -490,7 +500,7 @@ fn parties(ui: &mut Ui, b: &Books) {
|
||||
.map(|i| i.balance())
|
||||
.sum();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
|
||||
name(ui, &c.name, b.ledger.party_of_customer(c.id));
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
@@ -509,4 +519,7 @@ fn parties(ui: &mut Ui, b: &Books) {
|
||||
);
|
||||
}
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,9 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
}
|
||||
};
|
||||
widgets::pill(ui, e.origin, p.muted, p.hover);
|
||||
if let Some(v) = &e.vendor {
|
||||
if let Some(v) = &e.vendor
|
||||
&& e.party_id.is_none()
|
||||
{
|
||||
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
|
||||
}
|
||||
if e.missing_receipt {
|
||||
@@ -85,6 +87,26 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
shell.go(Route::Entry(r));
|
||||
}
|
||||
}
|
||||
if let Some(party) = e.party_id.and_then(|id| b.ledger.get_party(id)) {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} {}",
|
||||
super::parties::party_icon(party),
|
||||
party.label()
|
||||
))
|
||||
.color(p.accent),
|
||||
)
|
||||
.on_hover_text(format!(
|
||||
"The party this entry is with ({}): open them",
|
||||
super::parties::roles_text(party).to_lowercase()
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Party(party.id));
|
||||
}
|
||||
}
|
||||
if let Some(&asset) = b.entry_assets.get(&e.id) {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
|
||||
@@ -92,6 +92,74 @@ fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
/// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in
|
||||
/// words, and where it opens.
|
||||
fn subject_text(b: &Books, subject: Option<&Value>) -> (String, Option<Route>) {
|
||||
let kind = subject
|
||||
.and_then(|s| s.get("kind"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let id = subject.and_then(|s| s.get("id")).and_then(|v| v.as_u64());
|
||||
match (kind, id) {
|
||||
("entry", Some(e)) => (format!("#{e}"), Some(Route::Entry(e))),
|
||||
("account", Some(a)) => (b.account_label(a), Some(Route::Account(a))),
|
||||
("asset", Some(a)) => (b.asset_label(a), Some(Route::Asset(a))),
|
||||
("party", Some(p)) => (b.party_label(p), Some(Route::Party(p))),
|
||||
("company", _) => ("the company".into(), Some(Route::Company)),
|
||||
("payroll_run", Some(i)) => (format!("payroll run {i}"), None),
|
||||
(k, Some(i)) if !k.is_empty() => (format!("{} {i}", k.replace('_', " ")), None),
|
||||
_ => ("a subject".into(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A party's label, or "a party" when the event names none.
|
||||
fn party_name(b: &Books, id: Option<u64>) -> String {
|
||||
match id {
|
||||
Some(id) => b.party_label(id),
|
||||
None => "a party".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Display name of Gumroad, Inc. set to Gumroad", "Archived Etsy": one
|
||||
/// `party.updated` event in words. Names are today's (the display name's
|
||||
/// change names the legal name, so it reads the same afterwards).
|
||||
fn party_update_text(b: &Books, id: Option<u64>, change: Option<&Value>) -> String {
|
||||
let name = party_name(b, id);
|
||||
let legal = id
|
||||
.and_then(|i| b.ledger.get_party(i))
|
||||
.map_or_else(|| name.clone(), |p| p.name.clone());
|
||||
let field = change
|
||||
.and_then(|c| c.get("field"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let value = change.and_then(|c| c.get("value"));
|
||||
let text = value.and_then(|v| v.as_str());
|
||||
let words = match field {
|
||||
"name" => "Name",
|
||||
"display_name" => "Display name",
|
||||
"website" => "Website",
|
||||
"address" => "Address",
|
||||
"notes" => "Notes",
|
||||
_ => "",
|
||||
};
|
||||
match (field, text) {
|
||||
("archived", _) => {
|
||||
if value.and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
format!("Archived {name}")
|
||||
} else {
|
||||
format!("Unarchived {name}")
|
||||
}
|
||||
}
|
||||
("name", Some(t)) => format!("Renamed a party to {t}"),
|
||||
("display_name", Some(t)) => format!("Display name of {legal} set to {t}"),
|
||||
("display_name", None) => format!("Display name of {legal} cleared"),
|
||||
("notes", Some(_)) => format!("Notes of {name} updated"),
|
||||
("", _) => format!("Updated {name}"),
|
||||
(_, Some(t)) => format!("{words} of {name} set to {t}"),
|
||||
(_, None) => format!("{words} of {name} cleared"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sentence for a meaningful event; `None` for the intermediate steps of
|
||||
/// building an entry (created, line added), which the posted event covers.
|
||||
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
@@ -177,6 +245,67 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"attachment.linked" | "attachment.unlinked" => {
|
||||
let (what, route) = subject_text(b, p.get("subject"));
|
||||
let name = attachment_name(b, att);
|
||||
Some(Event {
|
||||
icon: if line.line_type == "attachment.linked" {
|
||||
icon::LINK
|
||||
} else {
|
||||
icon::LINK_BREAK
|
||||
},
|
||||
text: if line.line_type == "attachment.linked" {
|
||||
format!("Linked {name} to {what}")
|
||||
} else {
|
||||
format!("Unlinked {name} from {what}: {}", s(p, "reason"))
|
||||
},
|
||||
route: Some(Route::Document(att)),
|
||||
also: route,
|
||||
})
|
||||
}
|
||||
"statement.recorded" => {
|
||||
let account = p.get("account_id").and_then(|v| v.as_u64());
|
||||
let closing = s(p, "closing_balance")
|
||||
.parse()
|
||||
.map(fmt::dollars)
|
||||
.unwrap_or_default();
|
||||
Some(Event {
|
||||
icon: icon::FILE_TEXT,
|
||||
text: format!(
|
||||
"Statement for {}, {} to {}: closing balance {closing}",
|
||||
account.map_or("an account".into(), |a| b.account_label(a)),
|
||||
s(p, "period_start"),
|
||||
s(p, "period_end")
|
||||
),
|
||||
route: Some(Route::Document(att)),
|
||||
also: account.map(Route::Account),
|
||||
})
|
||||
}
|
||||
"reconciliation.completed" => {
|
||||
let account = p.get("account_id").and_then(|v| v.as_u64());
|
||||
let balance = s(p, "statement_balance")
|
||||
.parse()
|
||||
.map(fmt::dollars)
|
||||
.unwrap_or_default();
|
||||
Some(Event {
|
||||
icon: icon::CHECK_SQUARE,
|
||||
text: format!(
|
||||
"Reconciled {} through {} at {balance}",
|
||||
account.map_or("an account".into(), |a| b.account_label(a)),
|
||||
s(p, "statement_date")
|
||||
),
|
||||
route: account.map(Route::Account),
|
||||
also: p
|
||||
.get("attachment_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(Route::Document),
|
||||
})
|
||||
}
|
||||
"reconciliation.voided" => ev(
|
||||
icon::PROHIBIT,
|
||||
format!("Voided reconciliation {id}: {}", s(p, "reason")),
|
||||
None,
|
||||
),
|
||||
"attachment.withdrawn" => ev(
|
||||
icon::ARCHIVE,
|
||||
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
|
||||
@@ -236,24 +365,169 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
"vendor.added" => ev(
|
||||
icon::STOREFRONT,
|
||||
format!("Added vendor {}", s(p, "name")),
|
||||
Some(Route::Company),
|
||||
Some(
|
||||
p.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|v| b.ledger.party_of_vendor(v))
|
||||
.map_or(Route::Company, Route::Party),
|
||||
),
|
||||
),
|
||||
"customer.added" => ev(
|
||||
icon::USER_PLUS,
|
||||
format!("Added customer {}", s(p, "name")),
|
||||
Some(Route::Company),
|
||||
Some(
|
||||
p.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|c| b.ledger.party_of_customer(c))
|
||||
.map_or(Route::Company, Route::Party),
|
||||
),
|
||||
),
|
||||
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
|
||||
"vendor.1099_set" => ev(
|
||||
icon::IDENTIFICATION_CARD,
|
||||
"Updated a vendor's 1099 details".into(),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"entry.vendor_set" => ev(
|
||||
icon::STOREFRONT,
|
||||
format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"party.added" => {
|
||||
let roles: Vec<String> = p
|
||||
.get("roles")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|rs| {
|
||||
rs.iter()
|
||||
.filter_map(|r| r.as_str().and_then(ledger_core::Role::parse))
|
||||
.map(|r| super::parties::role_label(r).to_lowercase())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let name = match s(p, "name") {
|
||||
"" => party_name(b, p.get("id").and_then(|v| v.as_u64())),
|
||||
n => n.to_string(),
|
||||
};
|
||||
ev(
|
||||
icon::ADDRESS_BOOK,
|
||||
if roles.is_empty() {
|
||||
format!("Added party {name}")
|
||||
} else {
|
||||
format!("Added party {name} ({})", roles.join(", "))
|
||||
},
|
||||
p.get("id").and_then(|v| v.as_u64()).map(Route::Party),
|
||||
)
|
||||
}
|
||||
"party.updated" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
ev(
|
||||
icon::PENCIL_SIMPLE,
|
||||
party_update_text(b, pid, p.get("change")),
|
||||
pid.map(Route::Party),
|
||||
)
|
||||
}
|
||||
"party.role_set" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
let name = party_name(b, pid);
|
||||
let role = ledger_core::Role::parse(s(p, "role"));
|
||||
let on = p.get("on").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let text = match (role, on) {
|
||||
(Some(r), true) => format!("{name} is now {}", super::parties::role_noun(r)),
|
||||
(Some(r), false) => format!("{name} is no longer {}", super::parties::role_noun(r)),
|
||||
(None, _) => format!("Changed the roles of {name}"),
|
||||
};
|
||||
ev(icon::USER_GEAR, text, pid.map(Route::Party))
|
||||
}
|
||||
"party.w9_set" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
let name = party_name(b, pid);
|
||||
let text = match p.get("facts").filter(|f| !f.is_null()) {
|
||||
Some(f) => {
|
||||
let mut t = format!("W-9 for {name} recorded");
|
||||
if let Some(d) = f.get("received").and_then(|v| v.as_str()) {
|
||||
t.push_str(&format!(", received {d}"));
|
||||
}
|
||||
if let Some(e) = f.get("entity_type").and_then(|v| v.as_str()) {
|
||||
t.push_str(&format!(": {e}"));
|
||||
}
|
||||
if let Some(l4) = f.get("tin_last4").and_then(|v| v.as_str()) {
|
||||
t.push_str(&format!(", TIN ending {l4}"));
|
||||
}
|
||||
t
|
||||
}
|
||||
None => format!("W-9 facts for {name} cleared"),
|
||||
};
|
||||
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
|
||||
}
|
||||
"party.w4_set" => {
|
||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||
let name = party_name(b, pid);
|
||||
let text = match p.get("facts").filter(|f| !f.is_null()) {
|
||||
Some(f) => {
|
||||
let mut t = format!("W-4 for {name} recorded");
|
||||
if let Some(d) = f.get("received").and_then(|v| v.as_str()) {
|
||||
t.push_str(&format!(", received {d}"));
|
||||
}
|
||||
if let Some(st) = f.get("state").and_then(|v| v.as_str()) {
|
||||
t.push_str(&format!(" (works in {st})"));
|
||||
}
|
||||
t
|
||||
}
|
||||
None => format!("W-4 facts for {name} cleared"),
|
||||
};
|
||||
ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party))
|
||||
}
|
||||
"entry.party_set" => {
|
||||
let pid = p.get("party_id").and_then(|v| v.as_u64());
|
||||
let e = entry_id.unwrap_or(0);
|
||||
Some(Event {
|
||||
icon: icon::ADDRESS_BOOK,
|
||||
text: match pid {
|
||||
Some(_) => format!("Tagged #{e} with {}", party_name(b, pid)),
|
||||
None => format!("Cleared the party of #{e}"),
|
||||
},
|
||||
route: entry_id.map(Route::Entry),
|
||||
also: pid.map(Route::Party),
|
||||
})
|
||||
}
|
||||
"vendor.1099_set" => {
|
||||
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
|
||||
let name = vendor
|
||||
.and_then(|v| b.ledger.get_vendor(v))
|
||||
.map(|v| v.name.clone());
|
||||
let form = p
|
||||
.get("form_1099")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(ledger_core::Form1099::parse)
|
||||
.map(|f| f.label());
|
||||
let w9 = p.get("tin_on_file").and_then(|v| v.as_bool());
|
||||
let text = match (name, form, w9) {
|
||||
(Some(n), Some(f), Some(w)) => format!(
|
||||
"1099 details of {n}: {f}, W-9 {}",
|
||||
if w { "on file" } else { "not on file" }
|
||||
),
|
||||
(Some(n), None, Some(_)) => format!("{n} no longer gets a 1099"),
|
||||
(Some(n), _, None) => format!("Updated the 1099 details of {n}"),
|
||||
(None, ..) => "Updated a vendor's 1099 details".into(),
|
||||
};
|
||||
ev(
|
||||
icon::IDENTIFICATION_CARD,
|
||||
text,
|
||||
Some(
|
||||
vendor
|
||||
.and_then(|v| b.ledger.party_of_vendor(v))
|
||||
.map_or(Route::Company, Route::Party),
|
||||
),
|
||||
)
|
||||
}
|
||||
"entry.vendor_set" => {
|
||||
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
|
||||
let e = entry_id.unwrap_or(0);
|
||||
Some(Event {
|
||||
icon: icon::STOREFRONT,
|
||||
text: match vendor.and_then(|v| b.ledger.get_vendor(v)) {
|
||||
Some(v) => format!("Tagged #{e} with vendor {}", v.name),
|
||||
None if vendor.is_none() && p.get("vendor_id").is_some() => {
|
||||
format!("Cleared the vendor of #{e}")
|
||||
}
|
||||
None => format!("Tagged #{e} with a vendor"),
|
||||
},
|
||||
route: entry_id.map(Route::Entry),
|
||||
also: vendor
|
||||
.and_then(|v| b.ledger.party_of_vendor(v))
|
||||
.map(Route::Party),
|
||||
})
|
||||
}
|
||||
"invoice.created" => ev(
|
||||
icon::INVOICE,
|
||||
format!("Invoice {id} created"),
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod documents;
|
||||
pub mod entry;
|
||||
pub mod journal;
|
||||
pub mod overview;
|
||||
pub mod parties;
|
||||
pub mod profit;
|
||||
pub mod reports;
|
||||
pub mod transactions;
|
||||
@@ -55,6 +56,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
|
||||
Route::Account(id) => accounts::show_account(ui, b, *id, shell),
|
||||
Route::Assets => assets::show_register(ui, b, shell),
|
||||
Route::Asset(id) => assets::show_asset(ui, b, *id, shell),
|
||||
Route::Parties => parties::show_list(ui, b, shell),
|
||||
Route::Party(id) => parties::show_party(ui, b, *id, shell),
|
||||
Route::Documents => documents::show(ui, b, None, shell),
|
||||
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
|
||||
Route::Company => company::show(ui, b, shell),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,7 +78,7 @@ fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.hint_text(format!(
|
||||
"{} Search description, account, vendor, amount",
|
||||
"{} Search description, account, party, amount",
|
||||
icon::MAGNIFYING_GLASS
|
||||
))
|
||||
.desired_width(if narrow { ui.available_width() } else { 340.0 }),
|
||||
@@ -204,10 +204,11 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg
|
||||
max_w,
|
||||
);
|
||||
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
|
||||
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
|
||||
if let Some(v) = &e.party {
|
||||
sub.push_str(&format!(" · {v}"));
|
||||
}
|
||||
// The party first: it's what's cut last when the row is narrow.
|
||||
let sub = match &e.party {
|
||||
Some(v) => format!("#{} · {} · {v} · {}", e.id, e.origin, accounts.join(" → ")),
|
||||
None => format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → ")),
|
||||
};
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
egui::pos2(text_left, rect.center().y + 10.0),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
|
||||
//! amount, or #id), fixed assets (by tag, serial, or description), vendors,
|
||||
//! and documents, ranked by a simple fuzzy match.
|
||||
//! amount, or #id), fixed assets (by tag, serial, or description), parties
|
||||
//! (vendors and customers among them, by name or display name), and
|
||||
//! documents, ranked by a simple fuzzy match.
|
||||
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
@@ -224,6 +225,15 @@ pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||
/// tag, serial, and description; a query found whole in its tag or
|
||||
/// serial (ignoring case) adds 500, in its description 50.
|
||||
/// 3. Every asset result opens Route::Asset(its id).
|
||||
/// INVARIANTS (parties):
|
||||
/// 4. A party whose name or display name equals the trimmed query,
|
||||
/// ignoring case, scores 1500: above an entry's exact #id, below an
|
||||
/// exact asset tag.
|
||||
/// 5. Otherwise a party is listed when the query fuzzily matches its
|
||||
/// name and display name; a query found whole in either (ignoring
|
||||
/// case) adds 500.
|
||||
/// 6. Every party result opens Route::Party(its id); archived parties
|
||||
/// are found too, and say so.
|
||||
/// ```
|
||||
pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
let q = query.trim();
|
||||
@@ -329,13 +339,34 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
});
|
||||
}
|
||||
}
|
||||
for v in b.ledger.list_vendors() {
|
||||
if let Some(score) = fuzzy(&v.name, q) {
|
||||
for p in b.ledger.parties() {
|
||||
let display = p.display_name.as_deref().unwrap_or("");
|
||||
let exact = |t: &str| !t.is_empty() && t.trim().to_lowercase() == ql;
|
||||
let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql);
|
||||
let score = if exact(&p.name) || exact(display) {
|
||||
Some(1500)
|
||||
} else {
|
||||
fuzzy(&format!("{} {display}", p.name), q).map(|s| {
|
||||
s + if whole(&p.name) || whole(display) {
|
||||
500
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(score) = score {
|
||||
let mut detail = vec![crate::pages::parties::roles_text(p)];
|
||||
if p.display_name.is_some() {
|
||||
detail.push(p.name.clone());
|
||||
}
|
||||
if p.archived {
|
||||
detail.push("archived".into());
|
||||
}
|
||||
out.push(Item {
|
||||
icon: icon::STOREFRONT,
|
||||
title: v.name.clone(),
|
||||
detail: "Vendor".into(),
|
||||
route: Route::Company,
|
||||
icon: crate::pages::parties::party_icon(p),
|
||||
title: p.label().to_string(),
|
||||
detail: detail.join(" · "),
|
||||
route: Route::Party(p.id),
|
||||
score,
|
||||
});
|
||||
}
|
||||
@@ -378,6 +409,7 @@ pub fn describe(b: &Books, r: &Route) -> String {
|
||||
.map(|a| a.file_name.clone())
|
||||
.unwrap_or_default(),
|
||||
Route::Asset(id) => b.asset_label(*id),
|
||||
Route::Party(id) => b.party_label(*id),
|
||||
Route::JournalEvent(seq) => format!("Event {seq}"),
|
||||
_ => String::new(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
//! Parties in the viewer: each party's totals are the books' numbers
|
||||
//! (ledger-core's records and 1099 worksheet, summed exactly as
|
||||
//! `PartyInfo` defines); search, books.show, the journal, the entry page,
|
||||
//! and "Needs attention" know parties.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
|
||||
W4Facts, W9Facts, vendor_1099_report,
|
||||
};
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::nav::{Route, Section};
|
||||
use ledger_gui::pages::journal::describe;
|
||||
use ledger_gui::pages::parties::{
|
||||
State, W9Status, listed, masked_tin, role_counts, roles_text, w9_status,
|
||||
};
|
||||
use ledger_gui::{App, Source, attention, data, palette};
|
||||
use ledger_store::Store;
|
||||
use ledger_store::viewer::ViewerTarget;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static DIRS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
let n = DIRS.fetch_add(1, Ordering::Relaxed);
|
||||
let d = std::env::temp_dir().join(format!(
|
||||
"ledger-gui-parties-{tag}-{}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TempDir(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn cents(c: i64) -> Decimal {
|
||||
Decimal::new(c, 2)
|
||||
}
|
||||
|
||||
fn usd(c: i64) -> Money {
|
||||
Money::new(cents(c), Currency::USD)
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
d(2026, 10, 15)
|
||||
}
|
||||
|
||||
fn load(dir: &Path) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn doc(c: i64) -> Vec<DocumentLine> {
|
||||
vec![DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: cents(c),
|
||||
}]
|
||||
}
|
||||
|
||||
fn w9(last4: &str) -> W9Facts {
|
||||
W9Facts {
|
||||
received: d(2026, 9, 1),
|
||||
entity_type: "Individual/sole proprietor".into(),
|
||||
exempt_payee_code: None,
|
||||
fatca_code: None,
|
||||
tin_last4: last4.into(),
|
||||
kept_at: "wanderbooks vault".into(),
|
||||
vault_record: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
dir: TempDir,
|
||||
books: Books,
|
||||
/// The store's own ledger, reopened from disk (the oracle's input).
|
||||
oracle: ledger_core::Ledger,
|
||||
sam: PartyId,
|
||||
sam_vendor: u64,
|
||||
riley: PartyId,
|
||||
acme: PartyId,
|
||||
gumroad: PartyId,
|
||||
irs: PartyId,
|
||||
pat: PartyId,
|
||||
old: PartyId,
|
||||
bill_entry: EntryId,
|
||||
invoice_entry: EntryId,
|
||||
reversal: EntryId,
|
||||
draft: EntryId,
|
||||
remit: EntryId,
|
||||
}
|
||||
|
||||
/// Sam (a vendor marked for a 1099-NEC, no W-9) paid by cash, card, and
|
||||
/// an owner, last year and this, with a payment reversed, a bill partly
|
||||
/// paid, and a draft; Riley (a contractor, no vendor record) paid by
|
||||
/// cash; Acme (a customer) invoiced this year and last, partly paid;
|
||||
/// Gumroad paying out; the IRS paid a payroll remittance; Pat on payroll
|
||||
/// (one run by party, one older run by name); Old Co paid, then archived.
|
||||
fn fixture(tag: &str) -> Fixture {
|
||||
let dir = TempDir::new(tag);
|
||||
let data = dir.0.join("books-data");
|
||||
let mut s = Store::create(&data, Currency::USD).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = s
|
||||
.add_account("2050", "Business Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let owed = s
|
||||
.add_account(
|
||||
"2100",
|
||||
"Due to Shareholders",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let sales = s
|
||||
.add_account("4100", "Template Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let fees = s
|
||||
.add_account("6000", "Platform Fees", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let labor = s
|
||||
.add_account("6050", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(2025, 1, 1), "Owner contribution").unwrap();
|
||||
s.add_line(e, cash, Side::Debit, usd(10_000_000)).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, usd(10_000_000))
|
||||
.unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let pay = |s: &mut Store, date, what: &str, cr, c| {
|
||||
let e = s.create_entry(date, what).unwrap();
|
||||
s.add_line(e, labor, Side::Debit, usd(c)).unwrap();
|
||||
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
|
||||
let sam_vendor = s.add_vendor("Sam Okafor").unwrap();
|
||||
s.set_vendor_1099(sam_vendor, Some(Form1099::Nec), false)
|
||||
.unwrap();
|
||||
let sam = s.ledger().party_of_vendor(sam_vendor).unwrap();
|
||||
s.set_party_role(sam, Role::Contractor, true).unwrap();
|
||||
let riley = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
|
||||
let acme_c = s.add_customer("Acme Buyer").unwrap();
|
||||
let acme = s.ledger().party_of_customer(acme_c).unwrap();
|
||||
let gumroad = s
|
||||
.add_party("Gumroad, Inc.", &[Role::PayoutPlatform])
|
||||
.unwrap();
|
||||
s.update_party(gumroad, PartyField::DisplayName(Some("Gumroad".into())))
|
||||
.unwrap();
|
||||
let irs = s
|
||||
.add_party("Internal Revenue Service", &[Role::TaxAgency])
|
||||
.unwrap();
|
||||
let pat = s.add_party("Pat Lee", &[Role::Employee]).unwrap();
|
||||
s.set_party_w4(
|
||||
pat,
|
||||
Some(W4Facts {
|
||||
received: d(2026, 1, 2),
|
||||
state: "UT".into(),
|
||||
kept_at: "payroll provider".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let old = s.add_party("Old Co", &[Role::Contractor]).unwrap();
|
||||
|
||||
// Sam: last year (not this year's), then cash, card, an owner, a
|
||||
// reversed payment, a bill partly paid, and a draft.
|
||||
let e = pay(&mut s, d(2025, 12, 15), "Sam, December", cash, 50_000);
|
||||
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
|
||||
let e = pay(&mut s, d(2026, 2, 1), "Sam, February", cash, 100_000);
|
||||
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
|
||||
let e = pay(&mut s, d(2026, 3, 1), "Sam, by card", card, 20_000);
|
||||
s.set_entry_party(e, Some(sam)).unwrap();
|
||||
let e = pay(&mut s, d(2026, 4, 1), "Sam, paid by Stephen", owed, 30_000);
|
||||
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
|
||||
let e = pay(&mut s, d(2026, 5, 1), "Sam, twice by mistake", cash, 25_000);
|
||||
s.set_entry_vendor(e, Some(sam_vendor)).unwrap();
|
||||
let reversal = s.reverse_entry(e, d(2026, 5, 2), "Paid twice").unwrap();
|
||||
let bill = s
|
||||
.create_bill(sam_vendor, d(2026, 6, 1), d(2026, 7, 1), doc(80_000))
|
||||
.unwrap();
|
||||
let bill_entry = s.ledger().get_bill(bill).unwrap().entry_id;
|
||||
s.pay_bill(bill, d(2026, 6, 15), cents(50_000), cash)
|
||||
.unwrap();
|
||||
let draft = s.create_entry(d(2026, 10, 1), "Sam, October").unwrap();
|
||||
s.add_line(draft, labor, Side::Debit, usd(9_900)).unwrap();
|
||||
s.add_line(draft, cash, Side::Credit, usd(9_900)).unwrap();
|
||||
s.set_entry_party(draft, Some(sam)).unwrap();
|
||||
|
||||
// Riley, by cash, tagged by party.
|
||||
let e = pay(&mut s, d(2026, 7, 1), "Riley, July", cash, 70_000);
|
||||
s.set_entry_party(e, Some(riley)).unwrap();
|
||||
|
||||
// Acme: last year's invoice still open, this year's partly paid.
|
||||
s.create_invoice(acme_c, d(2025, 12, 1), d(2025, 12, 31), doc(40_000))
|
||||
.unwrap();
|
||||
let inv = s
|
||||
.create_invoice(acme_c, d(2026, 2, 10), d(2026, 3, 10), doc(100_000))
|
||||
.unwrap();
|
||||
let invoice_entry = s.ledger().get_invoice(inv).unwrap().entry_id;
|
||||
s.receive_payment(inv, d(2026, 3, 1), cents(60_000), cash)
|
||||
.unwrap();
|
||||
|
||||
// Gumroad's payout: net to cash, fee expensed, gross revenue.
|
||||
let e = s.create_entry(d(2026, 8, 1), "Gumroad payout").unwrap();
|
||||
s.add_line(e, cash, Side::Debit, usd(9_000)).unwrap();
|
||||
s.add_line(e, fees, Side::Debit, usd(1_000)).unwrap();
|
||||
s.add_line(e, sales, Side::Credit, usd(10_000)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s.set_entry_party(e, Some(gumroad)).unwrap();
|
||||
|
||||
// Payroll: Pat by party, then an older-style line by name; the
|
||||
// remittance goes to the IRS.
|
||||
let line = |employee: Option<PartyId>| PayrollLine {
|
||||
employee_name: "Pat Lee".into(),
|
||||
employee,
|
||||
gross_pay: cents(100_000),
|
||||
federal_withholding: cents(10_000),
|
||||
state_withholding: cents(3_000),
|
||||
ss_employee: cents(6_200),
|
||||
medicare_employee: cents(1_450),
|
||||
ss_employer: cents(6_200),
|
||||
medicare_employer: cents(1_450),
|
||||
other_deductions: Decimal::ZERO,
|
||||
};
|
||||
s.run_payroll(d(2026, 8, 15), vec![line(Some(pat))], cash)
|
||||
.unwrap();
|
||||
s.run_payroll(d(2026, 8, 31), vec![line(None)], cash)
|
||||
.unwrap();
|
||||
let remit = s.remit_payroll_taxes(d(2026, 9, 5), cash).unwrap();
|
||||
s.set_entry_party(remit, Some(irs)).unwrap();
|
||||
|
||||
// Old Co: paid, then archived.
|
||||
let e = pay(&mut s, d(2026, 1, 20), "Old Co", cash, 12_300);
|
||||
s.set_entry_party(e, Some(old)).unwrap();
|
||||
s.update_party(old, PartyField::Archived(true)).unwrap();
|
||||
drop(s);
|
||||
let oracle = Store::open(&data).unwrap().ledger().clone();
|
||||
Fixture {
|
||||
books: load(&dir.0),
|
||||
dir,
|
||||
oracle,
|
||||
sam,
|
||||
sam_vendor,
|
||||
riley,
|
||||
acme,
|
||||
gumroad,
|
||||
irs,
|
||||
pat,
|
||||
old,
|
||||
bill_entry,
|
||||
invoice_entry,
|
||||
reversal,
|
||||
draft,
|
||||
remit,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_partys_totals_are_the_books_numbers() {
|
||||
let f = fixture("totals");
|
||||
let b = &f.books;
|
||||
let o = &f.oracle;
|
||||
let info = |p| b.party(p).cloned().unwrap();
|
||||
|
||||
// Sam: this year's cash 1,000 + card 200 + owner 300 + (250 − 250
|
||||
// reversed) + 500 of the bill paid. Last year's 500, the bill itself,
|
||||
// and the draft move nothing this year.
|
||||
let sam = info(f.sam);
|
||||
assert_eq!(sam.paid_ytd, cents(200_000));
|
||||
let bill = o.get_bill(1).unwrap();
|
||||
assert_eq!(sam.billed_ytd, bill.total());
|
||||
assert_eq!(sam.open_bills, bill.balance());
|
||||
assert_eq!(sam.open_bills, cents(30_000));
|
||||
assert_eq!(sam.bills, vec![1]);
|
||||
// The 1099 amount is ledger-core's worksheet line, exactly.
|
||||
let r = vendor_1099_report(o, 2026, None).unwrap();
|
||||
let line = r
|
||||
.vendors
|
||||
.iter()
|
||||
.find(|v| v.vendor_id == f.sam_vendor)
|
||||
.unwrap();
|
||||
assert_eq!(sam.form_1099.as_ref(), Some(line));
|
||||
assert_eq!(sam.threshold_1099, Some(r.threshold));
|
||||
// (Cash only: card and owner-paid aren't in it.)
|
||||
assert_eq!(line.paid, cents(150_000));
|
||||
// Every entry tagged, drafts included, newest first.
|
||||
assert!(sam.entries.contains(&f.bill_entry));
|
||||
assert!(sam.entries.contains(&f.reversal));
|
||||
assert!(sam.entries.contains(&f.draft));
|
||||
assert_eq!(sam.entries.len(), 9, "{:?}", sam.entries);
|
||||
let dates: Vec<NaiveDate> = sam
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| b.entry(*e).unwrap().date)
|
||||
.collect();
|
||||
assert!(dates.windows(2).all(|w| w[0] >= w[1]), "{dates:?}");
|
||||
|
||||
// Riley: 700 by cash.
|
||||
assert_eq!(info(f.riley).paid_ytd, cents(70_000));
|
||||
|
||||
// Acme: received 600 on net (negative paid); invoiced this year 1,000;
|
||||
// still owes last year's 400 and this year's 400.
|
||||
let acme = info(f.acme);
|
||||
assert_eq!(acme.paid_ytd, cents(-60_000));
|
||||
let invoices: Vec<_> = (1..o.next_invoice_id())
|
||||
.filter_map(|i| o.get_invoice(i))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
acme.invoiced_ytd,
|
||||
invoices
|
||||
.iter()
|
||||
.filter(|i| i.date >= d(2026, 1, 1))
|
||||
.map(|i| i.total())
|
||||
.sum::<Decimal>()
|
||||
);
|
||||
assert_eq!(
|
||||
acme.open_invoices,
|
||||
invoices.iter().map(|i| i.balance()).sum::<Decimal>()
|
||||
);
|
||||
assert_eq!(acme.open_invoices, cents(80_000));
|
||||
assert_eq!(acme.invoices.len(), 2);
|
||||
assert!(acme.form_1099.is_none());
|
||||
|
||||
// Gumroad: the net deposit came in.
|
||||
assert_eq!(info(f.gumroad).paid_ytd, cents(-9_000));
|
||||
|
||||
// The IRS: the remittance's cash, from its own lines.
|
||||
let remit = o.get_entry(f.remit).unwrap();
|
||||
let cash = o.chart().find_by_code("1000").unwrap().id;
|
||||
let out: Decimal = remit
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.account == cash && l.side == Side::Credit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
assert!(out > Decimal::ZERO);
|
||||
assert_eq!(info(f.irs).paid_ytd, out);
|
||||
|
||||
// Pat: both runs (one by party, one by name), no tagged entries.
|
||||
let pat = info(f.pat);
|
||||
assert_eq!(pat.payroll_runs.len(), 2);
|
||||
assert!(pat.entries.is_empty());
|
||||
assert_eq!(pat.paid_ytd, Decimal::ZERO);
|
||||
|
||||
// Every entry's party is the one whose list holds it.
|
||||
for e in &b.entries {
|
||||
if let Some(p) = e.party_id {
|
||||
assert!(info(p).entries.contains(&e.id), "#{}", e.id);
|
||||
assert_eq!(e.party.as_deref(), Some(b.party_label(p).as_str()));
|
||||
}
|
||||
}
|
||||
assert_eq!(b.entry(f.bill_entry).unwrap().party_id, Some(f.sam));
|
||||
assert_eq!(b.entry(f.invoice_entry).unwrap().party_id, Some(f.acme));
|
||||
assert_eq!(b.entry(f.reversal).unwrap().party_id, Some(f.sam));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_directory_lists_filters_and_hides_archived() {
|
||||
let f = fixture("list");
|
||||
let b = &f.books;
|
||||
let names = |st: &State| -> Vec<String> {
|
||||
listed(b, st)
|
||||
.iter()
|
||||
.map(|p| p.label().to_string())
|
||||
.collect()
|
||||
};
|
||||
let mut st = State::default();
|
||||
assert_eq!(
|
||||
names(&st),
|
||||
vec![
|
||||
"Acme Buyer",
|
||||
"Gumroad",
|
||||
"Internal Revenue Service",
|
||||
"Pat Lee",
|
||||
"Riley Chen",
|
||||
"Sam Okafor"
|
||||
]
|
||||
);
|
||||
st.show_archived = true;
|
||||
assert!(names(&st).contains(&"Old Co".to_string()));
|
||||
st.role = Some(Role::Contractor);
|
||||
assert_eq!(names(&st), vec!["Old Co", "Riley Chen", "Sam Okafor"]);
|
||||
st.show_archived = false;
|
||||
st.query = " sam ".into();
|
||||
assert_eq!(names(&st), vec!["Sam Okafor"]);
|
||||
st.role = None;
|
||||
st.query = "gumroad inc".into();
|
||||
assert_eq!(names(&st), vec!["Gumroad"]);
|
||||
let counts = role_counts(b, false);
|
||||
assert!(counts.contains(&(Role::Contractor, 2)));
|
||||
assert!(counts.contains(&(Role::Vendor, 1)));
|
||||
assert!(!counts.iter().any(|(r, _)| *r == Role::Landlord));
|
||||
let sam = b.ledger.get_party(f.sam).unwrap();
|
||||
assert_eq!(roles_text(sam), "Vendor, contractor");
|
||||
assert_eq!(w9_status(b, sam), W9Status::Missing);
|
||||
assert_eq!(
|
||||
w9_status(b, b.ledger.get_party(f.gumroad).unwrap()),
|
||||
W9Status::NotNeeded
|
||||
);
|
||||
assert_eq!(masked_tin("1234"), "•••-••-1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_parties_by_name() {
|
||||
let f = fixture("search");
|
||||
let b = &f.books;
|
||||
for q in ["Gumroad", "gumroad, inc.", " GUMROAD "] {
|
||||
let items = palette::search(b, q);
|
||||
assert_eq!(items[0].route, Route::Party(f.gumroad), "{q}");
|
||||
assert_eq!(items[0].score, 1500, "{q}");
|
||||
}
|
||||
let first_party = |q: &str| {
|
||||
palette::search(b, q)
|
||||
.into_iter()
|
||||
.find(|i| matches!(i.route, Route::Party(_)))
|
||||
};
|
||||
assert_eq!(first_party("rile").unwrap().route, Route::Party(f.riley));
|
||||
// Vendors and customers are found as their parties.
|
||||
assert_eq!(
|
||||
palette::search(b, "Sam Okafor")[0].route,
|
||||
Route::Party(f.sam)
|
||||
);
|
||||
assert_eq!(first_party("acme").unwrap().route, Route::Party(f.acme));
|
||||
// Archived parties are found, and say so.
|
||||
let old = first_party("Old Co").unwrap();
|
||||
assert_eq!(old.route, Route::Party(f.old));
|
||||
assert!(old.detail.contains("archived"), "{}", old.detail);
|
||||
assert_eq!(palette::describe(b, &Route::Party(f.gumroad)), "Gumroad");
|
||||
// Transactions search by party name too.
|
||||
let sam_entry = b.entry(f.bill_entry).unwrap();
|
||||
assert!(sam_entry.haystack.contains("sam okafor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_and_books_show_open_parties() {
|
||||
let f = fixture("routes");
|
||||
assert_eq!(Route::Parties.section(), Section::Parties);
|
||||
assert_eq!(Route::Party(f.sam).section(), Section::Parties);
|
||||
assert_eq!(Section::Parties.home(), Route::Parties);
|
||||
assert!(Section::ALL.contains(&Section::Parties));
|
||||
let books = load(&f.dir.0);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 920.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
|
||||
h.state_mut().show(ViewerTarget::Page {
|
||||
page: "parties".into(),
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &Route::Parties);
|
||||
h.state_mut().show(ViewerTarget::Party {
|
||||
party_id: f.gumroad,
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &Route::Party(f.gumroad));
|
||||
// An unknown party is ignored.
|
||||
h.state_mut().show(ViewerTarget::Party { party_id: 999 });
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state_mut().route(), &Route::Party(f.gumroad));
|
||||
// Every party's page, and the entry page of a tagged entry, render.
|
||||
for p in [f.sam, f.riley, f.acme, f.gumroad, f.irs, f.pat, f.old, 999] {
|
||||
h.state_mut().goto(Route::Party(p));
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.state_mut().goto(Route::Entry(f.bill_entry));
|
||||
h.run_steps(2);
|
||||
h.state_mut().state_mut().parties.show_archived = true;
|
||||
h.state_mut().goto(Route::Parties);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_text_for_party_events() {
|
||||
let f = fixture("journal");
|
||||
let b = &f.books;
|
||||
let texts: Vec<(String, Option<Route>, Option<Route>)> = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| l.line_type.starts_with("party.") || l.line_type == "entry.party_set")
|
||||
.filter_map(|l| describe(b, l))
|
||||
.map(|e| (e.text, e.route, e.also))
|
||||
.collect();
|
||||
let has = |t: &str, route: Option<Route>| {
|
||||
assert!(
|
||||
texts.iter().any(|(x, r, _)| x == t && *r == route),
|
||||
"no {t:?} ({route:?}) in {texts:#?}"
|
||||
);
|
||||
};
|
||||
has(
|
||||
"Added party Riley Chen (contractor)",
|
||||
Some(Route::Party(f.riley)),
|
||||
);
|
||||
has(
|
||||
"Added party Gumroad, Inc. (payout platform)",
|
||||
Some(Route::Party(f.gumroad)),
|
||||
);
|
||||
has(
|
||||
"Display name of Gumroad, Inc. set to Gumroad",
|
||||
Some(Route::Party(f.gumroad)),
|
||||
);
|
||||
has("Sam Okafor is now a contractor", Some(Route::Party(f.sam)));
|
||||
has(
|
||||
"W-4 for Pat Lee recorded, received 2026-01-02 (works in UT)",
|
||||
Some(Route::Party(f.pat)),
|
||||
);
|
||||
has("Archived Old Co", Some(Route::Party(f.old)));
|
||||
let tag = texts
|
||||
.iter()
|
||||
.find(|(x, _, _)| x.starts_with("Tagged #") && x.ends_with("with Gumroad"))
|
||||
.expect("entry.party_set text");
|
||||
assert!(matches!(tag.1, Some(Route::Entry(_))));
|
||||
assert_eq!(tag.2, Some(Route::Party(f.gumroad)));
|
||||
// Party history: its own events and its records'.
|
||||
let sam = b.party(f.sam).unwrap();
|
||||
let kinds: Vec<&str> = sam
|
||||
.events
|
||||
.iter()
|
||||
.map(|&i| b.journal[i].line_type.as_str())
|
||||
.collect();
|
||||
for k in [
|
||||
"vendor.added",
|
||||
"vendor.1099_set",
|
||||
"party.role_set",
|
||||
"entry.vendor_set",
|
||||
"entry.party_set",
|
||||
] {
|
||||
assert!(kinds.contains(&k), "{k} not in {kinds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w9_text_never_shows_more_than_the_last_four() {
|
||||
let f = fixture("w9text");
|
||||
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
|
||||
s.set_party_w9(f.riley, Some(w9("6789"))).unwrap();
|
||||
s.set_party_w9(f.riley, None).unwrap();
|
||||
drop(s);
|
||||
let b = load(&f.dir.0);
|
||||
let texts: Vec<String> = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| l.line_type == "party.w9_set")
|
||||
.filter_map(|l| describe(&b, l))
|
||||
.map(|e| e.text)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![
|
||||
"W-9 for Riley Chen recorded, received 2026-09-01: Individual/sole proprietor, TIN ending 6789",
|
||||
"W-9 facts for Riley Chen cleared",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attention_asks_for_a_w9_until_its_facts_are_recorded() {
|
||||
let f = fixture("attention");
|
||||
let items = attention::find(&f.books);
|
||||
let item = |name: &str| {
|
||||
items
|
||||
.iter()
|
||||
.find(|a| a.title == format!("Collect a W-9 from {name}"))
|
||||
};
|
||||
let riley = item("Riley Chen").expect("Riley's W-9 item");
|
||||
assert_eq!(riley.target, Some(Route::Party(f.riley)));
|
||||
assert!(riley.detail.contains("$700.00"), "{}", riley.detail);
|
||||
assert!(riley.prompt.starts_with("In wanderbooks,"));
|
||||
assert!(riley.prompt.contains("books.set_w9"));
|
||||
assert!(riley.prompt.contains("last four"));
|
||||
assert!(item("Sam Okafor").is_some(), "Sam is paid, no W-9");
|
||||
assert!(item("Old Co").is_none(), "archived");
|
||||
assert!(item("Gumroad").is_none(), "no 1099 role");
|
||||
// The existing items are still found.
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.any(|a| a.title == "$300.00 owed on Due to Shareholders"),
|
||||
"{:#?}",
|
||||
items.iter().map(|a| &a.title).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Recording the facts clears it.
|
||||
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
|
||||
s.set_party_w9(f.riley, Some(w9("1234"))).unwrap();
|
||||
drop(s);
|
||||
let b = load(&f.dir.0);
|
||||
let items = attention::find(&b);
|
||||
assert!(
|
||||
!items
|
||||
.iter()
|
||||
.any(|a| a.title == "Collect a W-9 from Riley Chen")
|
||||
);
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.any(|a| a.title == "Collect a W-9 from Sam Okafor")
|
||||
);
|
||||
assert_eq!(
|
||||
w9_status(&b, b.ledger.get_party(f.riley).unwrap()),
|
||||
W9Status::OnFile {
|
||||
last4: "1234".into(),
|
||||
received: d(2026, 9, 1)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -134,6 +134,7 @@ fn the_viewer_never_writes_into_the_books() {
|
||||
.next()
|
||||
.map(|a| a.id)
|
||||
.unwrap();
|
||||
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
|
||||
drop(books);
|
||||
|
||||
// Follow live for a moment.
|
||||
@@ -174,6 +175,8 @@ fn the_viewer_never_writes_into_the_books() {
|
||||
Route::Account(cash),
|
||||
Route::Assets,
|
||||
Route::Asset(asset),
|
||||
Route::Parties,
|
||||
Route::Party(party),
|
||||
Route::Document(doc),
|
||||
Route::JournalEvent(event),
|
||||
Route::Documents,
|
||||
|
||||
@@ -51,6 +51,19 @@ fn screenshots() {
|
||||
.next()
|
||||
.map(|a| a.id)
|
||||
.unwrap_or(1);
|
||||
// A party with the most to show: W-9 facts first, else any.
|
||||
let party = std::env::var("SHOT_PARTY")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.or_else(|| {
|
||||
loaded
|
||||
.ledger
|
||||
.parties()
|
||||
.find(|p| p.w9.is_some() && p.vendor.is_some())
|
||||
.or_else(|| loaded.ledger.parties().next())
|
||||
.map(|p| p.id)
|
||||
})
|
||||
.unwrap_or(1);
|
||||
drop(loaded);
|
||||
let routes: Vec<(&str, Route)> = vec![
|
||||
("overview", Route::Overview),
|
||||
@@ -82,6 +95,8 @@ fn screenshots() {
|
||||
("account", Route::Account(first_cash)),
|
||||
("assets", Route::Assets),
|
||||
("asset", Route::Asset(first_asset)),
|
||||
("parties", Route::Parties),
|
||||
("party", Route::Party(party)),
|
||||
("document", Route::Document(last_doc)),
|
||||
("event", Route::JournalEvent(last_event)),
|
||||
("documents", Route::Documents),
|
||||
|
||||
@@ -614,6 +614,7 @@ fn more_origins_and_parties() {
|
||||
today,
|
||||
vec![ledger_core::PayrollLine {
|
||||
employee_name: "Pat".into(),
|
||||
employee: None,
|
||||
gross_pay: Decimal::new(100_000, 2),
|
||||
federal_withholding: Decimal::new(10_000, 2),
|
||||
state_withholding: Decimal::new(3_000, 2),
|
||||
|
||||
+722
-24
@@ -23,9 +23,9 @@
|
||||
//! preview MUST change nothing.
|
||||
//! 5. An entry the receipt policy covers MUST NOT post, or keep posted,
|
||||
//! without a receipt (crate::policy).
|
||||
//! 6. A reference (account, vendor, class) given as a number that names
|
||||
//! one thing by id and another by code or name is refused, never
|
||||
//! guessed.
|
||||
//! 6. A reference (account, vendor, class, party) given as a number
|
||||
//! that names one thing by id and another by code or name is
|
||||
//! refused, never guessed.
|
||||
//! 7. The server MUST answer every request with exactly one response
|
||||
//! whose id is the request's, and keep serving after any tool error.
|
||||
//! ```
|
||||
@@ -54,7 +54,7 @@ use rust_decimal::Decimal;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The agent operating contract, delivered via initialize.
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-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- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled
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- 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 WL-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 platform, tax agency, bank or card issuer, member, employee) gets a party: books.add_party with its roles (books.add_vendor and books.add_customer link their record to the party of the same name, or make one). Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too). Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. Never store a full TIN or SSN in the books, only the last four digits: the engine refuses any nine-digit number in a party field. books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Ru
Line truncated
|
||||
|
||||
/// Max receipt/document upload: 25 MB.
|
||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
@@ -71,6 +71,25 @@ pub struct BooksServer {
|
||||
#[cfg(test)]
|
||||
pub use crate::settings::CashRoles;
|
||||
|
||||
/// Where payments to vendors come from, for the 1099 worksheet: the
|
||||
/// `[books] cash_accounts` and `financing_accounts` in the chart (codes the
|
||||
/// chart lacks are skipped; `books.settings` reports them).
|
||||
fn payment_accounts(
|
||||
ledger: &ledger_core::Ledger,
|
||||
roles: &crate::settings::CashRoles,
|
||||
) -> ledger_core::PaymentAccounts {
|
||||
let ids = |codes: &[String]| {
|
||||
codes
|
||||
.iter()
|
||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||
.collect()
|
||||
};
|
||||
ledger_core::PaymentAccounts {
|
||||
cash: ids(&roles.cash),
|
||||
financing: ids(&roles.financing),
|
||||
}
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// The wanderbooks.toml settings that name account `code`.
|
||||
fn config_mentions(&self, code: &str) -> Vec<String> {
|
||||
@@ -626,6 +645,189 @@ fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a party by id or by name: the name ignoring case and
|
||||
/// surrounding space, among parties not archived (an archived party goes
|
||||
/// by id). A number that is one party's id and another's name is refused
|
||||
/// (INVARIANT 6).
|
||||
fn resolve_party(store: &Store, party: &str) -> Result<ledger_core::PartyId, String> {
|
||||
let ledger = store.ledger();
|
||||
let by_id = party
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| ledger.get_party(*id).is_some());
|
||||
let by_name = ledger.party_by_name(party).map(|p| p.id);
|
||||
match (by_id, by_name) {
|
||||
(Some(i), Some(n)) if i != n => Err(format!(
|
||||
"party {party:?} is ambiguous: it is party {i}'s id and party {n}'s name; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown party {party:?}: use an id or name from books.parties (an archived party goes by id), or add it with books.add_party"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse role names (`Role::key`), refusing any unknown one.
|
||||
fn parse_roles(roles: &[String]) -> Result<Vec<ledger_core::Role>, String> {
|
||||
roles
|
||||
.iter()
|
||||
.map(|r| {
|
||||
ledger_core::Role::parse(r.trim()).ok_or_else(|| {
|
||||
format!(
|
||||
"unknown role {r:?}: expected one of {}",
|
||||
ledger_core::Role::ALL.map(|r| r.key()).join(", ")
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A party's roles (vendor and customer included), in display order.
|
||||
fn party_roles(p: &ledger_core::Party) -> Vec<String> {
|
||||
let all = p.all_roles();
|
||||
ledger_core::Role::ALL
|
||||
.into_iter()
|
||||
.filter(|r| all.contains(r))
|
||||
.map(|r| r.key().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A party's facts for a tool's output.
|
||||
fn party_out(p: &ledger_core::Party) -> PartyOut {
|
||||
PartyOut {
|
||||
party_id: p.id,
|
||||
name: p.name.clone(),
|
||||
label: p.label().to_string(),
|
||||
display_name: p.display_name.clone(),
|
||||
roles: party_roles(p),
|
||||
website: p.website.clone(),
|
||||
address: p.address.clone(),
|
||||
notes: p.notes.clone(),
|
||||
archived: p.archived,
|
||||
vendor_id: p.vendor,
|
||||
customer_id: p.customer,
|
||||
w9: p.w9.as_ref().map(|w| W9Out {
|
||||
received_on: w.received.to_string(),
|
||||
entity_type: w.entity_type.clone(),
|
||||
exempt_payee_code: w.exempt_payee_code.clone(),
|
||||
fatca_code: w.fatca_code.clone(),
|
||||
tin_last4: w.tin_last4.clone(),
|
||||
kept_at: w.kept_at.clone(),
|
||||
}),
|
||||
w4: p.w4.as_ref().map(|w| W4Out {
|
||||
received_on: w.received.to_string(),
|
||||
state: w.state.clone(),
|
||||
kept_at: w.kept_at.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional party text from a tool: trimmed, and "" clears it.
|
||||
fn optional_text(s: &str) -> Option<String> {
|
||||
let t = s.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
}
|
||||
|
||||
/// A document's status for a tool's output.
|
||||
fn status_key(s: DocumentStatus) -> String {
|
||||
match s {
|
||||
DocumentStatus::Open => "open",
|
||||
DocumentStatus::Partial => "partial",
|
||||
DocumentStatus::Paid => "paid",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// An invoice or bill for `books.party`: its (date, due date), (total,
|
||||
/// paid so far), status, and entry.
|
||||
fn party_document(
|
||||
id: u64,
|
||||
(date, due): (NaiveDate, NaiveDate),
|
||||
(total, paid): (Decimal, Decimal),
|
||||
status: DocumentStatus,
|
||||
entry_id: u64,
|
||||
) -> PartyDocumentOut {
|
||||
PartyDocumentOut {
|
||||
id,
|
||||
date: date.to_string(),
|
||||
due_date: due.to_string(),
|
||||
total: money(total),
|
||||
paid: money(paid),
|
||||
balance: money(total - paid),
|
||||
status: status_key(status),
|
||||
entry_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the books did with one party (`PartyTotalsOut` defines it).
|
||||
struct PartyActivity {
|
||||
/// The party's entries, drafts included.
|
||||
entries: std::collections::BTreeSet<u64>,
|
||||
/// Paid to it in the year.
|
||||
paid: Decimal,
|
||||
/// Received from it in the year.
|
||||
received: Decimal,
|
||||
}
|
||||
|
||||
/// The party's entries and the year's money paid and received
|
||||
/// (`PartyTotalsOut`): ledger_core::parties::activity over the calendar
|
||||
/// year, with the `[books] cash_accounts`.
|
||||
fn party_activity(
|
||||
store: &Store,
|
||||
party: &ledger_core::Party,
|
||||
cash_codes: &[String],
|
||||
year: i32,
|
||||
) -> Result<PartyActivity, String> {
|
||||
let ledger = store.ledger();
|
||||
let cash: Vec<AccountId> = cash_codes
|
||||
.iter()
|
||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||
.collect();
|
||||
let span = (
|
||||
NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?,
|
||||
NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?,
|
||||
);
|
||||
let a = ledger_core::parties::activity(ledger, party.id, span, &cash);
|
||||
Ok(PartyActivity {
|
||||
entries: a.entries,
|
||||
paid: a.paid,
|
||||
received: a.received,
|
||||
})
|
||||
}
|
||||
|
||||
/// The party's customer record's invoices, by id.
|
||||
fn party_invoices<'a>(
|
||||
ledger: &'a Ledger,
|
||||
party: &ledger_core::Party,
|
||||
) -> Vec<&'a ledger_core::Invoice> {
|
||||
let Some(c) = party.customer else {
|
||||
return Vec::new();
|
||||
};
|
||||
ledger
|
||||
.invoices()
|
||||
.into_iter()
|
||||
.filter(|i| i.customer_id == c)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The party's vendor record's bills, by id.
|
||||
fn party_bills<'a>(ledger: &'a Ledger, party: &ledger_core::Party) -> Vec<&'a ledger_core::Bill> {
|
||||
let Some(v) = party.vendor else {
|
||||
return Vec::new();
|
||||
};
|
||||
ledger
|
||||
.bills()
|
||||
.into_iter()
|
||||
.filter(|b| b.vendor_id == v)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// This calendar year.
|
||||
fn this_year() -> i32 {
|
||||
chrono::Datelike::year(&today())
|
||||
}
|
||||
|
||||
fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
|
||||
form.map(|f| f.label().to_string())
|
||||
}
|
||||
@@ -1098,7 +1300,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.draft_entry",
|
||||
description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line."
|
||||
description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line. Pass party (id or name) for the counterparty: every entry with a vendor, platform, agency, bank, member, or contractor names its party (a party with a vendor record tags the vendor too, for 1099s)."
|
||||
)]
|
||||
async fn draft_entry(
|
||||
&self,
|
||||
@@ -1106,15 +1308,34 @@ impl BooksServer {
|
||||
) -> Result<Json<DraftEntryOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
// Resolve every reference before writing anything (INVARIANT 2).
|
||||
let date = parse_date(&p.date)?;
|
||||
let vendor = p
|
||||
.vendor
|
||||
.as_deref()
|
||||
.map(|v| resolve_vendor(&store, v))
|
||||
.transpose()?;
|
||||
let party = p
|
||||
.party
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
if let (Some(v), Some(pid)) = (vendor, party) {
|
||||
let party_vendor = store.ledger().get_party(pid).and_then(|p| p.vendor);
|
||||
if party_vendor != Some(v) {
|
||||
return Err(format!(
|
||||
"vendor {v} isn't party {pid}'s vendor record: pass only the party (its vendor record is tagged with it), or the vendor alone"
|
||||
));
|
||||
}
|
||||
}
|
||||
let id = store
|
||||
.create_entry(parse_date(&p.date)?, &p.description)
|
||||
.create_entry(date, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if vendor.is_some() {
|
||||
if party.is_some() {
|
||||
store
|
||||
.set_entry_party(id, party)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else if vendor.is_some() {
|
||||
store
|
||||
.set_entry_vendor(id, vendor)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -1604,6 +1825,12 @@ impl BooksServer {
|
||||
.entry_vendor(p.entry_id)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| v.name.clone()),
|
||||
party_id: store.ledger().entry_party(p.entry_id),
|
||||
party: store
|
||||
.ledger()
|
||||
.entry_party(p.entry_id)
|
||||
.and_then(|id| store.ledger().get_party(id))
|
||||
.map(|p| p.label().to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1653,7 +1880,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.show",
|
||||
description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
|
||||
description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn show(&self, params: Parameters<ShowParams>) -> Result<Json<ShowOut>, String> {
|
||||
@@ -1667,6 +1894,7 @@ impl BooksServer {
|
||||
p.report.is_some(),
|
||||
p.attachment_id.is_some(),
|
||||
p.asset.is_some(),
|
||||
p.party.is_some(),
|
||||
p.search.is_some(),
|
||||
p.page.is_some(),
|
||||
]
|
||||
@@ -1675,7 +1903,7 @@ impl BooksServer {
|
||||
.count();
|
||||
if given != 1 {
|
||||
return Err(
|
||||
"give exactly one of entry_id, account, report, attachment_id, asset, search, page"
|
||||
"give exactly one of entry_id, account, report, attachment_id, asset, party, search, page"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@@ -1695,7 +1923,7 @@ impl BooksServer {
|
||||
"ap_aging",
|
||||
"fixed_asset_register",
|
||||
];
|
||||
const PAGES: [&str; 10] = [
|
||||
const PAGES: [&str; 11] = [
|
||||
"overview",
|
||||
"cash",
|
||||
"profit",
|
||||
@@ -1703,6 +1931,7 @@ impl BooksServer {
|
||||
"reports",
|
||||
"accounts",
|
||||
"assets",
|
||||
"parties",
|
||||
"documents",
|
||||
"company",
|
||||
"journal",
|
||||
@@ -1721,6 +1950,13 @@ impl BooksServer {
|
||||
a.description
|
||||
),
|
||||
)
|
||||
} else if let Some(r) = &p.party {
|
||||
let id = resolve_party(&store, r)?;
|
||||
let party = ledger.get_party(id).expect("resolved");
|
||||
(
|
||||
ViewerTarget::Party { party_id: id },
|
||||
format!("party {id}, {}", party.label()),
|
||||
)
|
||||
} else if let Some(id) = p.entry_id {
|
||||
let e = ledger
|
||||
.get_entry(id)
|
||||
@@ -2357,7 +2593,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.list_vendors",
|
||||
description = "Vendors, with which 1099 each gets and whether its W-9 is on file.",
|
||||
description = "Vendors, with which 1099 each gets, whether its W-9 is on file, and the party each belongs to (books.party).",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn list_vendors(&self) -> Result<Json<ListVendorsOut>, String> {
|
||||
@@ -2372,6 +2608,7 @@ impl BooksServer {
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(v.id),
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
@@ -2404,6 +2641,7 @@ impl BooksServer {
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(id),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2447,8 +2685,13 @@ impl BooksServer {
|
||||
let store = self.lock()?;
|
||||
// A year past what wanderbooks knows is refused with guidance
|
||||
// (research or ask, pass `threshold`, then update the rule).
|
||||
let r = ledger_core::vendor_1099_report(store.ledger(), p.year, threshold)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let r = ledger_core::vendor_1099_report_with(
|
||||
store.ledger(),
|
||||
p.year,
|
||||
threshold,
|
||||
&payment_accounts(store.ledger(), &self.settings().roles),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let missing_w9 = r
|
||||
.vendors
|
||||
.iter()
|
||||
@@ -3793,30 +4036,460 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.add_customer",
|
||||
description = "Add a customer for accounts receivable."
|
||||
description = "Add a customer record for accounts receivable (invoices). It links to the party of the same name that has no customer record yet (ignoring case), else to a new party; the output names both ids."
|
||||
)]
|
||||
async fn add_customer(
|
||||
&self,
|
||||
params: Parameters<AddPartyParams>,
|
||||
) -> Result<Json<AddPartyOut>, String> {
|
||||
params: Parameters<AddRecordParams>,
|
||||
) -> Result<Json<AddRecordOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = store.add_customer(&p.name).map_err(|e| e.to_string())?;
|
||||
Ok(Json(AddPartyOut { id }))
|
||||
let party_id = store
|
||||
.ledger()
|
||||
.party_of_customer(id)
|
||||
.ok_or_else(|| format!("customer {id} has no party"))?;
|
||||
Ok(Json(AddRecordOut { id, party_id }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.add_vendor",
|
||||
description = "Add a vendor for accounts payable."
|
||||
description = "Add a vendor record for accounts payable (bills) and 1099s. It links to the party of the same name that has no vendor record yet (ignoring case), else to a new party; the output names both ids."
|
||||
)]
|
||||
async fn add_vendor(
|
||||
&self,
|
||||
params: Parameters<AddPartyParams>,
|
||||
) -> Result<Json<AddPartyOut>, String> {
|
||||
params: Parameters<AddRecordParams>,
|
||||
) -> Result<Json<AddRecordOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = store.add_vendor(&p.name).map_err(|e| e.to_string())?;
|
||||
Ok(Json(AddPartyOut { id }))
|
||||
let party_id = store
|
||||
.ledger()
|
||||
.party_of_vendor(id)
|
||||
.ok_or_else(|| format!("vendor {id} has no party"))?;
|
||||
Ok(Json(AddRecordOut { id, party_id }))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Parties (ledger_core::parties)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tool(
|
||||
name = "books.add_party",
|
||||
description = "Add a party to the directory: anyone the company deals with (a contractor, landlord, payout platform, tax agency, bank or card issuer, member, employee). Name it as on its documents (unique among parties not archived); give its roles: contractor, employee, member, payout_platform, tax_agency, financial_institution, landlord. Vendor and customer aren't given by hand: books.add_vendor and books.add_customer add those records and link them to the party of the same name. Never put a full TIN or SSN in any party field: the engine refuses nine-digit numbers. Journaled as party.added; returns the party."
|
||||
)]
|
||||
async fn add_party(
|
||||
&self,
|
||||
params: Parameters<AddPartyParams>,
|
||||
) -> Result<Json<PartyOut>, String> {
|
||||
let p = params.0;
|
||||
let mut roles = parse_roles(&p.roles)?;
|
||||
roles.sort();
|
||||
roles.dedup();
|
||||
let mut store = self.lock()?;
|
||||
let id = store
|
||||
.add_party(&p.name, &roles)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let party = store.ledger().get_party(id).expect("just added");
|
||||
Ok(Json(party_out(party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.update_party",
|
||||
description = "Change a party's facts: name, display_name, website, address, notes, archived. Give only the fields to change; an empty string clears display_name, website, address, or notes. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters) changes nothing; each is journaled as its own party.updated. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party."
|
||||
)]
|
||||
async fn update_party(
|
||||
&self,
|
||||
params: Parameters<UpdatePartyParams>,
|
||||
) -> Result<Json<PartyOut>, String> {
|
||||
use ledger_core::PartyField as F;
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let mut fields = Vec::new();
|
||||
if let Some(n) = p.name {
|
||||
fields.push(F::Name(n));
|
||||
}
|
||||
if let Some(v) = p.display_name {
|
||||
fields.push(F::DisplayName(optional_text(&v)));
|
||||
}
|
||||
if let Some(v) = p.website {
|
||||
fields.push(F::Website(optional_text(&v)));
|
||||
}
|
||||
if let Some(v) = p.address {
|
||||
fields.push(F::Address(optional_text(&v)));
|
||||
}
|
||||
if let Some(v) = p.notes {
|
||||
fields.push(F::Notes(optional_text(&v)));
|
||||
}
|
||||
if let Some(a) = p.archived {
|
||||
fields.push(F::Archived(a));
|
||||
}
|
||||
if fields.is_empty() {
|
||||
return Err(
|
||||
"give at least one field to change: name, display_name, website, address, notes, archived"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// Every field, in order, before any is written (INVARIANT 2).
|
||||
let mut rehearsal = store.ledger().clone();
|
||||
for f in &fields {
|
||||
rehearsal
|
||||
.update_party(id, f.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for f in fields {
|
||||
store.update_party(id, f).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_party_roles",
|
||||
description = "Give a party roles (add) or take them away (remove): contractor, employee, member, payout_platform, tax_agency, financial_institution, landlord. Vendor and customer follow their records and can't be set by hand. Adds apply before removes; a role it already has (or lacks) is left alone. Removing a party's last 1099 role (contractor, landlord) needs its W-9 facts cleared first (books.set_w9 clear), and removing employee its W-4 facts. All or nothing: a refusal changes nothing. Each change is journaled as party.role_set. Returns the party."
|
||||
)]
|
||||
async fn set_party_roles(
|
||||
&self,
|
||||
params: Parameters<SetPartyRolesParams>,
|
||||
) -> Result<Json<PartyOut>, String> {
|
||||
let p = params.0;
|
||||
let add = parse_roles(&p.add)?;
|
||||
let remove = parse_roles(&p.remove)?;
|
||||
if add.is_empty() && remove.is_empty() {
|
||||
return Err("give at least one role to add or remove".into());
|
||||
}
|
||||
if let Some(r) = add.iter().find(|r| remove.contains(r)) {
|
||||
return Err(format!("role {} is both added and removed", r.key()));
|
||||
}
|
||||
if add
|
||||
.iter()
|
||||
.chain(&remove)
|
||||
.any(|r| matches!(r, ledger_core::Role::Vendor | ledger_core::Role::Customer))
|
||||
{
|
||||
return Err(
|
||||
"vendor and customer roles follow their records: books.add_vendor or books.add_customer"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let has = store
|
||||
.ledger()
|
||||
.get_party(id)
|
||||
.expect("resolved")
|
||||
.roles
|
||||
.clone();
|
||||
let mut changes: Vec<(ledger_core::Role, bool)> = Vec::new();
|
||||
for r in add {
|
||||
if !has.contains(&r) && !changes.contains(&(r, true)) {
|
||||
changes.push((r, true));
|
||||
}
|
||||
}
|
||||
for r in remove {
|
||||
if has.contains(&r) && !changes.contains(&(r, false)) {
|
||||
changes.push((r, false));
|
||||
}
|
||||
}
|
||||
// The whole change, in order, before any is written (INVARIANT 2).
|
||||
let mut rehearsal = store.ledger().clone();
|
||||
for (r, on) in &changes {
|
||||
rehearsal
|
||||
.set_party_role(id, *r, *on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for (r, on) in changes {
|
||||
store.set_party_role(id, r, on).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_w9",
|
||||
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits only, and where the form is kept. Never pass the full TIN anywhere: the engine refuses any nine-digit number. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Journaled as party.w9_set. Returns the party."
|
||||
)]
|
||||
async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> {
|
||||
let p = params.0;
|
||||
let facts = if p.clear {
|
||||
if p.received_on.is_some()
|
||||
|| p.entity_type.is_some()
|
||||
|| p.exempt_payee_code.is_some()
|
||||
|| p.fatca_code.is_some()
|
||||
|| p.tin_last4.is_some()
|
||||
|| p.kept_at.is_some()
|
||||
{
|
||||
return Err("clear: true takes no other W-9 fact".into());
|
||||
}
|
||||
None
|
||||
} else {
|
||||
let need = |v: &Option<String>, name: &str| {
|
||||
v.as_deref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok_or_else(|| format!("{name} is required (or pass clear: true)"))
|
||||
};
|
||||
Some(ledger_core::W9Facts {
|
||||
received: parse_date(&need(&p.received_on, "received_on")?)?,
|
||||
entity_type: need(&p.entity_type, "entity_type")?,
|
||||
exempt_payee_code: p.exempt_payee_code.as_deref().and_then(optional_text),
|
||||
fatca_code: p.fatca_code.as_deref().and_then(optional_text),
|
||||
tin_last4: need(&p.tin_last4, "tin_last4")?,
|
||||
kept_at: need(&p.kept_at, "kept_at")?,
|
||||
vault_record: None,
|
||||
})
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let on_file = facts.is_some();
|
||||
store.set_party_w9(id, facts).map_err(|e| e.to_string())?;
|
||||
// Keep the vendor record's W-9 flag (the 1099 worksheet's) in step.
|
||||
let vendor = store
|
||||
.ledger()
|
||||
.get_party(id)
|
||||
.and_then(|p| p.vendor)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| (v.id, v.form_1099, v.tin_on_file));
|
||||
if let Some((v, form, tin_on_file)) = vendor
|
||||
&& tin_on_file != on_file
|
||||
{
|
||||
store
|
||||
.set_vendor_1099(v, form, on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_w4",
|
||||
description = "Record what an employee's W-4 says, or clear it (clear: true alone): the date received (received_on), the work state (two letters), and where the form is kept. Never the SSN. The party needs the employee role. Record it before their first payroll. Journaled as party.w4_set. Returns the party."
|
||||
)]
|
||||
async fn set_w4(&self, params: Parameters<SetW4Params>) -> Result<Json<PartyOut>, String> {
|
||||
let p = params.0;
|
||||
let facts = if p.clear {
|
||||
if p.received_on.is_some() || p.state.is_some() || p.kept_at.is_some() {
|
||||
return Err("clear: true takes no other W-4 fact".into());
|
||||
}
|
||||
None
|
||||
} else {
|
||||
let need = |v: &Option<String>, name: &str| {
|
||||
v.as_deref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok_or_else(|| format!("{name} is required (or pass clear: true)"))
|
||||
};
|
||||
Some(ledger_core::W4Facts {
|
||||
received: parse_date(&need(&p.received_on, "received_on")?)?,
|
||||
state: need(&p.state, "state")?,
|
||||
kept_at: need(&p.kept_at, "kept_at")?,
|
||||
})
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
store.set_party_w4(id, facts).map_err(|e| e.to_string())?;
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.parties",
|
||||
description = "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 facts (with the TIN's last four) and W-4 facts are on file, archived, and what was paid to and received from it in the year (default this year; defined as in books.party). Filter by role; archived parties only with include_archived.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn parties(
|
||||
&self,
|
||||
params: Parameters<ListPartiesParams>,
|
||||
) -> Result<Json<ListPartiesOut>, String> {
|
||||
let p = params.0;
|
||||
let role = p
|
||||
.role
|
||||
.as_deref()
|
||||
.map(|r| parse_roles(&[r.to_string()]).map(|v| v[0]))
|
||||
.transpose()?;
|
||||
let year = p.year.unwrap_or_else(this_year);
|
||||
let cash = self.settings().roles.cash.clone();
|
||||
let store = self.lock()?;
|
||||
let mut parties = Vec::new();
|
||||
for party in store.ledger().parties() {
|
||||
if party.archived && !p.include_archived {
|
||||
continue;
|
||||
}
|
||||
if role.is_some_and(|r| !party.all_roles().contains(&r)) {
|
||||
continue;
|
||||
}
|
||||
let act = party_activity(&store, party, &cash, year)?;
|
||||
parties.push(PartySummaryOut {
|
||||
party_id: party.id,
|
||||
name: party.name.clone(),
|
||||
label: party.label().to_string(),
|
||||
roles: party_roles(party),
|
||||
vendor_id: party.vendor,
|
||||
customer_id: party.customer,
|
||||
w9_on_file: party.w9.is_some(),
|
||||
tin_last4: party.w9.as_ref().map(|w| w.tin_last4.clone()),
|
||||
w4_on_file: party.w4.is_some(),
|
||||
archived: party.archived,
|
||||
paid: money(act.paid),
|
||||
received: money(act.received),
|
||||
});
|
||||
}
|
||||
Ok(Json(ListPartiesOut { year, parties }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.party",
|
||||
description = "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (default this year: paid to it, received from it, invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn party(&self, params: Parameters<PartyParams>) -> Result<Json<PartyDetailOut>, String> {
|
||||
let p = params.0;
|
||||
let year = p.year.unwrap_or_else(this_year);
|
||||
let cash = self.settings().roles.cash.clone();
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let party = ledger.get_party(id).expect("resolved");
|
||||
let act = party_activity(&store, party, &cash, year)?;
|
||||
let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year;
|
||||
let mut entries: Vec<PartyEntryOut> = act
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|&e| ledger.get_entry(e))
|
||||
.map(|e| PartyEntryOut {
|
||||
entry_id: e.id(),
|
||||
date: e.date().to_string(),
|
||||
description: e.description().to_string(),
|
||||
status: match e.status() {
|
||||
ledger_core::EntryStatus::Draft => "draft",
|
||||
ledger_core::EntryStatus::Posted => "posted",
|
||||
}
|
||||
.to_string(),
|
||||
total: money(ReceiptPolicy::entry_debits(e)),
|
||||
reversal_of: e.reversal_of(),
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id)));
|
||||
let invoices = party_invoices(ledger, party);
|
||||
let bills = party_bills(ledger, party);
|
||||
let amount_1099 = match party.vendor {
|
||||
Some(v) => {
|
||||
let r = ledger_core::vendor_1099_report_with(
|
||||
ledger,
|
||||
year,
|
||||
Some(Decimal::ZERO),
|
||||
&payment_accounts(ledger, &self.settings().roles),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Some(money(
|
||||
r.vendors
|
||||
.iter()
|
||||
.find(|l| l.vendor_id == v)
|
||||
.map(|l| l.paid)
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut payroll = Vec::new();
|
||||
let (mut gross, mut net) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for run in ledger.list_payroll_runs() {
|
||||
for line in run.lines.iter().filter(|l| l.employee == Some(id)) {
|
||||
if in_year(run.date) {
|
||||
gross += line.gross_pay;
|
||||
net += line.net_pay();
|
||||
}
|
||||
payroll.push(PartyPayrollOut {
|
||||
run_id: run.id,
|
||||
date: run.date.to_string(),
|
||||
entry_id: run.entry_id,
|
||||
gross_pay: money(line.gross_pay),
|
||||
net_pay: money(line.net_pay()),
|
||||
});
|
||||
}
|
||||
}
|
||||
let totals = PartyTotalsOut {
|
||||
paid: money(act.paid),
|
||||
received: money(act.received),
|
||||
invoiced: money(
|
||||
invoices
|
||||
.iter()
|
||||
.filter(|i| in_year(i.date))
|
||||
.map(|i| i.total())
|
||||
.sum(),
|
||||
),
|
||||
billed: money(
|
||||
bills
|
||||
.iter()
|
||||
.filter(|b| in_year(b.date))
|
||||
.map(|b| b.total())
|
||||
.sum(),
|
||||
),
|
||||
owed: money(bills.iter().map(|b| b.balance()).sum()),
|
||||
receivable: money(invoices.iter().map(|i| i.balance()).sum()),
|
||||
amount_1099,
|
||||
form_1099: party
|
||||
.vendor
|
||||
.and_then(|v| ledger.get_vendor(v))
|
||||
.and_then(|v| form_1099_label(v.form_1099)),
|
||||
gross_pay: money(gross),
|
||||
net_pay: money(net),
|
||||
};
|
||||
Ok(Json(PartyDetailOut {
|
||||
party: party_out(party),
|
||||
year,
|
||||
totals,
|
||||
entries,
|
||||
invoices: invoices
|
||||
.iter()
|
||||
.map(|i| {
|
||||
party_document(
|
||||
i.id,
|
||||
(i.date, i.due_date),
|
||||
(i.total(), i.amount_paid),
|
||||
i.status,
|
||||
i.entry_id,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
bills: bills
|
||||
.iter()
|
||||
.map(|b| {
|
||||
party_document(
|
||||
b.id,
|
||||
(b.date, b.due_date),
|
||||
(b.total(), b.amount_paid),
|
||||
b.status,
|
||||
b.entry_id,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
payroll,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
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. Metadata only: amounts never change. Journaled as entry.party_set. Returns the entry (as books.get_entry)."
|
||||
)]
|
||||
async fn set_entry_party(
|
||||
&self,
|
||||
params: Parameters<SetEntryPartyParams>,
|
||||
) -> Result<Json<GetEntryOut>, String> {
|
||||
let p = params.0;
|
||||
{
|
||||
let mut store = self.lock()?;
|
||||
let party = p
|
||||
.party
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
store
|
||||
.set_entry_party(p.entry_id, party)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
self.get_entry(Parameters(GetEntryParams {
|
||||
entry_id: p.entry_id,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -4046,7 +4719,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.run_payroll",
|
||||
description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats."
|
||||
description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats. Name each line's employee party (employee: id or name, a party with the employee role; add one with books.add_party and record its W-4 with books.set_w4 first): new runs should, so the run shows on the employee's books.party."
|
||||
)]
|
||||
async fn run_payroll(
|
||||
&self,
|
||||
@@ -4059,8 +4732,28 @@ impl BooksServer {
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let employee = l
|
||||
.employee
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
let employee_name = match (l.employee_name, employee) {
|
||||
(Some(n), _) => n,
|
||||
(None, Some(id)) => store
|
||||
.ledger()
|
||||
.get_party(id)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default(),
|
||||
(None, None) => {
|
||||
return Err(
|
||||
"each payroll line names its employee: employee (a party) or employee_name"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
Ok(ledger_core::PayrollLine {
|
||||
employee_name: l.employee_name,
|
||||
employee_name,
|
||||
employee,
|
||||
gross_pay: parse_amount(&l.gross_pay)?,
|
||||
federal_withholding: parse_amount(&l.federal_withholding)?,
|
||||
state_withholding: parse_amount(&l.state_withholding)?,
|
||||
@@ -4388,7 +5081,12 @@ impl BooksServer {
|
||||
));
|
||||
// 1099 worksheet for the calendar year of as_of.
|
||||
let year = chrono::Datelike::year(&as_of);
|
||||
match ledger_core::vendor_1099_report(ledger, year, None) {
|
||||
match ledger_core::vendor_1099_report_with(
|
||||
ledger,
|
||||
year,
|
||||
None,
|
||||
&payment_accounts(ledger, &self.settings().roles),
|
||||
) {
|
||||
Ok(r1099) => {
|
||||
let mut w = String::from("vendor,form,w9_on_file,paid,reportable\n");
|
||||
for v in &r1099.vendors {
|
||||
|
||||
@@ -315,6 +315,8 @@ pub struct VendorOut {
|
||||
pub form_1099: Option<String>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// The party the vendor record belongs to.
|
||||
pub party_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Result of `books.list_vendors`.
|
||||
@@ -432,10 +434,12 @@ pub struct ShowParams {
|
||||
pub attachment_id: Option<u64>,
|
||||
/// A fixed asset, by tag or id.
|
||||
pub asset: Option<String>,
|
||||
/// A party, by id or name (as `books.party`).
|
||||
pub party: Option<String>,
|
||||
/// Search the transactions list (description, account, vendor, amount).
|
||||
pub search: Option<String>,
|
||||
/// A page: overview, cash, profit, transactions, reports, accounts,
|
||||
/// assets, documents, company, journal.
|
||||
/// assets, parties, documents, company, journal.
|
||||
pub page: Option<String>,
|
||||
/// For account and report: first date, YYYY-MM-DD.
|
||||
pub start: Option<String>,
|
||||
@@ -938,6 +942,10 @@ pub struct DraftEntryParams {
|
||||
pub description: String,
|
||||
/// The vendor this entry pays (id or name), for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
/// The party this entry is with (id or name; see `books.parties`). A
|
||||
/// party with a vendor record also tags the entry's vendor; given with
|
||||
/// `vendor`, both must name the same vendor.
|
||||
pub party: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.draft_entry`.
|
||||
@@ -1298,6 +1306,10 @@ pub struct GetEntryOut {
|
||||
pub receipts: Vec<PreviewReceipt>,
|
||||
/// The vendor the entry is tagged with, for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
/// The id of the party the entry is tagged with (its counterparty).
|
||||
pub party_id: Option<u64>,
|
||||
/// That party's label (display name, else name).
|
||||
pub party: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2015,16 +2027,21 @@ pub struct ReconcileOut {
|
||||
|
||||
/// Parameters for `books.add_customer` / `books.add_vendor`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AddPartyParams {
|
||||
/// Customer or vendor name.
|
||||
pub struct AddRecordParams {
|
||||
/// Customer or vendor name. The record links to the party of that name
|
||||
/// (ignoring case and surrounding space, not archived) that has no such
|
||||
/// record yet, else to a new party.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Output of `books.add_customer` / `books.add_vendor`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AddPartyOut {
|
||||
/// The new party's id.
|
||||
pub struct AddRecordOut {
|
||||
/// The new customer's or vendor's id (for `books.create_invoice`,
|
||||
/// `books.create_bill`, `books.set_vendor_1099`).
|
||||
pub id: u64,
|
||||
/// The party the record belongs to (`books.party`).
|
||||
pub party_id: u64,
|
||||
}
|
||||
|
||||
/// One line on an invoice or bill.
|
||||
@@ -2146,8 +2163,13 @@ pub struct SetFxRateOut {
|
||||
/// One employee's payroll numbers. All amounts are decimal strings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PayrollLineIn {
|
||||
/// Employee name.
|
||||
pub employee_name: String,
|
||||
/// The employee's party (id or name), which must have the employee
|
||||
/// role. New runs should name it; without it the line carries only
|
||||
/// `employee_name`.
|
||||
pub employee: Option<String>,
|
||||
/// Employee name for the run record. Defaults to the employee party's
|
||||
/// name; required without `employee`.
|
||||
pub employee_name: Option<String>,
|
||||
/// Gross pay, decimal string.
|
||||
pub gross_pay: String,
|
||||
/// Federal withholding, decimal string.
|
||||
@@ -2325,3 +2347,369 @@ pub struct ExportCpaPackageOut {
|
||||
/// The zip, base64-encoded.
|
||||
pub content_base64: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parties (the directory of everyone the company deals with)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A `party` reference is a party's id or its name: the name ignoring case
|
||||
// and surrounding space, among parties not archived (an archived party goes
|
||||
// by id). A number that is one party's id and another party's name is
|
||||
// refused, never guessed.
|
||||
|
||||
/// Parameters for `books.add_party`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct AddPartyParams {
|
||||
/// Legal or trade name, as on its documents. Unique (ignoring case and
|
||||
/// surrounding space) among parties not archived; never a full TIN.
|
||||
pub name: String,
|
||||
/// Roles: contractor, employee, member, payout_platform, tax_agency,
|
||||
/// financial_institution, landlord. Not vendor or customer: those come
|
||||
/// with their records (`books.add_vendor`, `books.add_customer`, which
|
||||
/// link to the party of the same name). May be empty.
|
||||
#[serde(default)]
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.update_party`. Give at least one field to change.
|
||||
/// Every field given is checked before any is written: a bad one changes
|
||||
/// nothing. Each is journaled as its own `party.updated`, in this order:
|
||||
/// name, display_name, website, address, notes, archived.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct UpdatePartyParams {
|
||||
/// The party: id or name.
|
||||
pub party: String,
|
||||
/// A new legal or trade name (not blank, not another party's).
|
||||
pub name: Option<String>,
|
||||
/// A shorter name to show; "" clears it.
|
||||
pub display_name: Option<String>,
|
||||
/// Website; "" clears it.
|
||||
pub website: Option<String>,
|
||||
/// Business address; "" clears it.
|
||||
pub address: Option<String>,
|
||||
/// Notes; "" clears them.
|
||||
pub notes: Option<String>,
|
||||
/// True: no longer dealt with (its history stays; it no longer
|
||||
/// resolves by name, and its name is free for another party). False:
|
||||
/// dealt with again (refused while another party has its name).
|
||||
pub archived: Option<bool>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_party_roles`. Give at least one role to add
|
||||
/// or remove.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct SetPartyRolesParams {
|
||||
/// The party: id or name.
|
||||
pub party: String,
|
||||
/// Roles to give it (see `books.add_party`; not vendor or customer).
|
||||
#[serde(default)]
|
||||
pub add: Vec<String>,
|
||||
/// Roles to take away. Refused while facts need the role: clear the
|
||||
/// W-9 (`books.set_w9` clear) before removing its last 1099 role
|
||||
/// (contractor, landlord), and the W-4 before removing employee.
|
||||
#[serde(default)]
|
||||
pub remove: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_w9`: what a W-9 said, never the number
|
||||
/// itself. Either `clear: true` alone, or every required fact.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct SetW9Params {
|
||||
/// The party: id or name. Needs a 1099 role (contractor or landlord).
|
||||
pub party: String,
|
||||
/// When the W-9 was received, YYYY-MM-DD. Required unless clearing.
|
||||
pub received_on: Option<String>,
|
||||
/// Line 3 federal tax classification, e.g. "individual/sole
|
||||
/// proprietor", "C corporation", "LLC (partnership)". Required unless
|
||||
/// clearing.
|
||||
pub entity_type: Option<String>,
|
||||
/// Line 4 exempt payee code, if any.
|
||||
pub exempt_payee_code: Option<String>,
|
||||
/// Line 4 FATCA exemption code, if any.
|
||||
pub fatca_code: Option<String>,
|
||||
/// The TIN's last four digits: exactly four digits. A full TIN (nine
|
||||
/// digits, however written) is refused here and in every party field.
|
||||
/// Required unless clearing.
|
||||
pub tin_last4: Option<String>,
|
||||
/// Where the form itself is kept, e.g. "CPA portal" or "filed in the
|
||||
/// books, document 12". Required unless clearing.
|
||||
pub kept_at: Option<String>,
|
||||
/// True: clear the W-9 facts (give no other fact).
|
||||
#[serde(default)]
|
||||
pub clear: bool,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_w4`: what a W-4 said, never the SSN. Either
|
||||
/// `clear: true` alone, or every fact.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct SetW4Params {
|
||||
/// The party: id or name. Needs the employee role.
|
||||
pub party: String,
|
||||
/// When the W-4 was received, YYYY-MM-DD. Required unless clearing.
|
||||
pub received_on: Option<String>,
|
||||
/// The work state: two capital letters, e.g. "UT". Required unless
|
||||
/// clearing.
|
||||
pub state: Option<String>,
|
||||
/// Where the form itself is kept. Required unless clearing.
|
||||
pub kept_at: Option<String>,
|
||||
/// True: clear the W-4 facts (give no other fact).
|
||||
#[serde(default)]
|
||||
pub clear: bool,
|
||||
}
|
||||
|
||||
/// Parameters for `books.parties`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct ListPartiesParams {
|
||||
/// Only parties with this role (any role, vendor and customer
|
||||
/// included).
|
||||
pub role: Option<String>,
|
||||
/// Include archived parties (default false).
|
||||
#[serde(default)]
|
||||
pub include_archived: bool,
|
||||
/// Calendar year for `paid` and `received` (default: this year, so
|
||||
/// year to date).
|
||||
pub year: Option<i32>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.party`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct PartyParams {
|
||||
/// The party: id or name.
|
||||
pub party: String,
|
||||
/// Calendar year for the totals (default: this year, so year to date).
|
||||
pub year: Option<i32>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_party`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct SetEntryPartyParams {
|
||||
/// Entry id (draft or posted).
|
||||
pub entry_id: u64,
|
||||
/// The party (id or name); null or omitted clears the tag. A party with
|
||||
/// a vendor record also tags the entry's vendor (so the payment counts
|
||||
/// in `books.vendor_1099`); one without, or clearing, clears the vendor
|
||||
/// tag too.
|
||||
pub party: Option<String>,
|
||||
}
|
||||
|
||||
/// A party's W-9 facts.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct W9Out {
|
||||
/// When it was received, YYYY-MM-DD.
|
||||
pub received_on: String,
|
||||
/// Line 3 federal tax classification.
|
||||
pub entity_type: String,
|
||||
/// Line 4 exempt payee code, if any.
|
||||
pub exempt_payee_code: Option<String>,
|
||||
/// Line 4 FATCA exemption code, if any.
|
||||
pub fatca_code: Option<String>,
|
||||
/// The TIN's last four digits.
|
||||
pub tin_last4: String,
|
||||
/// Where the form is kept.
|
||||
pub kept_at: String,
|
||||
}
|
||||
|
||||
/// A party's W-4 facts.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct W4Out {
|
||||
/// When it was received, YYYY-MM-DD.
|
||||
pub received_on: String,
|
||||
/// The work state.
|
||||
pub state: String,
|
||||
/// Where the form is kept.
|
||||
pub kept_at: String,
|
||||
}
|
||||
|
||||
/// One party: its facts, as every party tool that changes one returns it.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyOut {
|
||||
/// Party id.
|
||||
pub party_id: u64,
|
||||
/// Legal or trade name.
|
||||
pub name: String,
|
||||
/// The name to show: the display name, else the name.
|
||||
pub label: String,
|
||||
/// Display name, if set.
|
||||
pub display_name: Option<String>,
|
||||
/// Roles, in display order: the ones set by hand, plus vendor and
|
||||
/// customer when it has those records.
|
||||
pub roles: Vec<String>,
|
||||
/// Website.
|
||||
pub website: Option<String>,
|
||||
/// Business address.
|
||||
pub address: Option<String>,
|
||||
/// Notes.
|
||||
pub notes: Option<String>,
|
||||
/// No longer dealt with.
|
||||
pub archived: bool,
|
||||
/// Its vendor record's id, if it bills the company.
|
||||
pub vendor_id: Option<u64>,
|
||||
/// Its customer record's id, if the company invoices it.
|
||||
pub customer_id: Option<u64>,
|
||||
/// W-9 facts, if on file.
|
||||
pub w9: Option<W9Out>,
|
||||
/// W-4 facts, if on file.
|
||||
pub w4: Option<W4Out>,
|
||||
}
|
||||
|
||||
/// One party in `books.parties`.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartySummaryOut {
|
||||
/// Party id.
|
||||
pub party_id: u64,
|
||||
/// Legal or trade name.
|
||||
pub name: String,
|
||||
/// The name to show.
|
||||
pub label: String,
|
||||
/// Roles, vendor and customer included.
|
||||
pub roles: Vec<String>,
|
||||
/// Its vendor record's id, if any.
|
||||
pub vendor_id: Option<u64>,
|
||||
/// Its customer record's id, if any.
|
||||
pub customer_id: Option<u64>,
|
||||
/// W-9 facts are on file.
|
||||
pub w9_on_file: bool,
|
||||
/// The W-9's TIN last four digits, when on file.
|
||||
pub tin_last4: Option<String>,
|
||||
/// W-4 facts are on file.
|
||||
pub w4_on_file: bool,
|
||||
/// No longer dealt with.
|
||||
pub archived: bool,
|
||||
/// Paid to the party in the year, decimal string (as `books.party`
|
||||
/// totals).
|
||||
pub paid: String,
|
||||
/// Received from the party in the year, decimal string (as
|
||||
/// `books.party` totals).
|
||||
pub received: String,
|
||||
}
|
||||
|
||||
/// Result of `books.parties`.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ListPartiesOut {
|
||||
/// The year `paid` and `received` cover.
|
||||
pub year: i32,
|
||||
/// Parties, by id.
|
||||
pub parties: Vec<PartySummaryOut>,
|
||||
}
|
||||
|
||||
/// A party's year, from the journal. "The party's entries" are the
|
||||
/// entries tagged with the party (`books.set_entry_party`, `books.draft_entry`
|
||||
/// party), the entries tagged with its vendor record (bills and their
|
||||
/// payments, `books.set_entry_vendor`), its customer record's invoices and
|
||||
/// the payments received on them, and reversals of any of these. Only
|
||||
/// posted entries dated in the calendar year count.
|
||||
///
|
||||
/// Money lines are the lines on the money accounts: the `[books]
|
||||
/// cash_accounts` (every asset account but receivables when none are set)
|
||||
/// and every liability account except the payables and payroll
|
||||
/// withholding accounts (the control roles in `books.roles`: 2000 and
|
||||
/// 2110-2150 by default). A liability counts because it is how the
|
||||
/// company paid or was paid: a card, a member paying for it, a loan. Each
|
||||
/// entry's net is its money lines' credits minus their debits: a positive
|
||||
/// net was paid to the party, a negative one received from it; a reversal
|
||||
/// counts against its original's side. An entry that only moves money
|
||||
/// between money accounts (borrowing, a customer's deposit booked to a
|
||||
/// liability) nets to zero. A bill is not paid until its payment is.
|
||||
/// (`ledger_core::parties::activity` computes it; the viewer shows the
|
||||
/// same numbers.)
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyTotalsOut {
|
||||
/// Paid to the party, decimal string.
|
||||
pub paid: String,
|
||||
/// Received from the party, decimal string.
|
||||
pub received: String,
|
||||
/// Its customer record's invoices dated in the year, total, decimal
|
||||
/// string.
|
||||
pub invoiced: String,
|
||||
/// Its vendor record's bills dated in the year, total, decimal string.
|
||||
pub billed: String,
|
||||
/// Still owed to it now: its open bills' balances (every year),
|
||||
/// decimal string.
|
||||
pub owed: String,
|
||||
/// Still owed by it now: its open invoices' balances (every year),
|
||||
/// decimal string.
|
||||
pub receivable: String,
|
||||
/// For a party with a vendor record: the cash `books.vendor_1099`
|
||||
/// counts as paid to that vendor in the year (asset accounts only, so
|
||||
/// card payments drop out), decimal string. Absent otherwise.
|
||||
pub amount_1099: Option<String>,
|
||||
/// The information return its vendor record gets ("1099-NEC",
|
||||
/// "1099-MISC"), if any.
|
||||
pub form_1099: Option<String>,
|
||||
/// Gross pay on payroll lines naming it, decimal string.
|
||||
pub gross_pay: String,
|
||||
/// Net pay on payroll lines naming it, decimal string.
|
||||
pub net_pay: String,
|
||||
}
|
||||
|
||||
/// One of a party's entries.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyEntryOut {
|
||||
/// Entry id.
|
||||
pub entry_id: u64,
|
||||
/// Entry date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// Description.
|
||||
pub description: String,
|
||||
/// "draft" or "posted".
|
||||
pub status: String,
|
||||
/// Total debits, decimal string.
|
||||
pub total: String,
|
||||
/// Id of the reversed entry, for reversals.
|
||||
pub reversal_of: Option<u64>,
|
||||
}
|
||||
|
||||
/// One of a party's invoices or bills.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyDocumentOut {
|
||||
/// Invoice or bill id.
|
||||
pub id: u64,
|
||||
/// Document date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// Due date, YYYY-MM-DD.
|
||||
pub due_date: String,
|
||||
/// Total, decimal string.
|
||||
pub total: String,
|
||||
/// Paid so far, decimal string.
|
||||
pub paid: String,
|
||||
/// Still owed, decimal string.
|
||||
pub balance: String,
|
||||
/// "open", "partial", or "paid".
|
||||
pub status: String,
|
||||
/// The posted entry that raised it.
|
||||
pub entry_id: u64,
|
||||
}
|
||||
|
||||
/// A payroll run line naming the party.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyPayrollOut {
|
||||
/// Payroll run id.
|
||||
pub run_id: u64,
|
||||
/// Pay date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// The run's posted entry.
|
||||
pub entry_id: u64,
|
||||
/// Gross pay, decimal string.
|
||||
pub gross_pay: String,
|
||||
/// Net pay, decimal string.
|
||||
pub net_pay: String,
|
||||
}
|
||||
|
||||
/// Result of `books.party`.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyDetailOut {
|
||||
/// Its facts and roles.
|
||||
pub party: PartyOut,
|
||||
/// The calendar year the totals cover.
|
||||
pub year: i32,
|
||||
/// The year's totals.
|
||||
pub totals: PartyTotalsOut,
|
||||
/// Its entries (every year, drafts included), newest first.
|
||||
pub entries: Vec<PartyEntryOut>,
|
||||
/// Its customer record's invoices, by id.
|
||||
pub invoices: Vec<PartyDocumentOut>,
|
||||
/// Its vendor record's bills, by id.
|
||||
pub bills: Vec<PartyDocumentOut>,
|
||||
/// Payroll lines naming it, by run.
|
||||
pub payroll: Vec<PartyPayrollOut>,
|
||||
}
|
||||
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 74, "expected 74 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 82, "expected 82 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
|
||||
@@ -51,11 +51,13 @@ const MONEY_KEYS: &[&str] = &[
|
||||
"adjustments",
|
||||
"after",
|
||||
"amount",
|
||||
"amount_1099",
|
||||
"assets",
|
||||
"balance",
|
||||
"balance_after",
|
||||
"balance_before",
|
||||
"before",
|
||||
"billed",
|
||||
"book_balance",
|
||||
"book_value",
|
||||
"cash_beginning",
|
||||
@@ -84,12 +86,18 @@ const MONEY_KEYS: &[&str] = &[
|
||||
"final_depreciation",
|
||||
"gain_loss",
|
||||
"gross",
|
||||
"gross_pay",
|
||||
"invoiced",
|
||||
"liabilities",
|
||||
"movement",
|
||||
"net_change_in_cash",
|
||||
"net_income",
|
||||
"net_pay",
|
||||
"owed",
|
||||
"paid",
|
||||
"proceeds",
|
||||
"received",
|
||||
"receivable",
|
||||
"remaining",
|
||||
"revenue",
|
||||
"salvage_value",
|
||||
@@ -588,6 +596,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
m.ok("books.list_recurring", json!({}));
|
||||
m.ok("books.list_schedules", json!({}));
|
||||
m.ok("books.list_vendors", json!({}));
|
||||
m.ok("books.parties", json!({}));
|
||||
m.ok("books.digest", json!({"since": "2026-01-01"}));
|
||||
|
||||
// Chart.
|
||||
@@ -645,6 +654,28 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true}),
|
||||
);
|
||||
m.ok("books.list_vendors", json!({}));
|
||||
m.ok(
|
||||
"books.add_party",
|
||||
json!({"name": "Pat", "roles": ["employee"]}),
|
||||
);
|
||||
m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Pat", "display_name": "Pat D.", "notes": "first hire"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_w4",
|
||||
json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_party_roles",
|
||||
json!({"party": "Landlord", "add": ["landlord"]}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
|
||||
"tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
|
||||
|
||||
// Owner contribution: draft, lines, receipt, preview, post.
|
||||
let e1 = m.draft(
|
||||
@@ -693,6 +724,10 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.set_entry_vendor",
|
||||
json!({"entry_id": e2, "vendor": vendor.to_string()}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": e2, "party": "Landlord"}),
|
||||
);
|
||||
// Replace the receipt: attach new, unlink old, retire it.
|
||||
m.attach(Some(e2), b"rent receipt v2");
|
||||
m.ok(
|
||||
@@ -853,7 +888,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
m.ok(
|
||||
"books.run_payroll",
|
||||
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [{
|
||||
"employee_name": "Pat", "gross_pay": "1000.00", "federal_withholding": "100.00",
|
||||
"employee": "Pat", "gross_pay": "1000.00", "federal_withholding": "100.00",
|
||||
"state_withholding": "50.00", "ss_employee": "62.00", "medicare_employee": "14.50",
|
||||
"ss_employer": "62.00", "medicare_employer": "14.50", "other_deductions": "0.00"}]}),
|
||||
);
|
||||
@@ -908,7 +943,11 @@ fn happy_path(name: &str) -> Mcp {
|
||||
m.ok("books.digest", json!({"since": "2026-01-01"}));
|
||||
m.ok("books.search_entries", json!({"text": "rent", "limit": 10}));
|
||||
m.ok("books.vendor_1099", json!({"year": 2026}));
|
||||
m.ok("books.party", json!({"party": "Landlord", "year": 2026}));
|
||||
m.ok("books.party", json!({"party": "Pat", "year": 2026}));
|
||||
m.ok("books.party", json!({"party": "Client"}));
|
||||
m.ok("books.verify", json!({}));
|
||||
m.ok("books.show", json!({"party": "Landlord"}));
|
||||
m.ok("books.show", json!({"entry_id": e1}));
|
||||
m.ok(
|
||||
"books.show",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,31 @@ use thiserror::Error;
|
||||
/// Every way a store operation can fail.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StoreError {
|
||||
/// An attachment link names a subject that doesn't exist.
|
||||
#[error("no {0} to link to")]
|
||||
UnknownSubject(String),
|
||||
/// The attachment is already linked to that subject.
|
||||
#[error("attachment {0} is already linked to {1}")]
|
||||
AlreadyLinked(u64, String),
|
||||
/// The attachment isn't linked to that subject.
|
||||
#[error("attachment {0} isn't linked to {1}")]
|
||||
NotLinkedTo(u64, String),
|
||||
/// A statement record that doesn't fit its account or itself.
|
||||
#[error("bad statement: {0}")]
|
||||
InvalidStatement(String),
|
||||
/// The books don't agree with the statement yet.
|
||||
#[error(
|
||||
"not reconciled: the cleared balance differs from the statement by {difference}; \
|
||||
clear the lines the statement shows (or find what's missing) first"
|
||||
)]
|
||||
NotReconciled {
|
||||
/// Cleared balance less the statement balance.
|
||||
difference: rust_decimal::Decimal,
|
||||
},
|
||||
/// A reconciliation command that breaks the sequence.
|
||||
#[error("{0}")]
|
||||
ReconciliationRefused(String),
|
||||
|
||||
/// An asset can't be corrected once depreciation for it is posted: the
|
||||
/// posted entries would disagree with the corrected schedule.
|
||||
#[error("asset {0} already has depreciation posted; correct it with entries instead")]
|
||||
|
||||
@@ -32,5 +32,5 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::{DiskCheck, Snapshot};
|
||||
pub use store::{
|
||||
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||
LOCK_FILE, PayrollLineInput, Store, VerifyReport,
|
||||
LOCK_FILE, PayrollLineInput, Reconciliation, Statement, Store, Subject, VerifyReport,
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 10;
|
||||
pub const SCHEMA_VERSION: u32 = 11;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -227,5 +227,44 @@ CREATE TABLE IF NOT EXISTS asset_tags_log(
|
||||
-- (TEXT, exact decimal, never REAL).
|
||||
ALTER TABLE assets ADD COLUMN tax_rule TEXT;
|
||||
ALTER TABLE assets ADD COLUMN contributor_basis TEXT;
|
||||
"#,
|
||||
r#"
|
||||
-- v11: attachments link to any subject (docs/parties-and-attachments.md):
|
||||
-- entries beyond the first, accounts, assets, parties, invoices, bills,
|
||||
-- periods, payroll runs, schedules, reconciliations, the company. Bank and
|
||||
-- card statements carry their account, period, and balances; a
|
||||
-- reconciliation records the statement, its balance, and the lines it
|
||||
-- cleared. Amounts are TEXT, exact decimal.
|
||||
CREATE TABLE IF NOT EXISTS attachment_links(
|
||||
attachment_id INTEGER NOT NULL,
|
||||
subject_kind TEXT NOT NULL,
|
||||
subject_id INTEGER NOT NULL,
|
||||
PRIMARY KEY(attachment_id, subject_kind, subject_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_links_subject ON attachment_links(subject_kind, subject_id);
|
||||
-- Payroll lines name their employee party (Ledger PayrollLine.employee).
|
||||
ALTER TABLE payroll_lines ADD COLUMN employee_id INTEGER;
|
||||
CREATE TABLE IF NOT EXISTS statements(
|
||||
attachment_id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
period_start TEXT NOT NULL,
|
||||
period_end TEXT NOT NULL,
|
||||
opening_balance TEXT NOT NULL,
|
||||
closing_balance TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reconciliations(
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
statement_date TEXT NOT NULL,
|
||||
statement_balance TEXT NOT NULL,
|
||||
attachment_id INTEGER NOT NULL,
|
||||
voided_reason TEXT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reconciliation_lines(
|
||||
reconciliation_id INTEGER NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
line_index INTEGER NOT NULL,
|
||||
PRIMARY KEY(reconciliation_id, entry_id, line_index)
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -725,6 +725,102 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Parties (ledger_core::parties)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Add a party. Journaled as `party.added`.
|
||||
pub fn add_party(
|
||||
&mut self,
|
||||
name: &str,
|
||||
roles: &[ledger_core::Role],
|
||||
) -> Result<ledger_core::PartyId, StoreError> {
|
||||
self.ledger.check_add_party(name, roles)?;
|
||||
let id = self.ledger.next_party_id();
|
||||
let line = self.append_journal(
|
||||
"party.added",
|
||||
json!({"id": id, "name": name.trim(), "roles": roles.iter().map(|r| r.key()).collect::<Vec<_>>()}),
|
||||
)?;
|
||||
let got = self.ledger.add_party(name, roles)?;
|
||||
debug_assert_eq!(got, id, "party id allocator diverged from journal");
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(got)
|
||||
}
|
||||
|
||||
/// Change one of a party's facts. Journaled as `party.updated`.
|
||||
pub fn update_party(
|
||||
&mut self,
|
||||
id: ledger_core::PartyId,
|
||||
field: ledger_core::PartyField,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger.check_update_party(id, &field)?;
|
||||
let line = self.append_journal("party.updated", json!({"id": id, "change": field}))?;
|
||||
self.ledger.update_party(id, field)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give or take a party role. Journaled as `party.role_set`.
|
||||
pub fn set_party_role(
|
||||
&mut self,
|
||||
id: ledger_core::PartyId,
|
||||
role: ledger_core::Role,
|
||||
on: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger.check_set_party_role(id, role, on)?;
|
||||
let line = self.append_journal(
|
||||
"party.role_set",
|
||||
json!({"id": id, "role": role.key(), "on": on}),
|
||||
)?;
|
||||
self.ledger.set_party_role(id, role, on)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record or clear a party's W-9 facts. Journaled as `party.w9_set`.
|
||||
pub fn set_party_w9(
|
||||
&mut self,
|
||||
id: ledger_core::PartyId,
|
||||
facts: Option<ledger_core::W9Facts>,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger.check_set_party_w9(id, facts.as_ref())?;
|
||||
let line = self.append_journal("party.w9_set", json!({"id": id, "facts": facts}))?;
|
||||
self.ledger.set_party_w9(id, facts)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record or clear a party's W-4 facts. Journaled as `party.w4_set`.
|
||||
pub fn set_party_w4(
|
||||
&mut self,
|
||||
id: ledger_core::PartyId,
|
||||
facts: Option<ledger_core::W4Facts>,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger.check_set_party_w4(id, facts.as_ref())?;
|
||||
let line = self.append_journal("party.w4_set", json!({"id": id, "facts": facts}))?;
|
||||
self.ledger.set_party_w4(id, facts)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tag an entry with its party, or clear the tag. Journaled as
|
||||
/// `entry.party_set`.
|
||||
pub fn set_entry_party(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
party: Option<ledger_core::PartyId>,
|
||||
) -> Result<(), StoreError> {
|
||||
let mut rehearsal = self.ledger.clone();
|
||||
rehearsal.set_entry_party(entry, party)?;
|
||||
let line = self.append_journal(
|
||||
"entry.party_set",
|
||||
json!({"entry_id": entry, "party_id": party}),
|
||||
)?;
|
||||
self.ledger.set_entry_party(entry, party)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct a registered asset's cost, salvage, life, or in-service date
|
||||
/// (see `Ledger::correct_asset`), with the reason. Refused once any
|
||||
/// depreciation for it is posted. Journaled as `asset.corrected`, with
|
||||
@@ -2416,7 +2512,10 @@ impl Store {
|
||||
/// while they may be needed).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment is unknown or already withdrawn: SHOULD refuse.
|
||||
/// 2. IF it is linked to an entry: SHOULD refuse (AttachmentLinked).
|
||||
/// 2. IF it is filed on an entry: SHOULD refuse (AttachmentLinked,
|
||||
/// undone with unlink_receipt). IF it is linked to any subject
|
||||
/// (link_attachment): SHOULD refuse (AlreadyLinked, naming the
|
||||
/// first subject; undone with unlink_attachment).
|
||||
/// 3. IF the reason is blank: SHOULD refuse.
|
||||
/// 4. Otherwise: SHOULD journal `attachment.withdrawn`; the store's
|
||||
/// listing marks it withdrawn with the reason (the MCP listing
|
||||
@@ -2432,6 +2531,9 @@ impl Store {
|
||||
return Err(StoreError::ReasonRequired);
|
||||
}
|
||||
self.check_linkable(attachment_id)?;
|
||||
if let Some(sub) = self.attachment_links(attachment_id)?.first() {
|
||||
return Err(StoreError::AlreadyLinked(attachment_id, sub.describe()));
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"attachment.withdrawn",
|
||||
json!({ "attachment_id": attachment_id, "reason": reason }),
|
||||
@@ -2440,6 +2542,443 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Attachment links, statements, reconciliations
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Whether `subject` exists in the books.
|
||||
fn subject_exists(&self, subject: Subject) -> Result<bool, StoreError> {
|
||||
let l = &self.ledger;
|
||||
Ok(match subject {
|
||||
Subject::Entry(i) => l.get_entry(i).is_some(),
|
||||
Subject::Account(i) => l.chart().get(i).is_some(),
|
||||
Subject::Asset(i) => l.get_asset(i).is_some(),
|
||||
Subject::Party(i) => l.get_party(i).is_some(),
|
||||
Subject::Invoice(i) => l.get_invoice(i).is_some(),
|
||||
Subject::Bill(i) => l.get_bill(i).is_some(),
|
||||
Subject::Period(i) => l.list_periods().iter().any(|p| p.id == i),
|
||||
Subject::PayrollRun(i) => l.get_payroll_run(i).is_some(),
|
||||
Subject::Schedule(i) => l.get_schedule(i).is_some(),
|
||||
Subject::Reconciliation(i) => self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM reconciliations WHERE id = ?",
|
||||
params![i as i64],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some(),
|
||||
Subject::Company => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// The subjects an attachment is linked to (its filing entry, if any,
|
||||
/// first).
|
||||
pub fn attachment_links(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
|
||||
let (entry, _) = self.attachment_state(attachment_id)?;
|
||||
let mut out: Vec<Subject> = entry.map(Subject::Entry).into_iter().collect();
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT subject_kind, subject_id FROM attachment_links WHERE attachment_id = ? ORDER BY subject_kind, subject_id",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![attachment_id as i64], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (kind, id) = row?;
|
||||
if let Some(sub) = Subject::from_parts(&kind, id as u64) {
|
||||
out.push(sub);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The attachments linked to a subject (for an entry: filed on it or
|
||||
/// linked to it), not withdrawn.
|
||||
pub fn attachments_for(&self, subject: Subject) -> Result<Vec<Attachment>, StoreError> {
|
||||
if let Subject::Entry(e) = subject {
|
||||
return Ok(query_attachments(&self.conn, Some(e))?
|
||||
.into_iter()
|
||||
.filter(|a| a.withdrawn_at.is_none())
|
||||
.collect());
|
||||
}
|
||||
let ids: Vec<i64> = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT attachment_id FROM attachment_links WHERE subject_kind = ? AND subject_id = ? ORDER BY attachment_id",
|
||||
)?
|
||||
.query_map(params![subject.kind(), subject.id() as i64], |r| r.get(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(query_attachments(&self.conn, None)?
|
||||
.into_iter()
|
||||
.filter(|a| ids.contains(&(a.id as i64)) && a.withdrawn_at.is_none())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Link a stored attachment to a subject: another entry, an account, an
|
||||
/// asset, a party, an invoice, a bill, a period, a payroll run, a
|
||||
/// schedule, a reconciliation, or the company.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one file, linked wherever it's evidence, without refiling.
|
||||
/// SOURCE: docs/parties-and-attachments.md "Attachments"; DECISION
|
||||
/// (Stephen, 2026-10-15).
|
||||
/// INVARIANTS:
|
||||
/// 1. Journaled as `attachment.linked {attachment_id, subject}`.
|
||||
/// 2. An entry's receipts are the files filed on it and the files
|
||||
/// linked to it; the receipt policy counts both.
|
||||
/// 3. A refusal journals nothing.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment is unknown or withdrawn, or the subject
|
||||
/// doesn't exist: SHOULD refuse.
|
||||
/// 2. IF it is already linked to that subject (or filed on that
|
||||
/// entry): SHOULD refuse.
|
||||
/// ```
|
||||
pub fn link_attachment(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
subject: Subject,
|
||||
) -> Result<(), StoreError> {
|
||||
let (entry, withdrawn) = self.attachment_state(attachment_id)?;
|
||||
if withdrawn {
|
||||
return Err(StoreError::AttachmentWithdrawn(attachment_id));
|
||||
}
|
||||
if !self.subject_exists(subject)? {
|
||||
return Err(StoreError::UnknownSubject(subject.describe()));
|
||||
}
|
||||
if entry.map(Subject::Entry) == Some(subject)
|
||||
|| self.attachment_links(attachment_id)?.contains(&subject)
|
||||
{
|
||||
return Err(StoreError::AlreadyLinked(attachment_id, subject.describe()));
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"attachment.linked",
|
||||
json!({"attachment_id": attachment_id, "subject": subject}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take a link off, with the reason. The file stays filed; a receipt
|
||||
/// filed on an entry comes off with `unlink_receipt` instead.
|
||||
///
|
||||
/// ```text
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the reason is blank, or the attachment isn't linked to the
|
||||
/// subject (through this table): SHOULD refuse.
|
||||
/// 2. Otherwise: journaled as `attachment.unlinked {attachment_id,
|
||||
/// subject, reason}`.
|
||||
/// ```
|
||||
pub fn unlink_attachment(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
subject: Subject,
|
||||
reason: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
return Err(StoreError::ReasonRequired);
|
||||
}
|
||||
self.attachment_state(attachment_id)?;
|
||||
let linked = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM attachment_links WHERE attachment_id = ? AND subject_kind = ? AND subject_id = ?",
|
||||
params![attachment_id as i64, subject.kind(), subject.id() as i64],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if !linked {
|
||||
return Err(StoreError::NotLinkedTo(attachment_id, subject.describe()));
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"attachment.unlinked",
|
||||
json!({"attachment_id": attachment_id, "subject": subject, "reason": reason}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record what a filed statement says: its account, period, and
|
||||
/// balances. Links it to the account.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the bank and card statements are in the books, so a
|
||||
/// reconciliation names the one it agrees with.
|
||||
/// INVARIANTS:
|
||||
/// 1. Journaled as `statement.recorded {attachment_id, account_id,
|
||||
/// period_start, period_end, opening_balance, closing_balance}`,
|
||||
/// then `attachment.linked` to the account.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment is unknown or withdrawn, already records a
|
||||
/// statement, the account is unknown or not an asset or liability
|
||||
/// (a bank, cash, or card account), the period runs backwards, or
|
||||
/// a balance has more than two decimal places: SHOULD refuse,
|
||||
/// journaling nothing.
|
||||
/// ```
|
||||
pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> {
|
||||
let (_, withdrawn) = self.attachment_state(st.attachment_id)?;
|
||||
if withdrawn {
|
||||
return Err(StoreError::AttachmentWithdrawn(st.attachment_id));
|
||||
}
|
||||
let bad = |why: &str| Err(StoreError::InvalidStatement(why.to_string()));
|
||||
let acct = self
|
||||
.ledger
|
||||
.chart()
|
||||
.get(st.account_id)
|
||||
.ok_or(LedgerError::UnknownAccount(st.account_id))?;
|
||||
if !matches!(
|
||||
acct.account_type,
|
||||
AccountType::Asset | AccountType::Liability
|
||||
) {
|
||||
return bad("a statement is for a bank, cash, or card account (an asset or liability)");
|
||||
}
|
||||
if st.period_end < st.period_start {
|
||||
return bad("the period ends before it starts");
|
||||
}
|
||||
if [st.opening_balance, st.closing_balance]
|
||||
.iter()
|
||||
.any(|b| b.normalize().scale() > 2)
|
||||
{
|
||||
return bad("balances are in cents at most");
|
||||
}
|
||||
if self.statement(st.attachment_id)?.is_some() {
|
||||
return bad("that attachment already records a statement");
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"statement.recorded",
|
||||
json!({
|
||||
"attachment_id": st.attachment_id,
|
||||
"account_id": st.account_id,
|
||||
"period_start": st.period_start.to_string(),
|
||||
"period_end": st.period_end.to_string(),
|
||||
"opening_balance": st.opening_balance.to_string(),
|
||||
"closing_balance": st.closing_balance.to_string(),
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
if !self
|
||||
.attachment_links(st.attachment_id)?
|
||||
.contains(&Subject::Account(st.account_id))
|
||||
{
|
||||
self.link_attachment(st.attachment_id, Subject::Account(st.account_id))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The statement an attachment records, if any.
|
||||
pub fn statement(&self, attachment_id: u64) -> Result<Option<Statement>, StoreError> {
|
||||
Ok(self
|
||||
.statements(None)?
|
||||
.into_iter()
|
||||
.find(|s| s.attachment_id == attachment_id))
|
||||
}
|
||||
|
||||
/// Recorded statements, optionally one account's, by period end.
|
||||
pub fn statements(&self, account: Option<AccountId>) -> Result<Vec<Statement>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (att, acct, start, end, open, close) = row?;
|
||||
if account.is_some_and(|a| a != acct as u64) {
|
||||
continue;
|
||||
}
|
||||
let dec = |t: &str| {
|
||||
t.parse::<Decimal>()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("statement amount {t}")))
|
||||
};
|
||||
out.push(Statement {
|
||||
attachment_id: att as u64,
|
||||
account_id: acct as u64,
|
||||
period_start: parse_date(&start)?,
|
||||
period_end: parse_date(&end)?,
|
||||
opening_balance: dec(&open)?,
|
||||
closing_balance: dec(&close)?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reconcile an account to a recorded statement: the books' cleared
|
||||
/// balance through the statement date must equal its closing balance.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a monthly close that leaves proof: which statement, which
|
||||
/// balance, which lines it cleared.
|
||||
/// SOURCE: CONVENTION (bank reconciliation); docs/parties-and-
|
||||
/// attachments.md "Reconciliations"; DECISION (Stephen,
|
||||
/// 2026-10-15).
|
||||
/// INVARIANTS:
|
||||
/// 1. Recorded only when the difference is exactly zero
|
||||
/// (Ledger::reconciliation_report against the statement's date and
|
||||
/// closing balance).
|
||||
/// 2. It records the cleared lines on the account dated on or before
|
||||
/// the statement date, and links the statement to it.
|
||||
/// 3. Reconciliations of an account go forward: a new one's date is
|
||||
/// after every one not voided.
|
||||
/// 4. Journaled as `reconciliation.completed`; never edited (void and
|
||||
/// redo).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment records no statement: SHOULD refuse.
|
||||
/// 2. IF the books differ from the statement: SHOULD refuse with
|
||||
/// NotReconciled naming the difference.
|
||||
/// 3. IF a reconciliation not voided is dated on or after it: SHOULD
|
||||
/// refuse.
|
||||
/// ```
|
||||
pub fn complete_reconciliation(
|
||||
&mut self,
|
||||
statement_attachment: u64,
|
||||
) -> Result<Reconciliation, StoreError> {
|
||||
let st = self.statement(statement_attachment)?.ok_or_else(|| {
|
||||
StoreError::InvalidStatement(format!(
|
||||
"attachment {statement_attachment} records no statement (record_statement first)"
|
||||
))
|
||||
})?;
|
||||
if let Some(last) = self
|
||||
.reconciliations(Some(st.account_id))?
|
||||
.into_iter()
|
||||
.filter(|r| r.voided.is_none())
|
||||
.map(|r| r.statement_date)
|
||||
.max()
|
||||
&& last >= st.period_end
|
||||
{
|
||||
return Err(StoreError::ReconciliationRefused(format!(
|
||||
"account {} is already reconciled through {last}; void that reconciliation to redo it",
|
||||
st.account_id
|
||||
)));
|
||||
}
|
||||
let report =
|
||||
self.ledger
|
||||
.reconciliation_report(st.account_id, st.period_end, st.closing_balance)?;
|
||||
if !report.difference.is_zero() {
|
||||
return Err(StoreError::NotReconciled {
|
||||
difference: report.difference,
|
||||
});
|
||||
}
|
||||
let mut cleared = Vec::new();
|
||||
for e in self.ledger.posted_entries() {
|
||||
if e.date() > st.period_end {
|
||||
continue;
|
||||
}
|
||||
for (i, l) in e.lines().iter().enumerate() {
|
||||
if l.account == st.account_id && self.ledger.is_cleared(e.id(), i) {
|
||||
cleared.push((e.id(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
cleared.sort_unstable();
|
||||
let id: u64 = self.conn.query_row(
|
||||
"SELECT COALESCE(MAX(id), 0) + 1 FROM reconciliations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)? as u64;
|
||||
let line = self.append_journal(
|
||||
"reconciliation.completed",
|
||||
json!({
|
||||
"id": id,
|
||||
"account_id": st.account_id,
|
||||
"statement_date": st.period_end.to_string(),
|
||||
"statement_balance": st.closing_balance.to_string(),
|
||||
"attachment_id": statement_attachment,
|
||||
"cleared": cleared,
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
self.link_attachment(statement_attachment, Subject::Reconciliation(id))?;
|
||||
Ok(Reconciliation {
|
||||
id,
|
||||
account_id: st.account_id,
|
||||
statement_date: st.period_end,
|
||||
statement_balance: st.closing_balance,
|
||||
attachment_id: statement_attachment,
|
||||
cleared,
|
||||
voided: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Void a reconciliation, with the reason (to redo it). Journaled as
|
||||
/// `reconciliation.voided`.
|
||||
pub fn void_reconciliation(&mut self, id: u64, reason: &str) -> Result<(), StoreError> {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
return Err(StoreError::ReasonRequired);
|
||||
}
|
||||
let r = self
|
||||
.reconciliations(None)?
|
||||
.into_iter()
|
||||
.find(|r| r.id == id)
|
||||
.ok_or_else(|| StoreError::UnknownSubject(format!("reconciliation {id}")))?;
|
||||
if r.voided.is_some() {
|
||||
return Err(StoreError::ReconciliationRefused(format!(
|
||||
"reconciliation {id} is already voided"
|
||||
)));
|
||||
}
|
||||
let line =
|
||||
self.append_journal("reconciliation.voided", json!({"id": id, "reason": reason}))?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconciliations, optionally one account's, by date.
|
||||
pub fn reconciliations(
|
||||
&self,
|
||||
account: Option<AccountId>,
|
||||
) -> Result<Vec<Reconciliation>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, account_id, statement_date, statement_balance, attachment_id, voided_reason FROM reconciliations ORDER BY statement_date, id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, i64>(4)?,
|
||||
r.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, acct, date, bal, att, voided) = row?;
|
||||
if account.is_some_and(|a| a != acct as u64) {
|
||||
continue;
|
||||
}
|
||||
let cleared: Vec<(EntryId, usize)> = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT entry_id, line_index FROM reconciliation_lines WHERE reconciliation_id = ? ORDER BY entry_id, line_index",
|
||||
)?
|
||||
.query_map(params![id], |r| {
|
||||
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as usize))
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
out.push(Reconciliation {
|
||||
id: id as u64,
|
||||
account_id: acct as u64,
|
||||
statement_date: parse_date(&date)?,
|
||||
statement_balance: bal.parse().map_err(|_| {
|
||||
StoreError::JournalCorrupt(format!("reconciliation balance {bal}"))
|
||||
})?,
|
||||
attachment_id: att as u64,
|
||||
cleared,
|
||||
voided,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `(linked entry, withdrawn?)` for an attachment.
|
||||
fn attachment_state(&self, attachment_id: u64) -> Result<(Option<EntryId>, bool), StoreError> {
|
||||
self.conn
|
||||
@@ -3002,7 +3541,7 @@ impl Store {
|
||||
for r in run_rows {
|
||||
let (id, date, entry_id) = r?;
|
||||
let mut pl_stmt = self.conn.prepare(
|
||||
"SELECT employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions FROM payroll_lines WHERE run_id = ? ORDER BY id",
|
||||
"SELECT employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions, employee_id FROM payroll_lines WHERE run_id = ? ORDER BY id",
|
||||
)?;
|
||||
let pl_rows = pl_stmt.query_map(params![id], |lr| {
|
||||
Ok((
|
||||
@@ -3015,13 +3554,15 @@ impl Store {
|
||||
lr.get::<_, String>(6)?,
|
||||
lr.get::<_, String>(7)?,
|
||||
lr.get::<_, String>(8)?,
|
||||
lr.get::<_, Option<i64>>(9)?,
|
||||
))
|
||||
})?;
|
||||
let mut lines = Vec::new();
|
||||
for lr in pl_rows {
|
||||
let (name, gross, fed, state, ss_e, med_e, ss_r, med_r, other) = lr?;
|
||||
let (name, gross, fed, state, ss_e, med_e, ss_r, med_r, other, emp) = lr?;
|
||||
lines.push(PayrollLine {
|
||||
employee_name: name,
|
||||
employee: emp.map(|e| e as u64),
|
||||
gross_pay: parse_payroll_amount(gross)?,
|
||||
federal_withholding: parse_payroll_amount(fed)?,
|
||||
state_withholding: parse_payroll_amount(state)?,
|
||||
@@ -3150,6 +3691,7 @@ fn flip_line_json(l: &ledger_core::JournalLine) -> Value {
|
||||
fn payroll_line_json(l: &PayrollLine) -> Value {
|
||||
json!({
|
||||
"employee_name": l.employee_name,
|
||||
"employee": l.employee,
|
||||
"gross_pay": l.gross_pay.to_string(),
|
||||
"federal_withholding": l.federal_withholding.to_string(),
|
||||
"state_withholding": l.state_withholding.to_string(),
|
||||
@@ -3177,6 +3719,12 @@ fn parse_payroll_lines(p: &Value) -> Result<Vec<PayrollLine>, StoreError> {
|
||||
};
|
||||
Ok(PayrollLine {
|
||||
employee_name: get_str(l, "employee_name")?.to_string(),
|
||||
employee: match l.get("employee") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("payroll line employee".into())
|
||||
})?),
|
||||
},
|
||||
gross_pay: amount("gross_pay")?,
|
||||
federal_withholding: amount("federal_withholding")?,
|
||||
state_withholding: amount("state_withholding")?,
|
||||
@@ -3498,6 +4046,13 @@ pub(crate) fn replay_line_into_ledger(
|
||||
pct,
|
||||
)?;
|
||||
}
|
||||
"attachment.linked"
|
||||
| "attachment.unlinked"
|
||||
| "statement.recorded"
|
||||
| "reconciliation.completed"
|
||||
| "reconciliation.voided" => {
|
||||
// Evidence, not accounting state: the projection records them.
|
||||
}
|
||||
"receipt.attached"
|
||||
| "receipt.linked"
|
||||
| "receipt.unlinked"
|
||||
@@ -3530,6 +4085,59 @@ pub(crate) fn replay_line_into_ledger(
|
||||
"asset.tagged" => {
|
||||
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?;
|
||||
}
|
||||
"party.added" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
let roles = p
|
||||
.get("roles")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.added roles".into()))?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.as_str()
|
||||
.and_then(ledger_core::Role::parse)
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.added role".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let got = ledger.add_party(get_str(p, "name")?, &roles)?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"party id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"party.updated" => {
|
||||
let field: ledger_core::PartyField = serde_json::from_value(p["change"].clone())
|
||||
.map_err(|_| StoreError::JournalCorrupt("party.updated change".into()))?;
|
||||
ledger.update_party(get_u64(p, "id")?, field)?;
|
||||
}
|
||||
"party.role_set" => {
|
||||
let role = ledger_core::Role::parse(get_str(p, "role")?)
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.role_set role".into()))?;
|
||||
let on = p
|
||||
.get("on")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?;
|
||||
ledger.set_party_role(get_u64(p, "id")?, role, on)?;
|
||||
}
|
||||
"party.w9_set" => {
|
||||
let facts: Option<ledger_core::W9Facts> = serde_json::from_value(p["facts"].clone())
|
||||
.map_err(|_| StoreError::JournalCorrupt("party.w9_set facts".into()))?;
|
||||
ledger.set_party_w9(get_u64(p, "id")?, facts)?;
|
||||
}
|
||||
"party.w4_set" => {
|
||||
let facts: Option<ledger_core::W4Facts> = serde_json::from_value(p["facts"].clone())
|
||||
.map_err(|_| StoreError::JournalCorrupt("party.w4_set facts".into()))?;
|
||||
ledger.set_party_w4(get_u64(p, "id")?, facts)?;
|
||||
}
|
||||
"entry.party_set" => {
|
||||
let party = match p.get("party_id") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("entry.party_set party_id".into())
|
||||
})?),
|
||||
};
|
||||
ledger.set_entry_party(get_u64(p, "entry_id")?, party)?;
|
||||
}
|
||||
"asset.corrected" => {
|
||||
ledger.correct_asset(
|
||||
get_u64(p, "asset_id")?,
|
||||
@@ -4030,9 +4638,82 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
"payroll.taxes_remitted" => {
|
||||
// The entry was projected by its own events; this only names it.
|
||||
}
|
||||
"role.assigned" => {
|
||||
// Roles live in the ledger (rebuilt by replay); the events
|
||||
// mirror below keeps the history.
|
||||
"attachment.linked" | "attachment.unlinked" => {
|
||||
let sub = &p["subject"];
|
||||
let kind = get_str(sub, "kind")?;
|
||||
let id = sub.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
if Subject::from_parts(kind, id).is_none() {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"attachment subject {kind}"
|
||||
)));
|
||||
}
|
||||
let att = get_u64(p, "attachment_id")? as i64;
|
||||
if line.line_type == "attachment.linked" {
|
||||
conn.execute(
|
||||
"INSERT INTO attachment_links(attachment_id, subject_kind, subject_id) VALUES (?,?,?)",
|
||||
params![att, kind, id as i64],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"DELETE FROM attachment_links WHERE attachment_id = ? AND subject_kind = ? AND subject_id = ?",
|
||||
params![att, kind, id as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"statement.recorded" => {
|
||||
conn.execute(
|
||||
"INSERT INTO statements(attachment_id, account_id, period_start, period_end, opening_balance, closing_balance) VALUES (?,?,?,?,?,?)",
|
||||
params![
|
||||
get_u64(p, "attachment_id")? as i64,
|
||||
get_u64(p, "account_id")? as i64,
|
||||
get_str(p, "period_start")?,
|
||||
get_str(p, "period_end")?,
|
||||
get_str(p, "opening_balance")?,
|
||||
get_str(p, "closing_balance")?,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"reconciliation.completed" => {
|
||||
let id = get_u64(p, "id")? as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO reconciliations(id, account_id, statement_date, statement_balance, attachment_id) VALUES (?,?,?,?,?)",
|
||||
params![
|
||||
id,
|
||||
get_u64(p, "account_id")? as i64,
|
||||
get_str(p, "statement_date")?,
|
||||
get_str(p, "statement_balance")?,
|
||||
get_u64(p, "attachment_id")? as i64,
|
||||
],
|
||||
)?;
|
||||
let cleared = p
|
||||
.get("cleared")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("reconciliation cleared".into()))?;
|
||||
for pair in cleared {
|
||||
let e = pair.get(0).and_then(|v| v.as_u64());
|
||||
let l = pair.get(1).and_then(|v| v.as_u64());
|
||||
let (Some(e), Some(l)) = (e, l) else {
|
||||
return Err(StoreError::JournalCorrupt(
|
||||
"reconciliation cleared line".into(),
|
||||
));
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO reconciliation_lines(reconciliation_id, entry_id, line_index) VALUES (?,?,?)",
|
||||
params![id, e as i64, l as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"reconciliation.voided" => {
|
||||
conn.execute(
|
||||
"UPDATE reconciliations SET voided_reason = ? WHERE id = ?",
|
||||
params![get_str(p, "reason")?, get_u64(p, "id")? as i64],
|
||||
)?;
|
||||
}
|
||||
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
|
||||
| "party.w4_set" | "entry.party_set" => {
|
||||
// Control roles and the party directory live in the ledger
|
||||
// (rebuilt by replay); the events mirror below keeps the
|
||||
// history.
|
||||
}
|
||||
"asset.tagged" => {
|
||||
let (id, tag) = (get_u64(p, "asset_id")? as i64, get_str(p, "tag")?);
|
||||
@@ -4373,7 +5054,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
)?;
|
||||
for l in parse_payroll_lines(p)? {
|
||||
conn.execute(
|
||||
"INSERT INTO payroll_lines(run_id, employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO payroll_lines(run_id, employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions, employee_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
run_id,
|
||||
l.employee_name,
|
||||
@@ -4385,6 +5066,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
l.ss_employer.to_string(),
|
||||
l.medicare_employer.to_string(),
|
||||
l.other_deductions.to_string(),
|
||||
l.employee.map(|e| e as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -4517,6 +5199,135 @@ pub(crate) fn compare_projections(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What an attachment can be linked to (docs/parties-and-attachments.md).
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
|
||||
pub enum Subject {
|
||||
/// An entry (beyond the one a receipt is filed on).
|
||||
Entry(u64),
|
||||
/// An account (e.g. its statements, the agreement that opened it).
|
||||
Account(u64),
|
||||
/// A fixed asset (photos, warranty, manuals).
|
||||
Asset(u64),
|
||||
/// A party (contracts, correspondence).
|
||||
Party(u64),
|
||||
/// An invoice.
|
||||
Invoice(u64),
|
||||
/// A bill.
|
||||
Bill(u64),
|
||||
/// An accounting period (its close package).
|
||||
Period(u64),
|
||||
/// A payroll run.
|
||||
PayrollRun(u64),
|
||||
/// An amortization schedule (the prepaid contract).
|
||||
Schedule(u64),
|
||||
/// A reconciliation (its statement).
|
||||
Reconciliation(u64),
|
||||
/// The company itself (formation documents).
|
||||
Company,
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
/// Its kind's wire name.
|
||||
pub fn kind(self) -> &'static str {
|
||||
match self {
|
||||
Subject::Entry(_) => "entry",
|
||||
Subject::Account(_) => "account",
|
||||
Subject::Asset(_) => "asset",
|
||||
Subject::Party(_) => "party",
|
||||
Subject::Invoice(_) => "invoice",
|
||||
Subject::Bill(_) => "bill",
|
||||
Subject::Period(_) => "period",
|
||||
Subject::PayrollRun(_) => "payroll_run",
|
||||
Subject::Schedule(_) => "schedule",
|
||||
Subject::Reconciliation(_) => "reconciliation",
|
||||
Subject::Company => "company",
|
||||
}
|
||||
}
|
||||
|
||||
/// Its id (0 for the company).
|
||||
pub fn id(self) -> u64 {
|
||||
match self {
|
||||
Subject::Entry(i)
|
||||
| Subject::Account(i)
|
||||
| Subject::Asset(i)
|
||||
| Subject::Party(i)
|
||||
| Subject::Invoice(i)
|
||||
| Subject::Bill(i)
|
||||
| Subject::Period(i)
|
||||
| Subject::PayrollRun(i)
|
||||
| Subject::Schedule(i)
|
||||
| Subject::Reconciliation(i) => i,
|
||||
Subject::Company => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The subject of `kind` and `id`.
|
||||
pub fn from_parts(kind: &str, id: u64) -> Option<Subject> {
|
||||
Some(match kind {
|
||||
"entry" => Subject::Entry(id),
|
||||
"account" => Subject::Account(id),
|
||||
"asset" => Subject::Asset(id),
|
||||
"party" => Subject::Party(id),
|
||||
"invoice" => Subject::Invoice(id),
|
||||
"bill" => Subject::Bill(id),
|
||||
"period" => Subject::Period(id),
|
||||
"payroll_run" => Subject::PayrollRun(id),
|
||||
"schedule" => Subject::Schedule(id),
|
||||
"reconciliation" => Subject::Reconciliation(id),
|
||||
"company" => Subject::Company,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Human words, e.g. "account 1", for messages.
|
||||
pub fn describe(self) -> String {
|
||||
match self {
|
||||
Subject::Company => "the company".into(),
|
||||
s => format!("{} {}", s.kind().replace('_', " "), s.id()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bank or card statement: the attachment that holds it, and what it
|
||||
/// says.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Statement {
|
||||
/// The attachment holding the statement.
|
||||
pub attachment_id: u64,
|
||||
/// The account it is for.
|
||||
pub account_id: AccountId,
|
||||
/// First day it covers.
|
||||
pub period_start: NaiveDate,
|
||||
/// Last day it covers (its statement date).
|
||||
pub period_end: NaiveDate,
|
||||
/// Its opening balance, in the account's normal direction.
|
||||
pub opening_balance: Decimal,
|
||||
/// Its closing balance, in the account's normal direction.
|
||||
pub closing_balance: Decimal,
|
||||
}
|
||||
|
||||
/// A completed reconciliation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Reconciliation {
|
||||
/// Its id.
|
||||
pub id: u64,
|
||||
/// The account reconciled.
|
||||
pub account_id: AccountId,
|
||||
/// The statement date it reconciles through.
|
||||
pub statement_date: NaiveDate,
|
||||
/// The statement's closing balance.
|
||||
pub statement_balance: Decimal,
|
||||
/// The statement's attachment.
|
||||
pub attachment_id: u64,
|
||||
/// The lines it found cleared, (entry, line index).
|
||||
pub cleared: Vec<(EntryId, usize)>,
|
||||
/// Why it was voided, if it was.
|
||||
pub voided: Option<String>,
|
||||
}
|
||||
|
||||
/// Attachments in the projection, optionally only one entry's, by id.
|
||||
pub(crate) fn query_attachments(
|
||||
conn: &Connection,
|
||||
@@ -4530,7 +5341,11 @@ pub(crate) fn query_attachments(
|
||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||
);
|
||||
if entry_id.is_some() {
|
||||
sql.push_str(" WHERE a.entry_id = ?");
|
||||
// Filed on the entry, or linked to it.
|
||||
sql.push_str(
|
||||
" WHERE a.entry_id = ?1 OR a.id IN \
|
||||
(SELECT attachment_id FROM attachment_links WHERE subject_kind = 'entry' AND subject_id = ?1)",
|
||||
);
|
||||
}
|
||||
sql.push_str(" ORDER BY a.id");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
|
||||
#[serde(tag = "show", rename_all = "snake_case")]
|
||||
pub enum ViewerTarget {
|
||||
/// A top-level page: overview, cash, profit, transactions, reports,
|
||||
/// accounts, assets, documents, company, journal.
|
||||
/// accounts, assets, parties, documents, company, journal.
|
||||
Page {
|
||||
/// Page name.
|
||||
page: String,
|
||||
@@ -62,6 +62,12 @@ pub enum ViewerTarget {
|
||||
/// Asset id.
|
||||
asset_id: u64,
|
||||
},
|
||||
/// One party: who they are, their roles, and everything the company
|
||||
/// did with them.
|
||||
Party {
|
||||
/// Party id.
|
||||
party_id: u64,
|
||||
},
|
||||
/// The transactions list, searched.
|
||||
Search {
|
||||
/// Search text: description, account, vendor, amount.
|
||||
|
||||
@@ -669,9 +669,37 @@ fn tax_basis_replays_on_reopen() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Downgrade the database to schema v10 (none of v11's attachment links,
|
||||
/// statements, reconciliations, or payroll_lines.employee_id).
|
||||
fn downgrade_db_to_v10(dir: &Path) {
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE attachment_links;
|
||||
DROP TABLE statements;
|
||||
DROP TABLE reconciliations;
|
||||
DROP TABLE reconciliation_lines;
|
||||
ALTER TABLE payroll_lines DROP COLUMN employee_id;
|
||||
UPDATE meta SET value = '10' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An existing v10 database migrates on open and verifies.
|
||||
#[test]
|
||||
fn a_v10_database_migrates_and_verifies() {
|
||||
let (dir, mut s, _, x, _) = fixture("v10");
|
||||
s.set_asset_tag(x, "WL-0001").unwrap();
|
||||
drop(s);
|
||||
downgrade_db_to_v10(&dir);
|
||||
let s = Store::open(&dir).expect("a v10 database opens");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0001"));
|
||||
}
|
||||
|
||||
/// Downgrade the database to schema v9 (no assets.tax_rule /
|
||||
/// assets.contributor_basis), as an older wanderbooks left it.
|
||||
fn downgrade_db_to_v9(dir: &Path) {
|
||||
downgrade_db_to_v10(dir);
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE assets DROP COLUMN tax_rule;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,754 @@
|
||||
//! Spec-derived tests for the party directory in the store.
|
||||
//!
|
||||
//! Oracles: the `Store` party methods' doc comments (each "journaled as"
|
||||
//! its event: `party.added {id, name, roles}`, `party.updated {id,
|
||||
//! change}`, `party.role_set {id, role, on}`, `party.w9_set {id, facts}`,
|
||||
//! `party.w4_set {id, facts}`, `entry.party_set {entry_id, party_id}`;
|
||||
//! `payroll.run` lines carry `employee`), the `ledger_core::parties` spec
|
||||
//! (INVARIANTS 1–4), `Store::verify` (a fresh replay equals the live
|
||||
//! ledger), and `docs/parties-and-attachments.md` "Migration" (old journals
|
||||
//! replay unchanged; vendors and customers get their parties by replay).
|
||||
//! Expected values are worked by hand from those texts.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
|
||||
PayrollLine, PayrollRun, Role, Side, W4Facts, W9Facts,
|
||||
};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
fn test_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wb-parties-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(dec(s), Currency::USD)
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
/// The journal's (type, payload) pairs, verified.
|
||||
fn journal(dir: &Path) -> Vec<(String, Value)> {
|
||||
read_and_verify_journal(&dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|l| (l.line_type, l.payload))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn journal_bytes(dir: &Path) -> Vec<u8> {
|
||||
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
|
||||
}
|
||||
|
||||
/// Run `f`; it must journal exactly one line, of type `ty`. Returns that
|
||||
/// line's payload.
|
||||
fn one_event<T>(
|
||||
s: &mut Store,
|
||||
dir: &Path,
|
||||
ty: &str,
|
||||
f: impl FnOnce(&mut Store) -> T,
|
||||
) -> (T, Value) {
|
||||
let seq = s.journal_seq();
|
||||
let len = journal(dir).len();
|
||||
let out = f(s);
|
||||
assert_eq!(s.journal_seq(), seq + 1, "{ty}: exactly one line");
|
||||
let j = journal(dir);
|
||||
assert_eq!(j.len(), len + 1, "{ty}: exactly one line on disk");
|
||||
let (t, p) = j.last().unwrap().clone();
|
||||
assert_eq!(t, ty);
|
||||
(out, p)
|
||||
}
|
||||
|
||||
/// Run `f`; it must fail and journal nothing.
|
||||
fn refused_nothing_journaled<T: std::fmt::Debug, E>(
|
||||
s: &mut Store,
|
||||
dir: &Path,
|
||||
what: &str,
|
||||
f: impl FnOnce(&mut Store) -> Result<T, E>,
|
||||
) {
|
||||
let seq = s.journal_seq();
|
||||
let bytes = journal_bytes(dir);
|
||||
let before = s.ledger().clone();
|
||||
let r = f(s);
|
||||
assert!(r.is_err(), "{what}: expected refusal, got {:?}", r.ok());
|
||||
assert_eq!(s.journal_seq(), seq, "{what}: journaled");
|
||||
assert_eq!(journal_bytes(dir), bytes, "{what}: journal changed");
|
||||
assert!(*s.ledger() == before, "{what}: ledger changed");
|
||||
}
|
||||
|
||||
fn w9(last4: &str) -> W9Facts {
|
||||
W9Facts {
|
||||
received: date(2026, 3, 1),
|
||||
entity_type: "individual/sole proprietor".to_string(),
|
||||
exempt_payee_code: None,
|
||||
fatca_code: None,
|
||||
tin_last4: last4.to_string(),
|
||||
kept_at: "wanderbooks vault".to_string(),
|
||||
vault_record: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn w4(state: &str) -> W4Facts {
|
||||
W4Facts {
|
||||
received: date(2026, 3, 1),
|
||||
state: state.to_string(),
|
||||
kept_at: "CPA portal".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pay_line(name: &str, employee: Option<PartyId>) -> PayrollLine {
|
||||
PayrollLine {
|
||||
employee_name: name.to_string(),
|
||||
employee,
|
||||
gross_pay: dec("1000.00"),
|
||||
federal_withholding: dec("100.00"),
|
||||
state_withholding: dec("0"),
|
||||
ss_employee: dec("62.00"),
|
||||
medicare_employee: dec("14.50"),
|
||||
ss_employer: dec("62.00"),
|
||||
medicare_employer: dec("14.50"),
|
||||
other_deductions: dec("0"),
|
||||
}
|
||||
}
|
||||
|
||||
struct Fx {
|
||||
cash: AccountId,
|
||||
entry: EntryId,
|
||||
}
|
||||
|
||||
/// A store with cash, capital, and one posted $500.00 entry.
|
||||
fn fixture(dir: &Path) -> (Store, Fx) {
|
||||
let mut s = Store::create(dir, Currency::USD).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account("3000", "Owner Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let entry = s.create_entry(date(2026, 1, 1), "Opening").unwrap();
|
||||
s.add_line(entry, cash, Side::Debit, usd("500.00")).unwrap();
|
||||
s.add_line(entry, capital, Side::Credit, usd("500.00"))
|
||||
.unwrap();
|
||||
s.post_entry(entry).unwrap();
|
||||
(s, Fx { cash, entry })
|
||||
}
|
||||
|
||||
/// What "replays to an equal ledger" compares: every party, every entry's
|
||||
/// party and vendor tags, every payroll run.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct View {
|
||||
parties: Vec<Party>,
|
||||
tags: Vec<(EntryId, Option<PartyId>, Option<u64>)>,
|
||||
runs: Vec<PayrollRun>,
|
||||
}
|
||||
|
||||
fn view(l: &Ledger) -> View {
|
||||
View {
|
||||
parties: l.parties().cloned().collect(),
|
||||
tags: (0..l.next_entry_id() + 2)
|
||||
.map(|e| (e, l.entry_party(e), l.entry_vendor(e)))
|
||||
.collect(),
|
||||
runs: l.list_payroll_runs().into_iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_db(dir: &Path) {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// A store exercising every party event.
|
||||
/// `named_employee`: whether the payroll run names Alice's party.
|
||||
fn rich(dir: &Path, named_employee: bool) -> Store {
|
||||
let (mut s, fx) = fixture(dir);
|
||||
let irs = s.add_party("IRS", &[Role::TaxAgency]).unwrap();
|
||||
let dev = s.add_party("Dev Shop", &[Role::Contractor]).unwrap();
|
||||
let alice = s.add_party("Alice", &[Role::Employee]).unwrap();
|
||||
s.update_party(dev, PartyField::Website(Some("https://dev.example".into())))
|
||||
.unwrap();
|
||||
s.update_party(dev, PartyField::DisplayName(Some("Dev".into())))
|
||||
.unwrap();
|
||||
s.update_party(irs, PartyField::Notes(Some("EFTPS".into())))
|
||||
.unwrap();
|
||||
s.set_party_role(dev, Role::Landlord, true).unwrap();
|
||||
s.set_party_role(dev, Role::Landlord, false).unwrap();
|
||||
s.set_party_w9(dev, Some(w9("6789"))).unwrap();
|
||||
s.set_party_w4(alice, Some(w4("UT"))).unwrap();
|
||||
let v = s.add_vendor("dev shop").unwrap();
|
||||
let c = s.add_customer("Acme").unwrap();
|
||||
s.add_vendor("Acme").unwrap();
|
||||
let _ = c;
|
||||
s.set_entry_party(fx.entry, Some(irs)).unwrap();
|
||||
s.set_entry_vendor(fx.entry, Some(v)).unwrap();
|
||||
let old = s.add_party("Old Name", &[]).unwrap();
|
||||
s.update_party(old, PartyField::Archived(true)).unwrap();
|
||||
s.add_vendor("Old Name").unwrap();
|
||||
s.run_payroll(
|
||||
date(2026, 2, 27),
|
||||
vec![
|
||||
pay_line("Alice", named_employee.then_some(alice)),
|
||||
pay_line("Bob", None),
|
||||
],
|
||||
fx.cash,
|
||||
)
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- journaling
|
||||
|
||||
/// Oracle: Store::add_party doc ("journaled as party.added") and the
|
||||
/// task's payload {id, name, roles}; roles by wire key.
|
||||
#[test]
|
||||
fn add_party_journals_party_added() {
|
||||
let dir = test_dir("added");
|
||||
let (mut s, _) = fixture(&dir);
|
||||
let next = s.ledger().next_party_id();
|
||||
let (id, p) = one_event(&mut s, &dir, "party.added", |s| {
|
||||
s.add_party("Internal Revenue Service", &[Role::TaxAgency])
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(id, next);
|
||||
assert_eq!(p["id"], json!(id));
|
||||
assert_eq!(p["name"], json!("Internal Revenue Service"));
|
||||
assert_eq!(p["roles"], json!(["tax_agency"]));
|
||||
|
||||
let (id2, p) = one_event(&mut s, &dir, "party.added", |s| {
|
||||
s.add_party("Stephen", &[Role::Member, Role::Employee])
|
||||
.unwrap()
|
||||
});
|
||||
assert_eq!(p["id"], json!(id2));
|
||||
let mut roles: Vec<String> = p["roles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
roles.sort();
|
||||
assert_eq!(roles, vec!["employee".to_string(), "member".to_string()]);
|
||||
}
|
||||
|
||||
/// Oracle: Store::update_party doc ("journaled as party.updated") with
|
||||
/// {id, change}; PartyField's wire form is `{field, value}` (its serde
|
||||
/// attributes: snake_case, tag "field", content "value").
|
||||
#[test]
|
||||
fn update_party_journals_party_updated() {
|
||||
let dir = test_dir("updated");
|
||||
let (mut s, _) = fixture(&dir);
|
||||
let id = s.add_party("Acme", &[]).unwrap();
|
||||
let cases = [
|
||||
(
|
||||
PartyField::Website(Some("https://acme.example".into())),
|
||||
json!({"field": "website", "value": "https://acme.example"}),
|
||||
),
|
||||
(
|
||||
PartyField::DisplayName(Some("ACME".into())),
|
||||
json!({"field": "display_name", "value": "ACME"}),
|
||||
),
|
||||
(
|
||||
PartyField::DisplayName(None),
|
||||
json!({"field": "display_name", "value": null}),
|
||||
),
|
||||
(
|
||||
PartyField::Address(Some("1 Main St".into())),
|
||||
json!({"field": "address", "value": "1 Main St"}),
|
||||
),
|
||||
(
|
||||
PartyField::Notes(Some("net 30".into())),
|
||||
json!({"field": "notes", "value": "net 30"}),
|
||||
),
|
||||
(
|
||||
PartyField::Name("Acme LLC".into()),
|
||||
json!({"field": "name", "value": "Acme LLC"}),
|
||||
),
|
||||
(
|
||||
PartyField::Archived(true),
|
||||
json!({"field": "archived", "value": true}),
|
||||
),
|
||||
];
|
||||
for (field, want) in cases {
|
||||
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
|
||||
s.update_party(id, field.clone()).unwrap()
|
||||
});
|
||||
assert_eq!(p["id"], json!(id));
|
||||
assert_eq!(p["change"], want, "{field:?}");
|
||||
}
|
||||
let got = s.ledger().get_party(id).unwrap();
|
||||
assert_eq!(got.name, "Acme LLC");
|
||||
assert!(got.archived);
|
||||
assert_eq!(got.notes.as_deref(), Some("net 30"));
|
||||
}
|
||||
|
||||
/// Oracle: Store::set_party_role doc ("journaled as party.role_set") with
|
||||
/// {id, role, on}.
|
||||
#[test]
|
||||
fn set_party_role_journals_party_role_set() {
|
||||
let dir = test_dir("role");
|
||||
let (mut s, _) = fixture(&dir);
|
||||
let id = s.add_party("Kat", &[]).unwrap();
|
||||
let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| {
|
||||
s.set_party_role(id, Role::PayoutPlatform, true).unwrap()
|
||||
});
|
||||
assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": true}));
|
||||
let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| {
|
||||
s.set_party_role(id, Role::PayoutPlatform, false).unwrap()
|
||||
});
|
||||
assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": false}));
|
||||
}
|
||||
|
||||
/// Oracle: Store::set_party_w9 / set_party_w4 docs (`party.w9_set` /
|
||||
/// `party.w4_set` {id, facts}); facts null when cleared.
|
||||
#[test]
|
||||
fn w9_and_w4_journal_their_facts() {
|
||||
let dir = test_dir("facts");
|
||||
let (mut s, _) = fixture(&dir);
|
||||
let c = s.add_party("Contractor", &[Role::Contractor]).unwrap();
|
||||
let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| {
|
||||
s.set_party_w9(c, Some(w9("6789"))).unwrap()
|
||||
});
|
||||
assert_eq!(p["id"], json!(c));
|
||||
assert_eq!(p["facts"]["tin_last4"], json!("6789"));
|
||||
assert_eq!(p["facts"]["received"], json!("2026-03-01"));
|
||||
assert_eq!(
|
||||
p["facts"]["entity_type"],
|
||||
json!("individual/sole proprietor")
|
||||
);
|
||||
assert_eq!(p["facts"]["kept_at"], json!("wanderbooks vault"));
|
||||
let back: W9Facts = serde_json::from_value(p["facts"].clone()).unwrap();
|
||||
assert_eq!(back, w9("6789"));
|
||||
let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| {
|
||||
s.set_party_w9(c, None).unwrap()
|
||||
});
|
||||
assert_eq!(p, json!({"id": c, "facts": null}));
|
||||
|
||||
let e = s.add_party("Employee", &[Role::Employee]).unwrap();
|
||||
let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| {
|
||||
s.set_party_w4(e, Some(w4("UT"))).unwrap()
|
||||
});
|
||||
assert_eq!(
|
||||
p,
|
||||
json!({"id": e, "facts": {"received": "2026-03-01", "state": "UT", "kept_at": "CPA portal"}})
|
||||
);
|
||||
let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| {
|
||||
s.set_party_w4(e, None).unwrap()
|
||||
});
|
||||
assert_eq!(p, json!({"id": e, "facts": null}));
|
||||
}
|
||||
|
||||
/// Oracle: Store::set_entry_party doc (`entry.party_set {entry_id,
|
||||
/// party_id}`, null when cleared).
|
||||
#[test]
|
||||
fn set_entry_party_journals_entry_party_set() {
|
||||
let dir = test_dir("entry");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let p = s.add_party("Utah", &[Role::TaxAgency]).unwrap();
|
||||
let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| {
|
||||
s.set_entry_party(fx.entry, Some(p)).unwrap()
|
||||
});
|
||||
assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": p}));
|
||||
assert_eq!(s.ledger().entry_party(fx.entry), Some(p));
|
||||
let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| {
|
||||
s.set_entry_party(fx.entry, None).unwrap()
|
||||
});
|
||||
assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": null}));
|
||||
assert_eq!(s.ledger().entry_party(fx.entry), None);
|
||||
}
|
||||
|
||||
/// Oracle: add_vendor / add_customer docs ("journaled as vendor.added" /
|
||||
/// "customer.added"): one event each; the party comes from the core
|
||||
/// auto-link (so from replay), not a second event.
|
||||
#[test]
|
||||
fn add_vendor_and_customer_journal_one_event_and_link_a_party() {
|
||||
let dir = test_dir("records");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let (v, _) = one_event(&mut s, &dir, "vendor.added", |s| {
|
||||
s.add_vendor("Acme").unwrap()
|
||||
});
|
||||
let (c, _) = one_event(&mut s, &dir, "customer.added", |s| {
|
||||
s.add_customer("ACME").unwrap()
|
||||
});
|
||||
let pv = s.ledger().party_of_vendor(v).unwrap();
|
||||
assert_eq!(s.ledger().party_of_customer(c), Some(pv));
|
||||
// set_entry_vendor: one entry.vendor_set line, and the party is tagged.
|
||||
let ((), pl) = one_event(&mut s, &dir, "entry.vendor_set", |s| {
|
||||
s.set_entry_vendor(fx.entry, Some(v)).unwrap()
|
||||
});
|
||||
assert_eq!(pl["entry_id"], json!(fx.entry));
|
||||
assert_eq!(s.ledger().entry_party(fx.entry), Some(pv));
|
||||
}
|
||||
|
||||
/// Oracle: payroll.run lines carry "employee" (the party id, or null).
|
||||
#[test]
|
||||
fn payroll_run_lines_carry_employee() {
|
||||
let dir = test_dir("payroll");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let alice = s.add_party("Alice", &[Role::Employee]).unwrap();
|
||||
s.run_payroll(
|
||||
date(2026, 2, 27),
|
||||
vec![pay_line("Alice", Some(alice)), pay_line("Bob", None)],
|
||||
fx.cash,
|
||||
)
|
||||
.unwrap();
|
||||
let j = journal(&dir);
|
||||
let (_, p) = j.iter().rev().find(|(t, _)| t == "payroll.run").unwrap();
|
||||
let lines = p["lines"].as_array().unwrap();
|
||||
assert_eq!(lines.len(), 2);
|
||||
let by_name = |n: &str| {
|
||||
lines
|
||||
.iter()
|
||||
.find(|l| l["employee_name"] == json!(n))
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(by_name("Alice")["employee"], json!(alice));
|
||||
assert_eq!(by_name("Bob")["employee"], Value::Null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refusals
|
||||
|
||||
/// Oracle: the core SCENARIOS (refusals) and the store's journal-first
|
||||
/// contract: a refusal journals nothing.
|
||||
#[test]
|
||||
fn refusals_journal_nothing() {
|
||||
let dir = test_dir("refusals");
|
||||
let (mut s, fx) = fixture(&dir);
|
||||
let a = s.add_party("Alpha", &[Role::Member]).unwrap();
|
||||
s.add_party("Beta", &[]).unwrap();
|
||||
let emp = s.add_party("Emp", &[Role::Employee]).unwrap();
|
||||
let ghost = s.ledger().next_party_id() + 50;
|
||||
refused_nothing_journaled(&mut s, &dir, "blank", |s| s.add_party(" ", &[]));
|
||||
refused_nothing_journaled(&mut s, &dir, "tin", |s| s.add_party("Bob 123-45-6789", &[]));
|
||||
refused_nothing_journaled(&mut s, &dir, "dup", |s| s.add_party(" alpha", &[]));
|
||||
refused_nothing_journaled(&mut s, &dir, "vendor role", |s| {
|
||||
s.add_party("Gamma", &[Role::Vendor])
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "update unknown", |s| {
|
||||
s.update_party(ghost, PartyField::Notes(None))
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "update tin", |s| {
|
||||
s.update_party(a, PartyField::Notes(Some("12-3456789".into())))
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "rename taken", |s| {
|
||||
s.update_party(a, PartyField::Name("BETA".into()))
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "customer role", |s| {
|
||||
s.set_party_role(a, Role::Customer, true)
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "w9 without 1099 role", |s| {
|
||||
s.set_party_w9(a, Some(w9("6789")))
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "w4 without employee", |s| {
|
||||
s.set_party_w4(a, Some(w4("UT")))
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "bad w4", |s| {
|
||||
s.set_party_w4(emp, Some(w4("Utah")))
|
||||
});
|
||||
s.set_party_w4(emp, Some(w4("UT"))).unwrap();
|
||||
refused_nothing_journaled(&mut s, &dir, "drop employee with w4", |s| {
|
||||
s.set_party_role(emp, Role::Employee, false)
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "entry party unknown", |s| {
|
||||
s.set_entry_party(fx.entry, Some(ghost))
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "entry unknown", |s| {
|
||||
s.set_entry_party(fx.entry + 99, Some(a))
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "payroll non-employee", |s| {
|
||||
s.run_payroll(date(2026, 2, 27), vec![pay_line("Alpha", Some(a))], fx.cash)
|
||||
});
|
||||
refused_nothing_journaled(&mut s, &dir, "payroll unknown party", |s| {
|
||||
s.run_payroll(
|
||||
date(2026, 2, 27),
|
||||
vec![pay_line("Ghost", Some(ghost))],
|
||||
fx.cash,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- replay
|
||||
|
||||
/// Oracle: Store::verify INVARIANT 2 (a fresh replay equals the live
|
||||
/// ledger) and the journal as system of record: reopening, with or
|
||||
/// without the database, gives the same directory, tags, and runs.
|
||||
fn replay_check(name: &str, named_employee: bool, verify: bool) {
|
||||
let dir = test_dir(name);
|
||||
let s = rich(&dir, named_employee);
|
||||
if verify {
|
||||
s.verify().unwrap();
|
||||
}
|
||||
let live = view(s.ledger());
|
||||
let live_ledger = s.ledger().clone();
|
||||
assert!(live.parties.len() >= 5);
|
||||
drop(s);
|
||||
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(view(s.ledger()), live, "reopen");
|
||||
assert!(
|
||||
*s.ledger() == live_ledger,
|
||||
"reopen: {:?}",
|
||||
s.ledger().differs_from(&live_ledger)
|
||||
);
|
||||
if verify {
|
||||
s.verify().unwrap();
|
||||
}
|
||||
drop(s);
|
||||
|
||||
delete_db(&dir);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(view(s.ledger()), live, "replay without the database");
|
||||
assert!(
|
||||
*s.ledger() == live_ledger,
|
||||
"no db: {:?}",
|
||||
s.ledger().differs_from(&live_ledger)
|
||||
);
|
||||
if verify {
|
||||
s.verify().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_on_reopen_and_without_db_gives_an_equal_ledger() {
|
||||
replay_check("replay", false, true);
|
||||
}
|
||||
|
||||
/// Same, with the payroll run naming its employee's party: replay must
|
||||
/// still agree (verify is the next test).
|
||||
#[test]
|
||||
fn replay_with_a_named_employee_gives_an_equal_ledger() {
|
||||
replay_check("replay-employee", true, false);
|
||||
}
|
||||
|
||||
/// Oracle: Store::verify INVARIANT 2 ("the SQLite read-back's ... payroll
|
||||
/// runs equal the replay's") with a run whose line names its employee.
|
||||
#[test]
|
||||
fn verify_passes_with_a_named_employee() {
|
||||
replay_check("verify-employee", true, true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- corruption
|
||||
|
||||
/// Append `ty`/`payload` with a valid chain, then expect open to refuse.
|
||||
fn assert_corrupt_on_open(name: &str, setup: impl FnOnce(&mut Store) -> Value, ty: &str) {
|
||||
let dir = test_dir(name);
|
||||
let (mut s, _) = fixture(&dir);
|
||||
let payload = setup(&mut s);
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append(ty, payload.clone()).unwrap();
|
||||
drop(w);
|
||||
if let Ok(_s) = Store::open(&dir) {
|
||||
panic!("{name}: a corrupt {ty} line {payload} opened");
|
||||
}
|
||||
delete_db(&dir);
|
||||
if let Ok(_s) = Store::open(&dir) {
|
||||
panic!("{name}: a corrupt {ty} line {payload} opened without the db");
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle: the payloads' documented shapes (Role keys, W9Facts/W4Facts
|
||||
/// and their check, integer ids, party.added's id = the next id) and the
|
||||
/// invariants: a line that cannot replay is refused on open.
|
||||
#[test]
|
||||
fn corrupt_party_payloads_are_refused_on_open() {
|
||||
assert_corrupt_on_open(
|
||||
"bad-role",
|
||||
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "boss", "on": true}),
|
||||
"party.role_set",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"record-role",
|
||||
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "vendor", "on": true}),
|
||||
"party.role_set",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"role-on-not-bool",
|
||||
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "member", "on": "yes"}),
|
||||
"party.role_set",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"w9-shape",
|
||||
|s| json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": {"nonsense": 1}}),
|
||||
"party.w9_set",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"w9-full-tin",
|
||||
|s| {
|
||||
let mut f = serde_json::to_value(w9("6789")).unwrap();
|
||||
f["tin_last4"] = json!("123456789");
|
||||
json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": f})
|
||||
},
|
||||
"party.w9_set",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"w9-no-1099-role",
|
||||
|s| {
|
||||
json!({"id": s.add_party("A", &[Role::Member]).unwrap(),
|
||||
"facts": serde_json::to_value(w9("6789")).unwrap()})
|
||||
},
|
||||
"party.w9_set",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"w4-bad-state",
|
||||
|s| {
|
||||
json!({"id": s.add_party("A", &[Role::Employee]).unwrap(),
|
||||
"facts": {"received": "2026-03-01", "state": "Utah", "kept_at": "x"}})
|
||||
},
|
||||
"party.w4_set",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"updated-bad-field",
|
||||
|s| json!({"id": s.add_party("A", &[]).unwrap(), "change": {"field": "ssn", "value": "x"}}),
|
||||
"party.updated",
|
||||
);
|
||||
for (i, bad) in [json!("3"), json!(-3), json!({"id": 1}), json!(true)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert_corrupt_on_open(
|
||||
&format!("party-id-{i}"),
|
||||
|s| {
|
||||
s.add_party("A", &[]).unwrap();
|
||||
json!({"entry_id": 0, "party_id": bad})
|
||||
},
|
||||
"entry.party_set",
|
||||
);
|
||||
}
|
||||
assert_corrupt_on_open(
|
||||
"added-id-mismatch",
|
||||
|s| json!({"id": s.ledger().next_party_id() + 5, "name": "X", "roles": []}),
|
||||
"party.added",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"added-bad-role",
|
||||
|s| json!({"id": s.ledger().next_party_id(), "name": "X", "roles": ["boss"]}),
|
||||
"party.added",
|
||||
);
|
||||
assert_corrupt_on_open(
|
||||
"added-full-tin",
|
||||
|s| json!({"id": s.ledger().next_party_id(), "name": "X 123456789", "roles": []}),
|
||||
"party.added",
|
||||
);
|
||||
}
|
||||
|
||||
/// Control for the corruption test: a well-formed hand-appended
|
||||
/// `party.added` and `entry.party_set` open and replay (so the refusals
|
||||
/// above are about the payloads, not about appending by hand).
|
||||
#[test]
|
||||
fn a_well_formed_appended_party_line_opens() {
|
||||
let dir = test_dir("well-formed");
|
||||
let (s, fx) = fixture(&dir);
|
||||
let next = s.ledger().next_party_id();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append(
|
||||
"party.added",
|
||||
json!({"id": next, "name": "Apple", "roles": ["payout_platform"]}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"entry.party_set",
|
||||
json!({"entry_id": fx.entry, "party_id": next}),
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
let p = s.ledger().get_party(next).unwrap();
|
||||
assert_eq!(p.name, "Apple");
|
||||
assert_eq!(p.roles, [Role::PayoutPlatform].into_iter().collect());
|
||||
assert_eq!(s.ledger().entry_party(fx.entry), Some(next));
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- migration
|
||||
|
||||
/// Copy `from`'s journal into a fresh dir via `JournalWriter`, letting
|
||||
/// `edit` drop or rewrite lines (a valid chain, old-format payloads).
|
||||
fn rewrite_journal(from: &Path, to: &Path, edit: impl Fn(&str, Value) -> Option<Value>) {
|
||||
let mut w = JournalWriter::open(&to.join(JOURNAL_FILE)).unwrap();
|
||||
for (t, p) in journal(from) {
|
||||
if let Some(p) = edit(&t, p) {
|
||||
w.append(&t, p).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle: docs "Migration": old journals replay unchanged; vendor.added
|
||||
/// and customer.added create parties; payroll lines from before carry the
|
||||
/// name only (`employee` absent). INVARIANT 4 holds after replay.
|
||||
#[test]
|
||||
fn books_from_before_parties_open_and_get_parties_by_replay() {
|
||||
let src = test_dir("old-src");
|
||||
let (mut s, fx) = fixture(&src);
|
||||
let v1 = s.add_vendor("Namecheap").unwrap();
|
||||
let v2 = s.add_vendor("Apple").unwrap();
|
||||
let c1 = s.add_customer("apple").unwrap();
|
||||
let c2 = s.add_customer("Beta Tester").unwrap();
|
||||
s.set_entry_vendor(fx.entry, Some(v1)).unwrap();
|
||||
s.run_payroll(date(2026, 2, 27), vec![pay_line("Alice", None)], fx.cash)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
|
||||
let dst = test_dir("old-dst");
|
||||
rewrite_journal(&src, &dst, |t, mut p| {
|
||||
assert!(
|
||||
!t.starts_with("party.") && t != "entry.party_set",
|
||||
"{t} in old books"
|
||||
);
|
||||
if t == "payroll.run" {
|
||||
for l in p["lines"].as_array_mut().unwrap() {
|
||||
l.as_object_mut().unwrap().remove("employee");
|
||||
}
|
||||
}
|
||||
Some(p)
|
||||
});
|
||||
let s = Store::open(&dst).unwrap();
|
||||
let l = s.ledger();
|
||||
// INVARIANT 4: each record links to exactly one party.
|
||||
for (vid, name) in [(v1, "Namecheap"), (v2, "Apple")] {
|
||||
let pid = l.party_of_vendor(vid).expect("vendor party");
|
||||
let owners: Vec<_> = l.parties().filter(|p| p.vendor == Some(vid)).collect();
|
||||
assert_eq!(owners.len(), 1);
|
||||
assert_eq!(owners[0].id, pid);
|
||||
assert_eq!(owners[0].name, name);
|
||||
assert!(owners[0].all_roles().contains(&Role::Vendor));
|
||||
}
|
||||
for cid in [c1, c2] {
|
||||
let owners: Vec<_> = l.parties().filter(|p| p.customer == Some(cid)).collect();
|
||||
assert_eq!(owners.len(), 1);
|
||||
assert_eq!(l.party_of_customer(cid), Some(owners[0].id));
|
||||
}
|
||||
// The auto-link spec: "apple" the customer joins "Apple" the vendor.
|
||||
assert_eq!(l.party_of_customer(c1), l.party_of_vendor(v2));
|
||||
assert_eq!(l.parties().count(), 3);
|
||||
// entry.vendor_set replays as the party tag too.
|
||||
assert_eq!(l.entry_vendor(fx.entry), Some(v1));
|
||||
assert_eq!(l.entry_party(fx.entry), l.party_of_vendor(v1));
|
||||
// The old payroll run has the name only.
|
||||
let runs = l.list_payroll_runs();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(runs[0].lines[0].employee_name, "Alice");
|
||||
assert_eq!(runs[0].lines[0].employee, None);
|
||||
s.verify().unwrap();
|
||||
let before = view(s.ledger());
|
||||
drop(s);
|
||||
delete_db(&dst);
|
||||
let s = Store::open(&dst).unwrap();
|
||||
assert_eq!(view(s.ledger()), before);
|
||||
}
|
||||
@@ -1197,6 +1197,7 @@ mod refusals {
|
||||
day(d),
|
||||
vec![ledger_core::PayrollLine {
|
||||
employee_name: "Pat".into(),
|
||||
employee: None,
|
||||
gross_pay: Decimal::new(g, 2),
|
||||
federal_withholding: Decimal::new(w, 2),
|
||||
state_withholding: Decimal::ZERO,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "ledger-vault"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "wanderbooks' encrypted TIN vault: full taxpayer identification numbers and W-9 files, outside the journal"
|
||||
|
||||
[dependencies]
|
||||
chacha20poly1305 = "0.11"
|
||||
sha2 = "0.10"
|
||||
getrandom = { version = "0.2", features = ["std"] }
|
||||
hex = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
zeroize = { version = "1", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
@@ -0,0 +1,403 @@
|
||||
//! The TIN vault: full taxpayer identification numbers and W-9 files,
|
||||
//! encrypted, outside the journal.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: hand the CPA complete 1099 data (full TINs) without the books ever
|
||||
//! holding one: the journal is permanent and replicated in git, so a
|
||||
//! TIN written there could never be purged.
|
||||
//! SOURCE: docs/parties-and-attachments.md "The TIN vault"; DECISIONS
|
||||
//! (Stephen, 2026-10-15): wanderbooks holds full TINs in an
|
||||
//! encrypted vault; no external dependencies (no OS keyring).
|
||||
//! INVARIANTS:
|
||||
//! 1. Records live in the vault directory (the books' `vault/`, which git
|
||||
//! ignores), each encrypted with XChaCha20-Poly1305 under a 256-bit key
|
||||
//! from a key file outside the books dir, a fresh random nonce per
|
||||
//! write.
|
||||
//! 2. A record is bound to its party and kind (associated data): moved,
|
||||
//! renamed, or altered, it fails to open rather than yield another
|
||||
//! party's TIN.
|
||||
//! 3. A TIN is exactly nine digits (hyphens allowed when given); an ITIN
|
||||
//! starts with 9 with fourth and fifth digits in 50-65, 70-88, 90-92,
|
||||
//! or 94-99, and an SSN or EIN doesn't start with 9 (IRS Pub. 1915;
|
||||
//! docs/spec/sources/irs-tin-formats.md).
|
||||
//! 4. The vault never writes a TIN or a key anywhere but its own files;
|
||||
//! errors never contain one.
|
||||
//! 5. `record_hash` is the SHA-256 of a record's bytes on disk, so the
|
||||
//! journal can prove a record existed without holding it.
|
||||
//! 6. Purging deletes the record's file; nothing else keeps a copy.
|
||||
//! SCENARIOS:
|
||||
//! 1. IF the key file is missing: `open` creates one (mode 0600) only when
|
||||
//! asked to (`create_key`), else the vault is locked (Error::NoKey).
|
||||
//! 2. IF a record is missing: `tin` returns None.
|
||||
//! 3. IF a record fails to decrypt (wrong key, tampered, moved): SHOULD
|
||||
//! refuse with Error::Unreadable, naming the party, never guessing.
|
||||
//! ```
|
||||
|
||||
use base64::Engine as _;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// What can go wrong.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// The key file isn't there (the vault is locked).
|
||||
#[error("the vault key file {0} is missing; the vault is locked")]
|
||||
NoKey(PathBuf),
|
||||
/// The key file isn't a 256-bit key.
|
||||
#[error("the vault key file {0} is not a vault key (64 hex digits)")]
|
||||
BadKey(PathBuf),
|
||||
/// A TIN of the wrong form.
|
||||
#[error("bad TIN: {0}")]
|
||||
BadTin(String),
|
||||
/// A record that fails to decrypt or parse.
|
||||
#[error(
|
||||
"the vault record for party {party} ({kind}) can't be read: wrong key, altered, or moved"
|
||||
)]
|
||||
Unreadable {
|
||||
/// The party.
|
||||
party: u64,
|
||||
/// Its kind.
|
||||
kind: &'static str,
|
||||
},
|
||||
/// Filesystem trouble.
|
||||
#[error("vault io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// A TIN's type.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TinType {
|
||||
/// Social Security number (an individual).
|
||||
Ssn,
|
||||
/// Employer identification number (a business).
|
||||
Ein,
|
||||
/// IRS individual taxpayer identification number.
|
||||
Itin,
|
||||
}
|
||||
|
||||
/// A full TIN: nine digits and its type. Its digits are wiped from memory
|
||||
/// when dropped, and its Debug never prints them.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Tin {
|
||||
digits: Zeroizing<String>,
|
||||
/// Its type.
|
||||
pub kind: TinType,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Tin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Tin({:?}, last4 {})", self.kind, self.last4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Tin {
|
||||
/// A TIN from its digits (hyphens allowed), checked against its type
|
||||
/// (INVARIANT 3).
|
||||
pub fn new(text: &str, kind: TinType) -> Result<Tin, Error> {
|
||||
let digits: String = text.chars().filter(|c| *c != '-').collect();
|
||||
if digits.len() != 9 || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(Error::BadTin("a TIN is nine digits".into()));
|
||||
}
|
||||
let starts_9 = digits.starts_with('9');
|
||||
match kind {
|
||||
TinType::Itin if !starts_9 => {
|
||||
return Err(Error::BadTin("an ITIN begins with 9".into()));
|
||||
}
|
||||
TinType::Itin => {
|
||||
let group: u8 = digits[3..5].parse().expect("two digits");
|
||||
if !matches!(group, 50..=65 | 70..=88 | 90..=92 | 94..=99) {
|
||||
return Err(Error::BadTin(
|
||||
"an ITIN's fourth and fifth digits are 50-65, 70-88, 90-92, or 94-99"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
TinType::Ssn | TinType::Ein if starts_9 => {
|
||||
return Err(Error::BadTin(
|
||||
"a nine-digit number beginning with 9 is an ITIN, not an SSN or EIN".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Tin {
|
||||
digits: Zeroizing::new(digits),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// The nine digits, for the 1099 worksheet only.
|
||||
pub fn digits(&self) -> &str {
|
||||
&self.digits
|
||||
}
|
||||
|
||||
/// The last four digits (what the books keep).
|
||||
pub fn last4(&self) -> &str {
|
||||
&self.digits[5..]
|
||||
}
|
||||
|
||||
/// Formatted as the IRS writes it: 123-45-6789 for an SSN or ITIN,
|
||||
/// 12-3456789 for an EIN.
|
||||
pub fn formatted(&self) -> String {
|
||||
let d = self.digits();
|
||||
match self.kind {
|
||||
TinType::Ein => format!("{}-{}", &d[..2], &d[2..]),
|
||||
_ => format!("{}-{}-{}", &d[..3], &d[3..5], &d[5..]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A W-9 file kept in the vault.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct W9File {
|
||||
/// Original file name.
|
||||
pub file_name: String,
|
||||
/// MIME type.
|
||||
pub mime_type: String,
|
||||
/// The bytes.
|
||||
#[serde(with = "b64")]
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
mod b64 {
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
pub fn serialize<S: Serializer>(b: &[u8], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(b))
|
||||
}
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(s)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
const MAGIC: &[u8; 4] = b"WBV1";
|
||||
const NONCE_LEN: usize = 24;
|
||||
|
||||
/// The two kinds of record.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Tin,
|
||||
W9,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Tin => "tin",
|
||||
Kind::W9 => "w9",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open vault.
|
||||
pub struct Vault {
|
||||
dir: PathBuf,
|
||||
cipher: XChaCha20Poly1305,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vault {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Vault")
|
||||
.field("dir", &self.dir)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// The default key file: `$XDG_CONFIG_HOME/wanderbooks/vault.key`, else
|
||||
/// `~/.config/wanderbooks/vault.key`.
|
||||
pub fn default_key_file() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("wanderbooks").join("vault.key")
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
/// Open the vault in `dir` with the key in `key_file`. With
|
||||
/// `create_key`, a missing key file is created (a fresh random key,
|
||||
/// mode 0600); without, a missing one is Error::NoKey. The directory is
|
||||
/// created on first write.
|
||||
pub fn open(dir: &Path, key_file: &Path, create_key: bool) -> Result<Vault, Error> {
|
||||
let key = match std::fs::read_to_string(key_file) {
|
||||
Ok(text) => {
|
||||
let text = Zeroizing::new(text);
|
||||
let bytes = Zeroizing::new(
|
||||
hex::decode(text.trim()).map_err(|_| Error::BadKey(key_file.to_path_buf()))?,
|
||||
);
|
||||
if bytes.len() != 32 {
|
||||
return Err(Error::BadKey(key_file.to_path_buf()));
|
||||
}
|
||||
let mut k = Zeroizing::new([0u8; 32]);
|
||||
k.copy_from_slice(&bytes);
|
||||
k
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && create_key => {
|
||||
let mut k = Zeroizing::new([0u8; 32]);
|
||||
getrandom::getrandom(&mut k[..])
|
||||
.map_err(|e| Error::Io(std::io::Error::other(e)))?;
|
||||
write_private(key_file, hex::encode(&k[..]).as_bytes())?;
|
||||
k
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(Error::NoKey(key_file.to_path_buf()));
|
||||
}
|
||||
Err(e) => return Err(Error::Io(e)),
|
||||
};
|
||||
let cipher = XChaCha20Poly1305::new_from_slice(&key[..]).expect("32-byte key");
|
||||
Ok(Vault {
|
||||
dir: dir.to_path_buf(),
|
||||
cipher,
|
||||
})
|
||||
}
|
||||
|
||||
fn path(&self, party: u64, kind: Kind) -> PathBuf {
|
||||
self.dir.join(format!("{}-{party}.bin", kind.name()))
|
||||
}
|
||||
|
||||
fn aad(party: u64, kind: Kind) -> Vec<u8> {
|
||||
format!("wanderbooks-vault-v1|{}|party:{party}", kind.name()).into_bytes()
|
||||
}
|
||||
|
||||
fn seal(&self, party: u64, kind: Kind, plain: &[u8]) -> Result<String, Error> {
|
||||
let mut nonce = [0u8; NONCE_LEN];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| Error::Io(std::io::Error::other(e)))?;
|
||||
let aad = Self::aad(party, kind);
|
||||
let ct = self
|
||||
.cipher
|
||||
.encrypt(
|
||||
&XNonce::from(nonce),
|
||||
Payload {
|
||||
msg: plain,
|
||||
aad: &aad,
|
||||
},
|
||||
)
|
||||
.expect("encryption of an in-memory buffer");
|
||||
let mut out = Vec::with_capacity(4 + NONCE_LEN + ct.len());
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&ct);
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
write_private(&self.path(party, kind), &out)?;
|
||||
Ok(hex::encode(Sha256::digest(&out)))
|
||||
}
|
||||
|
||||
fn open_record(&self, party: u64, kind: Kind) -> Result<Option<Zeroizing<Vec<u8>>>, Error> {
|
||||
let bytes = match std::fs::read(self.path(party, kind)) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(Error::Io(e)),
|
||||
};
|
||||
let unreadable = Error::Unreadable {
|
||||
party,
|
||||
kind: kind.name(),
|
||||
};
|
||||
if bytes.len() < 4 + NONCE_LEN || &bytes[..4] != MAGIC {
|
||||
return Err(unreadable);
|
||||
}
|
||||
let aad = Self::aad(party, kind);
|
||||
let plain = self
|
||||
.cipher
|
||||
.decrypt(
|
||||
&XNonce::try_from(&bytes[4..4 + NONCE_LEN]).expect("length checked"),
|
||||
Payload {
|
||||
msg: &bytes[4 + NONCE_LEN..],
|
||||
aad: &aad,
|
||||
},
|
||||
)
|
||||
.map_err(|_| unreadable)?;
|
||||
Ok(Some(Zeroizing::new(plain)))
|
||||
}
|
||||
|
||||
/// Store a party's TIN; returns the record's hash (INVARIANT 5).
|
||||
pub fn put_tin(&self, party: u64, tin: &Tin) -> Result<String, Error> {
|
||||
let plain = Zeroizing::new(serde_json::to_vec(tin).expect("serializes"));
|
||||
self.seal(party, Kind::Tin, &plain)
|
||||
}
|
||||
|
||||
/// A party's TIN, if the vault holds one.
|
||||
pub fn tin(&self, party: u64) -> Result<Option<Tin>, Error> {
|
||||
match self.open_record(party, Kind::Tin)? {
|
||||
None => Ok(None),
|
||||
Some(plain) => serde_json::from_slice(&plain)
|
||||
.map(Some)
|
||||
.map_err(|_| Error::Unreadable { party, kind: "tin" }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a party's W-9 file; returns the record's hash.
|
||||
pub fn put_w9(&self, party: u64, file: &W9File) -> Result<String, Error> {
|
||||
let plain = Zeroizing::new(serde_json::to_vec(file).expect("serializes"));
|
||||
self.seal(party, Kind::W9, &plain)
|
||||
}
|
||||
|
||||
/// A party's W-9 file, if the vault holds one.
|
||||
pub fn w9(&self, party: u64) -> Result<Option<W9File>, Error> {
|
||||
match self.open_record(party, Kind::W9)? {
|
||||
None => Ok(None),
|
||||
Some(plain) => serde_json::from_slice(&plain)
|
||||
.map(Some)
|
||||
.map_err(|_| Error::Unreadable { party, kind: "w9" }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SHA-256 of a party's TIN record as stored, if any.
|
||||
pub fn record_hash(&self, party: u64) -> Result<Option<String>, Error> {
|
||||
match std::fs::read(self.path(party, Kind::Tin)) {
|
||||
Ok(b) => Ok(Some(hex::encode(Sha256::digest(&b)))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(Error::Io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete everything the vault holds for a party (INVARIANT 6). Returns
|
||||
/// whether anything was there.
|
||||
pub fn purge(&self, party: u64) -> Result<bool, Error> {
|
||||
let mut any = false;
|
||||
for kind in [Kind::Tin, Kind::W9] {
|
||||
match std::fs::remove_file(self.path(party, kind)) {
|
||||
Ok(()) => any = true,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(Error::Io(e)),
|
||||
}
|
||||
}
|
||||
Ok(any)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a file readable by its owner only (0600 on Unix), creating its
|
||||
/// directory.
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), Error> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
{
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
use std::io::Write;
|
||||
let mut f = opts.open(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Base64 of bytes (for callers handing a W-9 to a tool).
|
||||
pub fn to_base64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! The vault against its spec (crates/ledger-vault/src/lib.rs).
|
||||
|
||||
use ledger_vault::{Error, Tin, TinType, Vault, W9File};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wb-vault-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn vault(d: &std::path::Path) -> Vault {
|
||||
Vault::open(&d.join("vault"), &d.join("keys/vault.key"), true).unwrap()
|
||||
}
|
||||
|
||||
/// INVARIANT 3: nine digits, hyphens allowed; ITINs start with 9 and SSNs
|
||||
/// and EINs don't.
|
||||
#[test]
|
||||
fn tins_are_nine_digits_of_their_type() {
|
||||
assert_eq!(
|
||||
Tin::new("123-45-6789", TinType::Ssn).unwrap().digits(),
|
||||
"123456789"
|
||||
);
|
||||
assert_eq!(
|
||||
Tin::new("12-3456789", TinType::Ein).unwrap().last4(),
|
||||
"6789"
|
||||
);
|
||||
// Pub. 1915: fourth and fifth digits 50-65, 70-88, 90-92, 94-99.
|
||||
for ok in [
|
||||
"900-50-0000",
|
||||
"900-65-0000",
|
||||
"900-70-0000",
|
||||
"900-88-0000",
|
||||
"900-90-0000",
|
||||
"900-92-0000",
|
||||
"900-94-0000",
|
||||
"900-99-0000",
|
||||
] {
|
||||
assert!(Tin::new(ok, TinType::Itin).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"900-49-0000",
|
||||
"900-66-0000",
|
||||
"900-69-0000",
|
||||
"900-89-0000",
|
||||
"900-93-0000",
|
||||
"900-00-0000",
|
||||
] {
|
||||
assert!(Tin::new(bad, TinType::Itin).is_err(), "{bad}");
|
||||
}
|
||||
for (bad, kind) in [
|
||||
("12345678", TinType::Ssn),
|
||||
("1234567890", TinType::Ssn),
|
||||
("12345678a", TinType::Ein),
|
||||
("123 45 6789", TinType::Ssn),
|
||||
("912-70-1234", TinType::Ssn),
|
||||
("923456789", TinType::Ein),
|
||||
("123456789", TinType::Itin),
|
||||
] {
|
||||
assert!(Tin::new(bad, kind).is_err(), "{bad} as {kind:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
Tin::new("123456789", TinType::Ssn).unwrap().formatted(),
|
||||
"123-45-6789"
|
||||
);
|
||||
assert_eq!(
|
||||
Tin::new("123456789", TinType::Ein).unwrap().formatted(),
|
||||
"12-3456789"
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 4: Debug and errors never show the digits.
|
||||
#[test]
|
||||
fn a_tin_never_prints() {
|
||||
let tin = Tin::new("123-45-6789", TinType::Ssn).unwrap();
|
||||
let shown = format!("{tin:?}");
|
||||
assert!(
|
||||
!shown.contains("12345") && shown.contains("6789"),
|
||||
"{shown}"
|
||||
);
|
||||
let err = Tin::new("123-45-678", TinType::Ssn)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(!err.contains("12345678"), "{err}");
|
||||
}
|
||||
|
||||
/// INVARIANT 1, 5: a TIN and a W-9 round-trip; the record on disk holds
|
||||
/// neither in the clear; record_hash matches the bytes.
|
||||
#[test]
|
||||
fn records_round_trip_encrypted() {
|
||||
let d = dir("roundtrip");
|
||||
let v = vault(&d);
|
||||
let tin = Tin::new("12-3456789", TinType::Ein).unwrap();
|
||||
let hash = v.put_tin(7, &tin).unwrap();
|
||||
let back = v.tin(7).unwrap().unwrap();
|
||||
assert_eq!(back.digits(), "123456789");
|
||||
assert_eq!(back.kind, TinType::Ein);
|
||||
let raw = std::fs::read(d.join("vault/tin-7.bin")).unwrap();
|
||||
assert!(
|
||||
!raw.windows(9).any(|w| w == b"123456789"),
|
||||
"TIN in the clear"
|
||||
);
|
||||
assert_eq!(v.record_hash(7).unwrap().as_deref(), Some(hash.as_str()));
|
||||
let w9 = W9File {
|
||||
file_name: "w9.pdf".into(),
|
||||
mime_type: "application/pdf".into(),
|
||||
bytes: b"%PDF secret 123456789".to_vec(),
|
||||
};
|
||||
v.put_w9(7, &w9).unwrap();
|
||||
assert_eq!(v.w9(7).unwrap().unwrap(), w9);
|
||||
assert!(v.tin(8).unwrap().is_none(), "SCENARIO 2: missing is None");
|
||||
// A fresh nonce each write: the same TIN twice is two different records.
|
||||
let again = v.put_tin(7, &tin).unwrap();
|
||||
assert_ne!(again, hash);
|
||||
}
|
||||
|
||||
/// INVARIANT 2 and SCENARIO 3: a record moved to another party, altered,
|
||||
/// or opened with another key is unreadable, never another party's TIN.
|
||||
#[test]
|
||||
fn moved_altered_or_foreign_records_refuse() {
|
||||
let d = dir("tamper");
|
||||
let v = vault(&d);
|
||||
v.put_tin(1, &Tin::new("111-22-3333", TinType::Ssn).unwrap())
|
||||
.unwrap();
|
||||
std::fs::copy(d.join("vault/tin-1.bin"), d.join("vault/tin-2.bin")).unwrap();
|
||||
assert!(matches!(v.tin(2), Err(Error::Unreadable { party: 2, .. })));
|
||||
let mut raw = std::fs::read(d.join("vault/tin-1.bin")).unwrap();
|
||||
let last = raw.len() - 1;
|
||||
raw[last] ^= 1;
|
||||
std::fs::write(d.join("vault/tin-1.bin"), &raw).unwrap();
|
||||
assert!(matches!(v.tin(1), Err(Error::Unreadable { party: 1, .. })));
|
||||
let other = Vault::open(&d.join("vault"), &d.join("other.key"), true).unwrap();
|
||||
v.put_tin(3, &Tin::new("111-22-3333", TinType::Ssn).unwrap())
|
||||
.unwrap();
|
||||
assert!(matches!(other.tin(3), Err(Error::Unreadable { .. })));
|
||||
}
|
||||
|
||||
/// SCENARIO 1: no key file and no permission to create one is a locked
|
||||
/// vault; a created key file is 0600 and survives reopening.
|
||||
#[test]
|
||||
fn the_key_file() {
|
||||
let d = dir("key");
|
||||
assert!(matches!(
|
||||
Vault::open(&d.join("vault"), &d.join("missing.key"), false),
|
||||
Err(Error::NoKey(_))
|
||||
));
|
||||
let v = vault(&d);
|
||||
v.put_tin(1, &Tin::new("123456789", TinType::Ein).unwrap())
|
||||
.unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(d.join("keys/vault.key"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
let reopened = Vault::open(&d.join("vault"), &d.join("keys/vault.key"), false).unwrap();
|
||||
assert_eq!(reopened.tin(1).unwrap().unwrap().digits(), "123456789");
|
||||
std::fs::write(d.join("bad.key"), "not hex").unwrap();
|
||||
assert!(matches!(
|
||||
Vault::open(&d.join("vault"), &d.join("bad.key"), false),
|
||||
Err(Error::BadKey(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// INVARIANT 6: purge deletes the party's records and only theirs.
|
||||
#[test]
|
||||
fn purge_removes_a_party() {
|
||||
let d = dir("purge");
|
||||
let v = vault(&d);
|
||||
let tin = Tin::new("123456789", TinType::Ein).unwrap();
|
||||
v.put_tin(1, &tin).unwrap();
|
||||
v.put_tin(2, &tin).unwrap();
|
||||
assert!(v.purge(1).unwrap());
|
||||
assert!(v.tin(1).unwrap().is_none());
|
||||
assert!(!d.join("vault/tin-1.bin").exists());
|
||||
assert!(v.tin(2).unwrap().is_some());
|
||||
assert!(!v.purge(1).unwrap(), "nothing left to purge");
|
||||
}
|
||||
+125
-14
@@ -111,8 +111,10 @@ below.
|
||||
|
||||
- `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder,
|
||||
balance }] }`. Balances included: one call answers "what do we have."
|
||||
- `books.draft_entry { date: "2026-10-10", description: "..." }`
|
||||
→ `{ entry_id, status: "draft" }`.
|
||||
- `books.draft_entry { date: "2026-10-10", description: "...", vendor?,
|
||||
party? }` → `{ entry_id, status: "draft" }`. `party` tags the
|
||||
counterparty (see "Tools — parties"); given with `vendor`, both must name
|
||||
the same vendor record, else nothing is drafted.
|
||||
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
||||
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
|
||||
class, bad amount) adds nothing.
|
||||
@@ -123,7 +125,8 @@ below.
|
||||
the receipt policy is enforced and `status` is `"posted"`.
|
||||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||||
preview/confirm pattern as post.
|
||||
- `books.get_entry { entry_id }` → full entry with lines.
|
||||
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
|
||||
its `vendor`, `party_id`, and `party` (the party's label) tags.
|
||||
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
||||
→ entries with lines, newest first. `limit` caps at 100.
|
||||
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
||||
@@ -222,12 +225,13 @@ through the policy.
|
||||
|
||||
## Tools — the viewer
|
||||
|
||||
- `books.show { entry_id | account | report | attachment_id | search |
|
||||
page, start?, end? }` → `{ shown, note }`. Asks the human's viewer
|
||||
(`ledger-gui`) to open an entry, an account register or a report (for
|
||||
start/end dates if given), a stored file, a transactions search, or a
|
||||
page. Exactly one target; entries, accounts, and attachments are checked
|
||||
to exist. Never touches the books: the request is a small JSON file in
|
||||
- `books.show { entry_id | account | report | attachment_id | asset |
|
||||
party | search | page, start?, end? }` → `{ shown, note }`. Asks the
|
||||
human's viewer (`ledger-gui`) to open an entry, an account register or a
|
||||
report (for start/end dates if given), a stored file, an asset, a party
|
||||
(by id or name, resolved as in "Tools — parties"), a transactions search,
|
||||
or a page (`parties` among them). Exactly one target; entries, accounts,
|
||||
attachments, assets, and parties are checked to exist. Never touches the books: the request is a small JSON file in
|
||||
the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-<hash
|
||||
of the data dir>.json`), written atomically, watched by the viewer. A
|
||||
viewer started within two minutes of a request opens it; older requests
|
||||
@@ -269,19 +273,116 @@ overridable, always for the CPA to confirm. TINs are never stored, only
|
||||
whether a W-9 is on file.
|
||||
|
||||
- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?,
|
||||
tin_on_file }] }`.
|
||||
tin_on_file, party_id }] }`.
|
||||
- `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none",
|
||||
tin_on_file? }` → the vendor. `vendor` is an id or a name.
|
||||
- `books.set_entry_vendor { entry_id, vendor? }` → the entry (as
|
||||
`books.get_entry`, which now shows `vendor`). Omitting `vendor` clears
|
||||
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
|
||||
takes `vendor` too; bills and their payments are tagged automatically.
|
||||
`books.set_entry_party` tags the vendor too when the party has a vendor
|
||||
record, so tagging by party counts for 1099s the same way.
|
||||
- `books.vendor_1099 { year, threshold? }` → `{ year, threshold, vendors:
|
||||
[{ vendor_id, name, form_1099?, tin_on_file, paid, reportable }],
|
||||
missing_w9, notes }`. For a year whose threshold wanderbooks doesn't know
|
||||
(after 2026 until the indexed amount is added) it refuses with what to
|
||||
do; pass `threshold` explicitly once the figure is confirmed.
|
||||
|
||||
## Tools — parties
|
||||
|
||||
The directory of everyone the company deals with (`ledger_core::parties`;
|
||||
design in `docs/parties-and-attachments.md`): vendors, contractors,
|
||||
landlords, customers, payout platforms, tax agencies, banks and card
|
||||
issuers, members, employees. Vendor and customer *records* (bills,
|
||||
invoices, aging, 1099 marks) stay where they are; each links to exactly one
|
||||
party. `books.add_vendor` and `books.add_customer` link their record to the
|
||||
party of the same name (ignoring case and surrounding space, not archived)
|
||||
that has no such record yet, or make a new party.
|
||||
|
||||
**References.** A `party` argument is an id or a name: the name ignoring
|
||||
case and surrounding space, among parties not archived (an archived party
|
||||
goes by id). A number that is one party's id and another party's name is
|
||||
refused, never guessed.
|
||||
|
||||
**Roles:** `vendor`, `contractor`, `customer`, `employee`, `member`,
|
||||
`payout_platform`, `tax_agency`, `financial_institution`, `landlord`.
|
||||
Vendor and customer follow the records and are never set by hand.
|
||||
|
||||
**Rules the engine enforces** (each refusal changes nothing, the journal
|
||||
byte for byte): no party field holds a full TIN (a run of nine digits,
|
||||
single spaces or hyphens allowed between them); a W-9's TIN is its last
|
||||
four digits only; W-9 facts need a 1099 role (contractor or landlord), W-4
|
||||
facts the employee role; names are unique among parties not archived;
|
||||
texts are at most 500 characters, no control characters.
|
||||
|
||||
- `books.add_party { name, roles? }` → the party (below). Journaled as
|
||||
`party.added`.
|
||||
- `books.update_party { party, name?, display_name?, website?, address?,
|
||||
notes?, archived? }` → the party. Give at least one field; `""` clears
|
||||
`display_name`, `website`, `address`, or `notes`. Every field is checked
|
||||
(in order, on a rehearsal) before any is written; each is journaled as
|
||||
its own `party.updated`, in the order name, display_name, website,
|
||||
address, notes, archived. Un-archiving is refused while another party
|
||||
has the name.
|
||||
- `books.set_party_roles { party, add?, remove? }` → the party. Adds apply
|
||||
before removes (so a contractor's W-9 survives a swap to landlord); roles
|
||||
it already has, or lacks, are skipped; each change is journaled as
|
||||
`party.role_set`. Removing the last 1099 role with W-9 facts on file, or
|
||||
employee with W-4 facts, is refused: clear the facts first.
|
||||
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
|
||||
fatca_code?, tin_last4, kept_at }` or `{ party, clear: true }` → the
|
||||
party. Journaled as `party.w9_set`. A party with a vendor record also
|
||||
gets the record's `tin_on_file` set to match (journaled as
|
||||
`vendor.1099_set`), so `books.vendor_1099`'s `missing_w9` follows.
|
||||
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
|
||||
true }` → the party. Journaled as `party.w4_set`.
|
||||
- `books.set_entry_party { entry_id, party? }` → the entry (as
|
||||
`books.get_entry`). `party` null or omitted clears the tag. A party with
|
||||
a vendor record also tags the entry's vendor; one without, or clearing,
|
||||
clears the vendor tag. Metadata only; posted entries may be tagged.
|
||||
Journaled as `entry.party_set`.
|
||||
- `books.parties { role?, include_archived?, year? }` → `{ year, parties:
|
||||
[{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file,
|
||||
tin_last4?, w4_on_file, archived, paid, received }] }`, by id.
|
||||
- `books.party { party, year? }` → `{ party, year, totals, entries,
|
||||
invoices, bills, payroll }`: the facts; the calendar year's totals
|
||||
(default this year); every entry it is on (drafts included, newest
|
||||
first); its invoices and bills (`{ id, date, due_date, total, paid,
|
||||
balance, status, entry_id }`); and the payroll lines naming it
|
||||
(`{ run_id, date, entry_id, gross_pay, net_pay }`).
|
||||
|
||||
The party (every mutating party tool returns it): `{ party_id, name,
|
||||
label, display_name?, roles, website?, address?, notes?, archived,
|
||||
vendor_id?, customer_id?, w9?: { received_on, entity_type,
|
||||
exempt_payee_code?, fatca_code?, tin_last4, kept_at }, w4?: { received_on,
|
||||
state, kept_at } }`. `label` is the display name, else the name; `roles`
|
||||
include vendor and customer when it has those records.
|
||||
|
||||
**Totals** (`books.party` `totals`; `paid` and `received` in
|
||||
`books.parties`), from the journal:
|
||||
|
||||
- *The party's entries:* tagged with the party; tagged with its vendor
|
||||
record (bills and their payments); its customer record's invoices and
|
||||
the payments received on them; and reversals of any of these. Only
|
||||
posted entries dated in the year count.
|
||||
- *Money accounts:* the `[books] cash_accounts` (every asset account but
|
||||
receivables when none are set), and every liability account except the
|
||||
payables and payroll withholding roles (`books.roles`). A liability
|
||||
counts as how the company paid or was paid: a card, a member paying for
|
||||
it, a loan.
|
||||
- `paid` / `received`: each entry's money lines, credits minus debits; a
|
||||
positive net was paid to the party, a negative one received from it; a
|
||||
reversal counts against its original's side. A bill isn't paid until its
|
||||
payment is; an entry only moving money between money accounts nets to
|
||||
zero.
|
||||
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
|
||||
`owed` / `receivable`: its open bills' and invoices' balances now (every
|
||||
year).
|
||||
- `amount_1099` (vendor parties only): what `books.vendor_1099` counts as
|
||||
paid to its vendor record in the year (asset accounts only, so card
|
||||
payments drop out); `form_1099` is the record's mark.
|
||||
- `gross_pay` / `net_pay`: payroll lines naming it, in the year.
|
||||
|
||||
## Tools — receipts & documents
|
||||
|
||||
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
|
||||
@@ -457,6 +558,11 @@ in the server's tool descriptions, so the model reads it at use time):
|
||||
wrong posting is a reversal. Never guess-post.
|
||||
5. **The digest is the receipt for your work.** After a batch of bookkeeping,
|
||||
offer the digest; the human reviews, you fix.
|
||||
6. **Keep the party directory current.** Every counterparty gets a party;
|
||||
every entry is tagged with its party; contractors and landlords get W-9
|
||||
facts before they are paid, employees W-4 facts before their first
|
||||
payroll, and every payroll line names its employee party. Never a full
|
||||
TIN or SSN: the engine refuses nine-digit numbers.
|
||||
|
||||
## Out of scope for this phase
|
||||
|
||||
@@ -492,8 +598,9 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
||||
|
||||
## Tools — A/R and A/P
|
||||
|
||||
- `books.add_customer { name }` → `{ id }`.
|
||||
- `books.add_vendor { name }` → `{ id }`.
|
||||
- `books.add_customer { name }` → `{ id, party_id }`.
|
||||
- `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to
|
||||
a party ("Tools — parties").
|
||||
- `books.create_invoice { party_id, date, due_date, lines: [{ description,
|
||||
amount }] }` → `{ id, total, entry_id }`. Auto-posts Dr `1200` Accounts
|
||||
Receivable / Cr `4000` Revenue.
|
||||
@@ -522,13 +629,17 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
||||
No tax tables: the caller supplies every number; the engine cuts correct
|
||||
entries.
|
||||
|
||||
- `books.run_payroll { date, cash_account, lines: [{ employee_name,
|
||||
gross_pay, federal_withholding, state_withholding, ss_employee,
|
||||
- `books.run_payroll { date, cash_account, lines: [{ employee?,
|
||||
employee_name?, gross_pay, federal_withholding, state_withholding, ss_employee,
|
||||
medicare_employee, ss_employer, medicare_employer, other_deductions }] }`
|
||||
→ `{ run_id, entry_id, total_gross, total_net }`. One balanced entry:
|
||||
Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense
|
||||
(employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash
|
||||
(net). Auto-creates the 61xx/21xx accounts. Respects closed periods.
|
||||
`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_name` then defaults to the party's name. A line needs one or
|
||||
the other.
|
||||
- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`.
|
||||
Clears every payroll payable to cash in one entry.
|
||||
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
|
||||
|
||||
@@ -21,25 +21,30 @@ descriptions get records.
|
||||
|
||||
```text
|
||||
Party {
|
||||
id: PartyId, // vendors and customers keep their ids (see Migration)
|
||||
id: PartyId, // its own id space (vendor and customer records keep theirs)
|
||||
name: String, // legal or trade name, as on documents
|
||||
display_name: Option<String>,
|
||||
roles: BTreeSet<Role>, // vendor, contractor, customer, employee, member,
|
||||
// payout_platform, tax_agency, financial_institution, landlord
|
||||
roles: BTreeSet<Role>, // set by hand: contractor, employee, member, payout_platform,
|
||||
// tax_agency, financial_institution, landlord (vendor and
|
||||
// customer follow the records; see all_roles)
|
||||
website: Option<String>,
|
||||
address: Option<String>, // business address; no personal PII beyond it
|
||||
w9: Option<W9Facts>, // contractor/landlord: the TIN itself is in the vault
|
||||
w4: Option<W4Facts>, // employee: never the SSN
|
||||
form_1099: Option<Form1099>, // carried over from Vendor
|
||||
member: Option<String>, // a member's name in wanderbooks.toml, when role member
|
||||
notes: Option<String>,
|
||||
archived: bool, // no longer dealt with; history stays
|
||||
w9: Option<W9Facts>, // contractor/landlord: the TIN itself is in the vault
|
||||
w4: Option<W4Facts>, // employee: never the SSN
|
||||
vendor: Option<VendorId>, // its vendor record (bills, payments, 1099 form)
|
||||
customer: Option<CustomerId>,// its customer record (invoices)
|
||||
}
|
||||
W9Facts { received: NaiveDate, entity_type: String, exempt_payee_code: Option<String>,
|
||||
fatca_code: Option<String>, tin_last4: String, kept_at: String }
|
||||
fatca_code: Option<String>, tin_last4: String, kept_at: String,
|
||||
vault_record: Option<String> /* SHA-256 of the vault record */ }
|
||||
W4Facts { received: NaiveDate, state: String, kept_at: String }
|
||||
```
|
||||
|
||||
The 1099 form stays on the vendor record (`vendor.1099_set`); a party
|
||||
reaches it through `vendor`.
|
||||
|
||||
Rules (to vendor as B-26 with sources):
|
||||
|
||||
- A full TIN or SSN is never in the journal: `tin_last4` is exactly four
|
||||
@@ -65,29 +70,32 @@ Rules (to vendor as B-26 with sources):
|
||||
|
||||
### Journal events
|
||||
|
||||
`party.added {id, name, roles}`, `party.updated {id, field, value}` (one
|
||||
field per event: name, display_name, website, address, notes, archived),
|
||||
`party.role_added {id, role}`, `party.role_removed {id, role}`,
|
||||
`party.added {id, name, roles}`, `party.updated {id, change}` (one
|
||||
`PartyField` per event, `{field, value}`: name, display_name, website,
|
||||
address, notes, archived), `party.role_set {id, role, on}`,
|
||||
`party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`,
|
||||
`entry.party_set`. Replayed and projected like the rest; every event gets
|
||||
viewer journal text (the GUI test enforces it).
|
||||
`entry.party_set {entry_id, party_id|null}`. Replayed and projected like
|
||||
the rest; every event gets viewer journal text (the GUI test enforces it).
|
||||
|
||||
### Migration
|
||||
|
||||
Old journals replay unchanged. `vendor.added` and `customer.added` keep
|
||||
their meaning and create parties with the vendor or customer role; the
|
||||
party id space starts above the highest vendor and customer ids, and a
|
||||
vendor and a customer with the same name stay two parties until the
|
||||
human merges them (`books.merge_parties`, journaled, re-pointing links).
|
||||
`entry.vendor_set` replays as `entry.party_set`. `vendor.1099_set` sets
|
||||
the party's `form_1099` and the contractor role.
|
||||
their meaning and, on replay as live, link their record to a party: the
|
||||
live party of that name (ignoring case and surrounding space) without
|
||||
such a record yet, else a new party. So a vendor and a customer of the
|
||||
same name share one party; a second vendor of a taken name gets a new
|
||||
party named "Name (2)" (names stay unique). Vendor and customer names
|
||||
obey the party text rules (no full TIN). `entry.vendor_set` also sets the
|
||||
entry's party, and `entry.party_set` to a party with a vendor record sets
|
||||
the vendor tag: one counterparty.
|
||||
|
||||
### Tools
|
||||
|
||||
`books.add_party`, `books.update_party`, `books.party_roles`,
|
||||
`books.set_w9`, `books.set_w4`, `books.parties` (list, filter by role),
|
||||
`books.party` (one: facts, roles, totals this year, documents, links),
|
||||
`books.merge_parties`, `books.set_entry_party`. `books.add_vendor` and
|
||||
`books.set_entry_party`. (Merging two parties, `books.merge_parties`, is
|
||||
not built yet: records of one name already share a party.) `books.add_vendor` and
|
||||
`books.add_customer` stay as shortcuts.
|
||||
|
||||
### Viewer
|
||||
@@ -111,10 +119,13 @@ worksheet it hands the CPA is complete, but never in the journal.
|
||||
W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random
|
||||
nonce per record; the file format carries a version and the party id as
|
||||
associated data, so a record can't be moved to another party).
|
||||
- **Key:** a random 256-bit key in the OS keyring (Secret Service on Linux,
|
||||
Keychain on macOS), created on first use; never on disk in the books dir.
|
||||
A recovery copy is the human's job: `just vault-export` writes the key
|
||||
and records as one passphrase-encrypted file for a password manager.
|
||||
- **Key:** a random 256-bit key in a key file outside the books dir
|
||||
(default `~/.config/wanderbooks/vault.key`, mode 0600; `[vault] key_file`
|
||||
in wanderbooks.toml overrides it), created on first use. No OS keyring
|
||||
or other system service (Stephen, 2026-10-15: no external dependencies),
|
||||
so the vault works anywhere wanderbooks runs. Recovery is a copy of that
|
||||
file in the human's password manager; without it the vault can't be
|
||||
read (the books still open and verify).
|
||||
- **Journal:** `party.w9_set` records that a W-9 was received, its date,
|
||||
entity type, codes, and last four, plus a hash of the vault record, so
|
||||
the books prove a W-9 existed without holding it.
|
||||
|
||||
@@ -378,6 +378,15 @@ years after 2026, indexed to inflation and rounded to the nearest $100
|
||||
(§6041(h)). Card payments are reported on Form 1099-K, not 1099-NEC/MISC.
|
||||
Attorneys' gross proceeds stay at $600 (not modeled).
|
||||
|
||||
What counts as paid: payments made in the calendar year (§6041(a)), so a
|
||||
prepayment counts when made. The engine counts credits minus debits to the
|
||||
payment sources on a vendor's tagged entries: `[books] cash_accounts` and
|
||||
`financing_accounts` (a member or lender paying on the company's behalf;
|
||||
the company is still the payer). A card charge leaves neither (1099-K); a
|
||||
bill is not paid until it is (`form1099::vendor_1099_report_with`,
|
||||
checked by `payments_come_from_cash_and_financing_only`). A W-9 is on
|
||||
file by the vendor's flag or its party's W-9 facts.
|
||||
|
||||
**Enforced:** `form1099::default_threshold`, a dated rule
|
||||
(`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from
|
||||
2026-11-01). For a later year the engine refuses with `RuleNotKnown`
|
||||
@@ -494,3 +503,26 @@ non-negative, in cents), `Asset::tax_basis`, `Asset::built_in_gain`,
|
||||
`missing_tax_basis`). **Checked:** `ledger-core` `tests/spec_tax_basis.rs`;
|
||||
`ledger-store` `tests/asset_tags.rs` (`asset.tax_basis_set` journaling,
|
||||
replay, v9 migration, corrupt payloads); `ledger-mcp` `tests/spec_assets.rs`.
|
||||
|
||||
## B-26 Parties: one directory, no full TIN in the books
|
||||
|
||||
**Status:** AUTHORITY (truncated TINs: `sources/irs-truncated-tin.md`);
|
||||
DECISIONS (Stephen, 2026-10-15)
|
||||
|
||||
Everyone the company deals with is a party, with roles (vendor,
|
||||
contractor, customer, employee, member, payout platform, tax agency,
|
||||
financial institution, landlord). Vendor and customer records link to
|
||||
their party; entries are tagged with theirs. Names are unique among parties
|
||||
not archived, ignoring case and surrounding space. A W-9 is recorded as its
|
||||
facts and the TIN's last four digits (the part a truncated TIN shows,
|
||||
Treas. Reg. §301.6109-4(a)); no party field may hold a nine-digit number,
|
||||
and the full TIN lives only in the encrypted vault, outside the journal
|
||||
(DECISION). W-9 facts need a 1099 role (contractor, landlord); W-4 facts
|
||||
need the employee role; payroll lines that name their employee name a
|
||||
party with that role.
|
||||
|
||||
**Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check,
|
||||
W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`,
|
||||
`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`.
|
||||
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
|
||||
`tests/parties.rs`; `ledger-mcp` `tests/spec_parties.rs`.
|
||||
@@ -0,0 +1,16 @@
|
||||
# TIN formats
|
||||
Governs `ledger_vault::Tin::new`: a TIN is nine digits; an ITIN begins with 9 and its fourth and fifth digits fall in the ranges below; an SSN or EIN doesn't begin with 9 (a nine-digit number beginning with 9 is in the ITIN series).
|
||||
|
||||
## IRS Publication 1915 (Rev. 11-2024), "Understanding Your IRS Individual Taxpayer Identification Number", General Information
|
||||
Source: <https://www.irs.gov/pub/irs-pdf/p1915.pdf>
|
||||
Retrieved: 2026-10-15
|
||||
SHA-256 of the retrieved file (`p1915.pdf`): `63a3cf3e79c2e73ed15738d5a0875ffb7496c2309e8c3e085836ade16b2e0920`
|
||||
|
||||
> An ITIN is a tax processing number, issued by the Internal Revenue Service, for certain resident and nonresident aliens, their spouses, and their dependents. It is a nine-digit number beginning with the number “9”, has a range of numbers from "50" to "65", "70" to "88", “90” to “92” and “94” to “99” for the fourth and fifth digits and is formatted like a SSN (i.e. 9XX-7X-XXXX).
|
||||
|
||||
## Instructions for Form W-7 (Rev. December 2024), What's an ITIN
|
||||
Source: <https://www.irs.gov/instructions/iw7>
|
||||
Retrieved: 2026-10-15
|
||||
SHA-256 of the retrieved file (`irs-iw7.html`): `1d674a26a63c34b59a2cb6e34d050a95f744c53ae35ea50172a137298b37a000`
|
||||
|
||||
> An ITIN is a nine-digit number issued by the IRS to individuals who are required for federal tax purposes to have a U.S. taxpayer identification number but who don’t have and aren’t eligible to get a social security number (SSN).
|
||||
@@ -0,0 +1,11 @@
|
||||
# Truncated taxpayer identification numbers
|
||||
Governs what of a TIN the party directory may hold (crates/ledger-core/src/parties.rs): the last four digits, the same part a truncated TIN shows. The full number lives only in the encrypted vault (docs/parties-and-attachments.md), never in the journal.
|
||||
|
||||
## Treas. Reg. §301.6109-4(a) and (b)(1)
|
||||
Source: <https://www.ecfr.gov/current/title-26/part-301/section-301.6109-4>
|
||||
Retrieved: 2026-10-15
|
||||
SHA-256 of the retrieved file (`ecfr301-6109-4.html`): `45702c366f7f185e41452a32acf685bb5371e00b91bfb6214174ffd153eba949`
|
||||
|
||||
> (a) In general — Definition. An IRS truncated taxpayer identification number (TTIN) is an individual's social security number (SSN), IRS individual taxpayer identification number (ITIN), IRS adoption taxpayer identification number (ATIN), or IRS employer identification number (EIN) in which the first five digits of the nine-digit number are replaced with Xs or asterisks. The TTIN takes the same format of the identifying number it replaces, for example XXX-XX-1234 when replacing an SSN, or XX-XXX1234 when replacing an EIN.
|
||||
|
||||
> (b) Use of a TTIN. (1) In general. Except as provided in paragraph (b)(2) of this section, a TTIN may be used to identify any person on any statement or other document that the internal revenue laws require to be furnished to another person. Use of a TTIN is permissive and not mandatory.
|
||||
Reference in new issue
Block a user