P3: party totals by what moved the money, once (defects 14, 11, 24)
Each party's part of an entry is signed as its own account would show it, with a direction and its cash and in-kind amounts; a reversal is its original negated. Totals on a cash or all basis: for a member, all equals the members' equity statement and cash the K-1's cash. Money is cash, cards and the financing accounts, and a liability credit never pays its own creditor (creditor_of), so accruals and declared distributions count once. tin_last4 from the TIN or W-9 facts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
52 files changed
+7535
-560
No files matched your search
@@ -43,6 +43,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
MEMBERS_CUMULATIVE,
|
||||
FEATURE_ACCOUNT_RENAMED,
|
||||
FEATURE_REQUIRE_TAX_CATEGORY,
|
||||
PARTY_CREDITORS,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -70,6 +71,10 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The feature `party.creditor_set` lines need: a party named the
|
||||
/// creditor of a liability (rules.md B-35, P3 defect 24).
|
||||
pub const PARTY_CREDITORS: &str = "party_creditors";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -119,6 +124,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"party.added",
|
||||
"party.creditor_set",
|
||||
"party.role_set",
|
||||
"party.tin_set",
|
||||
"party.updated",
|
||||
|
||||
@@ -55,16 +55,11 @@ pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The card accounts: liabilities whose names contain "card".
|
||||
/// The card accounts: liabilities whose names contain "card"
|
||||
/// (`ledger_core::parties::card_accounts`, the same rule party money
|
||||
/// reads, so the two never disagree on what a card is).
|
||||
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
|
||||
ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
ledger_core::parties::card_accounts(ledger)
|
||||
}
|
||||
|
||||
/// The accounts the books reconcile monthly (INVARIANT 1).
|
||||
|
||||
@@ -1238,6 +1238,30 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Name a party the creditor of a liability account the company owes
|
||||
/// it itself on, or stop (`on` false): ledger_core's
|
||||
/// `Ledger::set_party_creditor`. Journaled as `party.creditor_set
|
||||
/// {id, account_id, on}`, after the journal declares the feature it
|
||||
/// needs ([`crate::format::PARTY_CREDITORS`]) the first time, so an
|
||||
/// older wanderbooks stops there and says it is too old instead of
|
||||
/// misreading the books.
|
||||
pub fn set_party_creditor(
|
||||
&mut self,
|
||||
id: ledger_core::PartyId,
|
||||
account: AccountId,
|
||||
on: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger.check_set_party_creditor(id, account, on)?;
|
||||
self.ensure_feature(crate::format::PARTY_CREDITORS)?;
|
||||
let line = self.append_journal(
|
||||
"party.creditor_set",
|
||||
json!({"id": id, "account_id": account, "on": on}),
|
||||
)?;
|
||||
self.ledger.set_party_creditor(id, account, 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,
|
||||
@@ -4844,6 +4868,13 @@ pub(crate) fn replay_line_into_ledger(
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?;
|
||||
ledger.set_party_role(get_u64(p, "id")?, role, on)?;
|
||||
}
|
||||
"party.creditor_set" => {
|
||||
let on = p
|
||||
.get("on")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("party.creditor_set on".into()))?;
|
||||
ledger.set_party_creditor(get_u64(p, "id")?, get_u64(p, "account_id")?, 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()))?;
|
||||
@@ -5569,8 +5600,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
)?;
|
||||
}
|
||||
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
|
||||
| "party.w4_set" | "party.tin_set" | "entry.party_set" | "entry.parties_set"
|
||||
| "entry.no_party" => {
|
||||
| "party.w4_set" | "party.tin_set" | "party.creditor_set" | "entry.party_set"
|
||||
| "entry.parties_set" | "entry.no_party" => {
|
||||
// Control roles and the party directory live in the ledger
|
||||
// (rebuilt by replay); the events mirror below keeps the
|
||||
// history.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! A party named the creditor of a liability (rules.md B-35, P3 defect
|
||||
//! 24), in the store: journaled as `party.creditor_set {id, account_id,
|
||||
//! on}`, after the journal declares the feature it needs
|
||||
//! (`party_creditors`) the first time, and never otherwise.
|
||||
//!
|
||||
//! Oracles: `Store::set_party_creditor`'s doc, `format::KNOWN_FEATURES`
|
||||
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (nothing journaled
|
||||
//! when the feature is in force; books that never use it never change),
|
||||
//! and `Ledger::set_party_creditor`'s SCENARIOS. Expected values by hand.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("wb-creditors-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
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 bytes(dir: &Path) -> Vec<u8> {
|
||||
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
|
||||
}
|
||||
|
||||
/// Books with a card, the members' financing account, a member, and a
|
||||
/// posted entry.
|
||||
fn books(dir: &Path) -> (Store, u64, u64, u64, u64) {
|
||||
let mut s = Store::create(dir, 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 due = s
|
||||
.add_account(
|
||||
"2100",
|
||||
"Due to Members",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account(
|
||||
"3000",
|
||||
"Capital — Tobin Harlow",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let e = s
|
||||
.create_entry(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), "Contribution")
|
||||
.unwrap();
|
||||
let m = Money::new(Decimal::from(500), Currency::USD);
|
||||
s.add_line(e, cash, Side::Debit, m).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, m).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let issuer = s
|
||||
.add_party("Harbor Bank", &[ledger_core::Role::FinancialInstitution])
|
||||
.unwrap();
|
||||
let tobin = s
|
||||
.add_party("Tobin Harlow", &[ledger_core::Role::Member])
|
||||
.unwrap();
|
||||
(s, card, due, issuer, tobin)
|
||||
}
|
||||
|
||||
/// The feature is one this version reads.
|
||||
#[test]
|
||||
fn party_creditors_is_a_known_feature() {
|
||||
assert_eq!(PARTY_CREDITORS, "party_creditors");
|
||||
assert!(KNOWN_FEATURES.contains(&PARTY_CREDITORS));
|
||||
}
|
||||
|
||||
/// Store::set_party_creditor doc: the first one declares the feature (one
|
||||
/// journal.format_set keeping the format, at least 2, and every feature
|
||||
/// already declared), then journals party.creditor_set; later ones journal
|
||||
/// one line each; the books replay to the same directory, through the
|
||||
/// store and the viewer's snapshot alike.
|
||||
#[test]
|
||||
fn naming_a_creditor_declares_its_feature_once() {
|
||||
let dir = test_dir("declare");
|
||||
let (mut s, card, due, issuer, tobin) = books(&dir);
|
||||
let before = journal(&dir).len();
|
||||
s.set_party_creditor(issuer, card, true).unwrap();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j.len(), before + 2);
|
||||
assert_eq!(j[before].0, FORMAT_SET);
|
||||
assert_eq!(
|
||||
j[before].1,
|
||||
json!({"format": 2, "features": ["party_creditors"]})
|
||||
);
|
||||
assert_eq!(
|
||||
j[before + 1],
|
||||
(
|
||||
"party.creditor_set".to_string(),
|
||||
json!({"id": issuer, "account_id": card, "on": true})
|
||||
)
|
||||
);
|
||||
assert_eq!(s.journal_format().features, vec!["party_creditors"]);
|
||||
s.set_party_creditor(tobin, due, true).unwrap();
|
||||
s.set_party_creditor(tobin, due, false).unwrap();
|
||||
let j = journal(&dir);
|
||||
assert_eq!(j.len(), before + 4, "the feature is declared once");
|
||||
assert_eq!(
|
||||
j[before + 3].1,
|
||||
json!({"id": tobin, "account_id": due, "on": false})
|
||||
);
|
||||
s.set_party_creditor(tobin, due, true).unwrap();
|
||||
let live = s.ledger().clone();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.ledger().creditors_of(card), vec![issuer]);
|
||||
assert_eq!(s.ledger().creditors_of(due), vec![tobin]);
|
||||
assert!(*s.ledger() == live);
|
||||
drop(s);
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert!(*snap.ledger() == live);
|
||||
assert_eq!(snap.journal_format().features, vec!["party_creditors"]);
|
||||
// Rebuilt from the journal alone.
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert!(*s.ledger() == live);
|
||||
}
|
||||
|
||||
/// A refusal journals nothing: not the line, and not the feature either.
|
||||
#[test]
|
||||
fn a_refused_creditor_journals_nothing() {
|
||||
let dir = test_dir("refused");
|
||||
let (mut s, _, _, issuer, _) = books(&dir);
|
||||
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
let was = bytes(&dir);
|
||||
for (party, account) in [(issuer, cash), (999, cash), (issuer, 999)] {
|
||||
assert!(s.set_party_creditor(party, account, true).is_err());
|
||||
assert_eq!(bytes(&dir), was);
|
||||
}
|
||||
assert!(s.journal_format().features.is_empty());
|
||||
}
|
||||
|
||||
/// Books that never name a creditor never declare the feature: opening,
|
||||
/// verifying and rebuilding them leaves the journal byte-identical, in the
|
||||
/// format it was in.
|
||||
#[test]
|
||||
fn books_without_creditors_are_never_changed_by_the_feature() {
|
||||
let dir = test_dir("untouched");
|
||||
let (s, ..) = books(&dir);
|
||||
let format = s.journal_format().clone();
|
||||
drop(s);
|
||||
let was = bytes(&dir);
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
s.rebuild().unwrap();
|
||||
assert_eq!(s.journal_format(), &format);
|
||||
drop(s);
|
||||
assert_eq!(bytes(&dir), was);
|
||||
assert!(
|
||||
journal(&dir)
|
||||
.iter()
|
||||
.all(|(t, p)| t != FORMAT_SET || p["features"] == json!([]))
|
||||
);
|
||||
}
|
||||
|
||||
/// A line that can't replay is refused on open, with the database and
|
||||
/// without it: `on` not a bool, an unknown account, an account that isn't
|
||||
/// a liability.
|
||||
#[test]
|
||||
fn corrupt_creditor_lines_are_refused_on_open() {
|
||||
for (name, payload) in [
|
||||
(
|
||||
"on-not-bool",
|
||||
json!({"id": 1, "account_id": 2, "on": "yes"}),
|
||||
),
|
||||
(
|
||||
"unknown-account",
|
||||
json!({"id": 1, "account_id": 99, "on": true}),
|
||||
),
|
||||
(
|
||||
"not-a-liability",
|
||||
json!({"id": 1, "account_id": 1, "on": true}),
|
||||
),
|
||||
] {
|
||||
let dir = test_dir(name);
|
||||
let (mut s, card, ..) = books(&dir);
|
||||
// Declare the feature properly first, so only the line is wrong.
|
||||
let issuer = s.ledger().party_by_name("Harbor Bank").unwrap().id;
|
||||
s.set_party_creditor(issuer, card, true).unwrap();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append("party.creditor_set", payload.clone()).unwrap();
|
||||
drop(w);
|
||||
assert!(Store::open(&dir).is_err(), "{name}: {payload} opened");
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||
}
|
||||
assert!(
|
||||
Store::open(&dir).is_err(),
|
||||
"{name}: {payload} opened without the db"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1940,7 +1940,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
||||
.map(|_| ()),
|
||||
);
|
||||
}
|
||||
let money = ledger_core::parties::money_accounts(l, &cash);
|
||||
let money = ledger_core::parties::money_accounts(l, &cash, &[]);
|
||||
let party_members: Vec<ledger_core::PartyMember> = l
|
||||
.parties()
|
||||
.map(|p| ledger_core::PartyMember {
|
||||
@@ -1953,14 +1953,15 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
||||
for id in 1..l.next_entry_id() {
|
||||
let Some(e) = l.get_entry(id) else { continue };
|
||||
tally(ledger_core::parties::entry_flow(e, &money).map(|_| ()));
|
||||
tally(ledger_core::shares::share_target(e, &money).map(|_| ()));
|
||||
let _ = ledger_core::shares::resolve(e, l.entry_parties(id), &money, &party_members);
|
||||
tally(ledger_core::shares::target(l, id, l.entry_parties(id), &money).map(|_| ()));
|
||||
tally(ledger_core::parties::flow(l, id, l.entry_parties(id), &money).map(|_| ()));
|
||||
let _ = ledger_core::shares::resolve(l, id, l.entry_parties(id), &money, &party_members);
|
||||
let _ = e.validate(l.chart());
|
||||
}
|
||||
for p in l.parties() {
|
||||
for (start, end) in spans {
|
||||
let _ =
|
||||
ledger_core::parties::activity_with(l, p.id, (start, end), &cash, &party_members);
|
||||
ledger_core::parties::activity_with(l, p.id, (start, end), &money, &party_members);
|
||||
}
|
||||
}
|
||||
for inv in l.invoices() {
|
||||
|
||||
Reference in new issue
Block a user