Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
check / check (push) Waiting to run
check / check (push) Waiting to run
Entries may name several parties, each with an exact amount, a percent, or a share derived from the members' capital lines or [[company.members]] shares (entry.parties_set); shares always sum exactly. The 1099 worksheet counts a vendor's share. A member's party page is their partner capital statement, from the same ledger_core::k1 source as the CPA package. books.set_tin stores any party's TIN (members' SSNs for K-1s) in the vault (party.tin_set, last four in the books). The CPA package carries a K-1 member worksheet (k1-members.csv, with a members' total that ties to Schedule L) and the 1099 worksheet with full TINs, saved outside the books only. files_jointly_with notes spouses' two K-1s land on one 1040. Adversarial review fixes: reversed contributions no longer inflate K-1 contributions and distributions; a K-1 row never takes another party's TIN; TIN detection covers dot/slash/dash separators; refusals redact TINs; vendor tagging can't silently replace shared parties; reversals follow re-apportioning. 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
df3fc3f4e4
commit
4c74169d18
32 files changed
+7208
-351
No files matched your search
@@ -270,18 +270,22 @@ fiscal_year_start = "01-01"
|
|||||||
# Members of a multi-member LLC, one block each: shares come from the
|
# Members of a multi-member LLC, one block each: shares come from the
|
||||||
# operating agreement (IRC §704(a)), "50%" or "0.5", summing to 100%. Each
|
# operating agreement (IRC §704(a)), "50%" or "0.5", summing to 100%. Each
|
||||||
# names a capital account in the chart, and optionally a distributions
|
# names a capital account in the chart, and optionally a distributions
|
||||||
# account that a close zeroes into capital.
|
# account that a close zeroes into capital. Spouses who file one joint
|
||||||
|
# return each name the other in files_jointly_with (each still gets their
|
||||||
|
# own K-1; the CPA package notes both go on one Form 1040).
|
||||||
# [[company.members]]
|
# [[company.members]]
|
||||||
# name = "Alex Rivera"
|
# name = "Alex Rivera"
|
||||||
# share = "50%"
|
# share = "50%"
|
||||||
# capital_account = "3000"
|
# capital_account = "3000"
|
||||||
# distribution_account = "3100"
|
# distribution_account = "3100"
|
||||||
|
# files_jointly_with = "Sam Chen"
|
||||||
#
|
#
|
||||||
# [[company.members]]
|
# [[company.members]]
|
||||||
# name = "Sam Chen"
|
# name = "Sam Chen"
|
||||||
# share = "50%"
|
# share = "50%"
|
||||||
# capital_account = "3001"
|
# capital_account = "3001"
|
||||||
# distribution_account = "3101"
|
# distribution_account = "3101"
|
||||||
|
# files_jointly_with = "Alex Rivera"
|
||||||
|
|
||||||
[books]
|
[books]
|
||||||
# The books' data, relative to this file. The tools take it from --data-dir
|
# The books' data, relative to this file. The tools take it from --data-dir
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ pub struct Member {
|
|||||||
/// Code of their distributions account, if distributions are recorded
|
/// Code of their distributions account, if distributions are recorded
|
||||||
/// separately from capital (zeroed into capital at close).
|
/// separately from capital (zeroed into capital at close).
|
||||||
pub distribution_account: Option<String>,
|
pub distribution_account: Option<String>,
|
||||||
|
/// `files_jointly_with`: the other member this member files a joint
|
||||||
|
/// individual return with (spouses). Each still gets their own K-1
|
||||||
|
/// (a partnership reports each partner separately, IRS Instructions
|
||||||
|
/// for Form 1065, Schedule K-1); both K-1s go on the one Form 1040
|
||||||
|
/// (Schedule E, Part II). Informational: the CPA package says so.
|
||||||
|
pub files_jointly_with: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Company {
|
impl Default for Company {
|
||||||
@@ -73,6 +79,34 @@ impl Default for Company {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Company {
|
impl Company {
|
||||||
|
/// The members as party shares see them ([`ledger_core::PartyMember`]):
|
||||||
|
/// each member whose name (ignoring case and surrounding space) is a
|
||||||
|
/// party with the member role not archived, and whose capital account
|
||||||
|
/// is in the chart. Members missing either are left out, so their
|
||||||
|
/// shares can't be derived (and the viewer shows no capital
|
||||||
|
/// statement for them).
|
||||||
|
pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> {
|
||||||
|
self.members
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| {
|
||||||
|
let party = ledger.party_by_name(&m.name)?;
|
||||||
|
if !party.roles.contains(&ledger_core::Role::Member) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ledger_core::PartyMember {
|
||||||
|
party: party.id,
|
||||||
|
share: m.share,
|
||||||
|
capital: ledger.chart().find_by_code(&m.capital_account)?.id,
|
||||||
|
distributions: m
|
||||||
|
.distribution_account
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|c| ledger.chart().find_by_code(c))
|
||||||
|
.map(|a| a.id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
|
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
|
||||||
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
|
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
|
||||||
let (m, day) = self.fiscal_year_start;
|
let (m, day) = self.fiscal_year_start;
|
||||||
@@ -199,6 +233,7 @@ struct MemberToml {
|
|||||||
share: String,
|
share: String,
|
||||||
capital_account: Option<String>,
|
capital_account: Option<String>,
|
||||||
distribution_account: Option<String>,
|
distribution_account: Option<String>,
|
||||||
|
files_jointly_with: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Deserialize)]
|
#[derive(Default, Deserialize)]
|
||||||
@@ -411,6 +446,10 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
.distribution_account
|
.distribution_account
|
||||||
.map(|d| d.trim().to_string())
|
.map(|d| d.trim().to_string())
|
||||||
.filter(|d| !d.is_empty()),
|
.filter(|d| !d.is_empty()),
|
||||||
|
files_jointly_with: m
|
||||||
|
.files_jointly_with
|
||||||
|
.map(|d| d.trim().to_string())
|
||||||
|
.filter(|d| !d.is_empty()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (i, m) in company.members.iter().enumerate() {
|
for (i, m) in company.members.iter().enumerate() {
|
||||||
@@ -437,6 +476,32 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// files_jointly_with names another member, and both say so.
|
||||||
|
for m in &company.members {
|
||||||
|
if let Some(j) = &m.files_jointly_with {
|
||||||
|
if *j == m.name {
|
||||||
|
return Err(format!(
|
||||||
|
"member {}: files_jointly_with names themself",
|
||||||
|
m.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match company.members.iter().find(|o| o.name == *j) {
|
||||||
|
None => {
|
||||||
|
return Err(format!(
|
||||||
|
"member {}: files_jointly_with {j:?} isn't a member",
|
||||||
|
m.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(o) if o.files_jointly_with.as_deref() != Some(m.name.as_str()) => {
|
||||||
|
return Err(format!(
|
||||||
|
"member {} files jointly with {j}, but {j}'s files_jointly_with doesn't name {}: set it on both",
|
||||||
|
m.name, m.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if !company.members.is_empty() {
|
if !company.members.is_empty() {
|
||||||
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
||||||
if total != Decimal::ONE {
|
if total != Decimal::ONE {
|
||||||
@@ -586,6 +651,44 @@ pub fn load(path: &Path) -> Result<BooksSettings, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// files_jointly_with: names another member, on both sides.
|
||||||
|
#[test]
|
||||||
|
fn files_jointly_with() {
|
||||||
|
let m = |a: &str, b: &str| {
|
||||||
|
format!(
|
||||||
|
"[company]\nname = \"X\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n{a}\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n{b}\n"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let ok = parse(&m(
|
||||||
|
"files_jointly_with = \"B\"",
|
||||||
|
"files_jointly_with = \"A\"",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ok.company.members[0].files_jointly_with.as_deref(),
|
||||||
|
Some("B")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse(&m("files_jointly_with = \"B\"", ""))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("set it on both")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse(&m("files_jointly_with = \"C\"", ""))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("isn't a member")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse(&m("files_jointly_with = \"A\"", ""))
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("themself")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(&m("", "")).unwrap().company.members[1].files_jointly_with,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `[books] parties`: suggest by default; off, suggest, require (and
|
/// `[books] parties`: suggest by default; off, suggest, require (and
|
||||||
/// "required") parse; anything else is refused, naming the choices.
|
/// "required") parse; anything else is refused, naming the choices.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -691,12 +794,14 @@ mod tests {
|
|||||||
share: half,
|
share: half,
|
||||||
capital_account: "3000".into(),
|
capital_account: "3000".into(),
|
||||||
distribution_account: None,
|
distribution_account: None,
|
||||||
|
files_jointly_with: None,
|
||||||
},
|
},
|
||||||
Member {
|
Member {
|
||||||
name: "Katherine Pendergast".into(),
|
name: "Katherine Pendergast".into(),
|
||||||
share: half,
|
share: half,
|
||||||
capital_account: "3001".into(),
|
capital_account: "3001".into(),
|
||||||
distribution_account: None,
|
distribution_account: None,
|
||||||
|
files_jointly_with: None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
//! are reported by the card processor on 1099-K, not by the payer. A bill
|
//! 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
|
//! (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
|
//! (Dr prepaid, Cr cash) counts in full when made: §6041 reports payments
|
||||||
//! made in the year.
|
//! made in the year. An entry with several parties (a payment shared
|
||||||
|
//! between a contractor and a member) counts only the vendor's share of
|
||||||
|
//! that payment ([`vendor_1099_report_with_members`]).
|
||||||
//!
|
//!
|
||||||
//! Thresholds are set by law and change; [`default_threshold`] encodes
|
//! Thresholds are set by law and change; [`default_threshold`] encodes
|
||||||
//! them and the report takes an override. Confirm with the CPA.
|
//! them and the report takes an override. Confirm with the CPA.
|
||||||
@@ -115,6 +117,10 @@ pub struct Vendor1099Report {
|
|||||||
pub threshold: Decimal,
|
pub threshold: Decimal,
|
||||||
/// Every vendor marked for a 1099 or paid in the year, by name.
|
/// Every vendor marked for a 1099 or paid in the year, by name.
|
||||||
pub vendors: Vec<Vendor1099Line>,
|
pub vendors: Vec<Vendor1099Line>,
|
||||||
|
/// Entries naming several parties whose shares didn't resolve, in
|
||||||
|
/// posting order: each counted in full toward its vendor until its
|
||||||
|
/// shares are fixed ([`vendor_1099_report_with_members`] INVARIANT 4).
|
||||||
|
pub unresolved: Vec<crate::EntryId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cash paid per vendor in calendar `year`, from posted entries tagged
|
/// Cash paid per vendor in calendar `year`, from posted entries tagged
|
||||||
@@ -155,39 +161,22 @@ impl PaymentAccounts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`vendor_1099_report`], counting payments out of `sources`.
|
/// [`vendor_1099_report`], counting payments out of `sources`, with no
|
||||||
///
|
/// members: [`vendor_1099_report_with_members`] with none (an entry naming
|
||||||
/// ```text
|
/// several parties then needs amount or percent shares to resolve, which
|
||||||
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
|
/// a vendor beside anyone always does, since a vendor is not a member).
|
||||||
/// 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(
|
pub fn vendor_1099_report_with(
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
year: i32,
|
year: i32,
|
||||||
threshold: Option<Decimal>,
|
threshold: Option<Decimal>,
|
||||||
sources: &PaymentAccounts,
|
sources: &PaymentAccounts,
|
||||||
) -> Result<Vendor1099Report, LedgerError> {
|
) -> Result<Vendor1099Report, LedgerError> {
|
||||||
let threshold = match threshold {
|
vendor_1099_report_with_members(ledger, year, threshold, sources, &[])
|
||||||
Some(t) => t,
|
}
|
||||||
None => default_threshold(year)?,
|
|
||||||
};
|
/// What an entry paid out of `sources`: credits minus debits to them.
|
||||||
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
|
fn payment(ledger: &Ledger, entry: &crate::JournalEntry, sources: &PaymentAccounts) -> Decimal {
|
||||||
for entry in ledger.posted_entries() {
|
entry
|
||||||
if entry.date().year() != year {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let cash: Decimal = entry
|
|
||||||
.lines()
|
.lines()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| sources.includes(ledger, l.account))
|
.filter(|l| sources.includes(ledger, l.account))
|
||||||
@@ -195,8 +184,120 @@ pub fn vendor_1099_report_with(
|
|||||||
crate::Side::Credit => l.amount.amount,
|
crate::Side::Credit => l.amount.amount,
|
||||||
crate::Side::Debit => -l.amount.amount,
|
crate::Side::Debit => -l.amount.amount,
|
||||||
})
|
})
|
||||||
.sum();
|
.sum()
|
||||||
*paid.entry(vendor).or_default() += cash;
|
}
|
||||||
|
|
||||||
|
/// The vendor's part of `payment` when the entry names several parties:
|
||||||
|
/// its resolved share scaled from the share target to the payment (the
|
||||||
|
/// SCENARIOS of [`vendor_1099_report_with_members`], INVARIANT 3).
|
||||||
|
fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Decimal {
|
||||||
|
if payment == target {
|
||||||
|
share
|
||||||
|
} else if target.is_zero() {
|
||||||
|
Decimal::ZERO
|
||||||
|
} else {
|
||||||
|
(payment * share / target)
|
||||||
|
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`vendor_1099_report`], counting payments out of `sources`, each
|
||||||
|
/// vendor's part of an entry with several parties resolved with `members`
|
||||||
|
/// (the company's members, from the settings).
|
||||||
|
///
|
||||||
|
/// ```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); crate::shares (an entry
|
||||||
|
/// with several parties, each with their share; DECISION, Stephen,
|
||||||
|
/// 2026-10-15).
|
||||||
|
/// DEFINITIONS:
|
||||||
|
/// payment(e): credits minus debits to `sources` on entry e.
|
||||||
|
/// money: crate::parties::money_accounts(ledger, sources.cash), what the
|
||||||
|
/// shares resolve against (the MCP server checks them with the same).
|
||||||
|
/// target(e): crate::shares::share_target(e, money): the entry_flow net,
|
||||||
|
/// else the total debits.
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. Only posted entries dated in `year` and tagged with a vendor
|
||||||
|
/// (Ledger::entry_vendor) count, toward that vendor.
|
||||||
|
/// 2. An entry naming at most one party (Ledger::entry_parties): the
|
||||||
|
/// vendor's amount is payment(e), exactly as before shares.
|
||||||
|
/// 3. An entry naming several parties (one of them the vendor's party,
|
||||||
|
/// crate::shares INVARIANT 5): the vendor's amount is its resolved
|
||||||
|
/// share s (crate::shares::resolve with money and `members`) scaled
|
||||||
|
/// to the payment: s when payment(e) = target(e) (no rounding: an
|
||||||
|
/// expense paid from cash or by a member), else
|
||||||
|
/// payment(e) × s / target(e) rounded to the nearest cent, half away
|
||||||
|
/// from zero (0 when the payment is 0: charged to a card; 0 when the
|
||||||
|
/// target is 0). The other parties' parts are not anyone's 1099.
|
||||||
|
/// 4. An entry naming several parties whose shares don't resolve (the
|
||||||
|
/// members changed in the settings, say): counts payment(e) in full
|
||||||
|
/// toward the vendor, the conservative answer, and is listed in
|
||||||
|
/// `unresolved` so the human fixes the shares.
|
||||||
|
/// 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.
|
||||||
|
/// 3. $1,000.00 from the bank (Dr contract labor / Cr 1000) shared 60/40
|
||||||
|
/// by amount between a contractor and a member: the contractor's
|
||||||
|
/// 1099 counts 600.00.
|
||||||
|
/// 4. $100.01 from the bank, 50% / 50% contractor first: the
|
||||||
|
/// contractor gets 50.01 (the leftover cent goes to the first party,
|
||||||
|
/// crate::shares INVARIANT 8); named second, 50.00.
|
||||||
|
/// 5. $1,000.00 split 60/40, 600.00 of it from the bank and 400.00 on
|
||||||
|
/// the card: payment 600.00, target 1,000.00, the contractor counts
|
||||||
|
/// 600.00 × 600.00 / 1,000.00 = 360.00.
|
||||||
|
/// 6. Reversing scenario 3: the reversal carries the shares, resolves
|
||||||
|
/// to −600.00, and the year nets to 0.00.
|
||||||
|
/// ```
|
||||||
|
pub fn vendor_1099_report_with_members(
|
||||||
|
ledger: &Ledger,
|
||||||
|
year: i32,
|
||||||
|
threshold: Option<Decimal>,
|
||||||
|
sources: &PaymentAccounts,
|
||||||
|
members: &[crate::PartyMember],
|
||||||
|
) -> Result<Vendor1099Report, LedgerError> {
|
||||||
|
let threshold = match threshold {
|
||||||
|
Some(t) => t,
|
||||||
|
None => default_threshold(year)?,
|
||||||
|
};
|
||||||
|
let money = crate::parties::money_accounts(ledger, &sources.cash);
|
||||||
|
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
|
||||||
|
let mut unresolved = Vec::new();
|
||||||
|
for entry in ledger.posted_entries() {
|
||||||
|
if entry.date().year() != year {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let cash = payment(ledger, entry, sources);
|
||||||
|
let shares = ledger.entry_parties(entry.id());
|
||||||
|
let amount = if shares.len() < 2 {
|
||||||
|
cash
|
||||||
|
} else {
|
||||||
|
let party = ledger.party_of_vendor(vendor);
|
||||||
|
match crate::shares::resolve(entry, shares, &money, members) {
|
||||||
|
Ok(parts) => {
|
||||||
|
match parts.into_iter().find(|(p, _)| Some(*p) == party) {
|
||||||
|
Some((_, share)) => {
|
||||||
|
vendor_part(cash, share, crate::shares::share_target(entry, &money))
|
||||||
|
}
|
||||||
|
// Tagged by hand with a vendor none of the parties
|
||||||
|
// is: nothing to scale by, so the whole payment.
|
||||||
|
None => {
|
||||||
|
unresolved.push(entry.id());
|
||||||
|
cash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
unresolved.push(entry.id());
|
||||||
|
cash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*paid.entry(vendor).or_default() += amount;
|
||||||
}
|
}
|
||||||
let mut vendors: Vec<Vendor1099Line> = ledger
|
let mut vendors: Vec<Vendor1099Line> = ledger
|
||||||
.list_vendors()
|
.list_vendors()
|
||||||
@@ -224,5 +325,6 @@ pub fn vendor_1099_report_with(
|
|||||||
year,
|
year,
|
||||||
threshold,
|
threshold,
|
||||||
vendors,
|
vendors,
|
||||||
|
unresolved,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
//! The members' K-1 worksheet: each member's capital account for the
|
||||||
|
//! year, as Schedule K-1 (Form 1065) Item L reports it, with property
|
||||||
|
//! contributed apart from cash.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GOAL: hand the CPA, per member, what the K-1 needs from the books:
|
||||||
|
//! beginning capital, contributions (cash and property separately),
|
||||||
|
//! their share of income, distributions, ending capital.
|
||||||
|
//! SOURCE: Form 1065 Schedule K-1 instructions, Item L ("Partner's capital
|
||||||
|
//! account analysis", on the partnership's books); B-13;
|
||||||
|
//! DECISION (Stephen, 2026-10-15): the CPA package carries a K-1
|
||||||
|
//! worksheet per member.
|
||||||
|
//! INVARIANTS:
|
||||||
|
//! 1. Every figure but the contribution split is the statement of
|
||||||
|
//! changes in members' equity's (reports::members_equity) for the
|
||||||
|
//! same span, so the two never disagree.
|
||||||
|
//! 2. contributions_cash + contributions_property = contributions,
|
||||||
|
//! exactly, per member.
|
||||||
|
//! 3. Property is what an entry crediting a member's capital account
|
||||||
|
//! debits to asset accounts that are neither cash (`cash`),
|
||||||
|
//! receivables, nor prepaid: equipment, a vehicle, software a member
|
||||||
|
//! hands the company. The rest of a contribution is cash: deposits,
|
||||||
|
//! and what a member paid for the company (an expense, a debt they
|
||||||
|
//! took on, e.g. Dr 2100 / Cr capital, or a service paid in advance).
|
||||||
|
//! A prepaid account is the balance account of a prepaid-expense
|
||||||
|
//! schedule, or an asset account whose name says "prepaid" (1300
|
||||||
|
//! Prepaid Expenses before its schedule exists): a member paying the
|
||||||
|
//! company's service in advance paid cash on its behalf, and it reads
|
||||||
|
//! the same booked to capital directly or through 2100. Item L
|
||||||
|
//! property is property the member owned, with its own basis
|
||||||
|
//! (§723), never cash they spent for the company.
|
||||||
|
//! 4. Within one entry, the property (at most the members' credits in
|
||||||
|
//! it) is shared among the members credited in proportion to their
|
||||||
|
//! credits, to the cent, leftover cents by largest remainder
|
||||||
|
//! (kernel::apportion, ties to the earlier member): each takes at
|
||||||
|
//! most its own credit, and the split depends on the order the
|
||||||
|
//! members are given only for a tied leftover cent (never on line
|
||||||
|
//! order). Joint property is joint (B-13's
|
||||||
|
//! shares, the house convention that joint funds are 50/50).
|
||||||
|
//! 5. k1_detail's lines explain each row: per member, its cash lines
|
||||||
|
//! sum to contributions_cash, its property lines to
|
||||||
|
//! contributions_property, and its distribution lines to
|
||||||
|
//! −distributions, exactly (the same entries members_equity reads:
|
||||||
|
//! posted, in the span, not a period's closing entry, not cancelled
|
||||||
|
//! by its reversal in the span).
|
||||||
|
//! 6. An entry and its reversal both dated in the span cancel
|
||||||
|
//! (reports::cancelled_in_span; members_equity INVARIANT 6): neither
|
||||||
|
//! gives a line, so a reversed contribution is neither a contribution
|
||||||
|
//! nor a distribution, and a reversed distribution neither a
|
||||||
|
//! distribution nor a contribution.
|
||||||
|
//! SCENARIOS:
|
||||||
|
//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001
|
||||||
|
//! 500) reversed the next day: each member 0.00 contributed, 0.00
|
||||||
|
//! distributed.
|
||||||
|
//! 2. Members prepay an annual service from joint funds straight to
|
||||||
|
//! capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr 3001 1,500):
|
||||||
|
//! 1,500.00 cash each, no property.
|
||||||
|
//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000
|
||||||
|
//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
|
||||||
|
//! whichever member is listed first.
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::reports::{MemberCapital, members_equity};
|
||||||
|
use crate::{AccountId, AccountType, EntryId, Ledger, LedgerError, PartyId, Side};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
/// One member's row on the K-1 worksheet.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct K1Row {
|
||||||
|
/// The member's name, as in wanderbooks.toml.
|
||||||
|
pub name: String,
|
||||||
|
/// Share of income, e.g. 0.5.
|
||||||
|
pub share: Decimal,
|
||||||
|
/// Capital at the start of the span.
|
||||||
|
pub beginning: Decimal,
|
||||||
|
/// Contributed in cash (or paid for the company) in the span.
|
||||||
|
pub contributions_cash: Decimal,
|
||||||
|
/// Contributed as property (book value credited) in the span.
|
||||||
|
pub contributions_property: Decimal,
|
||||||
|
/// Their share of the span's net income (loss).
|
||||||
|
pub net_income: Decimal,
|
||||||
|
/// Distributions in the span (positive, subtracted).
|
||||||
|
pub distributions: Decimal,
|
||||||
|
/// Any other change, as on the statement of members' equity.
|
||||||
|
pub other: Decimal,
|
||||||
|
/// Capital at the end of the span.
|
||||||
|
pub ending: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one entry moved one member's capital ([`K1Line`]).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum K1Kind {
|
||||||
|
/// The cash part of a contribution (INVARIANT 3).
|
||||||
|
CashContribution,
|
||||||
|
/// The property part of a contribution (INVARIANTS 3-4).
|
||||||
|
PropertyContribution,
|
||||||
|
/// Taken out: debits to capital, net debits to distributions.
|
||||||
|
Distribution,
|
||||||
|
/// A period close allocating income into capital. Informational:
|
||||||
|
/// the row's `net_income` is the roll-forward's (INVARIANT 1), which
|
||||||
|
/// also counts income not yet closed.
|
||||||
|
IncomeClose,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry's effect on one member's capital in the span, by kind
|
||||||
|
/// ([`k1_detail`]).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct K1Line {
|
||||||
|
/// The member's index in the `members` given.
|
||||||
|
pub member: usize,
|
||||||
|
/// The entry.
|
||||||
|
pub entry: EntryId,
|
||||||
|
/// What it was.
|
||||||
|
pub kind: K1Kind,
|
||||||
|
/// Signed: positive raises their capital, negative lowers it.
|
||||||
|
pub amount: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The K-1 worksheet with the entries behind it ([`k1_detail`]).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct K1Detail {
|
||||||
|
/// One row per member, in order: [`k1_worksheet`]'s.
|
||||||
|
pub rows: Vec<K1Row>,
|
||||||
|
/// Every entry dated in the span that moved a member's capital
|
||||||
|
/// account (or distributions account), in journal order, a kind per
|
||||||
|
/// line (an entry with more than one kind for a member is more than
|
||||||
|
/// one line). Per member (INVARIANT 5):
|
||||||
|
/// Σ CashContribution = contributions_cash,
|
||||||
|
/// Σ PropertyContribution = contributions_property, and
|
||||||
|
/// −Σ Distribution = distributions, exactly.
|
||||||
|
pub lines: Vec<K1Line>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The K-1 worksheet for `start..=end`: one row per member, in order
|
||||||
|
/// (INVARIANTS 1-4). `cash` are the company's cash accounts; it must not
|
||||||
|
/// be empty (without it a bank deposit would read as property).
|
||||||
|
pub fn k1_worksheet(
|
||||||
|
ledger: &Ledger,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
members: &[MemberCapital],
|
||||||
|
cash: &[AccountId],
|
||||||
|
) -> Result<Vec<K1Row>, LedgerError> {
|
||||||
|
k1_detail(ledger, start, end, members, cash).map(|d| d.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`k1_worksheet`] with the per-entry lines behind each row's
|
||||||
|
/// contributions and distributions (INVARIANT 5): the one source the
|
||||||
|
/// viewer's member capital statement reads, so it never disagrees with
|
||||||
|
/// the CPA package.
|
||||||
|
pub fn k1_detail(
|
||||||
|
ledger: &Ledger,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
members: &[MemberCapital],
|
||||||
|
cash: &[AccountId],
|
||||||
|
) -> Result<K1Detail, LedgerError> {
|
||||||
|
if cash.is_empty() {
|
||||||
|
return Err(LedgerError::InvalidMembers(
|
||||||
|
"the K-1 worksheet needs the cash accounts".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let me = members_equity(ledger, start, end, members)?;
|
||||||
|
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
||||||
|
let closings = ledger.closing_entry_ids();
|
||||||
|
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||||
|
let prepaid: Vec<AccountId> = ledger
|
||||||
|
.schedules()
|
||||||
|
.filter(|s| s.kind == crate::schedules::ScheduleKind::PrepaidExpense)
|
||||||
|
.map(|s| s.balance_account)
|
||||||
|
.collect();
|
||||||
|
let property_account = |id: AccountId| {
|
||||||
|
!cash.contains(&id)
|
||||||
|
&& Some(id) != receivables
|
||||||
|
&& !prepaid.contains(&id)
|
||||||
|
&& ledger.chart().get(id).is_some_and(|a| {
|
||||||
|
a.account_type == AccountType::Asset && !a.name.to_lowercase().contains("prepaid")
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
|
||||||
|
e.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == side && l.account == account)
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum()
|
||||||
|
};
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for e in ledger
|
||||||
|
.posted_entries()
|
||||||
|
.filter(|e| e.date() >= start && e.date() <= end && !cancelled.contains(&e.id()))
|
||||||
|
{
|
||||||
|
let line = |member, kind, amount| K1Line {
|
||||||
|
member,
|
||||||
|
entry: e.id(),
|
||||||
|
kind,
|
||||||
|
amount,
|
||||||
|
};
|
||||||
|
if closings.contains(&e.id()) {
|
||||||
|
for (i, m) in members.iter().enumerate() {
|
||||||
|
let effect: Decimal = std::iter::once(m.capital_account)
|
||||||
|
.chain(m.distribution_account)
|
||||||
|
.map(|a| sum(e, Side::Credit, a) - sum(e, Side::Debit, a))
|
||||||
|
.sum();
|
||||||
|
if !effect.is_zero() {
|
||||||
|
lines.push(line(i, K1Kind::IncomeClose, effect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pool: Decimal = e
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == Side::Debit && property_account(l.account))
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum();
|
||||||
|
// Contributions are credits to capital (members_equity's).
|
||||||
|
let credits: Vec<Decimal> = members
|
||||||
|
.iter()
|
||||||
|
.map(|m| sum(e, Side::Credit, m.capital_account))
|
||||||
|
.collect();
|
||||||
|
let takes = share_property(pool, &credits);
|
||||||
|
for (i, m) in members.iter().enumerate() {
|
||||||
|
let (credit, take) = (credits[i], takes[i]);
|
||||||
|
if !(credit - take).is_zero() {
|
||||||
|
lines.push(line(i, K1Kind::CashContribution, credit - take));
|
||||||
|
}
|
||||||
|
if !take.is_zero() {
|
||||||
|
lines.push(line(i, K1Kind::PropertyContribution, take));
|
||||||
|
}
|
||||||
|
// Distributions: debits to capital, net debits to the
|
||||||
|
// distributions account (members_equity's).
|
||||||
|
let taken = sum(e, Side::Debit, m.capital_account)
|
||||||
|
+ m.distribution_account.map_or(Decimal::ZERO, |d| {
|
||||||
|
sum(e, Side::Debit, d) - sum(e, Side::Credit, d)
|
||||||
|
});
|
||||||
|
if !taken.is_zero() {
|
||||||
|
lines.push(line(i, K1Kind::Distribution, -taken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total = |i: usize, k: K1Kind| -> Decimal {
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.member == i && l.kind == k)
|
||||||
|
.map(|l| l.amount)
|
||||||
|
.sum()
|
||||||
|
};
|
||||||
|
let rows = me
|
||||||
|
.members
|
||||||
|
.into_iter()
|
||||||
|
.zip(members)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (col, m))| {
|
||||||
|
// Each entry's property is at most that member's credit in it,
|
||||||
|
// and contributions are those credits' sum: property never
|
||||||
|
// exceeds contributions, and is never negative.
|
||||||
|
let prop = total(i, K1Kind::PropertyContribution);
|
||||||
|
K1Row {
|
||||||
|
name: col.name,
|
||||||
|
share: m.share,
|
||||||
|
beginning: col.beginning,
|
||||||
|
contributions_cash: col.contributions - prop,
|
||||||
|
contributions_property: prop,
|
||||||
|
net_income: col.net_income,
|
||||||
|
distributions: col.distributions,
|
||||||
|
other: col.other,
|
||||||
|
ending: col.ending,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(K1Detail { rows, lines })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry's property shared among the members' credits in it
|
||||||
|
/// (INVARIANT 4): at most their total credit, in proportion to each
|
||||||
|
/// credit, rounded toward zero to the cent, the leftover cents by largest
|
||||||
|
/// remainder. Each part is at most its own credit.
|
||||||
|
fn share_property(pool: Decimal, credits: &[Decimal]) -> Vec<Decimal> {
|
||||||
|
use rust_decimal::RoundingStrategy::ToZero;
|
||||||
|
let total: Decimal = credits.iter().sum();
|
||||||
|
let whole = pool.min(total);
|
||||||
|
if whole <= Decimal::ZERO || total <= Decimal::ZERO {
|
||||||
|
return vec![Decimal::ZERO; credits.len()];
|
||||||
|
}
|
||||||
|
// whole × credit is exact; ÷ total is exact whenever the share is a
|
||||||
|
// whole number of cents, so truncating never loses a cent it is owed.
|
||||||
|
let exact: Vec<Decimal> = credits.iter().map(|c| whole * c / total).collect();
|
||||||
|
let mut parts: Vec<Decimal> = exact
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
||||||
|
.collect();
|
||||||
|
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
|
||||||
|
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2))
|
||||||
|
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The party a member (by name, as in wanderbooks.toml) is in the
|
||||||
|
/// directory: the party not archived with that name, ignoring case and
|
||||||
|
/// surrounding space ([`Ledger::party_by_name`]), and only when it has
|
||||||
|
/// the member role. The same rule as ledger-config's
|
||||||
|
/// `Company::party_members`, so the K-1 worksheet and the viewer's
|
||||||
|
/// capital statement always name the same party. A display name never
|
||||||
|
/// matches, and a party without the member role is never a member's: a
|
||||||
|
/// vendor whose display name is a member's name must never put its TIN
|
||||||
|
/// on that member's K-1 (the caller reports the member's TIN as a gap).
|
||||||
|
pub fn member_party(ledger: &Ledger, name: &str) -> Option<PartyId> {
|
||||||
|
ledger
|
||||||
|
.party_by_name(name)
|
||||||
|
.filter(|p| p.roles.contains(&crate::Role::Member))
|
||||||
|
.map(|p| p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{AccountType, Currency, Money};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn d(s: &str) -> Decimal {
|
||||||
|
Decimal::from_str(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand-worked: A contributes 300 cash and a 700 laptop in one entry
|
||||||
|
/// (Dr 1000 300, Dr 1500 700 / Cr 3000 1000); B contributes a 200
|
||||||
|
/// receivable-backed promise (Dr 1200 200 / Cr 3001 200, cash by
|
||||||
|
/// INVARIANT 3) and pays a 50 expense (Dr 6999 50 / Cr 3001 50).
|
||||||
|
#[test]
|
||||||
|
fn contributions_split_into_cash_and_property() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let add =
|
||||||
|
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||||
|
let cash = add(&mut l, "1000", AccountType::Asset);
|
||||||
|
let ar = add(&mut l, "1200", AccountType::Asset);
|
||||||
|
let equip = add(&mut l, "1500", AccountType::Asset);
|
||||||
|
let a = add(&mut l, "3000", AccountType::Equity);
|
||||||
|
let b = add(&mut l, "3001", AccountType::Equity);
|
||||||
|
let exp = add(&mut l, "6999", AccountType::Expense);
|
||||||
|
assert_eq!(
|
||||||
|
l.role_account(&crate::RECEIVABLES),
|
||||||
|
Some(ar),
|
||||||
|
"1200 by default"
|
||||||
|
);
|
||||||
|
let day = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
|
||||||
|
let mut post = |lines: &[(AccountId, Side, &str)]| {
|
||||||
|
let e = l.create_entry(day, "x");
|
||||||
|
for (acct, side, amt) in lines {
|
||||||
|
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
};
|
||||||
|
post(&[
|
||||||
|
(cash, Side::Debit, "300"),
|
||||||
|
(equip, Side::Debit, "700"),
|
||||||
|
(a, Side::Credit, "1000"),
|
||||||
|
]);
|
||||||
|
post(&[(ar, Side::Debit, "200"), (b, Side::Credit, "200")]);
|
||||||
|
post(&[(exp, Side::Debit, "50"), (b, Side::Credit, "50")]);
|
||||||
|
let members = [
|
||||||
|
MemberCapital {
|
||||||
|
name: "A".into(),
|
||||||
|
capital_account: a,
|
||||||
|
distribution_account: None,
|
||||||
|
share: d("0.5"),
|
||||||
|
},
|
||||||
|
MemberCapital {
|
||||||
|
name: "B".into(),
|
||||||
|
capital_account: b,
|
||||||
|
distribution_account: None,
|
||||||
|
share: d("0.5"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||||
|
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
|
||||||
|
let rows = k1_worksheet(&l, start, end, &members, &[cash]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(rows[0].contributions_cash, rows[0].contributions_property),
|
||||||
|
(d("300"), d("700"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(rows[1].contributions_cash, rows[1].contributions_property),
|
||||||
|
(d("250"), d("0"))
|
||||||
|
);
|
||||||
|
// Net income -50, split -25 each.
|
||||||
|
assert_eq!(rows[0].ending, d("975"));
|
||||||
|
assert_eq!(rows[1].ending, d("225"));
|
||||||
|
assert!(k1_worksheet(&l, start, end, &members, &[]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand-worked: A (capital 3000, distributions 3100) contributes 100
|
||||||
|
/// cash and 40 of equipment in one entry and takes 30 through 3100; B
|
||||||
|
/// takes 10 straight from 3001; revenue 60; Jan closed 50/50 (30
|
||||||
|
/// each, A's 3100 zeroed into 3000). A: 140 in (100 cash, 40
|
||||||
|
/// property), 30 out, 30 income, ending 140. B: 10 out, 30 income,
|
||||||
|
/// ending 20. The lines explain the rows (INVARIANT 5).
|
||||||
|
#[test]
|
||||||
|
fn detail_lines_explain_each_row() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let add =
|
||||||
|
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||||
|
let cash = add(&mut l, "1000", AccountType::Asset);
|
||||||
|
let equip = add(&mut l, "1500", AccountType::Asset);
|
||||||
|
let a = add(&mut l, "3000", AccountType::Equity);
|
||||||
|
let b = add(&mut l, "3001", AccountType::Equity);
|
||||||
|
let da = add(&mut l, "3100", AccountType::Equity);
|
||||||
|
let rev = add(&mut l, "4000", AccountType::Revenue);
|
||||||
|
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||||
|
let mut post = |date, lines: &[(AccountId, Side, &str)]| {
|
||||||
|
let e = l.create_entry(date, "x");
|
||||||
|
for (acct, side, amt) in lines {
|
||||||
|
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
e
|
||||||
|
};
|
||||||
|
let put_in = post(
|
||||||
|
day(1, 2),
|
||||||
|
&[
|
||||||
|
(cash, Side::Debit, "100"),
|
||||||
|
(equip, Side::Debit, "40"),
|
||||||
|
(a, Side::Credit, "140"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let a_out = post(
|
||||||
|
day(1, 3),
|
||||||
|
&[(da, Side::Debit, "30"), (cash, Side::Credit, "30")],
|
||||||
|
);
|
||||||
|
let b_out = post(
|
||||||
|
day(1, 4),
|
||||||
|
&[(b, Side::Debit, "10"), (cash, Side::Credit, "10")],
|
||||||
|
);
|
||||||
|
post(
|
||||||
|
day(1, 5),
|
||||||
|
&[(cash, Side::Debit, "60"), (rev, Side::Credit, "60")],
|
||||||
|
);
|
||||||
|
let jan = l.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
|
||||||
|
let close = l
|
||||||
|
.close_period_to(
|
||||||
|
jan,
|
||||||
|
&crate::periods::CloseTo::Members(vec![
|
||||||
|
crate::MemberClose {
|
||||||
|
capital: a,
|
||||||
|
share: d("0.5"),
|
||||||
|
distributions: Some(da),
|
||||||
|
},
|
||||||
|
crate::MemberClose {
|
||||||
|
capital: b,
|
||||||
|
share: d("0.5"),
|
||||||
|
distributions: None,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let members = [
|
||||||
|
MemberCapital {
|
||||||
|
name: "A".into(),
|
||||||
|
capital_account: a,
|
||||||
|
distribution_account: Some(da),
|
||||||
|
share: d("0.5"),
|
||||||
|
},
|
||||||
|
MemberCapital {
|
||||||
|
name: "B".into(),
|
||||||
|
capital_account: b,
|
||||||
|
distribution_account: None,
|
||||||
|
share: d("0.5"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (start, end) = (day(1, 1), day(12, 31));
|
||||||
|
let detail = k1_detail(&l, start, end, &members, &[cash]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
detail.rows,
|
||||||
|
k1_worksheet(&l, start, end, &members, &[cash]).unwrap()
|
||||||
|
);
|
||||||
|
let line = |member, entry, kind, amount: &str| K1Line {
|
||||||
|
member,
|
||||||
|
entry,
|
||||||
|
kind,
|
||||||
|
amount: d(amount),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
detail.lines,
|
||||||
|
vec![
|
||||||
|
line(0, put_in, K1Kind::CashContribution, "100"),
|
||||||
|
line(0, put_in, K1Kind::PropertyContribution, "40"),
|
||||||
|
line(0, a_out, K1Kind::Distribution, "-30"),
|
||||||
|
line(1, b_out, K1Kind::Distribution, "-10"),
|
||||||
|
line(0, close, K1Kind::IncomeClose, "30"),
|
||||||
|
line(1, close, K1Kind::IncomeClose, "30"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let ra = &detail.rows[0];
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
ra.beginning,
|
||||||
|
ra.contributions_cash,
|
||||||
|
ra.contributions_property
|
||||||
|
),
|
||||||
|
(d("0"), d("100"), d("40"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(ra.net_income, ra.distributions, ra.ending),
|
||||||
|
(d("30"), d("30"), d("140"))
|
||||||
|
);
|
||||||
|
let rb = &detail.rows[1];
|
||||||
|
assert_eq!(
|
||||||
|
(rb.net_income, rb.distributions, rb.ending),
|
||||||
|
(d("30"), d("10"), d("20"))
|
||||||
|
);
|
||||||
|
for (i, row) in detail.rows.iter().enumerate() {
|
||||||
|
let sum = |k| -> Decimal {
|
||||||
|
detail
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.member == i && x.kind == k)
|
||||||
|
.map(|x| x.amount)
|
||||||
|
.sum()
|
||||||
|
};
|
||||||
|
assert_eq!(sum(K1Kind::CashContribution), row.contributions_cash);
|
||||||
|
assert_eq!(
|
||||||
|
sum(K1Kind::PropertyContribution),
|
||||||
|
row.contributions_property
|
||||||
|
);
|
||||||
|
assert_eq!(-sum(K1Kind::Distribution), row.distributions);
|
||||||
|
assert_eq!(
|
||||||
|
row.beginning
|
||||||
|
+ row.contributions_cash
|
||||||
|
+ row.contributions_property
|
||||||
|
+ row.net_income
|
||||||
|
- row.distributions
|
||||||
|
+ row.other,
|
||||||
|
row.ending
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// February alone: nothing moved; the capital carries over.
|
||||||
|
let feb = k1_detail(&l, day(2, 1), day(2, 28), &members, &[cash]).unwrap();
|
||||||
|
assert!(feb.lines.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
(feb.rows[0].beginning, feb.rows[0].ending),
|
||||||
|
(d("140"), d("140"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two members A (3000) and B (3001), cash 1000, equipment 1500,
|
||||||
|
/// prepaid 1300, and a `post` that returns the entry.
|
||||||
|
struct Books {
|
||||||
|
l: Ledger,
|
||||||
|
cash: AccountId,
|
||||||
|
equip: AccountId,
|
||||||
|
prepaid: AccountId,
|
||||||
|
a: AccountId,
|
||||||
|
b: AccountId,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn books() -> Books {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let mut add =
|
||||||
|
|code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||||
|
let cash = add("1000", "Bluevine", AccountType::Asset);
|
||||||
|
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
|
||||||
|
let equip = add("1500", "Equipment", AccountType::Asset);
|
||||||
|
let a = add("3000", "Capital A", AccountType::Equity);
|
||||||
|
let b = add("3001", "Capital B", AccountType::Equity);
|
||||||
|
Books {
|
||||||
|
l,
|
||||||
|
cash,
|
||||||
|
equip,
|
||||||
|
prepaid,
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(l: &mut Ledger, m: u32, day: u32, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||||||
|
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, m, day).unwrap(), "x");
|
||||||
|
for (acct, side, amt) in lines {
|
||||||
|
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn members(bk: &Books) -> [MemberCapital; 2] {
|
||||||
|
[
|
||||||
|
MemberCapital {
|
||||||
|
name: "A".into(),
|
||||||
|
capital_account: bk.a,
|
||||||
|
distribution_account: None,
|
||||||
|
share: d("0.5"),
|
||||||
|
},
|
||||||
|
MemberCapital {
|
||||||
|
name: "B".into(),
|
||||||
|
capital_account: bk.b,
|
||||||
|
distribution_account: None,
|
||||||
|
share: d("0.5"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (cash, property, distributions) per member for 2026.
|
||||||
|
fn split(bk: &Books, members: &[MemberCapital]) -> Vec<(Decimal, Decimal, Decimal)> {
|
||||||
|
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||||
|
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), members, &[bk.cash]).unwrap();
|
||||||
|
detail
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r.contributions_cash,
|
||||||
|
r.contributions_property,
|
||||||
|
r.distributions,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANT 6 and SCENARIO 1, by hand. Case 1: a joint 1,000 cash
|
||||||
|
/// contribution reversed the next day: 0 in, 0 out each (not 500 of
|
||||||
|
/// each). Case 2: a 700 laptop contribution reversed: A 0 property, 0
|
||||||
|
/// out. Case 3: a real 500 + 500 contribution, then a 100 + 100
|
||||||
|
/// distribution reversed: 500 cash each, 0 out (not 600 in, 100 out).
|
||||||
|
#[test]
|
||||||
|
fn a_reversed_contribution_or_distribution_cancels() {
|
||||||
|
let mut bk = books();
|
||||||
|
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
|
||||||
|
let joint = post(
|
||||||
|
&mut bk.l,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
&[
|
||||||
|
(cash, Side::Debit, "1000"),
|
||||||
|
(a, Side::Credit, "500"),
|
||||||
|
(b, Side::Credit, "500"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let r =
|
||||||
|
bk.l.reverse_entry(joint, NaiveDate::from_ymd_opt(2026, 1, 3).unwrap(), "x")
|
||||||
|
.unwrap();
|
||||||
|
let ms = members(&bk);
|
||||||
|
let zero = (d("0"), d("0"), d("0"));
|
||||||
|
assert_eq!(split(&bk, &ms), vec![zero, zero]);
|
||||||
|
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||||
|
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), &ms, &[cash]).unwrap();
|
||||||
|
assert!(
|
||||||
|
detail
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.all(|l| l.entry != joint && l.entry != r),
|
||||||
|
"{:?}",
|
||||||
|
detail.lines
|
||||||
|
);
|
||||||
|
// The statement of members' equity agrees (INVARIANT 1).
|
||||||
|
let me = members_equity(&bk.l, year(1, 1), year(12, 31), &ms).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(me.members[0].contributions, me.members[0].distributions),
|
||||||
|
(d("0"), d("0"))
|
||||||
|
);
|
||||||
|
// Only the reversal in the span: by side, a distribution.
|
||||||
|
let feb_on = members_equity(&bk.l, year(1, 3), year(12, 31), &ms).unwrap();
|
||||||
|
assert_eq!(feb_on.members[0].distributions, d("500"));
|
||||||
|
|
||||||
|
let laptop = post(
|
||||||
|
&mut bk.l,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
&[(equip, Side::Debit, "700"), (a, Side::Credit, "700")],
|
||||||
|
);
|
||||||
|
bk.l.reverse_entry(laptop, year(2, 2), "x").unwrap();
|
||||||
|
assert_eq!(split(&bk, &ms), vec![zero, zero]);
|
||||||
|
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
&[
|
||||||
|
(cash, Side::Debit, "1000"),
|
||||||
|
(a, Side::Credit, "500"),
|
||||||
|
(b, Side::Credit, "500"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let out = post(
|
||||||
|
&mut bk.l,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
&[
|
||||||
|
(a, Side::Debit, "100"),
|
||||||
|
(b, Side::Debit, "100"),
|
||||||
|
(cash, Side::Credit, "200"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let undo = bk.l.reverse_entry(out, year(3, 3), "x").unwrap();
|
||||||
|
let five = (d("500"), d("0"), d("0"));
|
||||||
|
assert_eq!(split(&bk, &ms), vec![five, five]);
|
||||||
|
// Reversing the reversal reinstates the distribution.
|
||||||
|
bk.l.reverse_entry(undo, year(3, 4), "x").unwrap();
|
||||||
|
let back = (d("500"), d("0"), d("100"));
|
||||||
|
assert_eq!(split(&bk, &ms), vec![back, back]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANT 3 and SCENARIO 2, by hand: members prepay a 3,000 annual
|
||||||
|
/// service straight to capital (Dr 1300 Prepaid / Cr 3000 1,500, Cr
|
||||||
|
/// 3001 1,500): 1,500 cash each, no property, as when routed through
|
||||||
|
/// 2100. A prepaid-expense schedule's account is prepaid whatever its
|
||||||
|
/// name.
|
||||||
|
#[test]
|
||||||
|
fn a_prepaid_service_paid_by_members_is_cash() {
|
||||||
|
let mut bk = books();
|
||||||
|
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
&[
|
||||||
|
(prepaid, Side::Debit, "3000"),
|
||||||
|
(a, Side::Credit, "1500"),
|
||||||
|
(b, Side::Credit, "1500"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let ms = members(&bk);
|
||||||
|
let each = (d("1500"), d("0"), d("0"));
|
||||||
|
assert_eq!(split(&bk, &ms), vec![each, each]);
|
||||||
|
// An account named otherwise, backing a prepaid schedule.
|
||||||
|
let ins =
|
||||||
|
bk.l.add_account(
|
||||||
|
"1310",
|
||||||
|
"Insurance paid ahead",
|
||||||
|
AccountType::Asset,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let exp =
|
||||||
|
bk.l.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
&[(ins, Side::Debit, "1200"), (a, Side::Credit, "1200")],
|
||||||
|
);
|
||||||
|
assert_eq!(split(&bk, &ms)[0].1, d("1200"), "no schedule yet: property");
|
||||||
|
bk.l.register_schedule(crate::schedules::NewSchedule {
|
||||||
|
description: "2026 insurance".into(),
|
||||||
|
kind: crate::schedules::ScheduleKind::PrepaidExpense,
|
||||||
|
total: Money::new(d("1200"), Currency::USD),
|
||||||
|
start: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||||
|
months: 12,
|
||||||
|
balance_account: ins,
|
||||||
|
pl_account: exp,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(split(&bk, &ms)[0], (d("2700"), d("0"), d("0")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 /
|
||||||
|
/// Cr 3000 500, Cr 3001 500. Property 400 split by credit (500:500):
|
||||||
|
/// 200 each; cash 300 each, whichever member is listed first. Unequal
|
||||||
|
/// credits (Cr 3000 750, Cr 3001 250, property 401): 401 × 0.75 =
|
||||||
|
/// 300.75, 401 × 0.25 = 100.25, exact. Odd cents: property 0.01 on
|
||||||
|
/// equal credits goes to the earlier member on a tie.
|
||||||
|
#[test]
|
||||||
|
fn joint_property_is_shared_by_credit() {
|
||||||
|
let mut bk = books();
|
||||||
|
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
|
||||||
|
post(
|
||||||
|
&mut bk.l,
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
&[
|
||||||
|
(cash, Side::Debit, "600"),
|
||||||
|
(equip, Side::Debit, "400"),
|
||||||
|
(a, Side::Credit, "500"),
|
||||||
|
(b, Side::Credit, "500"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let ms = members(&bk);
|
||||||
|
let each = (d("300"), d("200"), d("0"));
|
||||||
|
assert_eq!(split(&bk, &ms), vec![each, each]);
|
||||||
|
let mut flipped = ms.clone();
|
||||||
|
flipped.reverse();
|
||||||
|
assert_eq!(split(&bk, &flipped), vec![each, each]);
|
||||||
|
assert_eq!(
|
||||||
|
share_property(d("401"), &[d("750"), d("250")]),
|
||||||
|
vec![d("300.75"), d("100.25")]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
share_property(d("0.01"), &[d("5"), d("5")]),
|
||||||
|
vec![d("0.01"), d("0.00")]
|
||||||
|
);
|
||||||
|
// More property than credit (part financed): at most the credits.
|
||||||
|
assert_eq!(
|
||||||
|
share_property(d("900"), &[d("300"), d("100")]),
|
||||||
|
vec![d("300"), d("100")]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
share_property(d("50"), &[d("0"), d("0")]),
|
||||||
|
vec![d("0"), d("0")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// member_party doc: the party of that name with the member role only.
|
||||||
|
/// A contractor whose display name is the member's name, or a party
|
||||||
|
/// of the name without the member role, is never the member's.
|
||||||
|
#[test]
|
||||||
|
fn member_party_needs_the_member_role_and_the_name() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let ke = l
|
||||||
|
.add_party("KE Consulting LLC", &[crate::Role::Contractor])
|
||||||
|
.unwrap();
|
||||||
|
l.update_party(
|
||||||
|
ke,
|
||||||
|
crate::PartyField::DisplayName(Some("Kat Example".into())),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(member_party(&l, "Kat Example"), None);
|
||||||
|
let plain = l.add_party("Kat Example", &[]).unwrap();
|
||||||
|
assert_eq!(member_party(&l, "kat example "), None, "no member role");
|
||||||
|
l.set_party_role(plain, crate::Role::Member, true).unwrap();
|
||||||
|
assert_eq!(member_party(&l, "kat example "), Some(plain));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,8 +264,9 @@ pub struct Ledger {
|
|||||||
/// The party directory ([`crate::parties`]).
|
/// The party directory ([`crate::parties`]).
|
||||||
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
|
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
|
||||||
next_party_id: crate::PartyId,
|
next_party_id: crate::PartyId,
|
||||||
/// The party each entry is with (the counterparty).
|
/// The parties each entry is with (its counterparties), each with
|
||||||
entry_parties: HashMap<EntryId, crate::PartyId>,
|
/// their share ([`crate::shares`]); never an empty list.
|
||||||
|
entry_parties: HashMap<EntryId, Vec<crate::PartyShare>>,
|
||||||
/// Entries marked as having no counterparty, with why.
|
/// Entries marked as having no counterparty, with why.
|
||||||
entry_no_party: HashMap<EntryId, String>,
|
entry_no_party: HashMap<EntryId, String>,
|
||||||
}
|
}
|
||||||
@@ -1381,7 +1382,8 @@ impl Ledger {
|
|||||||
if let Some(v) = self.entry_vendor(entry) {
|
if let Some(v) = self.entry_vendor(entry) {
|
||||||
self.entry_vendors.insert(id, v);
|
self.entry_vendors.insert(id, v);
|
||||||
}
|
}
|
||||||
if let Some(p) = self.entry_party(entry) {
|
// Every party and share: the reversal undoes each one's part.
|
||||||
|
if let Some(p) = self.entry_parties.get(&entry).cloned() {
|
||||||
self.entry_parties.insert(id, p);
|
self.entry_parties.insert(id, p);
|
||||||
}
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
@@ -2349,6 +2351,23 @@ impl Ledger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a vendor. Returns its id.
|
/// Add a vendor. Returns its id.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: a vendor record, linked to its party in the directory.
|
||||||
|
/// SOURCE: docs/parties-and-attachments.md; crate::shares INVARIANT 5
|
||||||
|
/// (at most one party on an entry has a vendor record);
|
||||||
|
/// Self::set_entry_parties INVARIANT 2 (the vendor tag follows
|
||||||
|
/// the one party with a vendor record).
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. The record links to the live party of that name without one,
|
||||||
|
/// else a new party (Self::link_record_party).
|
||||||
|
/// 2. IF that party is named on an entry beside another party with a
|
||||||
|
/// vendor record: SHOULD refuse, changing nothing (the entry would
|
||||||
|
/// name two vendors). Record each vendor's part as its own entry
|
||||||
|
/// first.
|
||||||
|
/// 3. Every entry naming that party with no vendor tag is tagged with
|
||||||
|
/// the new vendor, as set_entry_parties would have tagged it.
|
||||||
|
/// ```
|
||||||
pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, LedgerError> {
|
pub fn add_vendor(&mut self, name: &str) -> Result<VendorId, LedgerError> {
|
||||||
if name.trim().is_empty() {
|
if name.trim().is_empty() {
|
||||||
return Err(LedgerError::EmptyAssetDescription);
|
return Err(LedgerError::EmptyAssetDescription);
|
||||||
@@ -2356,6 +2375,22 @@ impl Ledger {
|
|||||||
// Its party takes this name, so the name obeys party rules (no
|
// Its party takes this name, so the name obeys party rules (no
|
||||||
// full TIN; parties INVARIANT 1).
|
// full TIN; parties INVARIANT 1).
|
||||||
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
|
crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?;
|
||||||
|
if let Some(p) = self.record_party_match(name, true) {
|
||||||
|
let mut ids: Vec<EntryId> = self.entry_parties.keys().copied().collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
if let Some(e) = ids.into_iter().find(|e| {
|
||||||
|
let named = self.entry_parties(*e);
|
||||||
|
named.iter().any(|s| s.party == p)
|
||||||
|
&& named
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.party != p && self.parties[&s.party].vendor.is_some())
|
||||||
|
}) {
|
||||||
|
return Err(LedgerError::InvalidParty(format!(
|
||||||
|
"party {p} ({}) is named on entry {e} beside another party with a vendor record; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry before adding this vendor",
|
||||||
|
self.parties[&p].name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
let id = self.next_vendor_id;
|
let id = self.next_vendor_id;
|
||||||
self.next_vendor_id += 1;
|
self.next_vendor_id += 1;
|
||||||
self.vendors.insert(
|
self.vendors.insert(
|
||||||
@@ -2368,6 +2403,20 @@ impl Ledger {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.link_record_party(name, true, id);
|
self.link_record_party(name, true, id);
|
||||||
|
// INVARIANT 3: the tag follows the party's new vendor record.
|
||||||
|
if let Some(p) = self.party_of_vendor(id) {
|
||||||
|
let untagged: Vec<EntryId> = self
|
||||||
|
.entry_parties
|
||||||
|
.iter()
|
||||||
|
.filter(|(e, named)| {
|
||||||
|
named.iter().any(|s| s.party == p) && !self.entry_vendors.contains_key(e)
|
||||||
|
})
|
||||||
|
.map(|(e, _)| *e)
|
||||||
|
.collect();
|
||||||
|
for e in untagged {
|
||||||
|
self.entry_vendors.insert(e, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2404,40 +2453,134 @@ impl Ledger {
|
|||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: tag an entry with the vendor it paid, for 1099s.
|
/// GOAL: tag an entry with the vendor it paid, for 1099s.
|
||||||
/// SOURCE: docs/spec/rules.md B-19.
|
/// SOURCE: docs/spec/rules.md B-19; Self::set_entry_parties
|
||||||
|
/// INVARIANTS 2 and 5 (the vendor tag follows the parties, never
|
||||||
|
/// the other way round, on an entry naming several).
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF the entry or vendor is unknown: SHOULD refuse.
|
/// 1. IF the entry or vendor is unknown: SHOULD refuse.
|
||||||
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
|
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
|
||||||
/// entries).
|
/// entries).
|
||||||
|
/// 3. On an entry naming no party or one: Some(v) makes v's party the
|
||||||
|
/// entry's one party (all of it); None clears the party too when
|
||||||
|
/// it was the vendor's.
|
||||||
|
/// 4. On an entry naming several parties, Some(v) with v's party
|
||||||
|
/// among them sets the tag and keeps every party and share as
|
||||||
|
/// they are. IF v's party is not among them (or v has none):
|
||||||
|
/// SHOULD refuse, changing nothing: naming v alone would drop the
|
||||||
|
/// others' shares and count all of it toward v's 1099. The list
|
||||||
|
/// is Self::set_entry_parties's to change.
|
||||||
|
/// 5. On an entry naming several parties, one of them the tagged
|
||||||
|
/// vendor's party, None SHOULD refuse, changing nothing: the tag
|
||||||
|
/// follows that party (set_entry_parties INVARIANT 2), so the
|
||||||
|
/// 1099 would drop a payment the party still shows. Drop the party
|
||||||
|
/// with Self::set_entry_parties instead.
|
||||||
|
/// 6. The entry's reversal chain (an entry, its reversal, theirs)
|
||||||
|
/// carries the same tags (set_entry_parties INVARIANT 5).
|
||||||
/// ```
|
/// ```
|
||||||
pub fn set_entry_vendor(
|
pub fn set_entry_vendor(
|
||||||
&mut self,
|
&mut self,
|
||||||
entry: EntryId,
|
entry: EntryId,
|
||||||
vendor: Option<VendorId>,
|
vendor: Option<VendorId>,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
self.check_set_entry_vendor(entry, vendor)?;
|
||||||
|
for e in self.reversal_chain(entry) {
|
||||||
|
self.tag_entry_vendor(e, vendor);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validation behind [`Self::set_entry_vendor`] (SCENARIOS 1, 4,
|
||||||
|
/// 5).
|
||||||
|
pub fn check_set_entry_vendor(
|
||||||
|
&self,
|
||||||
|
entry: EntryId,
|
||||||
|
vendor: Option<VendorId>,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
if !self.entries.contains_key(&entry) {
|
if !self.entries.contains_key(&entry) {
|
||||||
return Err(LedgerError::UnknownEntry(entry));
|
return Err(LedgerError::UnknownEntry(entry));
|
||||||
}
|
}
|
||||||
|
let named = self.entry_parties(entry);
|
||||||
match vendor {
|
match vendor {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
if !self.vendors.contains_key(&v) {
|
if !self.vendors.contains_key(&v) {
|
||||||
return Err(LedgerError::UnknownVendor(v));
|
return Err(LedgerError::UnknownVendor(v));
|
||||||
}
|
}
|
||||||
|
let p = self.party_of_vendor(v);
|
||||||
|
if named.len() >= 2 && !named.iter().any(|s| Some(s.party) == p) {
|
||||||
|
return Err(LedgerError::InvalidParty(format!(
|
||||||
|
"entry {entry} names {} parties and vendor {v}'s party isn't one of them; tagging the vendor would drop their shares: name the parties with books.set_entry_party (parties) instead",
|
||||||
|
named.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if named.len() >= 2
|
||||||
|
&& let Some(p) = self
|
||||||
|
.entry_vendor(entry)
|
||||||
|
.and_then(|v| self.party_of_vendor(v))
|
||||||
|
&& named.iter().any(|s| s.party == p)
|
||||||
|
{
|
||||||
|
return Err(LedgerError::InvalidParty(format!(
|
||||||
|
"entry {entry} names party {p}, whose vendor record is its vendor tag, among {} parties; the tag follows that party: drop it from the parties with books.set_entry_party (parties) instead",
|
||||||
|
named.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry's half of [`Self::set_entry_vendor`], checked already.
|
||||||
|
fn tag_entry_vendor(&mut self, entry: EntryId, vendor: Option<VendorId>) {
|
||||||
|
match vendor {
|
||||||
|
Some(v) => {
|
||||||
self.entry_vendors.insert(entry, v);
|
self.entry_vendors.insert(entry, v);
|
||||||
if let Some(p) = self.party_of_vendor(v) {
|
// Several parties: the list stays (SCENARIO 4).
|
||||||
self.entry_parties.insert(entry, p);
|
if self.entry_parties(entry).len() < 2
|
||||||
|
&& let Some(p) = self.party_of_vendor(v)
|
||||||
|
{
|
||||||
|
self.entry_parties
|
||||||
|
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// The party tag goes too only when it was the vendor's.
|
// The party tag goes too only when it was the vendor's.
|
||||||
if let Some(v) = self.entry_vendors.remove(&entry)
|
if let Some(v) = self.entry_vendors.remove(&entry)
|
||||||
&& self.entry_parties.get(&entry).copied() == self.party_of_vendor(v)
|
&& self.party_of_vendor(v).is_some()
|
||||||
|
&& self.entry_party(entry) == self.party_of_vendor(v)
|
||||||
{
|
{
|
||||||
self.entry_parties.remove(&entry);
|
self.entry_parties.remove(&entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
/// An entry's reversal chain, in order: the original it reverses (and
|
||||||
|
/// so on up), itself, the entry reversing it (and so on down). An
|
||||||
|
/// entry reversed by none and reversing none is its own chain. Each
|
||||||
|
/// entry is reversed at most once ([`Self::check_reversal`]).
|
||||||
|
pub fn reversal_chain(&self, entry: EntryId) -> Vec<EntryId> {
|
||||||
|
let mut top = entry;
|
||||||
|
while let Some(o) = self.entries.get(&top).and_then(JournalEntry::reversal_of) {
|
||||||
|
if !self.entries.contains_key(&o) || o == entry {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
top = o;
|
||||||
|
}
|
||||||
|
let mut chain = vec![top];
|
||||||
|
let mut cur = top;
|
||||||
|
while let Some(r) = self
|
||||||
|
.posted_entries()
|
||||||
|
.find(|e| e.reversal_of() == Some(cur))
|
||||||
|
.map(JournalEntry::id)
|
||||||
|
{
|
||||||
|
if chain.contains(&r) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chain.push(r);
|
||||||
|
cur = r;
|
||||||
|
}
|
||||||
|
chain
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag an entry with a vendor and that vendor's party (bills, bill
|
/// Tag an entry with a vendor and that vendor's party (bills, bill
|
||||||
@@ -2445,14 +2588,16 @@ impl Ledger {
|
|||||||
fn tag_vendor(&mut self, entry: EntryId, vendor: VendorId) {
|
fn tag_vendor(&mut self, entry: EntryId, vendor: VendorId) {
|
||||||
self.entry_vendors.insert(entry, vendor);
|
self.entry_vendors.insert(entry, vendor);
|
||||||
if let Some(p) = self.party_of_vendor(vendor) {
|
if let Some(p) = self.party_of_vendor(vendor) {
|
||||||
self.entry_parties.insert(entry, p);
|
self.entry_parties
|
||||||
|
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag an entry with a customer's party (invoices, invoice payments).
|
/// Tag an entry with a customer's party (invoices, invoice payments).
|
||||||
fn tag_customer(&mut self, entry: EntryId, customer: CustomerId) {
|
fn tag_customer(&mut self, entry: EntryId, customer: CustomerId) {
|
||||||
if let Some(p) = self.party_of_customer(customer) {
|
if let Some(p) = self.party_of_customer(customer) {
|
||||||
self.entry_parties.insert(entry, p);
|
self.entry_parties
|
||||||
|
.insert(entry, vec![crate::PartyShare::whole(p)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2462,14 +2607,7 @@ impl Ledger {
|
|||||||
/// ... when a live party already has the name). Deterministic, so replay
|
/// ... when a live party already has the name). Deterministic, so replay
|
||||||
/// rebuilds the same directory from the same records.
|
/// rebuilds the same directory from the same records.
|
||||||
fn link_record_party(&mut self, name: &str, vendor: bool, record: u64) {
|
fn link_record_party(&mut self, name: &str, vendor: bool, record: u64) {
|
||||||
let key = crate::parties::name_key(name);
|
let id = match self.record_party_match(name, vendor) {
|
||||||
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,
|
Some(id) => id,
|
||||||
None => {
|
None => {
|
||||||
let id = self.next_party_id;
|
let id = self.next_party_id;
|
||||||
@@ -2502,6 +2640,7 @@ impl Ledger {
|
|||||||
archived: false,
|
archived: false,
|
||||||
w9: None,
|
w9: None,
|
||||||
w4: None,
|
w4: None,
|
||||||
|
tin: None,
|
||||||
vendor: None,
|
vendor: None,
|
||||||
customer: None,
|
customer: None,
|
||||||
},
|
},
|
||||||
@@ -2517,6 +2656,18 @@ impl Ledger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The existing party a new vendor (or customer) record named `name`
|
||||||
|
/// links to ([`Self::link_record_party`]): the party not archived with
|
||||||
|
/// that name and no such record yet.
|
||||||
|
fn record_party_match(&self, name: &str, vendor: bool) -> Option<crate::PartyId> {
|
||||||
|
let key = crate::parties::name_key(name);
|
||||||
|
let slot = |p: &crate::Party| if vendor { p.vendor } else { p.customer };
|
||||||
|
self.parties
|
||||||
|
.values()
|
||||||
|
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key && slot(p).is_none())
|
||||||
|
.map(|p| p.id)
|
||||||
|
}
|
||||||
|
|
||||||
/// The party a vendor record belongs to.
|
/// The party a vendor record belongs to.
|
||||||
pub fn party_of_vendor(&self, vendor: VendorId) -> Option<crate::PartyId> {
|
pub fn party_of_vendor(&self, vendor: VendorId) -> Option<crate::PartyId> {
|
||||||
self.parties
|
self.parties
|
||||||
@@ -2552,9 +2703,28 @@ impl Ledger {
|
|||||||
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key)
|
.find(|p| !p.archived && crate::parties::name_key(&p.name) == key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The party an entry is with, if tagged.
|
/// The sole party an entry is with: Some only when it names exactly
|
||||||
|
/// one ([`Self::entry_parties`] lists them all).
|
||||||
pub fn entry_party(&self, entry: EntryId) -> Option<crate::PartyId> {
|
pub fn entry_party(&self, entry: EntryId) -> Option<crate::PartyId> {
|
||||||
self.entry_parties.get(&entry).copied()
|
match self.entry_parties.get(&entry).map(Vec::as_slice) {
|
||||||
|
Some([one]) => Some(one.party),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every party an entry is with, each with their share, in the order
|
||||||
|
/// named (empty when it names none). Resolve the shares to amounts with
|
||||||
|
/// [`crate::shares::resolve`].
|
||||||
|
pub fn entry_parties(&self, entry: EntryId) -> &[crate::PartyShare] {
|
||||||
|
self.entry_parties
|
||||||
|
.get(&entry)
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an entry names `party`, alone or among others.
|
||||||
|
pub fn entry_names_party(&self, entry: EntryId, party: crate::PartyId) -> bool {
|
||||||
|
self.entry_parties(entry).iter().any(|s| s.party == party)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The next party id the directory will assign.
|
/// The next party id the directory will assign.
|
||||||
@@ -2595,6 +2765,7 @@ impl Ledger {
|
|||||||
archived: false,
|
archived: false,
|
||||||
w9: None,
|
w9: None,
|
||||||
w4: None,
|
w4: None,
|
||||||
|
tin: None,
|
||||||
vendor: None,
|
vendor: None,
|
||||||
customer: None,
|
customer: None,
|
||||||
},
|
},
|
||||||
@@ -2798,6 +2969,57 @@ impl Ledger {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
f.check().map_err(LedgerError::InvalidParty)?;
|
f.check().map_err(LedgerError::InvalidParty)?;
|
||||||
|
if let Some(t) = &p.tin
|
||||||
|
&& t.last4 != f.tin_last4
|
||||||
|
{
|
||||||
|
return Err(LedgerError::InvalidParty(format!(
|
||||||
|
"the party's TIN on file ends in {}, the W-9 facts say {}: they must agree (record the W-9's TIN with it)",
|
||||||
|
t.last4, f.tin_last4
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record (or clear) a party's taxpayer-ID facts: the TIN's type, last
|
||||||
|
/// four, and vault record, never the number.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// SOURCE: crate::parties (INVARIANTS 1, 2, 2a); DECISION (Stephen,
|
||||||
|
/// 2026-10-15): every member's and payee's TIN in the vault.
|
||||||
|
/// SCENARIOS:
|
||||||
|
/// 1. Any role may have them (members need theirs for K-1s).
|
||||||
|
/// 2. IF the facts' form is wrong (TinFacts::check): SHOULD refuse.
|
||||||
|
/// 3. IF the party's W-9 facts name another last four: SHOULD refuse
|
||||||
|
/// (the two never disagree).
|
||||||
|
/// ```
|
||||||
|
pub fn set_party_tin(
|
||||||
|
&mut self,
|
||||||
|
id: crate::PartyId,
|
||||||
|
facts: Option<crate::TinFacts>,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
self.check_set_party_tin(id, facts.as_ref())?;
|
||||||
|
self.parties.get_mut(&id).expect("checked").tin = facts;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validation behind [`Self::set_party_tin`].
|
||||||
|
pub fn check_set_party_tin(
|
||||||
|
&self,
|
||||||
|
id: crate::PartyId,
|
||||||
|
facts: Option<&crate::TinFacts>,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?;
|
||||||
|
if let Some(f) = facts {
|
||||||
|
f.check().map_err(LedgerError::InvalidParty)?;
|
||||||
|
if let Some(w) = &p.w9
|
||||||
|
&& w.tin_last4 != f.last4
|
||||||
|
{
|
||||||
|
return Err(LedgerError::InvalidParty(format!(
|
||||||
|
"the party's W-9 facts say the TIN ends in {}, not {}: a new TIN for a W-9 payee comes with its W-9 (books.set_w9)",
|
||||||
|
w.tin_last4, f.last4
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2831,44 +3053,121 @@ impl Ledger {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag an entry with the party it was with, or clear the tag. A party
|
/// Tag an entry with the party it was with, or clear the tag: one
|
||||||
/// with a vendor record also tags the entry's vendor (for 1099s), as
|
/// party with all of it ([`Self::set_entry_parties`] with one derived
|
||||||
/// [`Self::set_entry_vendor`] does the other way round. Metadata: no
|
/// share). A party with a vendor record also tags the entry's vendor
|
||||||
/// amount changes, so posted entries may be tagged.
|
/// (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(
|
pub fn set_entry_party(
|
||||||
&mut self,
|
&mut self,
|
||||||
entry: EntryId,
|
entry: EntryId,
|
||||||
party: Option<crate::PartyId>,
|
party: Option<crate::PartyId>,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
if !self.entries.contains_key(&entry) {
|
let shares: Vec<crate::PartyShare> =
|
||||||
return Err(LedgerError::UnknownEntry(entry));
|
party.into_iter().map(crate::PartyShare::whole).collect();
|
||||||
|
self.set_entry_parties(entry, &shares)
|
||||||
}
|
}
|
||||||
match party {
|
|
||||||
Some(p) => {
|
/// Name the parties an entry was with, each with their share, or
|
||||||
let vendor = self
|
/// clear them (an empty list). Metadata: no amount changes, so posted
|
||||||
.parties
|
/// entries may be tagged.
|
||||||
.get(&p)
|
///
|
||||||
.ok_or(LedgerError::UnknownParty(p))?
|
/// ```text
|
||||||
.vendor;
|
/// GOAL: one entry with several counterparties (both members, two
|
||||||
self.entry_parties.insert(entry, p);
|
/// contractors), apportioned exactly.
|
||||||
self.entry_no_party.remove(&entry);
|
/// SOURCE: crate::shares (INVARIANTS 1–5); DECISION (Stephen,
|
||||||
|
/// 2026-10-15).
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. The shares are checked as crate::shares::check_shares says, and
|
||||||
|
/// at most one party has a vendor record; refused otherwise,
|
||||||
|
/// changing nothing. Whether they resolve against the entry's lines
|
||||||
|
/// depends on settings (cash accounts, members) core doesn't hold:
|
||||||
|
/// callers check crate::shares::resolve before journaling.
|
||||||
|
/// 2. The entry's vendor tag follows the one party with a vendor
|
||||||
|
/// record; with none, it is cleared.
|
||||||
|
/// 3. Naming any party clears a no-party mark.
|
||||||
|
/// 4. entry_party(entry) is Some only for exactly one party.
|
||||||
|
/// 5. An entry and its reversal (and theirs: Self::reversal_chain)
|
||||||
|
/// name the same parties, shares, and vendor tag: tagging any one
|
||||||
|
/// of them tags the whole chain, so each party's part of an entry
|
||||||
|
/// and its reversal still nets to zero (crate::shares INVARIANT 6;
|
||||||
|
/// form1099 SCENARIO 6), however it is re-apportioned later.
|
||||||
|
/// SCENARIOS:
|
||||||
|
/// 1. IF the entry or a party is unknown, a party is named twice, or
|
||||||
|
/// two parties have vendor records: SHOULD refuse.
|
||||||
|
/// 2. Both members, no shares: two derived shares.
|
||||||
|
/// 3. A reversed $1,000.00 payment named 600/400, then re-apportioned
|
||||||
|
/// 700/300: the reversal is 700/300 too, and the pair nets to 0.00
|
||||||
|
/// for each party.
|
||||||
|
/// ```
|
||||||
|
pub fn set_entry_parties(
|
||||||
|
&mut self,
|
||||||
|
entry: EntryId,
|
||||||
|
shares: &[crate::PartyShare],
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
let vendor = self.check_set_entry_parties(entry, shares)?;
|
||||||
|
for e in self.reversal_chain(entry) {
|
||||||
|
if shares.is_empty() {
|
||||||
|
self.entry_parties.remove(&e);
|
||||||
|
self.entry_vendors.remove(&e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.entry_parties.insert(e, shares.to_vec());
|
||||||
|
self.entry_no_party.remove(&e);
|
||||||
match vendor {
|
match vendor {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
self.entry_vendors.insert(entry, v);
|
self.entry_vendors.insert(e, v);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
self.entry_vendors.remove(&entry);
|
self.entry_vendors.remove(&e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
self.entry_parties.remove(&entry);
|
|
||||||
self.entry_vendors.remove(&entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The validation behind [`Self::set_entry_parties`]: returns the
|
||||||
|
/// vendor record the entry's vendor tag follows, if any.
|
||||||
|
pub fn check_set_entry_parties(
|
||||||
|
&self,
|
||||||
|
entry: EntryId,
|
||||||
|
shares: &[crate::PartyShare],
|
||||||
|
) -> Result<Option<VendorId>, LedgerError> {
|
||||||
|
if !self.entries.contains_key(&entry) {
|
||||||
|
return Err(LedgerError::UnknownEntry(entry));
|
||||||
|
}
|
||||||
|
self.check_party_shares(shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The checks [`Self::check_set_entry_parties`] makes of the shares
|
||||||
|
/// themselves, for an entry not yet created: returns the vendor record
|
||||||
|
/// the vendor tag would follow, if any.
|
||||||
|
pub fn check_party_shares(
|
||||||
|
&self,
|
||||||
|
shares: &[crate::PartyShare],
|
||||||
|
) -> Result<Option<VendorId>, LedgerError> {
|
||||||
|
if let Some(s) = shares.iter().find(|s| !self.parties.contains_key(&s.party)) {
|
||||||
|
return Err(LedgerError::UnknownParty(s.party));
|
||||||
|
}
|
||||||
|
crate::shares::check_shares(shares, |p| self.parties.contains_key(&p))
|
||||||
|
.map_err(LedgerError::InvalidParty)?;
|
||||||
|
let vendors: Vec<(crate::PartyId, VendorId)> = shares
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| self.parties[&s.party].vendor.map(|v| (s.party, v)))
|
||||||
|
.collect();
|
||||||
|
if let [_, _, ..] = vendors.as_slice() {
|
||||||
|
return Err(LedgerError::InvalidParty(format!(
|
||||||
|
"parties {} have vendor records; a 1099 counts a payment toward one vendor, so record each vendor's part as its own entry",
|
||||||
|
vendors
|
||||||
|
.iter()
|
||||||
|
.map(|(p, _)| p.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(vendors.first().map(|(_, v)| *v))
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark an entry as having no counterparty, with why (a transfer
|
/// Mark an entry as having no counterparty, with why (a transfer
|
||||||
/// between the company's own accounts, an accrual, a correction), so a
|
/// between the company's own accounts, an accrual, a correction), so a
|
||||||
/// books policy that requires parties can tell "none" from "not yet".
|
/// books policy that requires parties can tell "none" from "not yet".
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub mod classes;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod form1099;
|
pub mod form1099;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
|
pub mod k1;
|
||||||
pub mod kernel;
|
pub mod kernel;
|
||||||
pub mod ledger;
|
pub mod ledger;
|
||||||
pub mod money;
|
pub mod money;
|
||||||
@@ -36,6 +37,7 @@ mod proofs;
|
|||||||
pub mod reports;
|
pub mod reports;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod schedules;
|
pub mod schedules;
|
||||||
|
pub mod shares;
|
||||||
pub mod tax;
|
pub mod tax;
|
||||||
|
|
||||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||||
@@ -52,7 +54,7 @@ pub use classes::{Class, ClassId};
|
|||||||
pub use error::LedgerError;
|
pub use error::LedgerError;
|
||||||
pub use form1099::{
|
pub use form1099::{
|
||||||
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
|
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
|
||||||
vendor_1099_report_with,
|
vendor_1099_report_with, vendor_1099_report_with_members,
|
||||||
};
|
};
|
||||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||||
pub use ledger::{
|
pub use ledger::{
|
||||||
@@ -60,7 +62,7 @@ pub use ledger::{
|
|||||||
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
|
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
|
||||||
};
|
};
|
||||||
pub use money::{Currency, Money};
|
pub use money::{Currency, Money};
|
||||||
pub use parties::{Party, PartyField, PartyId, Role, W4Facts, W9Facts};
|
pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts};
|
||||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||||
pub use periods::{
|
pub use periods::{
|
||||||
@@ -78,4 +80,5 @@ pub use schedules::{
|
|||||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||||
ScheduleId, ScheduleKind, TemplateLine,
|
ScheduleId, ScheduleKind, TemplateLine,
|
||||||
};
|
};
|
||||||
|
pub use shares::{PartyMember, PartyShare, Share};
|
||||||
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
|
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
|
||||||
@@ -16,7 +16,11 @@
|
|||||||
//! run of nine digits (single spaces or hyphens between them allowed)
|
//! 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.
|
//! 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
|
//! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need
|
||||||
//! the employee role.
|
//! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type,
|
||||||
|
//! last four, and vault record) are allowed for any role: members
|
||||||
|
//! need theirs for K-1s.
|
||||||
|
//! 2a. A party's TIN facts and W-9 facts never disagree: both on file
|
||||||
|
//! means the same last four (DECISION, Stephen, 2026-10-15).
|
||||||
//! 3. Names are unique, ignoring case and surrounding space, among parties
|
//! 3. Names are unique, ignoring case and surrounding space, among parties
|
||||||
//! not archived.
|
//! not archived.
|
||||||
//! 4. Every vendor and customer record links to exactly one party.
|
//! 4. Every vendor and customer record links to exactly one party.
|
||||||
@@ -116,6 +120,75 @@ pub struct W9Facts {
|
|||||||
pub vault_record: Option<String>,
|
pub vault_record: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A taxpayer identification number'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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TinType {
|
||||||
|
/// Its wire name: ssn, ein, or itin.
|
||||||
|
pub fn key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TinType::Ssn => "ssn",
|
||||||
|
TinType::Ein => "ein",
|
||||||
|
TinType::Itin => "itin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a form names it: SSN, EIN, or ITIN.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TinType::Ssn => "SSN",
|
||||||
|
TinType::Ein => "EIN",
|
||||||
|
TinType::Itin => "ITIN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the books know of a party's taxpayer identification number,
|
||||||
|
/// without the number: its type, its last four digits, and the hash of
|
||||||
|
/// the TIN vault record that holds it in full. Any role may have one
|
||||||
|
/// (a member's SSN for their K-1, a contractor's from their W-9).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TinFacts {
|
||||||
|
/// SSN, EIN, or ITIN.
|
||||||
|
pub tin_type: TinType,
|
||||||
|
/// The last four digits (exactly four digits).
|
||||||
|
pub last4: String,
|
||||||
|
/// SHA-256 of the vault record holding the full TIN; `None` once the
|
||||||
|
/// record is purged (or when the vault never held it).
|
||||||
|
#[serde(default)]
|
||||||
|
pub vault_record: Option<String>,
|
||||||
|
/// When and why the vault's copy was purged, if it was.
|
||||||
|
#[serde(default)]
|
||||||
|
pub purged: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TinFacts {
|
||||||
|
/// Check the facts' form. Returns why not.
|
||||||
|
pub fn check(&self) -> Result<(), String> {
|
||||||
|
if self.last4.len() != 4 || !self.last4.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return Err("a TIN's last four is exactly four digits".into());
|
||||||
|
}
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
if let Some(t) = &self.purged {
|
||||||
|
check_text("purged", t)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a W-4 said, without the SSN.
|
/// What a W-4 said, without the SSN.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct W4Facts {
|
pub struct W4Facts {
|
||||||
@@ -157,6 +230,10 @@ pub struct Party {
|
|||||||
/// W-4 facts (employee).
|
/// W-4 facts (employee).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub w4: Option<W4Facts>,
|
pub w4: Option<W4Facts>,
|
||||||
|
/// Taxpayer-ID facts (any role): the TIN's type and last four, and
|
||||||
|
/// its vault record. The full number is only ever in the vault.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tin: Option<TinFacts>,
|
||||||
/// Its vendor record, if it bills the company.
|
/// Its vendor record, if it bills the company.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vendor: Option<crate::VendorId>,
|
pub vendor: Option<crate::VendorId>,
|
||||||
@@ -190,35 +267,94 @@ pub fn name_key(name: &str) -> String {
|
|||||||
name.trim().to_lowercase()
|
name.trim().to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `text` holds a run of nine digits, with at most one space or
|
/// The characters that may sit between a TIN's digits: space, hyphen,
|
||||||
/// hyphen between neighbors (an SSN, EIN, or ITIN however it's written).
|
/// dot, slash, underscore, and the Unicode dashes and no-break space
|
||||||
/// Any Unicode numeral counts as a digit (full-width, Arabic-Indic, ...),
|
/// (`571-93-2846`, `571.93.2846`, `571/93/2846`, `571 - 93 - 2846`).
|
||||||
/// so a number can't slip through in another script.
|
fn tin_separator(c: char) -> bool {
|
||||||
pub fn has_full_tin(text: &str) -> bool {
|
matches!(
|
||||||
let chars: Vec<char> = text.chars().collect();
|
c,
|
||||||
|
' ' | '-'
|
||||||
|
| '.'
|
||||||
|
| '/'
|
||||||
|
| '_'
|
||||||
|
| '\u{a0}'
|
||||||
|
| '\u{2010}'
|
||||||
|
| '\u{2011}'
|
||||||
|
| '\u{2012}'
|
||||||
|
| '\u{2013}'
|
||||||
|
| '\u{2014}'
|
||||||
|
| '\u{2212}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most separators that may sit between two of a TIN's digits
|
||||||
|
/// (`" - "` is three).
|
||||||
|
const MAX_TIN_GAP: usize = 3;
|
||||||
|
|
||||||
|
/// Each run in `chars` that holds nine digits or more, as (first, last)
|
||||||
|
/// char indices of its digits: digits with at most [`MAX_TIN_GAP`]
|
||||||
|
/// separators ([`tin_separator`]) between neighbors.
|
||||||
|
fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
|
||||||
|
let mut runs = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < chars.len() {
|
while i < chars.len() {
|
||||||
if chars[i].is_numeric() {
|
if !chars[i].is_numeric() {
|
||||||
let (mut digits, mut j) = (1, i + 1);
|
i += 1;
|
||||||
while j < chars.len() && digits < 9 {
|
continue;
|
||||||
|
}
|
||||||
|
let (mut digits, mut last, mut j) = (1, i, i + 1);
|
||||||
|
while j < chars.len() {
|
||||||
if chars[j].is_numeric() {
|
if chars[j].is_numeric() {
|
||||||
digits += 1;
|
digits += 1;
|
||||||
|
last = j;
|
||||||
j += 1;
|
j += 1;
|
||||||
} else if matches!(chars[j], ' ' | '-')
|
continue;
|
||||||
&& chars.get(j + 1).is_some_and(|c| c.is_numeric())
|
}
|
||||||
|
let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count();
|
||||||
|
if (1..=MAX_TIN_GAP).contains(&gap)
|
||||||
|
&& chars.get(j + gap).is_some_and(|c| c.is_numeric())
|
||||||
{
|
{
|
||||||
j += 1;
|
j += gap;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if digits >= 9 {
|
if digits >= 9 {
|
||||||
return true;
|
runs.push((i, last));
|
||||||
|
}
|
||||||
|
i = last + 1;
|
||||||
|
}
|
||||||
|
runs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `text` holds a run of nine digits, with at most three
|
||||||
|
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
|
||||||
|
/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's
|
||||||
|
/// written (`571-93-2846`, `571.93.2846`, `571 - 93 - 2846`). 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();
|
||||||
|
!full_tin_runs(&chars).is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` with every run [`has_full_tin`] finds masked: each of its
|
||||||
|
/// digits becomes `•`, separators kept. For refusals that quote what they
|
||||||
|
/// were given, so no error echoes a full TIN (docs/spec/rules.md B-26).
|
||||||
|
pub fn redact_full_tins(text: &str) -> std::borrow::Cow<'_, str> {
|
||||||
|
let mut chars: Vec<char> = text.chars().collect();
|
||||||
|
let runs = full_tin_runs(&chars);
|
||||||
|
if runs.is_empty() {
|
||||||
|
return std::borrow::Cow::Borrowed(text);
|
||||||
|
}
|
||||||
|
for (first, last) in runs {
|
||||||
|
for c in &mut chars[first..=last] {
|
||||||
|
if c.is_numeric() {
|
||||||
|
*c = '•';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
i += 1;
|
|
||||||
}
|
}
|
||||||
false
|
std::borrow::Cow::Owned(chars.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check one party text field: not a full TIN, at most 500 characters, no
|
/// Check one party text field: not a full TIN, at most 500 characters, no
|
||||||
@@ -312,6 +448,10 @@ pub struct Activity {
|
|||||||
pub paid: rust_decimal::Decimal,
|
pub paid: rust_decimal::Decimal,
|
||||||
/// Received from it in the span (net of reversals of receipts).
|
/// Received from it in the span (net of reversals of receipts).
|
||||||
pub received: rust_decimal::Decimal,
|
pub received: rust_decimal::Decimal,
|
||||||
|
/// Posted entries in the span naming it among several parties whose
|
||||||
|
/// shares don't resolve against the entry and the members given
|
||||||
|
/// ([`crate::shares::resolve`]): counted in neither total.
|
||||||
|
pub unresolved: BTreeSet<crate::EntryId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The accounts [`entry_flow`] reads money from: the cash accounts (every
|
/// The accounts [`entry_flow`] reads money from: the cash accounts (every
|
||||||
@@ -457,37 +597,59 @@ pub fn entry_flow(entry: &crate::JournalEntry, money: &MoneyAccounts) -> EntryFl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One party's entries, and the money paid to and received from it in
|
/// One party's entries, and the money paid to and received from it in
|
||||||
/// `start..=end`.
|
/// `start..=end`, with no members ([`activity_with`] with none: an entry
|
||||||
|
/// naming several parties then needs explicit shares to count).
|
||||||
|
pub fn activity(
|
||||||
|
ledger: &crate::Ledger,
|
||||||
|
party: PartyId,
|
||||||
|
span: (NaiveDate, NaiveDate),
|
||||||
|
cash: &[crate::AccountId],
|
||||||
|
) -> Activity {
|
||||||
|
activity_with(ledger, party, span, cash, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One party's entries, and its share of the money paid to and received
|
||||||
|
/// from the entries' parties in `start..=end`.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: one answer to "what did we pay them this year?" for the MCP
|
/// GOAL: one answer to "what did we pay them this year?" for the MCP
|
||||||
/// server and the viewer alike.
|
/// server and the viewer alike, right when an entry was with
|
||||||
/// SOURCE: docs/parties-and-attachments.md (Parties); DECISION (Stephen,
|
/// several parties.
|
||||||
/// 2026-10-15).
|
/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION
|
||||||
|
/// (Stephen, 2026-10-15).
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. entries: every entry Ledger::entry_party names the party
|
/// 1. entries: every entry Ledger::entry_parties names the party in
|
||||||
/// (hand tags, vendor tags, bills and their payments, invoices and
|
/// (hand tags, vendor tags, bills and their payments, invoices and
|
||||||
/// their payments, and reversals of any of these carry it).
|
/// their payments, and reversals of any of these carry it).
|
||||||
/// 2. Each posted entry dated in the span counts its entry_flow net:
|
/// 2. Each posted entry dated in the span whose entry_flow net is not
|
||||||
/// positive is a payment, negative a receipt. A reversal counts on
|
/// zero counts the party's resolved share of it
|
||||||
/// its original's side, so it subtracts.
|
/// (crate::shares::resolve with `members`; the whole net for a sole
|
||||||
/// 3. A card charge counts as paid (the company paid them, by card); a
|
/// party): positive is a payment, negative a receipt. A reversal
|
||||||
|
/// counts on its original's side, so it subtracts.
|
||||||
|
/// 3. An entry moving no money (net zero, like property contributed)
|
||||||
|
/// counts nothing. An entry whose shares don't resolve counts
|
||||||
|
/// nothing and is listed in `unresolved`.
|
||||||
|
/// 4. Over all the parties an entry names, the shares counted sum to its
|
||||||
|
/// net exactly (crate::shares INVARIANT 6).
|
||||||
|
/// 5. 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
|
/// bill doesn't until it is paid. Not the 1099 amount: that is
|
||||||
/// form1099::vendor_1099_report_with.
|
/// form1099::vendor_1099_report_with.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn activity(
|
pub fn activity_with(
|
||||||
ledger: &crate::Ledger,
|
ledger: &crate::Ledger,
|
||||||
party: PartyId,
|
party: PartyId,
|
||||||
(start, end): (NaiveDate, NaiveDate),
|
(start, end): (NaiveDate, NaiveDate),
|
||||||
cash: &[crate::AccountId],
|
cash: &[crate::AccountId],
|
||||||
|
members: &[crate::PartyMember],
|
||||||
) -> Activity {
|
) -> Activity {
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
|
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
|
||||||
.filter(|&id| ledger.entry_party(id) == Some(party))
|
.filter(|&id| ledger.entry_names_party(id, party))
|
||||||
.collect();
|
.collect();
|
||||||
let money = money_accounts(ledger, cash);
|
let money = money_accounts(ledger, cash);
|
||||||
let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).net;
|
let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).net;
|
||||||
let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO);
|
let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO);
|
||||||
|
let mut unresolved = BTreeSet::new();
|
||||||
for &id in &entries {
|
for &id in &entries {
|
||||||
let Some(e) = ledger.get_entry(id) else {
|
let Some(e) = ledger.get_entry(id) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -496,19 +658,34 @@ pub fn activity(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let net = net_out(e);
|
let net = net_out(e);
|
||||||
|
if net.is_zero() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let share = match crate::shares::resolve(e, ledger.entry_parties(id), &money, members) {
|
||||||
|
Ok(parts) => parts
|
||||||
|
.into_iter()
|
||||||
|
.find(|(p, _)| *p == party)
|
||||||
|
.map(|(_, v)| v)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(_) => {
|
||||||
|
unresolved.insert(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) {
|
let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) {
|
||||||
Some(original) => net_out(original),
|
Some(original) => net_out(original),
|
||||||
None => net,
|
None => net,
|
||||||
};
|
};
|
||||||
if side > Decimal::ZERO {
|
if side > Decimal::ZERO {
|
||||||
paid += net;
|
paid += share;
|
||||||
} else {
|
} else {
|
||||||
received -= net;
|
received -= share;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Activity {
|
Activity {
|
||||||
entries,
|
entries,
|
||||||
paid,
|
paid,
|
||||||
received,
|
received,
|
||||||
|
unresolved,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,6 +809,33 @@ pub struct MembersEquityStatement {
|
|||||||
pub total: MemberEquityColumn,
|
pub total: MemberEquityColumn,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The posted entries in `[start, end]` that cancel: each reversal dated
|
||||||
|
/// in the span with the original it reverses, when that is dated in the
|
||||||
|
/// span too. Paired in journal order, an entry at most once, so in a chain
|
||||||
|
/// (an entry, its reversal, the reversal's reversal) the first two cancel
|
||||||
|
/// and the third, which reinstates the original, counts.
|
||||||
|
pub fn cancelled_in_span(
|
||||||
|
ledger: &Ledger,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
) -> BTreeSet<crate::EntryId> {
|
||||||
|
let in_span = |d: NaiveDate| d >= start && d <= end;
|
||||||
|
let mut out = BTreeSet::new();
|
||||||
|
for e in ledger.posted_entries().filter(|e| in_span(e.date())) {
|
||||||
|
if let Some(o) = e.reversal_of()
|
||||||
|
&& ledger
|
||||||
|
.get_entry(o)
|
||||||
|
.is_some_and(|x| x.status() == crate::EntryStatus::Posted && in_span(x.date()))
|
||||||
|
&& !out.contains(&o)
|
||||||
|
&& !out.contains(&e.id())
|
||||||
|
{
|
||||||
|
out.insert(o);
|
||||||
|
out.insert(e.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the statement of changes in members' equity for `[start, end]`.
|
/// Build the statement of changes in members' equity for `[start, end]`.
|
||||||
///
|
///
|
||||||
/// Each member's capital account supplies contributions (credits) and
|
/// Each member's capital account supplies contributions (credits) and
|
||||||
@@ -843,8 +870,16 @@ pub struct MembersEquityStatement {
|
|||||||
/// already moved into capital were rounded by that close, B-13.)
|
/// already moved into capital were rounded by that close, B-13.)
|
||||||
/// 4. NEVER hide an unexplained change: it goes in "other".
|
/// 4. NEVER hide an unexplained change: it goes in "other".
|
||||||
/// 5. An entry dated `start` is period activity, not beginning capital.
|
/// 5. An entry dated `start` is period activity, not beginning capital.
|
||||||
|
/// 6. An entry and its reversal both dated in the span cancel
|
||||||
|
/// ([`cancelled_in_span`]): neither is a contribution or a
|
||||||
|
/// distribution, since the correction undoes the original (B-9: "it
|
||||||
|
/// is just as if you had written nothing"). A reversal whose original
|
||||||
|
/// is outside the span counts by side, like any entry.
|
||||||
/// SCENARIOS:
|
/// SCENARIOS:
|
||||||
/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse.
|
/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse.
|
||||||
|
/// 2. A joint 1,000.00 contribution (500.00 each) reversed the next day:
|
||||||
|
/// contributions 0.00 and distributions 0.00 for each member, not
|
||||||
|
/// 500.00 of each.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn members_equity(
|
pub fn members_equity(
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
@@ -901,12 +936,15 @@ pub fn members_equity(
|
|||||||
// Contributions and distributions move a capital account; a closing
|
// Contributions and distributions move a capital account; a closing
|
||||||
// entry allocating earnings into it is income, already counted below.
|
// entry allocating earnings into it is income, already counted below.
|
||||||
let closings = ledger.closing_entry_ids();
|
let closings = ledger.closing_entry_ids();
|
||||||
|
let cancelled = cancelled_in_span(ledger, start, end);
|
||||||
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
|
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
|
||||||
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
|
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
|
||||||
for e in ledger
|
for e in ledger.posted_entries().filter(|e| {
|
||||||
.posted_entries()
|
e.date() >= start
|
||||||
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
|
&& e.date() <= end
|
||||||
{
|
&& !closings.contains(&e.id())
|
||||||
|
&& !cancelled.contains(&e.id())
|
||||||
|
}) {
|
||||||
for l in e.lines().iter().filter(|l| l.account == id) {
|
for l in e.lines().iter().filter(|l| l.account == id) {
|
||||||
match l.side {
|
match l.side {
|
||||||
crate::Side::Credit => credits += l.amount.amount,
|
crate::Side::Credit => credits += l.amount.amount,
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
//! Party shares: an entry with more than one party, each with their part of
|
||||||
|
//! what it moved.
|
||||||
|
//!
|
||||||
|
//! An entry names its counterparties as a list of [`PartyShare`]s. Most
|
||||||
|
//! name one party, who had all of it. A joint transaction names several:
|
||||||
|
//! the two members of a partnership reimbursed together, a payment split
|
||||||
|
//! between two contractors, a contribution from both members' joint funds.
|
||||||
|
//! Each share is an exact amount, a percentage, or derived from the entry
|
||||||
|
//! itself ([`Share::Derived`]).
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GOAL: "what did we pay each of them?" right when one entry was with
|
||||||
|
//! several parties, with apportioning the human controls exactly.
|
||||||
|
//! SOURCE: DECISION (Stephen, 2026-10-15): no "joint" party; a joint
|
||||||
|
//! transaction is with both members, each with their share, and
|
||||||
|
//! shares can be apportioned explicitly ("do you allow for
|
||||||
|
//! controlled apportioning?" Yes). docs/parties-and-attachments.md
|
||||||
|
//! "Shares".
|
||||||
|
//! DEFINITIONS:
|
||||||
|
//! flow: crate::parties::entry_flow(entry, money).
|
||||||
|
//! target: flow.net when it is not zero, else the entry's total debits
|
||||||
|
//! (a member contributing property moves no money but is still all
|
||||||
|
//! theirs). magnitude = |target|; sign = the sign of target (positive
|
||||||
|
//! when net is zero).
|
||||||
|
//! A member: a PartyMember (party, income share, capital account,
|
||||||
|
//! distributions account), passed in by the caller from the company's
|
||||||
|
//! settings ([[company.members]]); core doesn't read the settings.
|
||||||
|
//! INVARIANTS (of an entry's shares, checked by check_shares, journaled):
|
||||||
|
//! 1. No party appears twice; every party is in the directory.
|
||||||
|
//! 2. All shares are one kind: all Amount, all Percent, or all Derived.
|
||||||
|
//! 3. An Amount is positive with at most two decimal places (cents).
|
||||||
|
//! 4. A Percent is positive, at most 100, and the percents sum to exactly
|
||||||
|
//! 100.
|
||||||
|
//! 5. At most one party has a vendor record: a 1099 counts a payment
|
||||||
|
//! toward a single vendor.
|
||||||
|
//! INVARIANTS (of resolve, against the entry's lines and the members):
|
||||||
|
//! 6. Resolved amounts sum to target exactly, and each has target's sign.
|
||||||
|
//! 7. Amount: each party gets sign × its amount; refused unless the
|
||||||
|
//! amounts sum to magnitude.
|
||||||
|
//! 8. Percent: each party gets magnitude × percent / 100 rounded toward
|
||||||
|
//! zero to the cent; the cents left over (fewer than one per party)
|
||||||
|
//! go to the first party in order. Then the sign.
|
||||||
|
//! 9. Derived, one party: it gets target.
|
||||||
|
//! 10. Derived, several parties: every one must be a member (else
|
||||||
|
//! refused: give amounts or percents). If the entry has a line on any
|
||||||
|
//! named member's capital or distributions account, each member gets
|
||||||
|
//! |credits − debits| on their own two accounts, and those must sum
|
||||||
|
//! to magnitude (else refused). Otherwise each gets their income share
|
||||||
|
//! of magnitude, the shares rescaled to the members named, rounded as
|
||||||
|
//! in 8 (remainder to the first).
|
||||||
|
//! SCENARIOS:
|
||||||
|
//! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named,
|
||||||
|
//! derived, 50/50: $50.00 each.
|
||||||
|
//! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100):
|
||||||
|
//! derived from the capital lines, Stephen −200.00, Katherine −100.00
|
||||||
|
//! (received).
|
||||||
|
//! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001
|
||||||
|
//! 500): net is zero; target is the total, 500.00 each.
|
||||||
|
//! 4. $100.00 split 1/3 each three ways by percent 33.33, 33.33, 33.34:
|
||||||
|
//! 33.33, 33.33, 33.34. By 33.333/33.333/33.334: 33.34, 33.33, 33.33
|
||||||
|
//! (the leftover cent to the first).
|
||||||
|
//! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused.
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::{AccountId, JournalEntry, PartyId, Side};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What part of an entry one party had.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Share {
|
||||||
|
/// Exactly this much of what the entry moved (a positive amount, in
|
||||||
|
/// cents; the sign follows the entry).
|
||||||
|
Amount(Decimal),
|
||||||
|
/// This percent of what the entry moved (0 < p ≤ 100).
|
||||||
|
Percent(Decimal),
|
||||||
|
/// From the entry itself: all of it for a sole party; for members,
|
||||||
|
/// their capital lines or their income share (see the module's
|
||||||
|
/// INVARIANT 10).
|
||||||
|
Derived,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One party named on an entry, with their share.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PartyShare {
|
||||||
|
/// The party.
|
||||||
|
#[serde(rename = "party_id")]
|
||||||
|
pub party: PartyId,
|
||||||
|
/// Their share.
|
||||||
|
pub share: Share,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartyShare {
|
||||||
|
/// The sole party, with all of it.
|
||||||
|
pub fn whole(party: PartyId) -> Self {
|
||||||
|
PartyShare {
|
||||||
|
party,
|
||||||
|
share: Share::Derived,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member of the company as shares see them: their party, their share of
|
||||||
|
/// income (a fraction, e.g. 0.50), and their capital and distributions
|
||||||
|
/// accounts. Built by the caller from the settings' members.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PartyMember {
|
||||||
|
/// The member's party in the directory.
|
||||||
|
pub party: PartyId,
|
||||||
|
/// Their share of income, as a fraction (the members' sum to 1).
|
||||||
|
pub share: Decimal,
|
||||||
|
/// Their capital account.
|
||||||
|
pub capital: AccountId,
|
||||||
|
/// Their distributions account, if they have one.
|
||||||
|
pub distributions: Option<AccountId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The structural checks on an entry's shares (the module's INVARIANTS 1–4;
|
||||||
|
/// 5 needs the directory and is [`crate::Ledger::check_set_entry_parties`]'s).
|
||||||
|
/// `known` says whether a party is in the directory. Returns why not.
|
||||||
|
pub fn check_shares(shares: &[PartyShare], known: impl Fn(PartyId) -> bool) -> Result<(), String> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for s in shares {
|
||||||
|
if !known(s.party) {
|
||||||
|
return Err(format!("unknown party {}", s.party));
|
||||||
|
}
|
||||||
|
if !seen.insert(s.party) {
|
||||||
|
return Err(format!("party {} is named twice", s.party));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind = |s: &Share| match s {
|
||||||
|
Share::Amount(_) => 0,
|
||||||
|
Share::Percent(_) => 1,
|
||||||
|
Share::Derived => 2,
|
||||||
|
};
|
||||||
|
if let Some(first) = shares.first()
|
||||||
|
&& shares.iter().any(|s| kind(&s.share) != kind(&first.share))
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"give every party the same kind of share: all amounts, all percents, or none (derived)"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut percent = Decimal::ZERO;
|
||||||
|
let mut any_percent = false;
|
||||||
|
for s in shares {
|
||||||
|
match s.share {
|
||||||
|
Share::Amount(a) => {
|
||||||
|
if a <= Decimal::ZERO {
|
||||||
|
return Err(format!("party {}'s amount must be positive", s.party));
|
||||||
|
}
|
||||||
|
if a.normalize().scale() > 2 {
|
||||||
|
return Err(format!(
|
||||||
|
"party {}'s amount {a} is finer than a cent",
|
||||||
|
s.party
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Share::Percent(p) => {
|
||||||
|
if p <= Decimal::ZERO || p > Decimal::ONE_HUNDRED {
|
||||||
|
return Err(format!(
|
||||||
|
"party {}'s percent must be more than 0 and at most 100",
|
||||||
|
s.party
|
||||||
|
));
|
||||||
|
}
|
||||||
|
any_percent = true;
|
||||||
|
percent += p;
|
||||||
|
}
|
||||||
|
Share::Derived => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any_percent && percent != Decimal::ONE_HUNDRED {
|
||||||
|
return Err(format!("the percents sum to {percent}, not 100"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the shares are split over: the entry's flow net, else its total
|
||||||
|
/// debits (the module's DEFINITIONS).
|
||||||
|
pub fn share_target(entry: &JournalEntry, money: &crate::parties::MoneyAccounts) -> Decimal {
|
||||||
|
let net = crate::parties::entry_flow(entry, money).net;
|
||||||
|
if net.is_zero() {
|
||||||
|
entry
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == Side::Debit)
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum()
|
||||||
|
} else {
|
||||||
|
net
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `magnitude` (non-negative) by `weights` (positive, any sum): each
|
||||||
|
/// part rounded toward zero to the cent, the cents left over to the first.
|
||||||
|
fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Vec<Decimal> {
|
||||||
|
use rust_decimal::RoundingStrategy::ToZero;
|
||||||
|
let total: Decimal = weights.iter().copied().sum();
|
||||||
|
if weights.is_empty() || total.is_zero() {
|
||||||
|
return vec![Decimal::ZERO; weights.len()];
|
||||||
|
}
|
||||||
|
let mut parts: Vec<Decimal> = weights
|
||||||
|
.iter()
|
||||||
|
.map(|w| (magnitude * w / total).round_dp_with_strategy(2, ToZero))
|
||||||
|
.collect();
|
||||||
|
let left = magnitude - parts.iter().copied().sum::<Decimal>();
|
||||||
|
parts[0] += left;
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each party's exact part of what `entry` moved (the module's INVARIANTS
|
||||||
|
/// 6–10), in the shares' order. Refused, with why, when the shares can't
|
||||||
|
/// resolve against these lines and members.
|
||||||
|
pub fn resolve(
|
||||||
|
entry: &JournalEntry,
|
||||||
|
shares: &[PartyShare],
|
||||||
|
money: &crate::parties::MoneyAccounts,
|
||||||
|
members: &[PartyMember],
|
||||||
|
) -> Result<Vec<(PartyId, Decimal)>, String> {
|
||||||
|
let target = share_target(entry, money);
|
||||||
|
let magnitude = target.abs();
|
||||||
|
let signed = |v: Decimal| if target.is_sign_negative() { -v } else { v };
|
||||||
|
let Some(first) = shares.first() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let parts: Vec<Decimal> = match first.share {
|
||||||
|
Share::Amount(_) => {
|
||||||
|
let amounts: Vec<Decimal> = shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| match s.share {
|
||||||
|
Share::Amount(a) => a,
|
||||||
|
_ => Decimal::ZERO,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sum: Decimal = amounts.iter().copied().sum();
|
||||||
|
if sum != magnitude {
|
||||||
|
return Err(format!(
|
||||||
|
"the parties' amounts sum to {sum}, but entry {} moved {magnitude}",
|
||||||
|
entry.id()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
amounts
|
||||||
|
}
|
||||||
|
Share::Percent(_) => {
|
||||||
|
let weights: Vec<Decimal> = shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| match s.share {
|
||||||
|
Share::Percent(p) => p,
|
||||||
|
_ => Decimal::ZERO,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
split_first(magnitude, &weights)
|
||||||
|
}
|
||||||
|
Share::Derived if shares.len() == 1 => vec![magnitude],
|
||||||
|
Share::Derived => {
|
||||||
|
let named: Vec<&PartyMember> = shares
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
members.iter().find(|m| m.party == s.party).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"party {} isn't a member, so its share can't be derived: give each party an amount or a percent",
|
||||||
|
s.party
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
let on = |m: &PartyMember, a: AccountId| a == m.capital || Some(a) == m.distributions;
|
||||||
|
let touches = entry
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.any(|l| named.iter().any(|m| on(m, l.account)));
|
||||||
|
if touches {
|
||||||
|
let parts: Vec<Decimal> = named
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
entry
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| on(m, l.account))
|
||||||
|
.map(|l| match l.side {
|
||||||
|
Side::Credit => l.amount.amount,
|
||||||
|
Side::Debit => -l.amount.amount,
|
||||||
|
})
|
||||||
|
.sum::<Decimal>()
|
||||||
|
.abs()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sum: Decimal = parts.iter().copied().sum();
|
||||||
|
if sum != magnitude {
|
||||||
|
return Err(format!(
|
||||||
|
"the members' capital lines on entry {} come to {sum}, but it moved {magnitude}: give each member an amount",
|
||||||
|
entry.id()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
parts
|
||||||
|
} else {
|
||||||
|
let weights: Vec<Decimal> = named.iter().map(|m| m.share).collect();
|
||||||
|
if weights.iter().any(|w| *w <= Decimal::ZERO) {
|
||||||
|
return Err(
|
||||||
|
"a member named has no income share: give each party an amount or a percent"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
split_first(magnitude, &weights)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(shares
|
||||||
|
.iter()
|
||||||
|
.zip(parts)
|
||||||
|
.map(|(s, v)| (s.party, signed(v)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -262,3 +262,397 @@ fn payments_come_from_cash_and_financing_only() {
|
|||||||
let plain = vendor_1099_report(&l, 2026, None).unwrap();
|
let plain = vendor_1099_report(&l, 2026, None).unwrap();
|
||||||
assert_eq!(plain.vendors[0].paid, dec("700"));
|
assert_eq!(plain.vendors[0].paid, dec("700"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Books for the shared-payment tests: bank 1000, card 2050, contract
|
||||||
|
/// labor 6000; Dana (1099-NEC) and member Stephen as parties.
|
||||||
|
struct Shared {
|
||||||
|
l: Ledger,
|
||||||
|
bank: ledger_core::AccountId,
|
||||||
|
card: ledger_core::AccountId,
|
||||||
|
labor: ledger_core::AccountId,
|
||||||
|
dana: ledger_core::VendorId,
|
||||||
|
dana_p: ledger_core::PartyId,
|
||||||
|
stephen: ledger_core::PartyId,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_books() -> Shared {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let bank = l
|
||||||
|
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let card = l
|
||||||
|
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let labor = l
|
||||||
|
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let dana = l.add_vendor("Dana").unwrap();
|
||||||
|
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
|
||||||
|
let dana_p = l.party_of_vendor(dana).unwrap();
|
||||||
|
let stephen = l
|
||||||
|
.add_party("Stephen", &[ledger_core::Role::Member])
|
||||||
|
.unwrap();
|
||||||
|
Shared {
|
||||||
|
l,
|
||||||
|
bank,
|
||||||
|
card,
|
||||||
|
labor,
|
||||||
|
dana,
|
||||||
|
dana_p,
|
||||||
|
stephen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post `lines` on `date` and name `shares` on it.
|
||||||
|
fn post_shared(
|
||||||
|
s: &mut Shared,
|
||||||
|
date: NaiveDate,
|
||||||
|
lines: &[(ledger_core::AccountId, Side, &str)],
|
||||||
|
shares: &[(ledger_core::PartyId, ledger_core::Share)],
|
||||||
|
) -> ledger_core::EntryId {
|
||||||
|
let e = s.l.create_entry(date, "shared payment");
|
||||||
|
for (a, side, amt) in lines {
|
||||||
|
s.l.add_line(e, *a, *side, Money::new(dec(amt), Currency::USD))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
s.l.post_entry(e).unwrap();
|
||||||
|
let shares: Vec<ledger_core::PartyShare> = shares
|
||||||
|
.iter()
|
||||||
|
.map(|&(party, share)| ledger_core::PartyShare { party, share })
|
||||||
|
.collect();
|
||||||
|
s.l.set_entry_parties(e, &shares).unwrap();
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dana_paid(s: &Shared) -> (Decimal, Vec<ledger_core::EntryId>) {
|
||||||
|
let r = vendor_1099_report(&s.l, 2026, None).unwrap();
|
||||||
|
let line = r.vendors.iter().find(|v| v.vendor_id == s.dana).unwrap();
|
||||||
|
(line.paid, r.unresolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vendor_1099_report_with_members SCENARIO 3, by hand: $1,000.00 from
|
||||||
|
/// the bank (Dr 6000 / Cr 1000), Dana $600.00 and Stephen $400.00 by
|
||||||
|
/// amount. Payment = target = 1,000.00, so Dana's 1099 counts her share
|
||||||
|
/// unchanged: 600.00 (not the whole 1,000.00).
|
||||||
|
#[test]
|
||||||
|
fn a_shared_payment_counts_the_vendors_share() {
|
||||||
|
use ledger_core::Share::Amount;
|
||||||
|
let mut s = shared_books();
|
||||||
|
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||||
|
let e = post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 3, 1),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "1000.00"),
|
||||||
|
(bank, Side::Credit, "1000.00"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(dana_p, Amount(dec("600.00"))),
|
||||||
|
(stephen, Amount(dec("400.00"))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.l.entry_vendor(e),
|
||||||
|
Some(s.dana),
|
||||||
|
"the vendor tag follows Dana"
|
||||||
|
);
|
||||||
|
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||||
|
// With sources and members named, the same.
|
||||||
|
let r = ledger_core::vendor_1099_report_with_members(
|
||||||
|
&s.l,
|
||||||
|
2026,
|
||||||
|
None,
|
||||||
|
&ledger_core::PaymentAccounts {
|
||||||
|
cash: vec![bank],
|
||||||
|
financing: vec![],
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(r.vendors[0].paid, dec("600.00"));
|
||||||
|
assert!(!r.vendors[0].reportable, "600.00 is under 2,000");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 4, by hand: $100.01 from the bank split 50% / 50%. Each part
|
||||||
|
/// is 100.01 × 50 / 100 = 50.005, rounded toward zero to 50.00; the cent
|
||||||
|
/// left over goes to the party named first (shares INVARIANT 8). Dana
|
||||||
|
/// first: 50.01. A second payment with Dana named second: 50.00. Total
|
||||||
|
/// 100.01.
|
||||||
|
#[test]
|
||||||
|
fn a_percent_split_gives_the_leftover_cent_to_the_first() {
|
||||||
|
use ledger_core::Share::Percent;
|
||||||
|
let mut s = shared_books();
|
||||||
|
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||||
|
let lines = [
|
||||||
|
(labor, Side::Debit, "100.01"),
|
||||||
|
(bank, Side::Credit, "100.01"),
|
||||||
|
];
|
||||||
|
post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 4, 1),
|
||||||
|
&lines,
|
||||||
|
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
|
||||||
|
);
|
||||||
|
assert_eq!(dana_paid(&s).0, dec("50.01"));
|
||||||
|
post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 4, 2),
|
||||||
|
&lines,
|
||||||
|
&[(stephen, Percent(dec("50"))), (dana_p, Percent(dec("50")))],
|
||||||
|
);
|
||||||
|
assert_eq!(dana_paid(&s).0, dec("100.01"), "50.01 + 50.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 6, by hand: SCENARIO 3's payment reversed the same year. The
|
||||||
|
/// reversal carries the shares; its target is −1,000.00, Dana's amount
|
||||||
|
/// resolves to −600.00, and the payment is −1,000.00 = target, so she
|
||||||
|
/// nets 600.00 − 600.00 = 0.00 (still listed: she is marked for a 1099).
|
||||||
|
#[test]
|
||||||
|
fn a_reversed_shared_payment_nets_to_zero() {
|
||||||
|
use ledger_core::Share::Amount;
|
||||||
|
let mut s = shared_books();
|
||||||
|
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||||
|
let e = post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 3, 1),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "1000.00"),
|
||||||
|
(bank, Side::Credit, "1000.00"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(dana_p, Amount(dec("600.00"))),
|
||||||
|
(stephen, Amount(dec("400.00"))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
|
||||||
|
assert_eq!(s.l.entry_parties(rev), s.l.entry_parties(e));
|
||||||
|
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 5, by hand: $1,000.00 of contract labor, 600.00 from the
|
||||||
|
/// bank and 400.00 on the card, Dana 60% / Stephen 40%. Target (the flow
|
||||||
|
/// net, the card counted as money) 1,000.00, Dana's share 600.00, the
|
||||||
|
/// 1099 payment 600.00 (the card is not a payment source): 600.00 ×
|
||||||
|
/// 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the bank and
|
||||||
|
/// 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 =
|
||||||
|
/// 16.665, rounded half away from zero to 16.67. Total 376.67.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_payment_partly_on_a_card_scales_to_the_payment() {
|
||||||
|
use ledger_core::Share::Percent;
|
||||||
|
let mut s = shared_books();
|
||||||
|
let (bank, card, labor, dana_p, stephen) = (s.bank, s.card, s.labor, s.dana_p, s.stephen);
|
||||||
|
post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 5, 1),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "1000.00"),
|
||||||
|
(bank, Side::Credit, "600.00"),
|
||||||
|
(card, Side::Credit, "400.00"),
|
||||||
|
],
|
||||||
|
&[(dana_p, Percent(dec("60"))), (stephen, Percent(dec("40")))],
|
||||||
|
);
|
||||||
|
assert_eq!(dana_paid(&s).0, dec("360.00"));
|
||||||
|
post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 5, 2),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "100.00"),
|
||||||
|
(bank, Side::Credit, "33.33"),
|
||||||
|
(card, Side::Credit, "66.67"),
|
||||||
|
],
|
||||||
|
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
|
||||||
|
);
|
||||||
|
assert_eq!(dana_paid(&s).0, dec("376.67"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANT 4, by hand: amounts of 600.00 and 300.00 on a 1,000.00
|
||||||
|
/// payment don't resolve (shares SCENARIO 5), so the whole 1,000.00
|
||||||
|
/// counts toward Dana and the entry is listed. A single-party entry
|
||||||
|
/// (300.00, Dana alone) counts in full, as before shares: 1,300.00.
|
||||||
|
#[test]
|
||||||
|
fn unresolved_shares_count_in_full_and_are_listed() {
|
||||||
|
use ledger_core::Share::{Amount, Derived};
|
||||||
|
let mut s = shared_books();
|
||||||
|
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||||
|
let e = post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 6, 1),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "1000.00"),
|
||||||
|
(bank, Side::Credit, "1000.00"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(dana_p, Amount(dec("600.00"))),
|
||||||
|
(stephen, Amount(dec("300.00"))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 6, 2),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "300.00"),
|
||||||
|
(bank, Side::Credit, "300.00"),
|
||||||
|
],
|
||||||
|
&[(dana_p, Derived)],
|
||||||
|
);
|
||||||
|
assert_eq!(dana_paid(&s), (dec("1300.00"), vec![e]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SCENARIO 3 payment: $1,000.00 from the bank, Dana 600.00 and
|
||||||
|
/// Stephen 400.00 by amount.
|
||||||
|
fn dana_and_stephen(s: &mut Shared) -> ledger_core::EntryId {
|
||||||
|
use ledger_core::Share::Amount;
|
||||||
|
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||||
|
post_shared(
|
||||||
|
s,
|
||||||
|
d(2026, 3, 1),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "1000.00"),
|
||||||
|
(bank, Side::Credit, "1000.00"),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
(dana_p, Amount(dec("600.00"))),
|
||||||
|
(stephen, Amount(dec("400.00"))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// set_entry_vendor SCENARIO 4, by hand: re-tagging Dana's vendor on the
|
||||||
|
/// shared payment keeps both shares, so her 1099 stays 600.00 (not the
|
||||||
|
/// whole 1,000.00) and Stephen keeps his part. Tagging a vendor whose
|
||||||
|
/// party isn't named is refused, changing nothing.
|
||||||
|
#[test]
|
||||||
|
fn tagging_the_vendor_keeps_a_shared_entrys_parties() {
|
||||||
|
let mut s = shared_books();
|
||||||
|
let e = dana_and_stephen(&mut s);
|
||||||
|
let before = s.l.entry_parties(e).to_vec();
|
||||||
|
s.l.set_entry_vendor(e, Some(s.dana)).unwrap();
|
||||||
|
assert_eq!(s.l.entry_parties(e), before.as_slice());
|
||||||
|
assert!(s.l.entry_names_party(e, s.stephen));
|
||||||
|
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||||
|
let other = s.l.add_vendor("Other Co").unwrap();
|
||||||
|
let err = s.l.set_entry_vendor(e, Some(other)).unwrap_err();
|
||||||
|
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||||
|
assert_eq!(s.l.entry_parties(e), before.as_slice());
|
||||||
|
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
|
||||||
|
// One party or none: the vendor's party becomes the entry's one party.
|
||||||
|
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
|
||||||
|
let lone = post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 3, 5),
|
||||||
|
&[(labor, Side::Debit, "50.00"), (bank, Side::Credit, "50.00")],
|
||||||
|
&[(stephen, ledger_core::Share::Derived)],
|
||||||
|
);
|
||||||
|
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
|
||||||
|
assert_eq!(s.l.entry_party(lone), Some(s.dana_p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// set_entry_vendor SCENARIO 5: clearing the tag on the shared payment,
|
||||||
|
/// whose vendor's party (Dana) is named, is refused and changes nothing,
|
||||||
|
/// so the 1099 (600.00) and Dana's party activity never disagree.
|
||||||
|
#[test]
|
||||||
|
fn clearing_the_vendor_on_a_shared_entry_is_refused() {
|
||||||
|
let mut s = shared_books();
|
||||||
|
let e = dana_and_stephen(&mut s);
|
||||||
|
let err = s.l.set_entry_vendor(e, None).unwrap_err();
|
||||||
|
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||||
|
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
|
||||||
|
assert!(s.l.entry_names_party(e, s.dana_p));
|
||||||
|
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||||
|
// Dropping Dana from the parties clears the tag with her.
|
||||||
|
s.l.set_entry_parties(e, &[ledger_core::PartyShare::whole(s.stephen)])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.l.entry_vendor(e), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// set_entry_parties SCENARIO 3, by hand: the shared payment reversed,
|
||||||
|
/// then re-apportioned 700.00 / 300.00. The reversal is re-apportioned
|
||||||
|
/// too, so Dana nets 700.00 − 700.00 = 0.00 (not 700.00 − 600.00 =
|
||||||
|
/// 100.00). Re-apportioning the reversal itself does the same for the
|
||||||
|
/// original.
|
||||||
|
#[test]
|
||||||
|
fn re_apportioning_a_reversed_entry_re_apportions_its_reversal() {
|
||||||
|
use ledger_core::Share::Amount;
|
||||||
|
let mut s = shared_books();
|
||||||
|
let e = dana_and_stephen(&mut s);
|
||||||
|
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
|
||||||
|
assert_eq!(s.l.reversal_chain(e), vec![e, rev]);
|
||||||
|
assert_eq!(s.l.reversal_chain(rev), vec![e, rev]);
|
||||||
|
let shares = |a: &str, b: &str| {
|
||||||
|
vec![
|
||||||
|
ledger_core::PartyShare {
|
||||||
|
party: s.dana_p,
|
||||||
|
share: Amount(dec(a)),
|
||||||
|
},
|
||||||
|
ledger_core::PartyShare {
|
||||||
|
party: s.stephen,
|
||||||
|
share: Amount(dec(b)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let new = shares("700.00", "300.00");
|
||||||
|
s.l.set_entry_parties(e, &new).unwrap();
|
||||||
|
assert_eq!(s.l.entry_parties(rev), new.as_slice());
|
||||||
|
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||||
|
let back = shares("550.00", "450.00");
|
||||||
|
s.l.set_entry_parties(rev, &back).unwrap();
|
||||||
|
assert_eq!(s.l.entry_parties(e), back.as_slice());
|
||||||
|
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||||
|
// Re-tagging the vendor on a reversed one-party entry tags both.
|
||||||
|
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
|
||||||
|
let lone = post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 4, 1),
|
||||||
|
&[(labor, Side::Debit, "80.00"), (bank, Side::Credit, "80.00")],
|
||||||
|
&[(stephen, ledger_core::Share::Derived)],
|
||||||
|
);
|
||||||
|
let lone_rev = s.l.reverse_entry(lone, d(2026, 4, 2), "oops").unwrap();
|
||||||
|
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
|
||||||
|
assert_eq!(s.l.entry_vendor(lone_rev), Some(s.dana));
|
||||||
|
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// add_vendor INVARIANTS 2-3: a party named on a shared entry beside
|
||||||
|
/// Dana (who has a vendor record) can't gain a vendor record, which
|
||||||
|
/// would leave the entry naming two vendors (shares INVARIANT 5); a party
|
||||||
|
/// named with no vendor party gains the tag with its new record.
|
||||||
|
#[test]
|
||||||
|
fn a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry() {
|
||||||
|
use ledger_core::Share::Percent;
|
||||||
|
let mut s = shared_books();
|
||||||
|
let beta = s.l.add_party("Beta", &[]).unwrap();
|
||||||
|
let (bank, labor, dana_p) = (s.bank, s.labor, s.dana_p);
|
||||||
|
let e = post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 7, 1),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "100.00"),
|
||||||
|
(bank, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
&[(dana_p, Percent(dec("60"))), (beta, Percent(dec("40")))],
|
||||||
|
);
|
||||||
|
let vendors_before = s.l.list_vendors().len();
|
||||||
|
let err = s.l.add_vendor("Beta").unwrap_err();
|
||||||
|
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||||
|
assert_eq!(s.l.list_vendors().len(), vendors_before, "nothing changed");
|
||||||
|
assert_eq!(s.l.get_party(beta).unwrap().vendor, None);
|
||||||
|
// Re-issuing the same shares is still accepted.
|
||||||
|
let same = s.l.entry_parties(e).to_vec();
|
||||||
|
s.l.set_entry_parties(e, &same).unwrap();
|
||||||
|
// Gamma, named only beside Stephen (a member, no vendor record).
|
||||||
|
let gamma = s.l.add_party("Gamma", &[]).unwrap();
|
||||||
|
let stephen = s.stephen;
|
||||||
|
let g = post_shared(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 7, 2),
|
||||||
|
&[
|
||||||
|
(labor, Side::Debit, "100.00"),
|
||||||
|
(bank, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
&[(stephen, Percent(dec("50"))), (gamma, Percent(dec("50")))],
|
||||||
|
);
|
||||||
|
assert_eq!(s.l.entry_vendor(g), None);
|
||||||
|
let gv = s.l.add_vendor("Gamma").unwrap();
|
||||||
|
assert_eq!(s.l.get_party(gamma).unwrap().vendor, Some(gv));
|
||||||
|
assert_eq!(s.l.entry_vendor(g), Some(gv), "the tag follows the party");
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::parties::{check_text, has_full_tin, name_key};
|
use ledger_core::parties::{check_text, has_full_tin, name_key, redact_full_tins};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
|
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
|
||||||
PayrollLine, Role, Side, W4Facts, W9Facts,
|
PayrollLine, Role, Side, W4Facts, W9Facts,
|
||||||
@@ -129,6 +129,7 @@ fn bare_party(name: &str) -> Party {
|
|||||||
archived: false,
|
archived: false,
|
||||||
w9: None,
|
w9: None,
|
||||||
w4: None,
|
w4: None,
|
||||||
|
tin: None,
|
||||||
vendor: None,
|
vendor: None,
|
||||||
customer: None,
|
customer: None,
|
||||||
}
|
}
|
||||||
@@ -151,8 +152,9 @@ fn pay_line(name: &str, employee: Option<PartyId>) -> PayrollLine {
|
|||||||
|
|
||||||
// ------------------------------------------------- has_full_tin truth table
|
// ------------------------------------------------- has_full_tin truth table
|
||||||
|
|
||||||
/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most one
|
/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most three
|
||||||
/// space or hyphen between neighbors") and INVARIANT 1.
|
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
|
||||||
|
/// no-break space) between neighbors") and INVARIANT 1.
|
||||||
#[test]
|
#[test]
|
||||||
fn has_full_tin_truth_table() {
|
fn has_full_tin_truth_table() {
|
||||||
let cases: &[(&str, bool)] = &[
|
let cases: &[(&str, bool)] = &[
|
||||||
@@ -164,9 +166,18 @@ fn has_full_tin_truth_table() {
|
|||||||
("1-2-3-4-5-6-7-8-9", true),
|
("1-2-3-4-5-6-7-8-9", true),
|
||||||
("TIN 123456789 on file", true), // inside longer text
|
("TIN 123456789 on file", true), // inside longer text
|
||||||
("abc123456789def", true),
|
("abc123456789def", true),
|
||||||
("12 3456789", false), // two spaces break the run: 2 + 7 digits
|
("12 3456789", true), // two spaces
|
||||||
("12--3456789", false), // two hyphens
|
("12--3456789", true), // two hyphens
|
||||||
("12 -3456789", false), // space and hyphen between neighbors
|
("12 -3456789", true), // space and hyphen between neighbors
|
||||||
|
("638.27.4915", true), // dots
|
||||||
|
("wrong one, was 638.27.4915", true),
|
||||||
|
("571/93/2846", true), // slashes
|
||||||
|
("571_93_2846", true), // underscores
|
||||||
|
("Bob 571 - 93 - 2846", true), // three separators
|
||||||
|
("571\u{2013}93\u{2013}2846", true), // en dashes
|
||||||
|
("571\u{a0}93\u{a0}2846", true), // no-break spaces
|
||||||
|
("12 3456789", false), // four separators break the run: 2 + 7
|
||||||
|
("571, 93, 2846", false), // a comma is not a separator
|
||||||
("12\t3456789", false), // a tab is not a space
|
("12\t3456789", false), // a tab is not a space
|
||||||
("12345678", false), // eight digits
|
("12345678", false), // eight digits
|
||||||
("123-45-678", false),
|
("123-45-678", false),
|
||||||
@@ -181,6 +192,38 @@ fn has_full_tin_truth_table() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Oracle: `redact_full_tins` doc: every run `has_full_tin` finds has its
|
||||||
|
/// digits masked, separators kept; other text is untouched.
|
||||||
|
#[test]
|
||||||
|
fn redact_full_tins_masks_every_full_tin() {
|
||||||
|
assert_eq!(
|
||||||
|
redact_full_tins("unknown party \"571-93-2846\""),
|
||||||
|
"unknown party \"•••-••-••••\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
redact_full_tins("was 638.27.4915 and 12-3456789, call 801-555"),
|
||||||
|
"was •••.••.•••• and ••-•••••••, call 801-555"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
redact_full_tins("invalid type: integer `571932846`"),
|
||||||
|
"invalid type: integer `•••••••••`"
|
||||||
|
);
|
||||||
|
assert_eq!(redact_full_tins("party 12 of 3"), "party 12 of 3");
|
||||||
|
for t in ["571 - 93 - 2846", "571932846", "1234567890"] {
|
||||||
|
assert!(!has_full_tin(&redact_full_tins(t)), "{t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: `check_text` refuses what `has_full_tin` finds, so a purge
|
||||||
|
/// reason or note quoting the old number with dots is refused (B-26: no
|
||||||
|
/// party field may hold a nine-digit number).
|
||||||
|
#[test]
|
||||||
|
fn check_text_refuses_a_dotted_tin() {
|
||||||
|
assert!(check_text("purged", "wrong one, was 638.27.4915").is_err());
|
||||||
|
assert!(check_text("no_party_reason", "ssn 571.93.2846").is_err());
|
||||||
|
assert!(check_text("name", "Bob 571 - 93 - 2846").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
/// Oracle: INVARIANT 1 says "any run of nine digits"; a run of ten digits
|
/// Oracle: INVARIANT 1 says "any run of nine digits"; a run of ten digits
|
||||||
/// holds a run of nine.
|
/// holds a run of nine.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1210,3 +1253,571 @@ fn entry_flow_scenarios() {
|
|||||||
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
|
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
|
||||||
assert_eq!(f.net, dec("100.00"));
|
assert_eq!(f.net, dec("100.00"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- party shares
|
||||||
|
//
|
||||||
|
// Oracles: the spec block of `ledger_core::shares` (DEFINITIONS,
|
||||||
|
// INVARIANTS 1–10, SCENARIOS 1–5), `Ledger::set_entry_parties` (INVARIANTS
|
||||||
|
// 1–4), `parties::activity_with` (INVARIANTS 1–4), and
|
||||||
|
// docs/parties-and-attachments.md "Shares". Values worked by hand.
|
||||||
|
|
||||||
|
mod shares {
|
||||||
|
use super::{d, date, refused, usd};
|
||||||
|
use ledger_core::parties::{activity_with, money_accounts};
|
||||||
|
use ledger_core::shares::{check_shares, resolve};
|
||||||
|
use ledger_core::{
|
||||||
|
AccountId, AccountType, Currency, EntryId, Ledger, PartyId, PartyMember, PartyShare, Role,
|
||||||
|
Share, Side,
|
||||||
|
};
|
||||||
|
use proptest::prelude::*;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
struct Books {
|
||||||
|
l: Ledger,
|
||||||
|
bank: AccountId,
|
||||||
|
due: AccountId,
|
||||||
|
cap_s: AccountId,
|
||||||
|
cap_k: AccountId,
|
||||||
|
equip: AccountId,
|
||||||
|
exp: AccountId,
|
||||||
|
stephen: PartyId,
|
||||||
|
kat: PartyId,
|
||||||
|
acme: PartyId,
|
||||||
|
members: Vec<PartyMember>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bluevine 1000, Due to Shareholders 2100, capital 3000 and 3001,
|
||||||
|
/// equipment 1500, an expense 6050; members Stephen and Katherine at
|
||||||
|
/// 50/50; a party Acme (no records).
|
||||||
|
fn books() -> Books {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let add = |l: &mut Ledger, c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||||||
|
let bank = add(&mut l, "1000", AccountType::Asset);
|
||||||
|
let equip = add(&mut l, "1500", AccountType::Asset);
|
||||||
|
let due = add(&mut l, "2100", AccountType::Liability);
|
||||||
|
let cap_s = add(&mut l, "3000", AccountType::Equity);
|
||||||
|
let cap_k = add(&mut l, "3001", AccountType::Equity);
|
||||||
|
let exp = add(&mut l, "6050", AccountType::Expense);
|
||||||
|
let stephen = l.add_party("Stephen Waits", &[Role::Member]).unwrap();
|
||||||
|
let kat = l
|
||||||
|
.add_party("Katherine Pendergast", &[Role::Member])
|
||||||
|
.unwrap();
|
||||||
|
let acme = l.add_party("Acme", &[Role::Contractor]).unwrap();
|
||||||
|
let half = d("0.5");
|
||||||
|
let members = vec![
|
||||||
|
PartyMember {
|
||||||
|
party: stephen,
|
||||||
|
share: half,
|
||||||
|
capital: cap_s,
|
||||||
|
distributions: None,
|
||||||
|
},
|
||||||
|
PartyMember {
|
||||||
|
party: kat,
|
||||||
|
share: half,
|
||||||
|
capital: cap_k,
|
||||||
|
distributions: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
Books {
|
||||||
|
l,
|
||||||
|
bank,
|
||||||
|
due,
|
||||||
|
cap_s,
|
||||||
|
cap_k,
|
||||||
|
equip,
|
||||||
|
exp,
|
||||||
|
stephen,
|
||||||
|
kat,
|
||||||
|
acme,
|
||||||
|
members,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(l: &mut Ledger, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||||||
|
let e = l.try_create_entry(date(2026, 5, 1), "joint").unwrap();
|
||||||
|
for (a, s, v) in lines {
|
||||||
|
l.add_line(e, *a, *s, usd(v)).unwrap();
|
||||||
|
}
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derived(ps: &[PartyId]) -> Vec<PartyShare> {
|
||||||
|
ps.iter().map(|&p| PartyShare::whole(p)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn amounts(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
|
||||||
|
ps.iter()
|
||||||
|
.map(|&(party, v)| PartyShare {
|
||||||
|
party,
|
||||||
|
share: Share::Amount(d(v)),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percents(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
|
||||||
|
ps.iter()
|
||||||
|
.map(|&(party, v)| PartyShare {
|
||||||
|
party,
|
||||||
|
share: Share::Percent(d(v)),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved(b: &Books, e: EntryId) -> Result<Vec<(PartyId, Decimal)>, String> {
|
||||||
|
let money = money_accounts(&b.l, &[b.bank]);
|
||||||
|
resolve(
|
||||||
|
b.l.get_entry(e).unwrap(),
|
||||||
|
b.l.entry_parties(e),
|
||||||
|
&money,
|
||||||
|
&b.members,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn year() -> (chrono::NaiveDate, chrono::NaiveDate) {
|
||||||
|
(date(2026, 1, 1), date(2026, 12, 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) {
|
||||||
|
let a = activity_with(&b.l, p, year(), &[b.bank], &b.members);
|
||||||
|
(a.paid, a.received)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 1: a reimbursement to both members, derived, 50/50.
|
||||||
|
#[test]
|
||||||
|
fn reimbursing_both_members_splits_by_member_share() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.due, Side::Debit, "100.00"),
|
||||||
|
(b.bank, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, e).unwrap(),
|
||||||
|
vec![(b.stephen, d("50.00")), (b.kat, d("50.00"))]
|
||||||
|
);
|
||||||
|
assert_eq!(b.l.entry_party(e), None, "two parties: no sole party");
|
||||||
|
assert!(b.l.entry_names_party(e, b.kat));
|
||||||
|
assert_eq!(paid_received(&b, b.stephen), (d("50.00"), Decimal::ZERO));
|
||||||
|
assert_eq!(paid_received(&b, b.kat), (d("50.00"), Decimal::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 2: a joint contribution derives from the capital lines.
|
||||||
|
#[test]
|
||||||
|
fn joint_contribution_derives_from_capital_lines() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.bank, Side::Debit, "300.00"),
|
||||||
|
(b.cap_s, Side::Credit, "200.00"),
|
||||||
|
(b.cap_k, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, e).unwrap(),
|
||||||
|
vec![(b.stephen, d("-200.00")), (b.kat, d("-100.00"))]
|
||||||
|
);
|
||||||
|
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, d("200.00")));
|
||||||
|
assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, d("100.00")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 3: property contributed moves no money; the shares are of
|
||||||
|
/// the total, and activity counts nothing.
|
||||||
|
#[test]
|
||||||
|
fn property_contribution_resolves_against_the_total() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.equip, Side::Debit, "1000.00"),
|
||||||
|
(b.cap_s, Side::Credit, "500.00"),
|
||||||
|
(b.cap_k, Side::Credit, "500.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, e).unwrap(),
|
||||||
|
vec![(b.stephen, d("500.00")), (b.kat, d("500.00"))]
|
||||||
|
);
|
||||||
|
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capital lines that don't account for the whole flow are refused.
|
||||||
|
#[test]
|
||||||
|
fn capital_lines_short_of_the_flow_are_refused() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.bank, Side::Debit, "300.00"),
|
||||||
|
(b.cap_s, Side::Credit, "200.00"),
|
||||||
|
(b.exp, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
assert!(resolved(&b, e).is_err());
|
||||||
|
let a = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members);
|
||||||
|
assert!(a.unresolved.contains(&e));
|
||||||
|
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 4: percent rounding, remainder to the first party.
|
||||||
|
#[test]
|
||||||
|
fn percent_rounding_gives_the_remainder_to_the_first() {
|
||||||
|
let mut b = books();
|
||||||
|
let c = b.l.add_party("Third", &[]).unwrap();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "100.00"),
|
||||||
|
(b.bank, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(
|
||||||
|
e,
|
||||||
|
&percents(&[(b.acme, "33.33"), (b.stephen, "33.33"), (c, "33.34")]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, e).unwrap(),
|
||||||
|
vec![
|
||||||
|
(b.acme, d("33.33")),
|
||||||
|
(b.stephen, d("33.33")),
|
||||||
|
(c, d("33.34"))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(
|
||||||
|
e,
|
||||||
|
&percents(&[(b.acme, "33.333"), (b.stephen, "33.333"), (c, "33.334")]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, e).unwrap(),
|
||||||
|
vec![
|
||||||
|
(b.acme, d("33.34")),
|
||||||
|
(b.stephen, d("33.33")),
|
||||||
|
(c, d("33.33"))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCENARIO 5 and INVARIANT 7: amounts must sum to what moved.
|
||||||
|
#[test]
|
||||||
|
fn amounts_must_sum_to_the_flow() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "100.00"),
|
||||||
|
(b.bank, Side::Credit, "100.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(e, &amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")]))
|
||||||
|
.unwrap();
|
||||||
|
assert!(resolved(&b, e).is_err());
|
||||||
|
b.l.set_entry_parties(e, &amounts(&[(b.stephen, "70.00"), (b.kat, "30.00")]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, e).unwrap(),
|
||||||
|
vec![(b.stephen, d("70.00")), (b.kat, d("30.00"))]
|
||||||
|
);
|
||||||
|
assert_eq!(paid_received(&b, b.stephen), (d("70.00"), Decimal::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANT 10: several derived shares need members.
|
||||||
|
#[test]
|
||||||
|
fn derived_needs_members_for_several_parties() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "10.00"),
|
||||||
|
(b.bank, Side::Credit, "10.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.acme]))
|
||||||
|
.unwrap();
|
||||||
|
assert!(resolved(&b, e).is_err());
|
||||||
|
// With no members passed (plain activity), two members can't derive.
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
let money = money_accounts(&b.l, &[b.bank]);
|
||||||
|
assert!(resolve(b.l.get_entry(e).unwrap(), b.l.entry_parties(e), &money, &[]).is_err());
|
||||||
|
// A sole party needs no members.
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap();
|
||||||
|
assert_eq!(resolved(&b, e).unwrap(), vec![(b.acme, d("10.00"))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANT 10: income shares rescale to the members named.
|
||||||
|
#[test]
|
||||||
|
fn member_shares_rescale_to_those_named() {
|
||||||
|
let mut b = books();
|
||||||
|
let third = b.l.add_party("Third Member", &[Role::Member]).unwrap();
|
||||||
|
let cap_t =
|
||||||
|
b.l.add_account("3002", "Capital T", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
b.members[0].share = d("0.5");
|
||||||
|
b.members[1].share = d("0.3");
|
||||||
|
b.members.push(PartyMember {
|
||||||
|
party: third,
|
||||||
|
share: d("0.2"),
|
||||||
|
capital: cap_t,
|
||||||
|
distributions: None,
|
||||||
|
});
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.due, Side::Debit, "80.00"),
|
||||||
|
(b.bank, Side::Credit, "80.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, e).unwrap(),
|
||||||
|
vec![(b.stephen, d("50.00")), (b.kat, d("30.00"))]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANTS 1–5 of shares and set_entry_parties 1: refusals change
|
||||||
|
/// nothing.
|
||||||
|
#[test]
|
||||||
|
fn bad_shares_are_refused_changing_nothing() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "10.00"),
|
||||||
|
(b.bank, Side::Credit, "10.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let (s, k) = (b.stephen, b.kat);
|
||||||
|
refused(&mut b.l, "duplicate", |l| {
|
||||||
|
l.set_entry_parties(e, &derived(&[s, s]))
|
||||||
|
});
|
||||||
|
refused(&mut b.l, "unknown party", |l| {
|
||||||
|
l.set_entry_parties(e, &derived(&[s, 999]))
|
||||||
|
});
|
||||||
|
refused(&mut b.l, "unknown entry", |l| {
|
||||||
|
l.set_entry_parties(999, &derived(&[s]))
|
||||||
|
});
|
||||||
|
refused(&mut b.l, "mixed kinds", |l| {
|
||||||
|
l.set_entry_parties(
|
||||||
|
e,
|
||||||
|
&[
|
||||||
|
PartyShare {
|
||||||
|
party: s,
|
||||||
|
share: Share::Amount(d("5.00")),
|
||||||
|
},
|
||||||
|
PartyShare::whole(k),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
});
|
||||||
|
refused(&mut b.l, "percents sum to 99", |l| {
|
||||||
|
l.set_entry_parties(e, &percents(&[(s, "50"), (k, "49")]))
|
||||||
|
});
|
||||||
|
refused(&mut b.l, "percent over 100", |l| {
|
||||||
|
l.set_entry_parties(e, &percents(&[(s, "101"), (k, "-1")]))
|
||||||
|
});
|
||||||
|
refused(&mut b.l, "finer than a cent", |l| {
|
||||||
|
l.set_entry_parties(e, &amounts(&[(s, "5.005"), (k, "4.995")]))
|
||||||
|
});
|
||||||
|
refused(&mut b.l, "zero amount", |l| {
|
||||||
|
l.set_entry_parties(e, &amounts(&[(s, "10.00"), (k, "0")]))
|
||||||
|
});
|
||||||
|
let v1 = b.l.add_vendor("Vendor One").unwrap();
|
||||||
|
let v2 = b.l.add_vendor("Vendor Two").unwrap();
|
||||||
|
let (p1, p2) = (
|
||||||
|
b.l.party_of_vendor(v1).unwrap(),
|
||||||
|
b.l.party_of_vendor(v2).unwrap(),
|
||||||
|
);
|
||||||
|
refused(&mut b.l, "two vendors", |l| {
|
||||||
|
l.set_entry_parties(e, &amounts(&[(p1, "5.00"), (p2, "5.00")]))
|
||||||
|
});
|
||||||
|
// The checker agrees on its own.
|
||||||
|
assert!(check_shares(&percents(&[(s, "100")]), |_| true).is_ok());
|
||||||
|
assert!(check_shares(&percents(&[(s, "60"), (k, "40.0")]), |_| true).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// set_entry_parties INVARIANT 2: the vendor tag follows the one party
|
||||||
|
/// with a vendor record; clearing clears it.
|
||||||
|
#[test]
|
||||||
|
fn vendor_tag_follows_the_one_vendor_party() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "10.00"),
|
||||||
|
(b.bank, Side::Credit, "10.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let v = b.l.add_vendor("Vendor One").unwrap();
|
||||||
|
let pv = b.l.party_of_vendor(v).unwrap();
|
||||||
|
b.l.set_entry_parties(e, &amounts(&[(pv, "6.00"), (b.stephen, "4.00")]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(b.l.entry_vendor(e), Some(v));
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(b.l.entry_vendor(e), None);
|
||||||
|
b.l.set_entry_parties(e, &[]).unwrap();
|
||||||
|
assert!(b.l.entry_parties(e).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-party tagging is one derived share; INVARIANT 3: naming a
|
||||||
|
/// party clears the no-party mark, and the mark is refused while
|
||||||
|
/// parties are named.
|
||||||
|
#[test]
|
||||||
|
fn single_party_and_no_party_mark() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "10.00"),
|
||||||
|
(b.bank, Side::Credit, "10.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_party(e, Some(b.acme)).unwrap();
|
||||||
|
assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(b.acme)]);
|
||||||
|
assert_eq!(b.l.entry_party(e), Some(b.acme));
|
||||||
|
b.l.set_entry_party(e, None).unwrap();
|
||||||
|
b.l.set_entry_no_party(e, "own transfer").unwrap();
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(b.l.entry_no_party(e), None);
|
||||||
|
refused(&mut b.l, "no-party while named", |l| {
|
||||||
|
l.set_entry_no_party(e, "none")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reversals carry every share; the pair nets to zero for each party.
|
||||||
|
#[test]
|
||||||
|
fn reversal_carries_all_shares() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "90.00"),
|
||||||
|
(b.bank, Side::Credit, "90.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let shares = amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")]);
|
||||||
|
b.l.set_entry_parties(e, &shares).unwrap();
|
||||||
|
let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap();
|
||||||
|
assert_eq!(b.l.entry_parties(r), shares.as_slice());
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&b, r).unwrap(),
|
||||||
|
vec![(b.stephen, d("-60.00")), (b.kat, d("-30.00"))]
|
||||||
|
);
|
||||||
|
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO));
|
||||||
|
assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, Decimal::ZERO));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain `activity` is activity_with no members: a sole party still
|
||||||
|
/// counts the whole net.
|
||||||
|
#[test]
|
||||||
|
fn activity_counts_a_sole_party_in_full() {
|
||||||
|
let mut b = books();
|
||||||
|
let e = post(
|
||||||
|
&mut b.l,
|
||||||
|
&[
|
||||||
|
(b.exp, Side::Debit, "12.34"),
|
||||||
|
(b.bank, Side::Credit, "12.34"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
b.l.set_entry_party(e, Some(b.acme)).unwrap();
|
||||||
|
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
|
||||||
|
assert_eq!(a.paid, d("12.34"));
|
||||||
|
assert!(a.entries.contains(&e));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cents() -> impl Strategy<Value = Decimal> {
|
||||||
|
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
/// INVARIANT 6: resolved shares sum to the target exactly, each
|
||||||
|
/// with its sign, for percents (any split summing to 100, to
|
||||||
|
/// three decimals), member-derived splits, and capital lines.
|
||||||
|
#[test]
|
||||||
|
fn resolved_shares_sum_exactly(
|
||||||
|
total in cents(),
|
||||||
|
refund in any::<bool>(),
|
||||||
|
cuts in proptest::collection::vec(1u32..100_000, 1..6),
|
||||||
|
member_share in 1u32..1000,
|
||||||
|
) {
|
||||||
|
let mut b = books();
|
||||||
|
let (dr, cr) = if refund { (b.bank, b.exp) } else { (b.exp, b.bank) };
|
||||||
|
let t = total.to_string();
|
||||||
|
let e = post(&mut b.l, &[(dr, Side::Debit, &t), (cr, Side::Credit, &t)]);
|
||||||
|
let target = if refund { -total } else { total };
|
||||||
|
// Percents: cut 100.000 at sorted points.
|
||||||
|
let mut points: Vec<u32> = cuts.iter().map(|c| c % 100_000).collect();
|
||||||
|
points.sort_unstable();
|
||||||
|
points.dedup();
|
||||||
|
points.retain(|&p| p > 0);
|
||||||
|
let mut bounds = vec![0u32];
|
||||||
|
bounds.extend(points);
|
||||||
|
bounds.push(100_000);
|
||||||
|
let mut ps = Vec::new();
|
||||||
|
for (i, w) in bounds.windows(2).enumerate() {
|
||||||
|
let party = b.l.add_party(&format!("P{i}"), &[]).unwrap();
|
||||||
|
ps.push((party, Decimal::new(i64::from(w[1] - w[0]), 3)));
|
||||||
|
}
|
||||||
|
let shares: Vec<PartyShare> = ps
|
||||||
|
.iter()
|
||||||
|
.map(|&(party, p)| PartyShare { party, share: Share::Percent(p) })
|
||||||
|
.collect();
|
||||||
|
b.l.set_entry_parties(e, &shares).unwrap();
|
||||||
|
let r = resolved(&b, e).unwrap();
|
||||||
|
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||||||
|
prop_assert!(r.iter().all(|(_, v)| v.is_zero() || v.is_sign_negative() == refund));
|
||||||
|
prop_assert!(r.iter().all(|(_, v)| v.normalize().scale() <= 2));
|
||||||
|
// Member-derived with uneven income shares.
|
||||||
|
let s = Decimal::new(i64::from(member_share), 3);
|
||||||
|
b.members[0].share = s;
|
||||||
|
b.members[1].share = Decimal::ONE - s;
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap();
|
||||||
|
let r = resolved(&b, e).unwrap();
|
||||||
|
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||||||
|
// Amounts that sum: split at the first party's resolved part.
|
||||||
|
let first = r[0].1.abs();
|
||||||
|
let rest = total - first;
|
||||||
|
if !first.is_zero() && !rest.is_zero() {
|
||||||
|
b.l.set_entry_parties(e, &[
|
||||||
|
PartyShare { party: b.stephen, share: Share::Amount(first) },
|
||||||
|
PartyShare { party: b.kat, share: Share::Amount(rest) },
|
||||||
|
]).unwrap();
|
||||||
|
let r = resolved(&b, e).unwrap();
|
||||||
|
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capital-line derivation: two members contribute any amounts in
|
||||||
|
/// one entry; each gets their own line, summing to the flow.
|
||||||
|
#[test]
|
||||||
|
fn capital_line_shares_sum_exactly(a in cents(), c in cents()) {
|
||||||
|
let mut b = books();
|
||||||
|
let total = a + c;
|
||||||
|
let e = post(&mut b.l, &[
|
||||||
|
(b.bank, Side::Debit, &total.to_string()),
|
||||||
|
(b.cap_s, Side::Credit, &a.to_string()),
|
||||||
|
(b.cap_k, Side::Credit, &c.to_string()),
|
||||||
|
]);
|
||||||
|
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap();
|
||||||
|
let r = resolved(&b, e).unwrap();
|
||||||
|
prop_assert_eq!(r, vec![(b.stephen, -a), (b.kat, -c)]);
|
||||||
|
let sa = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members);
|
||||||
|
let sk = activity_with(&b.l, b.kat, year(), &[b.bank], &b.members);
|
||||||
|
prop_assert_eq!(sa.received + sk.received, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -399,7 +399,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
|||||||
e.status == EntryStatus::Posted
|
e.status == EntryStatus::Posted
|
||||||
&& e.date >= year_start
|
&& e.date >= year_start
|
||||||
&& e.date <= today
|
&& e.date <= today
|
||||||
&& e.party_id.is_none()
|
&& e.parties.is_empty()
|
||||||
&& b.ledger.entry_no_party(e.id).is_none()
|
&& b.ledger.entry_no_party(e.id).is_none()
|
||||||
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
|
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
|
||||||
})
|
})
|
||||||
|
|||||||
+419
-33
@@ -47,15 +47,37 @@ pub struct EntryInfo {
|
|||||||
/// entry doesn't touch cash.
|
/// entry doesn't touch cash.
|
||||||
pub cash_effect: Decimal,
|
pub cash_effect: Decimal,
|
||||||
/// The counterparty's name: its directory party's label (see
|
/// The counterparty's name: its directory party's label (see
|
||||||
/// [`PartyInfo`] for which entries are a party's), else its vendor's
|
/// [`PartyInfo`] for which entries are a party's; several parties'
|
||||||
/// or invoice customer's name.
|
/// labels joined with " & "), else its vendor's or invoice customer's
|
||||||
|
/// name.
|
||||||
pub party: Option<String>,
|
pub party: Option<String>,
|
||||||
/// The directory party the entry is with (see [`PartyInfo`]).
|
/// The directory party the entry is with, when it names exactly one
|
||||||
|
/// (see [`PartyInfo`]).
|
||||||
pub party_id: Option<PartyId>,
|
pub party_id: Option<PartyId>,
|
||||||
|
/// Every party the entry names, in order, each with its share.
|
||||||
|
pub parties: Vec<EntryParty>,
|
||||||
/// Lower-cased text for search: description, accounts, vendor, amount.
|
/// Lower-cased text for search: description, accounts, vendor, amount.
|
||||||
pub haystack: String,
|
pub haystack: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One party an entry names, with its share.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// SOURCE: ledger_core::shares (resolve), with Books::cash_accounts and
|
||||||
|
/// Books::members, as the server's books.get_entry resolves them.
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. resolved: the party's part from ledger_core::shares::resolve, None
|
||||||
|
/// when the entry's shares don't resolve; over an entry's parties the
|
||||||
|
/// resolved parts sum to what it moved exactly.
|
||||||
|
/// ```
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct EntryParty {
|
||||||
|
pub id: PartyId,
|
||||||
|
pub label: String,
|
||||||
|
pub share: ledger_core::Share,
|
||||||
|
pub resolved: Option<Decimal>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One fixed asset's trail through the journal and the entries.
|
/// One fixed asset's trail through the journal and the entries.
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub struct AssetTrail {
|
pub struct AssetTrail {
|
||||||
@@ -99,12 +121,14 @@ impl AssetTrail {
|
|||||||
/// module's rule (every number from ledger-core; this indexes and
|
/// module's rule (every number from ledger-core; this indexes and
|
||||||
/// sums); DECISION (the year and the payment accounts, below).
|
/// sums); DECISION (the year and the payment accounts, below).
|
||||||
/// DEFINITIONS:
|
/// DEFINITIONS:
|
||||||
/// The party's entries: Ledger::entry_party names it (core tags hand
|
/// The party's entries: Ledger::entry_parties names it, alone or among
|
||||||
/// tags, vendor tags, bills, invoices, their payments, reversals).
|
/// others (core tags hand tags, vendor tags, bills, invoices, their
|
||||||
|
/// payments, reversals).
|
||||||
/// The year: the calendar year of `today`, Jan 1 through today (the
|
/// The year: the calendar year of `today`, Jan 1 through today (the
|
||||||
/// year 1099s are counted in).
|
/// year 1099s are counted in).
|
||||||
/// Money: ledger_core::parties::activity with Books::cash_accounts
|
/// Money: Books::activity (ledger_core::parties::activity_with with
|
||||||
/// (the same numbers books.party reports).
|
/// Books::cash_accounts and Books::members: a party's share of an
|
||||||
|
/// entry naming several; the same numbers books.party reports).
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. entries: every entry that is the party's, drafts included, in
|
/// 1. entries: every entry that is the party's, drafts included, in
|
||||||
/// Books::entries order (newest first).
|
/// Books::entries order (newest first).
|
||||||
@@ -125,7 +149,8 @@ impl AssetTrail {
|
|||||||
/// or (older runs, no party on the line) whose employee_name is the
|
/// or (older runs, no party on the line) whose employee_name is the
|
||||||
/// party's name ignoring case and surrounding space.
|
/// party's name ignoring case and surrounding space.
|
||||||
/// 7. events: indexes into Books::journal of party.* events with its
|
/// 7. events: indexes into Books::journal of party.* events with its
|
||||||
/// id, entry.party_set naming it, and its records' vendor.added,
|
/// id, entry.party_set or entry.parties_set naming it, and its
|
||||||
|
/// records' vendor.added,
|
||||||
/// customer.added, vendor.1099_set, and entry.vendor_set events.
|
/// customer.added, vendor.1099_set, and entry.vendor_set events.
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
@@ -201,8 +226,24 @@ pub struct Books {
|
|||||||
pub entries: Vec<EntryInfo>,
|
pub entries: Vec<EntryInfo>,
|
||||||
pub entry_index: HashMap<EntryId, usize>,
|
pub entry_index: HashMap<EntryId, usize>,
|
||||||
pub cash_accounts: Vec<AccountId>,
|
pub cash_accounts: Vec<AccountId>,
|
||||||
|
/// The `[[company.members]]` that are parties (role member, matched
|
||||||
|
/// by name), with their capital accounts: what derived party shares
|
||||||
|
/// and member capital statements read
|
||||||
|
/// (ledger_config::Company::party_members).
|
||||||
|
pub members: Vec<ledger_core::PartyMember>,
|
||||||
/// `[books] financing_accounts`, resolved (empty when not set).
|
/// `[books] financing_accounts`, resolved (empty when not set).
|
||||||
pub financing_accounts: Vec<AccountId>,
|
pub financing_accounts: Vec<AccountId>,
|
||||||
|
/// The cash accounts the member capital statement hands
|
||||||
|
/// ledger_core::k1 to tell cash from property: `[books]
|
||||||
|
/// cash_accounts`, resolved; when that is empty, core's fallback
|
||||||
|
/// (ledger_core::parties::money_accounts: every asset account but
|
||||||
|
/// receivables), so the viewer reads money as core does. Without the
|
||||||
|
/// setting every contribution therefore reads as cash: property can't
|
||||||
|
/// be told from cash without knowing which accounts hold cash.
|
||||||
|
/// Kept apart from `cash_accounts`, whose name-based guess the Cash
|
||||||
|
/// page, dashboard and charts keep (changing those is out of scope:
|
||||||
|
/// "every asset" would put equipment on the Cash page).
|
||||||
|
pub k1_cash_accounts: Vec<AccountId>,
|
||||||
/// `[books] investing_accounts`, resolved (empty when not set).
|
/// `[books] investing_accounts`, resolved (empty when not set).
|
||||||
pub investing_accounts: Vec<AccountId>,
|
pub investing_accounts: Vec<AccountId>,
|
||||||
pub card_accounts: Vec<AccountId>,
|
pub card_accounts: Vec<AccountId>,
|
||||||
@@ -249,16 +290,20 @@ pub enum PartyPeriod {
|
|||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: answer "how much did we spend with them, and how much did we
|
/// GOAL: answer "how much did we spend with them, and how much did we
|
||||||
/// get back?" (Stephen, 2026-10-15) with the books' own numbers.
|
/// get back?" (Stephen, 2026-10-15) with the books' own numbers.
|
||||||
/// SOURCE: ledger_core::parties::activity (paid, received) and
|
/// SOURCE: ledger_core::parties::activity_with (paid, received) and
|
||||||
/// ledger_core::parties::entry_flow (what for), with
|
/// ledger_core::parties::entry_flow (what for), with
|
||||||
/// Books::cash_accounts, as PartyInfo uses them.
|
/// Books::cash_accounts and Books::members, as PartyInfo uses
|
||||||
|
/// them.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. paid, received: activity(ledger, party, span, cash accounts)'s,
|
/// 1. paid, received: Books::activity(party, span)'s, exactly.
|
||||||
/// exactly.
|
|
||||||
/// 2. net() = paid − received.
|
/// 2. net() = paid − received.
|
||||||
/// 3. what_for: over the party's posted entries dated in the span, the
|
/// 3. what_for: over the party's posted entries dated in the span, the
|
||||||
/// sum by account of each entry_flow's what_for (a liability paid
|
/// sum by account of each entry_flow's what_for (a liability paid
|
||||||
/// down, like a reimbursement, appears as what the money paid for),
|
/// down, like a reimbursement, appears as what the money paid for),
|
||||||
|
/// for an entry naming several parties this party's part of it: a
|
||||||
|
/// member's own capital and distributions lines when they come to
|
||||||
|
/// its resolved share exactly, else the what_for apportioned to the
|
||||||
|
/// share (share_of_what_for; nothing when the shares don't resolve),
|
||||||
/// the accounts whose sum is zero left out, largest magnitude first
|
/// the accounts whose sum is zero left out, largest magnitude first
|
||||||
/// (then by account id). Positive: what money paid for; negative:
|
/// (then by account id). Positive: what money paid for; negative:
|
||||||
/// where money received came from (or, on payables and
|
/// where money received came from (or, on payables and
|
||||||
@@ -283,6 +328,102 @@ impl PartyFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What moved a member's capital account in a span, by kind
|
||||||
|
/// ([`Books::capital_statement`]).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CapitalKind {
|
||||||
|
/// Cash put in, or paid for the company (ledger_core::k1's cash part).
|
||||||
|
CashContribution,
|
||||||
|
/// Property put in, at the value booked: fair market value
|
||||||
|
/// (ledger_core::k1's property part).
|
||||||
|
PropertyContribution,
|
||||||
|
/// A period close allocating their share of net income (or loss).
|
||||||
|
IncomeShare,
|
||||||
|
/// Taken out.
|
||||||
|
Distribution,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry's effect on a member's capital, split by kind.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct CapitalLine {
|
||||||
|
pub entry: EntryId,
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub description: String,
|
||||||
|
pub kind: CapitalKind,
|
||||||
|
/// Signed: positive raises their capital, negative lowers it.
|
||||||
|
pub amount: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member's capital account statement over a span: the partner capital
|
||||||
|
/// statement, read from ledger_core::k1 (the CPA package's K-1 worksheet).
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: a member's party page answers "what is my stake?" as a partner
|
||||||
|
/// capital statement does (Stephen, 2026-10-15), with the same
|
||||||
|
/// numbers the CPA's K-1 worksheet carries: one source of truth.
|
||||||
|
/// SOURCE: ledger_core::k1::k1_detail (members_equity's roll-forward,
|
||||||
|
/// split into cash and property); ledger_config::Company::members
|
||||||
|
/// ([[company.members]], all of them, in order, as the CPA
|
||||||
|
/// package builds its MemberCapital list); Books::k1_cash_accounts;
|
||||||
|
/// Books::financing_accounts; DECISION (the due-to accounts, and
|
||||||
|
/// the cash fallback, below).
|
||||||
|
/// DEFINITIONS:
|
||||||
|
/// row: k1_detail(span, members, k1_cash_accounts).rows[i], i this
|
||||||
|
/// member's position in [[company.members]] (matched by capital
|
||||||
|
/// account).
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. opening = row.beginning; contributions_cash =
|
||||||
|
/// row.contributions_cash; contributions_property =
|
||||||
|
/// row.contributions_property; income_share = row.net_income;
|
||||||
|
/// distributions_taken = row.distributions; other = row.other;
|
||||||
|
/// closing = row.ending. Opening and closing include the member's
|
||||||
|
/// share of earnings not yet closed into capital (members_equity
|
||||||
|
/// INVARIANT 3), and income_share is their share of the span's net
|
||||||
|
/// income whether or not a period close has moved it yet.
|
||||||
|
/// 2. closing = opening + contributions_cash + contributions_property +
|
||||||
|
/// income_share − distributions_taken + other, exactly.
|
||||||
|
/// 3. lines: k1_detail's lines for this member. Σ CashContribution =
|
||||||
|
/// contributions_cash, Σ PropertyContribution =
|
||||||
|
/// contributions_property, −Σ Distribution = distributions_taken;
|
||||||
|
/// IncomeShare lines are the closes in the span (they need not sum to
|
||||||
|
/// income_share: income not yet closed has no entry on capital).
|
||||||
|
/// 4. share: the member's [[company.members]] share (a fraction).
|
||||||
|
/// 5. due_to_members: the balance at span.1 of the liabilities the
|
||||||
|
/// members advance: [books] financing_accounts (Books::
|
||||||
|
/// financing_accounts, its liability accounts) when set; only when it
|
||||||
|
/// is empty, the liability accounts whose name says "due to" or
|
||||||
|
/// "owed to" (DECISION: a name is a guess; the setting is the books'
|
||||||
|
/// word). owed_to_member: this member's part of it by the members'
|
||||||
|
/// shares (ledger_core::periods::allocate over every member, so the
|
||||||
|
/// parts sum to the balance).
|
||||||
|
/// 6. None when the party isn't a member, a [[company.members]]
|
||||||
|
/// account isn't in the chart, or k1_detail refuses (shares not
|
||||||
|
/// summing to 1, accounts not distinct equity accounts).
|
||||||
|
/// ```
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct CapitalStatement {
|
||||||
|
pub party: PartyId,
|
||||||
|
pub capital: AccountId,
|
||||||
|
pub distributions: Option<AccountId>,
|
||||||
|
pub span: (NaiveDate, NaiveDate),
|
||||||
|
pub share: Decimal,
|
||||||
|
pub opening: Decimal,
|
||||||
|
pub contributions_cash: Decimal,
|
||||||
|
pub contributions_property: Decimal,
|
||||||
|
pub income_share: Decimal,
|
||||||
|
/// Taken out, as a positive amount.
|
||||||
|
pub distributions_taken: Decimal,
|
||||||
|
/// Any other change in their capital (members_equity's "other").
|
||||||
|
pub other: Decimal,
|
||||||
|
pub closing: Decimal,
|
||||||
|
pub lines: Vec<CapitalLine>,
|
||||||
|
pub due_to_members: Decimal,
|
||||||
|
pub owed_to_member: Decimal,
|
||||||
|
/// `[[company.members]] files_jointly_with`: the member they file a
|
||||||
|
/// joint individual return with.
|
||||||
|
pub files_jointly_with: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One month of a party's money, for the out-versus-in chart.
|
/// One month of a party's money, for the out-versus-in chart.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct PartyMonth {
|
pub struct PartyMonth {
|
||||||
@@ -396,12 +537,27 @@ impl Books {
|
|||||||
)?;
|
)?;
|
||||||
let receipt_threshold = policy.threshold();
|
let receipt_threshold = policy.threshold();
|
||||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||||
|
let members = settings.company.party_members(&ledger);
|
||||||
|
let share_money = ledger_core::parties::money_accounts(&ledger, &cash_accounts);
|
||||||
let codes = |cs: &[String]| -> Vec<AccountId> {
|
let codes = |cs: &[String]| -> Vec<AccountId> {
|
||||||
cs.iter()
|
cs.iter()
|
||||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let financing_accounts = codes(&settings.financing_accounts);
|
let financing_accounts = codes(&settings.financing_accounts);
|
||||||
|
let k1_cash_accounts = {
|
||||||
|
let configured = codes(&settings.cash_accounts);
|
||||||
|
if configured.is_empty() {
|
||||||
|
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[])
|
||||||
|
.cash
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
all.sort_unstable();
|
||||||
|
all
|
||||||
|
} else {
|
||||||
|
configured
|
||||||
|
}
|
||||||
|
};
|
||||||
let investing_accounts = codes(&settings.investing_accounts);
|
let investing_accounts = codes(&settings.investing_accounts);
|
||||||
let card_accounts: Vec<AccountId> = ledger
|
let card_accounts: Vec<AccountId> = ledger
|
||||||
.chart()
|
.chart()
|
||||||
@@ -507,10 +663,32 @@ impl Books {
|
|||||||
// Core tags every entry with its counterparty (hand tags,
|
// Core tags every entry with its counterparty (hand tags,
|
||||||
// vendor tags, bills, invoices, their payments, reversals).
|
// vendor tags, bills, invoices, their payments, reversals).
|
||||||
let party_id = ledger.entry_party(id);
|
let party_id = ledger.entry_party(id);
|
||||||
let party = party_id
|
let shares = ledger.entry_parties(id);
|
||||||
.and_then(|p| ledger.get_party(p))
|
let resolved = ledger_core::shares::resolve(e, shares, &share_money, &members).ok();
|
||||||
|
let parties: Vec<EntryParty> = shares
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, s)| EntryParty {
|
||||||
|
id: s.party,
|
||||||
|
label: ledger
|
||||||
|
.get_party(s.party)
|
||||||
.map(|p| p.label().to_string())
|
.map(|p| p.label().to_string())
|
||||||
.or_else(|| vendor.clone());
|
.unwrap_or_else(|| format!("party {}", s.party)),
|
||||||
|
share: s.share,
|
||||||
|
resolved: resolved.as_ref().map(|r| r[i].1),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let party = if parties.is_empty() {
|
||||||
|
vendor.clone()
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
parties
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.label.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" & "),
|
||||||
|
)
|
||||||
|
};
|
||||||
let mut haystack = format!(
|
let mut haystack = format!(
|
||||||
"{} {} #{} {}",
|
"{} {} #{} {}",
|
||||||
e.description(),
|
e.description(),
|
||||||
@@ -527,12 +705,12 @@ impl Books {
|
|||||||
haystack.push(' ');
|
haystack.push(' ');
|
||||||
haystack.push_str(v);
|
haystack.push_str(v);
|
||||||
}
|
}
|
||||||
if let Some(p) = party_id.and_then(|p| ledger.get_party(p))
|
for p in parties.iter().filter_map(|x| ledger.get_party(x.id)) {
|
||||||
&& p.display_name.is_some()
|
if p.display_name.is_some() {
|
||||||
{
|
|
||||||
haystack.push(' ');
|
haystack.push(' ');
|
||||||
haystack.push_str(&p.name);
|
haystack.push_str(&p.name);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
entries.push(EntryInfo {
|
entries.push(EntryInfo {
|
||||||
id,
|
id,
|
||||||
date: e.date(),
|
date: e.date(),
|
||||||
@@ -550,6 +728,7 @@ impl Books {
|
|||||||
cash_effect,
|
cash_effect,
|
||||||
party,
|
party,
|
||||||
party_id,
|
party_id,
|
||||||
|
parties,
|
||||||
haystack: haystack.to_lowercase(),
|
haystack: haystack.to_lowercase(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -574,7 +753,9 @@ impl Books {
|
|||||||
entries,
|
entries,
|
||||||
entry_index,
|
entry_index,
|
||||||
cash_accounts,
|
cash_accounts,
|
||||||
|
members,
|
||||||
financing_accounts,
|
financing_accounts,
|
||||||
|
k1_cash_accounts,
|
||||||
investing_accounts,
|
investing_accounts,
|
||||||
card_accounts,
|
card_accounts,
|
||||||
fresh,
|
fresh,
|
||||||
@@ -796,12 +977,7 @@ impl Books {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for (id, info) in out.iter_mut() {
|
for (id, info) in out.iter_mut() {
|
||||||
let a = ledger_core::parties::activity(
|
let a = self.activity(*id, (year_start, self.today));
|
||||||
l,
|
|
||||||
*id,
|
|
||||||
(year_start, self.today),
|
|
||||||
&self.cash_accounts,
|
|
||||||
);
|
|
||||||
info.entries = self
|
info.entries = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -874,6 +1050,20 @@ impl Books {
|
|||||||
} else {
|
} else {
|
||||||
match t {
|
match t {
|
||||||
"entry.party_set" => u(p, "party_id"),
|
"entry.party_set" => u(p, "party_id"),
|
||||||
|
"entry.parties_set" => {
|
||||||
|
let named = p
|
||||||
|
.get("parties")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|x| u(x, "party_id"));
|
||||||
|
for pid in named {
|
||||||
|
if let Some(info) = out.get_mut(&pid) {
|
||||||
|
info.events.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
"vendor.added" => u(p, "id").and_then(|v| l.party_of_vendor(v)),
|
"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)),
|
"customer.added" => u(p, "id").and_then(|c| l.party_of_customer(c)),
|
||||||
"vendor.1099_set" | "entry.vendor_set" => {
|
"vendor.1099_set" | "entry.vendor_set" => {
|
||||||
@@ -1192,7 +1382,7 @@ impl Books {
|
|||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| e.status == EntryStatus::Posted && e.date <= self.today)
|
.filter(|e| e.status == EntryStatus::Posted && e.date <= self.today)
|
||||||
.filter(|e| party.is_none_or(|p| e.party_id == Some(p)))
|
.filter(|e| party.is_none_or(|p| e.parties.iter().any(|x| x.id == p)))
|
||||||
.map(|e| e.date.year())
|
.map(|e| e.date.year())
|
||||||
.filter(|&x| x < y - 1)
|
.filter(|&x| x < y - 1)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1214,7 +1404,7 @@ impl Books {
|
|||||||
with_breakdown: bool,
|
with_breakdown: bool,
|
||||||
) -> PartyFlow {
|
) -> PartyFlow {
|
||||||
let l = &self.ledger;
|
let l = &self.ledger;
|
||||||
let a = ledger_core::parties::activity(l, party, span, &self.cash_accounts);
|
let a = self.activity(party, span);
|
||||||
let posted: Vec<&ledger_core::JournalEntry> = a
|
let posted: Vec<&ledger_core::JournalEntry> = a
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1228,7 +1418,31 @@ impl Books {
|
|||||||
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
|
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
|
||||||
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
|
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
|
||||||
for e in &posted {
|
for e in &posted {
|
||||||
for (account, amount) in ledger_core::parties::entry_flow(e, &money).what_for {
|
let flow = ledger_core::parties::entry_flow(e, &money);
|
||||||
|
let share = if l.entry_parties(e.id()).len() == 1 {
|
||||||
|
flow.net
|
||||||
|
} else {
|
||||||
|
self.party_share(e, party)
|
||||||
|
};
|
||||||
|
// A member's own capital lines are what their share was
|
||||||
|
// for, when they account for it exactly.
|
||||||
|
let own: Vec<(AccountId, Decimal)> = self
|
||||||
|
.member(party)
|
||||||
|
.map(|m| {
|
||||||
|
flow.what_for
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|(a, _)| *a == m.capital || Some(*a) == m.distributions)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let parts =
|
||||||
|
if !own.is_empty() && own.iter().map(|(_, v)| *v).sum::<Decimal>() == share {
|
||||||
|
own
|
||||||
|
} else {
|
||||||
|
share_of_what_for(&flow, share)
|
||||||
|
};
|
||||||
|
for (account, amount) in parts {
|
||||||
*by.entry(account).or_default() += amount;
|
*by.entry(account).or_default() += amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1243,6 +1457,153 @@ impl Books {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ledger_core::parties::activity_with over `span`, with the books'
|
||||||
|
/// cash accounts and members: the one source of a party's money.
|
||||||
|
pub fn activity(
|
||||||
|
&self,
|
||||||
|
party: PartyId,
|
||||||
|
span: (NaiveDate, NaiveDate),
|
||||||
|
) -> ledger_core::parties::Activity {
|
||||||
|
ledger_core::parties::activity_with(
|
||||||
|
&self.ledger,
|
||||||
|
party,
|
||||||
|
span,
|
||||||
|
&self.cash_accounts,
|
||||||
|
&self.members,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A party's resolved share of an entry's flow (its entry_flow net
|
||||||
|
/// for a sole party; zero when the shares don't resolve or it isn't
|
||||||
|
/// named).
|
||||||
|
pub fn party_share(&self, e: &ledger_core::JournalEntry, party: PartyId) -> Decimal {
|
||||||
|
self.entries
|
||||||
|
.get(self.entry_index.get(&e.id()).copied().unwrap_or(usize::MAX))
|
||||||
|
.and_then(|info| info.parties.iter().find(|p| p.id == party))
|
||||||
|
.and_then(|p| p.resolved)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A member's capital statement over `span` (see
|
||||||
|
/// [`CapitalStatement`]); None when the party isn't a member.
|
||||||
|
pub fn capital_statement(
|
||||||
|
&self,
|
||||||
|
party: PartyId,
|
||||||
|
span: (NaiveDate, NaiveDate),
|
||||||
|
) -> Option<CapitalStatement> {
|
||||||
|
use ledger_core::k1::K1Kind;
|
||||||
|
let m = *self.member(party)?;
|
||||||
|
let l = &self.ledger;
|
||||||
|
let members = self.member_capitals()?;
|
||||||
|
let i = members
|
||||||
|
.iter()
|
||||||
|
.position(|x| x.capital_account == m.capital)?;
|
||||||
|
let detail =
|
||||||
|
ledger_core::k1::k1_detail(l, span.0, span.1, &members, &self.k1_cash_accounts).ok()?;
|
||||||
|
let row = detail.rows.get(i)?;
|
||||||
|
let lines: Vec<CapitalLine> = detail
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.member == i)
|
||||||
|
.filter_map(|x| {
|
||||||
|
let e = l.get_entry(x.entry)?;
|
||||||
|
Some(CapitalLine {
|
||||||
|
entry: x.entry,
|
||||||
|
date: e.date(),
|
||||||
|
description: e.description().to_string(),
|
||||||
|
kind: match x.kind {
|
||||||
|
K1Kind::CashContribution => CapitalKind::CashContribution,
|
||||||
|
K1Kind::PropertyContribution => CapitalKind::PropertyContribution,
|
||||||
|
K1Kind::Distribution => CapitalKind::Distribution,
|
||||||
|
K1Kind::IncomeClose => CapitalKind::IncomeShare,
|
||||||
|
},
|
||||||
|
amount: x.amount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let due_accounts: Vec<AccountId> = if self.financing_accounts.is_empty() {
|
||||||
|
l.chart()
|
||||||
|
.all()
|
||||||
|
.filter(|a| {
|
||||||
|
a.account_type == AccountType::Liability && {
|
||||||
|
let n = a.name.to_lowercase();
|
||||||
|
n.contains("due to") || n.contains("owed to")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|a| a.id)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
self.financing_accounts
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&a| {
|
||||||
|
l.chart()
|
||||||
|
.get(a)
|
||||||
|
.is_some_and(|a| a.account_type == AccountType::Liability)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let due_to_members: Decimal = due_accounts
|
||||||
|
.iter()
|
||||||
|
.map(|&a| self.balance_on(a, span.1))
|
||||||
|
.sum();
|
||||||
|
let shares: Vec<Decimal> = members.iter().map(|x| x.share).collect();
|
||||||
|
let owed_to_member = ledger_core::periods::allocate(due_to_members, &shares)[i];
|
||||||
|
Some(CapitalStatement {
|
||||||
|
party,
|
||||||
|
capital: m.capital,
|
||||||
|
distributions: m.distributions,
|
||||||
|
span,
|
||||||
|
share: row.share,
|
||||||
|
opening: row.beginning,
|
||||||
|
contributions_cash: row.contributions_cash,
|
||||||
|
contributions_property: row.contributions_property,
|
||||||
|
income_share: row.net_income,
|
||||||
|
distributions_taken: row.distributions,
|
||||||
|
other: row.other,
|
||||||
|
closing: row.ending,
|
||||||
|
lines,
|
||||||
|
due_to_members,
|
||||||
|
owed_to_member,
|
||||||
|
files_jointly_with: self
|
||||||
|
.settings
|
||||||
|
.company
|
||||||
|
.members
|
||||||
|
.get(i)
|
||||||
|
.and_then(|x| x.files_jointly_with.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `[[company.members]]`, in order, as ledger_core's
|
||||||
|
/// MemberCapital (the CPA package's list); None when an account isn't
|
||||||
|
/// in the chart or there are no members.
|
||||||
|
pub fn member_capitals(&self) -> Option<Vec<ledger_core::MemberCapital>> {
|
||||||
|
let chart = self.ledger.chart();
|
||||||
|
let members = &self.settings.company.members;
|
||||||
|
if members.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
Some(ledger_core::MemberCapital {
|
||||||
|
name: m.name.clone(),
|
||||||
|
capital_account: chart.find_by_code(&m.capital_account)?.id,
|
||||||
|
distribution_account: match &m.distribution_account {
|
||||||
|
Some(c) => Some(chart.find_by_code(c)?.id),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
share: m.share,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The member a party is, if it is one of `[[company.members]]`.
|
||||||
|
pub fn member(&self, party: PartyId) -> Option<&ledger_core::PartyMember> {
|
||||||
|
self.members.iter().find(|m| m.party == party)
|
||||||
|
}
|
||||||
|
|
||||||
/// A party's 1099 worksheet line for a calendar year and that year's
|
/// A party's 1099 worksheet line for a calendar year and that year's
|
||||||
/// threshold: ledger-core's vendor_1099_report_with (cash and
|
/// threshold: ledger-core's vendor_1099_report_with (cash and
|
||||||
/// financing accounts) line for its vendor record; None without a
|
/// financing accounts) line for its vendor record; None without a
|
||||||
@@ -1269,12 +1630,7 @@ impl Books {
|
|||||||
while m <= span.1 {
|
while m <= span.1 {
|
||||||
let end = (m + chrono::Months::new(1) - chrono::Days::new(1)).min(span.1);
|
let end = (m + chrono::Months::new(1) - chrono::Days::new(1)).min(span.1);
|
||||||
let start = m.max(span.0);
|
let start = m.max(span.0);
|
||||||
let a = ledger_core::parties::activity(
|
let a = self.activity(party, (start, end));
|
||||||
&self.ledger,
|
|
||||||
party,
|
|
||||||
(start, end),
|
|
||||||
&self.cash_accounts,
|
|
||||||
);
|
|
||||||
out.push(PartyMonth {
|
out.push(PartyMonth {
|
||||||
month: m,
|
month: m,
|
||||||
paid: a.paid,
|
paid: a.paid,
|
||||||
@@ -1291,6 +1647,36 @@ impl Books {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An entry flow's what_for apportioned to one party's `share` of its net:
|
||||||
|
/// the whole of it when the share is the net; else each account's amount
|
||||||
|
/// times share / net, rounded toward zero to the cent, the cents left over
|
||||||
|
/// going to the first account, so the parts sum to `share` exactly.
|
||||||
|
pub fn share_of_what_for(
|
||||||
|
flow: &ledger_core::parties::EntryFlow,
|
||||||
|
share: Decimal,
|
||||||
|
) -> Vec<(AccountId, Decimal)> {
|
||||||
|
use rust_decimal::RoundingStrategy::ToZero;
|
||||||
|
if share == flow.net || flow.what_for.is_empty() {
|
||||||
|
return flow.what_for.clone();
|
||||||
|
}
|
||||||
|
if flow.net.is_zero() || share.is_zero() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut parts: Vec<(AccountId, Decimal)> = flow
|
||||||
|
.what_for
|
||||||
|
.iter()
|
||||||
|
.map(|(a, v)| {
|
||||||
|
(
|
||||||
|
*a,
|
||||||
|
(*v * share / flow.net).round_dp_with_strategy(2, ToZero),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let left = share - parts.iter().map(|(_, v)| *v).sum::<Decimal>();
|
||||||
|
parts[0].1 += left;
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
/// Every registered asset's trail, from the journal (depreciation,
|
/// Every registered asset's trail, from the journal (depreciation,
|
||||||
/// disposal, its own events) and the entries (the purchase).
|
/// disposal, its own events) and the entries (the purchase).
|
||||||
fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, AssetTrail> {
|
fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, AssetTrail> {
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
//! A member's capital account statement, on their party page: the partner
|
||||||
|
//! capital statement (opening balance, contributions of cash and of
|
||||||
|
//! property, share of net income, distributions, closing balance), their
|
||||||
|
//! share, and their part of what the company owes the members jointly.
|
||||||
|
//! Every number is [`crate::books::CapitalStatement`]'s (which reads
|
||||||
|
//! ledger_core::k1, the CPA package's K-1 worksheet); this module only
|
||||||
|
//! lays it out.
|
||||||
|
|
||||||
|
use crate::books::{Books, CapitalKind, CapitalStatement};
|
||||||
|
use crate::fmt;
|
||||||
|
use crate::nav::Route;
|
||||||
|
use crate::theme::{self, pal};
|
||||||
|
use crate::widgets;
|
||||||
|
use egui::{Align, Layout, RichText, Sense, Ui, vec2};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
/// What a capital line is, in words.
|
||||||
|
pub fn kind_label(k: CapitalKind) -> &'static str {
|
||||||
|
match k {
|
||||||
|
CapitalKind::CashContribution => "Contribution — cash",
|
||||||
|
CapitalKind::PropertyContribution => "Contribution — property",
|
||||||
|
CapitalKind::IncomeShare => "Share of net income",
|
||||||
|
CapitalKind::Distribution => "Distribution",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A share as a percent: 0.5 → "50%", 0.3333 → "33.33%".
|
||||||
|
pub fn percent(share: Decimal) -> String {
|
||||||
|
format!("{}%", (share * Decimal::ONE_HUNDRED).normalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One statement row: an indented label, an amount column, and an
|
||||||
|
/// optional total column; `strong` for the balances.
|
||||||
|
fn line(
|
||||||
|
ui: &mut Ui,
|
||||||
|
label: &str,
|
||||||
|
indent: f32,
|
||||||
|
amount: Option<String>,
|
||||||
|
total: Option<String>,
|
||||||
|
strong: bool,
|
||||||
|
) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let col = 130.0;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(indent);
|
||||||
|
let font = if strong {
|
||||||
|
theme::semibold(14.0)
|
||||||
|
} else {
|
||||||
|
theme::regular(14.0)
|
||||||
|
};
|
||||||
|
ui.label(RichText::new(label).font(font));
|
||||||
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||||
|
let t = total.unwrap_or_default();
|
||||||
|
widgets::amount_cell(ui, &t, col, p.text, strong, false);
|
||||||
|
let a = amount.unwrap_or_default();
|
||||||
|
widgets::amount_cell(ui, &a, col, p.text, false, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short rule over the amount column (`double`: two, under a total).
|
||||||
|
fn underline(ui: &mut Ui, total_col: bool, double: bool) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let col = 130.0;
|
||||||
|
let w = ui.available_width();
|
||||||
|
let (rect, _) = ui.allocate_exact_size(vec2(w, if double { 5.0 } else { 3.0 }), Sense::hover());
|
||||||
|
let right = if total_col {
|
||||||
|
rect.right()
|
||||||
|
} else {
|
||||||
|
rect.right() - col - ui.spacing().item_spacing.x
|
||||||
|
};
|
||||||
|
let left = right - col + 30.0;
|
||||||
|
let stroke = egui::Stroke::new(1.0, p.text);
|
||||||
|
let y = rect.top() + 1.0;
|
||||||
|
ui.painter()
|
||||||
|
.line_segment([egui::pos2(left, y), egui::pos2(right, y)], stroke);
|
||||||
|
if double {
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[egui::pos2(left, y + 3.0), egui::pos2(right, y + 3.0)],
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The member's capital account card.
|
||||||
|
pub fn capital_card(
|
||||||
|
ui: &mut Ui,
|
||||||
|
b: &Books,
|
||||||
|
st: &CapitalStatement,
|
||||||
|
name: &str,
|
||||||
|
go: &mut Option<Route>,
|
||||||
|
) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let account = b.account_name(st.capital);
|
||||||
|
let (open_account, ()) = widgets::titled_card(
|
||||||
|
ui,
|
||||||
|
"Capital account",
|
||||||
|
|ui| {
|
||||||
|
widgets::link(
|
||||||
|
ui,
|
||||||
|
RichText::new(&account)
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.accent),
|
||||||
|
)
|
||||||
|
.on_hover_text("Open the capital account's register")
|
||||||
|
.clicked()
|
||||||
|
},
|
||||||
|
|ui| {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.label(RichText::new(name).font(theme::semibold(15.0)));
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!(
|
||||||
|
"Statement of partner's capital · {} – {}",
|
||||||
|
fmt::date(st.span.0),
|
||||||
|
fmt::date(st.span.1)
|
||||||
|
))
|
||||||
|
.font(theme::regular(12.5))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
if let Some(j) = &st.files_jointly_with {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!(
|
||||||
|
"Files a joint return with {j}: a K-1 each, both on one Form 1040"
|
||||||
|
))
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(10.0);
|
||||||
|
let amt = |d: Decimal| Some(fmt::accounting(d));
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
&format!("Capital, {}", fmt::date(st.span.0)),
|
||||||
|
0.0,
|
||||||
|
None,
|
||||||
|
Some(format!("${}", fmt::accounting(st.opening))),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Add")
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
"Contributions of cash",
|
||||||
|
16.0,
|
||||||
|
amt(st.contributions_cash),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
"Contributions of property (at fair market value)",
|
||||||
|
16.0,
|
||||||
|
amt(st.contributions_property),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
&format!("Share of net income ({})", percent(st.share)),
|
||||||
|
16.0,
|
||||||
|
amt(st.income_share),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Less")
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
"Distributions",
|
||||||
|
16.0,
|
||||||
|
Some(fmt::accounting(-st.distributions_taken)),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if !st.other.is_zero() {
|
||||||
|
line(ui, "Other changes", 16.0, amt(st.other), None, false);
|
||||||
|
}
|
||||||
|
underline(ui, false, false);
|
||||||
|
let change = st.contributions_cash + st.contributions_property + st.income_share
|
||||||
|
- st.distributions_taken
|
||||||
|
+ st.other;
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
"Net change in capital",
|
||||||
|
0.0,
|
||||||
|
None,
|
||||||
|
Some(fmt::accounting(change)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
underline(ui, true, false);
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
&format!("Capital, {}", fmt::date(st.span.1)),
|
||||||
|
0.0,
|
||||||
|
None,
|
||||||
|
Some(format!("${}", fmt::accounting(st.closing))),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
underline(ui, true, true);
|
||||||
|
if !st.income_share.is_zero()
|
||||||
|
&& !st.lines.iter().any(|l| l.kind == CapitalKind::IncomeShare)
|
||||||
|
{
|
||||||
|
ui.label(
|
||||||
|
RichText::new(
|
||||||
|
"No period closed in this span: the share of net income is earned but not yet closed into the capital account.",
|
||||||
|
)
|
||||||
|
.font(theme::regular(11.5))
|
||||||
|
.color(p.faint),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.add_space(10.0);
|
||||||
|
widgets::rule(ui);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
"Share of profit, loss, and capital",
|
||||||
|
0.0,
|
||||||
|
None,
|
||||||
|
Some(percent(st.share)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let owed = format!(
|
||||||
|
"Owed to them on Due to Shareholders ({} of {})",
|
||||||
|
percent(st.share),
|
||||||
|
fmt::dollars(st.due_to_members)
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
ui,
|
||||||
|
&owed,
|
||||||
|
0.0,
|
||||||
|
None,
|
||||||
|
Some(fmt::dollars(st.owed_to_member)),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if !st.lines.is_empty() {
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.collapsing(
|
||||||
|
RichText::new(format!(
|
||||||
|
"{} in this span",
|
||||||
|
fmt::count(st.lines.len(), "capital entry", "capital entries")
|
||||||
|
))
|
||||||
|
.font(theme::regular(12.5)),
|
||||||
|
|ui| {
|
||||||
|
for l in &st.lines {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(fmt::date(l.date))
|
||||||
|
.font(theme::regular(12.5))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
if widgets::link(
|
||||||
|
ui,
|
||||||
|
RichText::new(format!("#{} {}", l.entry, l.description))
|
||||||
|
.font(theme::regular(12.5)),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
*go = Some(Route::Entry(l.entry));
|
||||||
|
}
|
||||||
|
ui.label(
|
||||||
|
RichText::new(kind_label(l.kind))
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.faint),
|
||||||
|
);
|
||||||
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||||
|
widgets::amount_cell(
|
||||||
|
ui,
|
||||||
|
&fmt::accounting(l.amount),
|
||||||
|
130.0,
|
||||||
|
p.text,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if open_account {
|
||||||
|
*go = Some(Route::Account(st.capital));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
|||||||
};
|
};
|
||||||
widgets::pill(ui, e.origin, p.muted, p.hover);
|
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()
|
&& e.parties.is_empty()
|
||||||
{
|
{
|
||||||
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
|
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
|
||||||
}
|
}
|
||||||
@@ -87,19 +87,24 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
|||||||
shell.go(Route::Entry(r));
|
shell.go(Route::Entry(r));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(party) = e.party_id.and_then(|id| b.ledger.get_party(id)) {
|
let several = e.parties.len() > 1;
|
||||||
|
for share in &e.parties {
|
||||||
|
let Some(party) = b.ledger.get_party(share.id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
if widgets::link(
|
let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
|
||||||
ui,
|
if several {
|
||||||
RichText::new(format!(
|
text.push_str(&format!(" · {}", share_text(share)));
|
||||||
"{} {}",
|
}
|
||||||
super::parties::party_icon(party),
|
if widgets::link(ui, RichText::new(text).color(p.accent))
|
||||||
party.label()
|
|
||||||
))
|
|
||||||
.color(p.accent),
|
|
||||||
)
|
|
||||||
.on_hover_text(format!(
|
.on_hover_text(format!(
|
||||||
"The party this entry is with ({}): open them",
|
"{} this entry is with ({}): open them",
|
||||||
|
if several {
|
||||||
|
"One of the parties"
|
||||||
|
} else {
|
||||||
|
"The party"
|
||||||
|
},
|
||||||
super::parties::roles_text(party).to_lowercase()
|
super::parties::roles_text(party).to_lowercase()
|
||||||
))
|
))
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -107,7 +112,17 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
|||||||
shell.go(Route::Party(party.id));
|
shell.go(Route::Party(party.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e.party_id.is_none()
|
if several && e.parties.iter().any(|x| x.resolved.is_none()) {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!(
|
||||||
|
"{} The parties' shares don't add up to what this entry moved",
|
||||||
|
icon::WARNING
|
||||||
|
))
|
||||||
|
.color(p.negative),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if e.parties.is_empty()
|
||||||
&& let Some(why) = b.ledger.entry_no_party(e.id)
|
&& let Some(why) = b.ledger.entry_no_party(e.id)
|
||||||
{
|
{
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
@@ -490,3 +505,22 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One party's share of an entry, in words: "$50.00 (derived)", "$70.00",
|
||||||
|
/// "$33.34 (33.333%)", or the share alone when it doesn't resolve.
|
||||||
|
pub fn share_text(s: &crate::books::EntryParty) -> String {
|
||||||
|
let how = match s.share {
|
||||||
|
ledger_core::Share::Amount(_) => None,
|
||||||
|
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
|
||||||
|
ledger_core::Share::Derived => Some("derived".to_string()),
|
||||||
|
};
|
||||||
|
match (s.resolved, how) {
|
||||||
|
(Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())),
|
||||||
|
(Some(v), None) => fmt::dollars(v.abs()),
|
||||||
|
(None, Some(h)) => h,
|
||||||
|
(None, None) => match s.share {
|
||||||
|
ledger_core::Share::Amount(a) => fmt::dollars(a),
|
||||||
|
_ => String::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -473,6 +473,30 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
|||||||
};
|
};
|
||||||
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
|
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
|
||||||
}
|
}
|
||||||
|
"party.tin_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 kind = match f.get("tin_type").and_then(|v| v.as_str()) {
|
||||||
|
Some("ssn") => "SSN",
|
||||||
|
Some("ein") => "EIN",
|
||||||
|
Some("itin") => "ITIN",
|
||||||
|
_ => "TIN",
|
||||||
|
};
|
||||||
|
let l4 = f.get("last4").and_then(|v| v.as_str()).unwrap_or("????");
|
||||||
|
if f.get("purged").is_some_and(|v| !v.is_null()) {
|
||||||
|
format!("Purged {name}'s {kind} (••••{l4}) from the vault")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Taxpayer ID for {name} recorded: {kind} ending {l4}, kept in the vault"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => format!("Taxpayer ID facts for {name} cleared"),
|
||||||
|
};
|
||||||
|
ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party))
|
||||||
|
}
|
||||||
"party.w4_set" => {
|
"party.w4_set" => {
|
||||||
let pid = p.get("id").and_then(|v| v.as_u64());
|
let pid = p.get("id").and_then(|v| v.as_u64());
|
||||||
let name = party_name(b, pid);
|
let name = party_name(b, pid);
|
||||||
@@ -491,6 +515,59 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
|||||||
};
|
};
|
||||||
ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party))
|
ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party))
|
||||||
}
|
}
|
||||||
|
"entry.parties_set" => {
|
||||||
|
let e = entry_id.unwrap_or(0);
|
||||||
|
let named: Vec<(u64, String)> = p
|
||||||
|
.get("parties")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|x| {
|
||||||
|
let id = x.get("party_id").and_then(|v| v.as_u64())?;
|
||||||
|
let share = match x.get("share") {
|
||||||
|
Some(Value::Object(o)) => o
|
||||||
|
.get("amount")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|a| {
|
||||||
|
rust_decimal::Decimal::from_str_exact(a)
|
||||||
|
.map(fmt::dollars)
|
||||||
|
.unwrap_or_else(|_| a.to_string())
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
o.get("percent")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|pc| format!("{pc}%"))
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let name = party_name(b, Some(id));
|
||||||
|
Some((
|
||||||
|
id,
|
||||||
|
match share {
|
||||||
|
Some(sh) => format!("{name} ({sh})"),
|
||||||
|
None => name,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(Event {
|
||||||
|
icon: icon::ADDRESS_BOOK,
|
||||||
|
text: if named.is_empty() {
|
||||||
|
format!("Cleared the parties of #{e}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Tagged #{e} with {}",
|
||||||
|
named
|
||||||
|
.iter()
|
||||||
|
.map(|(_, n)| n.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
route: entry_id.map(Route::Entry),
|
||||||
|
also: named.first().map(|(id, _)| Route::Party(*id)),
|
||||||
|
})
|
||||||
|
}
|
||||||
"entry.party_set" => {
|
"entry.party_set" => {
|
||||||
let pid = p.get("party_id").and_then(|v| v.as_u64());
|
let pid = p.get("party_id").and_then(|v| v.as_u64());
|
||||||
let e = entry_id.unwrap_or(0);
|
let e = entry_id.unwrap_or(0);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod capital;
|
||||||
pub mod cash;
|
pub mod cash;
|
||||||
pub mod company;
|
pub mod company;
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
|
|||||||
@@ -159,6 +159,21 @@ pub fn masked_tin(last4: &str) -> String {
|
|||||||
format!("•••-••-{last4}")
|
format!("•••-••-{last4}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A party's taxpayer-ID facts as the viewer shows them: "TIN on file
|
||||||
|
/// ••••1234 (SSN)", never more than the last four; once purged from the
|
||||||
|
/// vault, "TIN ••••1234 (SSN), purged from the vault".
|
||||||
|
pub fn tin_on_file(t: &ledger_core::TinFacts) -> String {
|
||||||
|
if t.vault_record.is_none() && t.purged.is_some() {
|
||||||
|
format!(
|
||||||
|
"TIN ••••{} ({}), purged from the vault",
|
||||||
|
t.last4,
|
||||||
|
t.tin_type.label()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("TIN on file ••••{} ({})", t.last4, t.tin_type.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Where a party stands on its W-9.
|
/// Where a party stands on its W-9.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
@@ -741,6 +756,11 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
|||||||
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &value.to_string()));
|
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &value.to_string()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A member's page is their capital account statement too.
|
||||||
|
if let Some(st) = b.capital_statement(id, span) {
|
||||||
|
super::capital::capital_card(ui, b, &st, party.label(), &mut go);
|
||||||
|
ui.add_space(14.0);
|
||||||
|
}
|
||||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||||
if col == 0 {
|
if col == 0 {
|
||||||
what_for_card(ui, b, &flow, &label, &mut go);
|
what_for_card(ui, b, &flow, &label, &mut go);
|
||||||
@@ -882,6 +902,19 @@ fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option<Route>) {
|
|||||||
&party.id.to_string(),
|
&party.id.to_string(),
|
||||||
);
|
);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
|
k(ui, "Taxpayer ID");
|
||||||
|
match &party.tin {
|
||||||
|
Some(t) => {
|
||||||
|
ui.label(RichText::new(tin_on_file(t)).monospace())
|
||||||
|
.on_hover_text(
|
||||||
|
"Only the last four digits are in the books; the full number is kept in the encrypted TIN vault, for the CPA package.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui.label(RichText::new("not recorded").color(p.faint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
if let Some(v) = party.vendor {
|
if let Some(v) = party.vendor {
|
||||||
k(ui, "Vendor record");
|
k(ui, "Vendor record");
|
||||||
if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent))
|
if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent))
|
||||||
@@ -1141,12 +1174,31 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
|
|||||||
let text = if draft { p.muted } else { p.text };
|
let text = if draft { p.muted } else { p.text };
|
||||||
let accounts: Vec<String> =
|
let accounts: Vec<String> =
|
||||||
e.accounts.iter().map(|a| b.account_name(*a)).collect();
|
e.accounts.iter().map(|a| b.account_name(*a)).collect();
|
||||||
let sub = accounts.join(" → ");
|
let mut sub = accounts.join(" → ");
|
||||||
let kind = if draft {
|
let kind = if draft {
|
||||||
format!("{} · draft", e.origin)
|
format!("{} · draft", e.origin)
|
||||||
} else {
|
} else {
|
||||||
e.origin.to_string()
|
e.origin.to_string()
|
||||||
};
|
};
|
||||||
|
// Shared with other parties: show their part.
|
||||||
|
let mut amount = e.amount;
|
||||||
|
if e.parties.len() > 1
|
||||||
|
&& let Some(mine) = e.parties.iter().find(|x| x.id == info.id)
|
||||||
|
{
|
||||||
|
let others: Vec<&str> = e
|
||||||
|
.parties
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.id != info.id)
|
||||||
|
.map(|x| x.label.as_str())
|
||||||
|
.collect();
|
||||||
|
sub = format!(
|
||||||
|
"Their share, {} of {}, with {} · {sub}",
|
||||||
|
super::entry::share_text(mine),
|
||||||
|
fmt::dollars(e.amount),
|
||||||
|
others.join(" & ")
|
||||||
|
);
|
||||||
|
amount = mine.resolved.map(|v| v.abs()).unwrap_or(e.amount);
|
||||||
|
}
|
||||||
let resp = row(
|
let resp = row(
|
||||||
ui,
|
ui,
|
||||||
w,
|
w,
|
||||||
@@ -1155,7 +1207,7 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>
|
|||||||
(format!("#{}", e.id), 64.0, false, p.muted),
|
(format!("#{}", e.id), 64.0, false, p.muted),
|
||||||
(e.description.clone(), 0.0, false, text),
|
(e.description.clone(), 0.0, false, text),
|
||||||
(kind, 150.0, false, if draft { p.warning } else { p.muted }),
|
(kind, 150.0, false, if draft { p.warning } else { p.muted }),
|
||||||
(fmt::money(e.amount), amt, true, text),
|
(fmt::money(amount), amt, true, text),
|
||||||
],
|
],
|
||||||
Some((sub.as_str(), p.faint)),
|
Some((sub.as_str(), p.faint)),
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use egui_kittest::Harness;
|
|||||||
use ledger_core::arap::DocumentLine;
|
use ledger_core::arap::DocumentLine;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
|
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
|
||||||
W4Facts, W9Facts, vendor_1099_report,
|
TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report,
|
||||||
};
|
};
|
||||||
use ledger_gui::books::Books;
|
use ledger_gui::books::Books;
|
||||||
use ledger_gui::nav::{Route, Section};
|
use ledger_gui::nav::{Route, Section};
|
||||||
@@ -577,6 +577,56 @@ fn w9_text_never_shows_more_than_the_last_four() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// pages/journal.rs `party.tin_set`: recorded, purged, and cleared, each
|
||||||
|
/// in plain language with the TIN's type and last four, never more of it.
|
||||||
|
#[test]
|
||||||
|
fn tin_text_never_shows_more_than_the_last_four() {
|
||||||
|
let f = fixture("tintext");
|
||||||
|
let mut s = Store::open(&f.dir.0.join("books-data")).unwrap();
|
||||||
|
let facts = |tin_type, purged: Option<&str>| TinFacts {
|
||||||
|
tin_type,
|
||||||
|
last4: "4915".into(),
|
||||||
|
vault_record: purged.is_none().then(|| "ab".repeat(32)),
|
||||||
|
purged: purged.map(str::to_string),
|
||||||
|
};
|
||||||
|
s.set_party_tin(f.pat, Some(facts(TinType::Ssn, None)))
|
||||||
|
.unwrap();
|
||||||
|
s.set_party_tin(
|
||||||
|
f.pat,
|
||||||
|
Some(facts(
|
||||||
|
TinType::Ssn,
|
||||||
|
Some("purged from the wanderbooks vault on 2026-10-15: retention over"),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.set_party_tin(f.pat, None).unwrap();
|
||||||
|
s.set_party_tin(f.pat, Some(facts(TinType::Itin, None)))
|
||||||
|
.unwrap();
|
||||||
|
drop(s);
|
||||||
|
let b = load(&f.dir.0);
|
||||||
|
let events: Vec<_> = b
|
||||||
|
.journal
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.line_type == "party.tin_set")
|
||||||
|
.filter_map(|l| describe(&b, l))
|
||||||
|
.collect();
|
||||||
|
let texts: Vec<&str> = events.iter().map(|e| e.text.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
texts,
|
||||||
|
vec![
|
||||||
|
"Taxpayer ID for Pat Lee recorded: SSN ending 4915, kept in the vault",
|
||||||
|
"Purged Pat Lee's SSN (••••4915) from the vault",
|
||||||
|
"Taxpayer ID facts for Pat Lee cleared",
|
||||||
|
"Taxpayer ID for Pat Lee recorded: ITIN ending 4915, kept in the vault",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(events.iter().all(|e| e.route == Some(Route::Party(f.pat))));
|
||||||
|
// No more than four digits in any of them.
|
||||||
|
for t in &texts {
|
||||||
|
assert!(t.chars().filter(|c| c.is_ascii_digit()).count() <= 4, "{t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attention_asks_for_a_w9_until_its_facts_are_recorded() {
|
fn attention_asks_for_a_w9_until_its_facts_are_recorded() {
|
||||||
let f = fixture("attention");
|
let f = fixture("attention");
|
||||||
@@ -914,3 +964,510 @@ fn entries_without_a_counterparty_need_attention_unless_off() {
|
|||||||
let (dir, _s, ..) = counterparty_books("np-off", Some("off"));
|
let (dir, _s, ..) = counterparty_books("np-off", Some("off"));
|
||||||
assert!(item(&load_with_toml(&dir.0)).is_none());
|
assert!(item(&load_with_toml(&dir.0)).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- party shares and the capital statement
|
||||||
|
//
|
||||||
|
// Oracles: EntryParty (resolved shares), PartyFlow (each party's share),
|
||||||
|
// CapitalStatement (INVARIANTS 1–6), ledger_core::k1, docs/parties-and-attachments.md
|
||||||
|
// "Shares" and "A member's capital statement". Values worked by hand.
|
||||||
|
|
||||||
|
struct MemberBooks {
|
||||||
|
dir: TempDir,
|
||||||
|
stephen: PartyId,
|
||||||
|
kat: PartyId,
|
||||||
|
reimburse: EntryId,
|
||||||
|
property: EntryId,
|
||||||
|
distribution: EntryId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two 50/50 members. 2025: a joint cash contribution of $1,000.00 each.
|
||||||
|
/// 2026: equipment contributed ($600.00 each, Feb), an expense paid from
|
||||||
|
/// joint funds ($300.00 on 2100, Mar), revenue $1,000.00 (Apr), $100.00
|
||||||
|
/// of 2100 reimbursed to both (May), $200.00 distributed to each (Jun),
|
||||||
|
/// and Jan–Jun closed to the members ($700.00 net: $350.00 each).
|
||||||
|
fn member_books(tag: &str) -> MemberBooks {
|
||||||
|
let dir = TempDir::new(tag);
|
||||||
|
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||||
|
let acct = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
|
||||||
|
let bank = acct(&mut s, "1000", "Bluevine", AccountType::Asset);
|
||||||
|
let equip = acct(&mut s, "1500", "Equipment", AccountType::Asset);
|
||||||
|
let due = acct(
|
||||||
|
&mut s,
|
||||||
|
"2100",
|
||||||
|
"Due to Shareholders",
|
||||||
|
AccountType::Liability,
|
||||||
|
);
|
||||||
|
let cap_s = acct(
|
||||||
|
&mut s,
|
||||||
|
"3000",
|
||||||
|
"Owner Capital — Stephen Waits",
|
||||||
|
AccountType::Equity,
|
||||||
|
);
|
||||||
|
let cap_k = acct(
|
||||||
|
&mut s,
|
||||||
|
"3001",
|
||||||
|
"Owner Capital — Katherine Pendergast",
|
||||||
|
AccountType::Equity,
|
||||||
|
);
|
||||||
|
let rev = acct(&mut s, "4000", "App Revenue", AccountType::Revenue);
|
||||||
|
let exp = acct(&mut s, "6050", "Domain & Hosting", AccountType::Expense);
|
||||||
|
let stephen = s.add_party("Stephen Waits", &[Role::Member]).unwrap();
|
||||||
|
let kat = s
|
||||||
|
.add_party("Katherine Pendergast", &[Role::Member])
|
||||||
|
.unwrap();
|
||||||
|
let both = [
|
||||||
|
ledger_core::PartyShare::whole(stephen),
|
||||||
|
ledger_core::PartyShare::whole(kat),
|
||||||
|
];
|
||||||
|
let post = |s: &mut Store, date, what: &str, lines: &[(u64, Side, i64)]| {
|
||||||
|
let e = s.create_entry(date, what).unwrap();
|
||||||
|
for &(a, side, c) in lines {
|
||||||
|
s.add_line(e, a, side, usd(c)).unwrap();
|
||||||
|
}
|
||||||
|
s.post_entry(e).unwrap();
|
||||||
|
e
|
||||||
|
};
|
||||||
|
let open = post(
|
||||||
|
&mut s,
|
||||||
|
d(2025, 6, 1),
|
||||||
|
"Opening contribution",
|
||||||
|
&[
|
||||||
|
(bank, Side::Debit, 200_000),
|
||||||
|
(cap_s, Side::Credit, 100_000),
|
||||||
|
(cap_k, Side::Credit, 100_000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
s.set_entry_parties(open, &both).unwrap();
|
||||||
|
let property = post(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 2, 1),
|
||||||
|
"Laptop contributed",
|
||||||
|
&[
|
||||||
|
(equip, Side::Debit, 120_000),
|
||||||
|
(cap_s, Side::Credit, 60_000),
|
||||||
|
(cap_k, Side::Credit, 60_000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
s.set_entry_parties(property, &both).unwrap();
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 3, 1),
|
||||||
|
"Hosting from joint funds",
|
||||||
|
&[(exp, Side::Debit, 30_000), (due, Side::Credit, 30_000)],
|
||||||
|
);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 4, 1),
|
||||||
|
"Sales",
|
||||||
|
&[(bank, Side::Debit, 100_000), (rev, Side::Credit, 100_000)],
|
||||||
|
);
|
||||||
|
let reimburse = post(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 5, 1),
|
||||||
|
"Reimburse the household",
|
||||||
|
&[(due, Side::Debit, 10_000), (bank, Side::Credit, 10_000)],
|
||||||
|
);
|
||||||
|
s.set_entry_parties(reimburse, &both).unwrap();
|
||||||
|
let distribution = post(
|
||||||
|
&mut s,
|
||||||
|
d(2026, 6, 1),
|
||||||
|
"Distribution",
|
||||||
|
&[
|
||||||
|
(cap_s, Side::Debit, 20_000),
|
||||||
|
(cap_k, Side::Debit, 20_000),
|
||||||
|
(bank, Side::Credit, 40_000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
s.set_entry_parties(distribution, &both).unwrap();
|
||||||
|
let period = s
|
||||||
|
.create_period("H1 2026", d(2026, 1, 1), d(2026, 6, 30))
|
||||||
|
.unwrap();
|
||||||
|
let half = Decimal::new(5, 1);
|
||||||
|
s.close_period_to(
|
||||||
|
period,
|
||||||
|
&ledger_core::periods::CloseTo::Members(vec![
|
||||||
|
ledger_core::MemberClose {
|
||||||
|
capital: cap_s,
|
||||||
|
share: half,
|
||||||
|
distributions: None,
|
||||||
|
},
|
||||||
|
ledger_core::MemberClose {
|
||||||
|
capital: cap_k,
|
||||||
|
share: half,
|
||||||
|
distributions: None,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_member_toml(&dir.0, "cash_accounts = [\"1000\"]\n");
|
||||||
|
MemberBooks {
|
||||||
|
dir,
|
||||||
|
stephen,
|
||||||
|
kat,
|
||||||
|
reimburse,
|
||||||
|
property,
|
||||||
|
distribution,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture's wanderbooks.toml, with `books` as its `[books]` table.
|
||||||
|
fn write_member_toml(dir: &Path, books: &str) {
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("wanderbooks.toml"),
|
||||||
|
format!(
|
||||||
|
"[books]\n{books}[company]\nname = \"Wanderland Labs LLC\"\n\
|
||||||
|
[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||||
|
[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post one entry to the fixture's books after the fact.
|
||||||
|
fn post_more(m: &MemberBooks, date: NaiveDate, lines: &[(&str, Side, i64)]) -> EntryId {
|
||||||
|
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||||
|
let e = s.create_entry(date, "Later").unwrap();
|
||||||
|
for &(code, side, c) in lines {
|
||||||
|
let a = s.ledger().chart().find_by_code(code).unwrap().id;
|
||||||
|
s.add_line(e, a, side, usd(c)).unwrap();
|
||||||
|
}
|
||||||
|
s.post_entry(e).unwrap();
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entries_list_every_party_with_its_resolved_share() {
|
||||||
|
let m = member_books("shares-entries");
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let e = b.entry(m.reimburse).unwrap();
|
||||||
|
assert_eq!(e.party_id, None, "two parties: no sole party");
|
||||||
|
assert_eq!(
|
||||||
|
e.parties
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.id, p.resolved))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![(m.stephen, Some(cents(5_000))), (m.kat, Some(cents(5_000)))]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
e.party.as_deref(),
|
||||||
|
Some("Stephen Waits & Katherine Pendergast")
|
||||||
|
);
|
||||||
|
// Property: net zero, shares of the total.
|
||||||
|
let e = b.entry(m.property).unwrap();
|
||||||
|
assert_eq!(e.parties[1].resolved, Some(cents(60_000)));
|
||||||
|
// The journal says who, for parties_set.
|
||||||
|
let line = b
|
||||||
|
.journal
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.line_type == "entry.parties_set")
|
||||||
|
.unwrap();
|
||||||
|
let ev = describe(&b, line).unwrap();
|
||||||
|
assert!(
|
||||||
|
ev.text.contains("Stephen Waits and Katherine Pendergast"),
|
||||||
|
"{}",
|
||||||
|
ev.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_members_money_is_their_share() {
|
||||||
|
let m = member_books("shares-flow");
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let span = (d(2026, 1, 1), today());
|
||||||
|
for who in [m.stephen, m.kat] {
|
||||||
|
let f = b.party_flow(who, span, true);
|
||||||
|
// Half the reimbursement plus their own distribution; the
|
||||||
|
// property moved no money.
|
||||||
|
assert_eq!(f.paid, cents(25_000));
|
||||||
|
assert_eq!(f.received, Decimal::ZERO);
|
||||||
|
let what: Decimal = f.what_for.iter().map(|(_, v)| *v).sum();
|
||||||
|
assert_eq!(what, f.net(), "what for sums to the net");
|
||||||
|
assert!(b.party(who).unwrap().entries.contains(&m.distribution));
|
||||||
|
}
|
||||||
|
assert_eq!(b.party(m.kat).unwrap().paid_ytd, cents(25_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_members_page_is_their_capital_statement() {
|
||||||
|
let m = member_books("capital");
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let st = b
|
||||||
|
.capital_statement(m.stephen, (d(2026, 1, 1), today()))
|
||||||
|
.expect("a member");
|
||||||
|
assert_eq!(st.share, Decimal::new(5, 1));
|
||||||
|
assert_eq!(st.opening, cents(100_000));
|
||||||
|
assert_eq!(st.contributions_cash, Decimal::ZERO);
|
||||||
|
assert_eq!(st.contributions_property, cents(60_000));
|
||||||
|
assert_eq!(st.income_share, cents(35_000));
|
||||||
|
assert_eq!(st.distributions_taken, cents(20_000));
|
||||||
|
assert_eq!(st.closing, cents(175_000));
|
||||||
|
assert_eq!(
|
||||||
|
st.closing,
|
||||||
|
st.opening + st.contributions_cash + st.contributions_property + st.income_share
|
||||||
|
- st.distributions_taken
|
||||||
|
);
|
||||||
|
assert_eq!(st.other, Decimal::ZERO);
|
||||||
|
// No [books] financing_accounts: 2100's name says "due to"; $300.00
|
||||||
|
// advanced less $100.00 reimbursed, half each.
|
||||||
|
assert_eq!(st.due_to_members, cents(20_000));
|
||||||
|
assert_eq!(st.owed_to_member, cents(10_000));
|
||||||
|
// The lines behind it (k1_detail INVARIANT 5), in journal order.
|
||||||
|
use ledger_gui::books::CapitalKind as K;
|
||||||
|
let kinds: Vec<(EntryId, K, Decimal)> = st
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.entry, l.kind, l.amount))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kinds.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
kinds[0],
|
||||||
|
(m.property, K::PropertyContribution, cents(60_000))
|
||||||
|
);
|
||||||
|
assert_eq!(kinds[1], (m.distribution, K::Distribution, cents(-20_000)));
|
||||||
|
assert_eq!((kinds[2].1, kinds[2].2), (K::IncomeShare, cents(35_000)));
|
||||||
|
// Over all time: the 2025 contribution is cash (1000 is
|
||||||
|
// [books] cash_accounts), the laptop property.
|
||||||
|
let all = b
|
||||||
|
.capital_statement(m.kat, (d(2025, 1, 1), today()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(all.opening, Decimal::ZERO);
|
||||||
|
assert_eq!(all.contributions_cash, cents(100_000));
|
||||||
|
assert_eq!(all.contributions_property, cents(60_000));
|
||||||
|
assert_eq!(all.income_share, cents(35_000));
|
||||||
|
assert_eq!(all.distributions_taken, cents(20_000));
|
||||||
|
assert_eq!(all.closing, cents(175_000));
|
||||||
|
// Not a member: no statement.
|
||||||
|
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||||
|
let other = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
|
||||||
|
drop(s);
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
assert!(
|
||||||
|
b.capital_statement(other, (d(2026, 1, 1), today()))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
// The page shows it.
|
||||||
|
let mut h = Harness::builder()
|
||||||
|
.with_size(egui::vec2(1440.0, 1400.0))
|
||||||
|
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(m.stephen)));
|
||||||
|
h.run_steps(3);
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
h.get_by_label("Capital account");
|
||||||
|
h.get_by_label("Contributions of property (at fair market value)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The viewer's statement is the K-1 worksheet's row, field for field,
|
||||||
|
/// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called
|
||||||
|
/// here with the members and cash accounts as the CPA package builds them.
|
||||||
|
#[test]
|
||||||
|
fn the_capital_statement_is_the_k1_worksheet_row() {
|
||||||
|
let m = member_books("capital-k1");
|
||||||
|
// Income not yet closed, so beginnings carry undistributed earnings.
|
||||||
|
post_more(
|
||||||
|
&m,
|
||||||
|
d(2026, 7, 1),
|
||||||
|
&[
|
||||||
|
("1000", Side::Debit, 10_000),
|
||||||
|
("4000", Side::Credit, 10_000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for (books, fallback) in [("cash_accounts = [\"1000\"]\n", false), ("", true)] {
|
||||||
|
write_member_toml(&m.dir.0, books);
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let chart = b.ledger.chart();
|
||||||
|
let id = |c: &str| chart.find_by_code(c).unwrap().id;
|
||||||
|
let members =
|
||||||
|
[("Stephen Waits", "3000"), ("Katherine Pendergast", "3001")].map(|(name, code)| {
|
||||||
|
ledger_core::MemberCapital {
|
||||||
|
name: name.into(),
|
||||||
|
capital_account: id(code),
|
||||||
|
distribution_account: None,
|
||||||
|
share: Decimal::new(5, 1),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cash: Vec<_> = if fallback {
|
||||||
|
let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[])
|
||||||
|
.cash
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
all.sort_unstable();
|
||||||
|
all
|
||||||
|
} else {
|
||||||
|
vec![id("1000")]
|
||||||
|
};
|
||||||
|
for span in [
|
||||||
|
(d(2025, 1, 1), today()),
|
||||||
|
(d(2026, 1, 1), today()),
|
||||||
|
(d(2026, 2, 1), d(2026, 5, 31)),
|
||||||
|
(d(2026, 7, 1), today()),
|
||||||
|
(d(2026, 8, 1), today()),
|
||||||
|
] {
|
||||||
|
let rows =
|
||||||
|
ledger_core::k1::k1_worksheet(&b.ledger, span.0, span.1, &members, &cash).unwrap();
|
||||||
|
for (who, row) in [m.stephen, m.kat].into_iter().zip(&rows) {
|
||||||
|
let st = b.capital_statement(who, span).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
st.share,
|
||||||
|
st.opening,
|
||||||
|
st.contributions_cash,
|
||||||
|
st.contributions_property,
|
||||||
|
st.income_share,
|
||||||
|
st.distributions_taken,
|
||||||
|
st.other,
|
||||||
|
st.closing,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
row.share,
|
||||||
|
row.beginning,
|
||||||
|
row.contributions_cash,
|
||||||
|
row.contributions_property,
|
||||||
|
row.net_income,
|
||||||
|
row.distributions,
|
||||||
|
row.other,
|
||||||
|
row.ending,
|
||||||
|
),
|
||||||
|
"{} over {span:?} (fallback {fallback})",
|
||||||
|
row.name
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
st.closing,
|
||||||
|
st.opening
|
||||||
|
+ st.contributions_cash
|
||||||
|
+ st.contributions_property
|
||||||
|
+ st.income_share
|
||||||
|
- st.distributions_taken
|
||||||
|
+ st.other
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Income not yet closed is the member's (members_equity INVARIANT 3):
|
||||||
|
/// $100.00 of July revenue, unclosed, is $50.00 each of income in the
|
||||||
|
/// span and of capital after it. Stephen, 2026: 1,000 + 600 + (350 + 50)
|
||||||
|
/// − 200 = 1,800. July on: 1,750 + 50 = 1,800. August on: opening 1,800
|
||||||
|
/// (the July income carried), nothing moved.
|
||||||
|
#[test]
|
||||||
|
fn the_capital_statement_counts_income_not_yet_closed() {
|
||||||
|
let m = member_books("capital-unclosed");
|
||||||
|
post_more(
|
||||||
|
&m,
|
||||||
|
d(2026, 7, 1),
|
||||||
|
&[
|
||||||
|
("1000", Side::Debit, 10_000),
|
||||||
|
("4000", Side::Credit, 10_000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let st = b
|
||||||
|
.capital_statement(m.stephen, (d(2026, 1, 1), today()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(st.opening, cents(100_000));
|
||||||
|
assert_eq!(st.income_share, cents(40_000));
|
||||||
|
assert_eq!(st.closing, cents(180_000));
|
||||||
|
// The close is still the only income line: July's has no entry on
|
||||||
|
// capital (INVARIANT 3).
|
||||||
|
let closes: Decimal = st
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.kind == ledger_gui::books::CapitalKind::IncomeShare)
|
||||||
|
.map(|l| l.amount)
|
||||||
|
.sum();
|
||||||
|
assert_eq!(closes, cents(35_000));
|
||||||
|
let july = b
|
||||||
|
.capital_statement(m.stephen, (d(2026, 7, 1), today()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(july.opening, july.income_share, july.closing),
|
||||||
|
(cents(175_000), cents(5_000), cents(180_000))
|
||||||
|
);
|
||||||
|
assert!(july.lines.is_empty());
|
||||||
|
let aug = b
|
||||||
|
.capital_statement(m.kat, (d(2026, 8, 1), today()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(aug.opening, aug.income_share, aug.closing),
|
||||||
|
(cents(180_000), Decimal::ZERO, cents(180_000))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Owed to them" reads [books] financing_accounts first (INVARIANT 5):
|
||||||
|
/// with 2200 "Member advances" ($40.00 advanced in July) as the financing
|
||||||
|
/// account, 2100's "due to" name no longer counts: $40.00, $20.00 each.
|
||||||
|
/// Without the setting, the name rule: 2100's $200.00 (2200's name says
|
||||||
|
/// neither "due to" nor "owed to").
|
||||||
|
#[test]
|
||||||
|
fn owed_to_members_reads_the_financing_accounts_first() {
|
||||||
|
let m = member_books("capital-financing");
|
||||||
|
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||||
|
s.add_account(
|
||||||
|
"2200",
|
||||||
|
"Member advances",
|
||||||
|
AccountType::Liability,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(s);
|
||||||
|
post_more(
|
||||||
|
&m,
|
||||||
|
d(2026, 7, 2),
|
||||||
|
&[("6050", Side::Debit, 4_000), ("2200", Side::Credit, 4_000)],
|
||||||
|
);
|
||||||
|
let span = (d(2026, 1, 1), today());
|
||||||
|
write_member_toml(
|
||||||
|
&m.dir.0,
|
||||||
|
"cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2200\"]\n",
|
||||||
|
);
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let st = b.capital_statement(m.kat, span).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(st.due_to_members, st.owed_to_member),
|
||||||
|
(cents(4_000), cents(2_000))
|
||||||
|
);
|
||||||
|
write_member_toml(&m.dir.0, "cash_accounts = [\"1000\"]\n");
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let st = b.capital_statement(m.kat, span).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(st.due_to_members, st.owed_to_member),
|
||||||
|
(cents(20_000), cents(10_000))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without [books] cash_accounts the capital statement reads money as
|
||||||
|
/// core does (every asset account but receivables: Books::
|
||||||
|
/// k1_cash_accounts), so the laptop on 1500 reads as cash: Stephen's 2026
|
||||||
|
/// contributions are $600.00 cash, none property. The Cash page keeps its
|
||||||
|
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
|
||||||
|
#[test]
|
||||||
|
fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
||||||
|
let m = member_books("capital-no-cash");
|
||||||
|
write_member_toml(&m.dir.0, "");
|
||||||
|
let b = load_with_toml(&m.dir.0);
|
||||||
|
let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id;
|
||||||
|
assert_eq!(b.k1_cash_accounts, vec![id("1000"), id("1500")]);
|
||||||
|
assert!(
|
||||||
|
b.cash_accounts.is_empty(),
|
||||||
|
"the Cash page's guess is unchanged"
|
||||||
|
);
|
||||||
|
let st = b
|
||||||
|
.capital_statement(m.stephen, (d(2026, 1, 1), today()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(st.contributions_cash, st.contributions_property),
|
||||||
|
(cents(60_000), Decimal::ZERO)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
st.lines[0].kind,
|
||||||
|
ledger_gui::books::CapitalKind::CashContribution
|
||||||
|
);
|
||||||
|
assert_eq!(st.closing, cents(175_000));
|
||||||
|
let all = b
|
||||||
|
.capital_statement(m.kat, (d(2025, 1, 1), today()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(all.contributions_cash, all.contributions_property),
|
||||||
|
(cents(160_000), Decimal::ZERO)
|
||||||
|
);
|
||||||
|
}
|
||||||
+907
-109
File diff suppressed because it is too large.
Load diff
+141
-10
@@ -550,6 +550,8 @@ pub struct MemberSettingOut {
|
|||||||
pub capital_account: String,
|
pub capital_account: String,
|
||||||
/// Distributions account code, if any.
|
/// Distributions account code, if any.
|
||||||
pub distribution_account: Option<String>,
|
pub distribution_account: Option<String>,
|
||||||
|
/// The member they file a joint individual return with, if any.
|
||||||
|
pub files_jointly_with: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `books.settings`.
|
/// Result of `books.settings`.
|
||||||
@@ -990,8 +992,53 @@ pub struct DraftEntryParams {
|
|||||||
pub party: Option<String>,
|
pub party: Option<String>,
|
||||||
/// Why the entry has no counterparty (a transfer between the company's
|
/// Why the entry has no counterparty (a transfer between the company's
|
||||||
/// own accounts, an accrual), when `[books] parties = "require"` and
|
/// own accounts, an accrual), when `[books] parties = "require"` and
|
||||||
/// none applies. Not with `party` or `vendor`.
|
/// none applies. Not with `party`, `parties`, or `vendor`.
|
||||||
pub no_party_reason: Option<String>,
|
pub no_party_reason: Option<String>,
|
||||||
|
/// Several parties, each with their share (as `books.set_entry_party`
|
||||||
|
/// `parties`), in place of `party`. Not with `party`, `vendor`, or
|
||||||
|
/// `no_party_reason`. Whether the shares add up is checked once the
|
||||||
|
/// entry has balanced lines (`books.post_entry` refuses until they do).
|
||||||
|
pub parties: Option<Vec<PartyShareParam>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One party named on an entry, with their share. Give `amount` or
|
||||||
|
/// `percent`, or neither (derived): a sole party gets all of it; members
|
||||||
|
/// named together get what their capital (or distributions) lines on the
|
||||||
|
/// entry say, else their `[[company.members]]` share. Every party on an
|
||||||
|
/// entry gives the same kind of share.
|
||||||
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
|
pub struct PartyShareParam {
|
||||||
|
/// The party: id or name.
|
||||||
|
pub party: String,
|
||||||
|
/// Exactly this much of what the entry moved, a positive decimal
|
||||||
|
/// string in cents ("30.00"). The amounts must sum to what the entry
|
||||||
|
/// moved (its counterparty money, or its total when it moves none).
|
||||||
|
pub amount: Option<String>,
|
||||||
|
/// This percent of what the entry moved, a decimal string ("60",
|
||||||
|
/// "33.333"); the percents must sum to exactly 100. Each part is
|
||||||
|
/// rounded down to the cent and the leftover cents go to the first
|
||||||
|
/// party listed.
|
||||||
|
pub percent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One party on an entry, with its share and what it resolves to.
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct EntryPartyOut {
|
||||||
|
/// Party id.
|
||||||
|
pub party_id: u64,
|
||||||
|
/// Its label (display name, else name).
|
||||||
|
pub party: String,
|
||||||
|
/// "amount", "percent", or "derived".
|
||||||
|
pub share: String,
|
||||||
|
/// The amount given, for an amount share, decimal string.
|
||||||
|
pub amount: Option<String>,
|
||||||
|
/// The percent given, for a percent share, decimal string.
|
||||||
|
pub percent: Option<String>,
|
||||||
|
/// Its exact part of what the entry moved (positive: paid to it;
|
||||||
|
/// negative: received from it; for an entry moving no money, its part
|
||||||
|
/// of the total), decimal string. Null when the shares don't resolve
|
||||||
|
/// (see `parties_error`).
|
||||||
|
pub resolved: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `books.draft_entry`.
|
/// Result of `books.draft_entry`.
|
||||||
@@ -1109,10 +1156,17 @@ pub struct PostEntryOut {
|
|||||||
pub split_detected: Vec<u64>,
|
pub split_detected: Vec<u64>,
|
||||||
/// `[books] parties`: "off", "suggest", or "require".
|
/// `[books] parties`: "off", "suggest", or "require".
|
||||||
pub party_policy: String,
|
pub party_policy: String,
|
||||||
/// The entry's counterparty, when tagged.
|
/// The entry's counterparty, when it names exactly one.
|
||||||
pub party_id: Option<u64>,
|
pub party_id: Option<u64>,
|
||||||
/// Its name.
|
/// Its name.
|
||||||
pub party: Option<String>,
|
pub party: Option<String>,
|
||||||
|
/// Every party the entry names, each with its share resolved to the
|
||||||
|
/// cent against the entry's lines (they sum to what it moved).
|
||||||
|
pub parties: Vec<EntryPartyOut>,
|
||||||
|
/// Why the parties' shares don't resolve against the lines (amounts
|
||||||
|
/// that don't sum, a derived share for a non-member), when they don't.
|
||||||
|
/// Posting with confirmed=true fails (party_shares) in this state.
|
||||||
|
pub parties_error: Option<String>,
|
||||||
/// Why it has none, when marked so.
|
/// Why it has none, when marked so.
|
||||||
pub no_party_reason: Option<String>,
|
pub no_party_reason: Option<String>,
|
||||||
/// True when the entry names no party and isn't marked as having none
|
/// True when the entry names no party and isn't marked as having none
|
||||||
@@ -1399,10 +1453,16 @@ pub struct GetEntryOut {
|
|||||||
pub receipts: Vec<PreviewReceipt>,
|
pub receipts: Vec<PreviewReceipt>,
|
||||||
/// The vendor the entry is tagged with, for 1099 tracking.
|
/// The vendor the entry is tagged with, for 1099 tracking.
|
||||||
pub vendor: Option<String>,
|
pub vendor: Option<String>,
|
||||||
/// The id of the party the entry is tagged with (its counterparty).
|
/// The id of the party the entry is tagged with (its counterparty),
|
||||||
|
/// when it names exactly one.
|
||||||
pub party_id: Option<u64>,
|
pub party_id: Option<u64>,
|
||||||
/// That party's label (display name, else name).
|
/// That party's label (display name, else name).
|
||||||
pub party: Option<String>,
|
pub party: Option<String>,
|
||||||
|
/// Every party the entry names, with their shares resolved (as in the
|
||||||
|
/// `books.post_entry` preview).
|
||||||
|
pub parties: Vec<EntryPartyOut>,
|
||||||
|
/// Why the shares don't resolve, when they don't.
|
||||||
|
pub parties_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2474,6 +2534,18 @@ pub struct ExportCpaPackageParams {
|
|||||||
/// documents (default true).
|
/// documents (default true).
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub include_receipts: bool,
|
pub include_receipts: bool,
|
||||||
|
/// Carry every member's and 1099 payee's full TIN from the TIN vault
|
||||||
|
/// in the K-1 and 1099 worksheets (default true: the CPA needs them).
|
||||||
|
/// Then `save_to` is required, outside the books dir, and the package
|
||||||
|
/// never comes back in the output. False: last fours only, and the
|
||||||
|
/// zip comes back as base64 unless `save_to` is given.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub include_tins: bool,
|
||||||
|
/// Where to write the package: a new `.zip` file, or an existing
|
||||||
|
/// folder (the zip goes in it as `cpa-package-<as_of>.zip`). Written
|
||||||
|
/// readable by its owner only. With TINs, never inside the books dir
|
||||||
|
/// (git keeps it forever, `exports/` included).
|
||||||
|
pub save_to: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of `books.export_cpa_package`.
|
/// Output of `books.export_cpa_package`.
|
||||||
@@ -2481,8 +2553,21 @@ pub struct ExportCpaPackageParams {
|
|||||||
pub struct ExportCpaPackageOut {
|
pub struct ExportCpaPackageOut {
|
||||||
/// File name, e.g. "cpa-package-2026-10-11.zip".
|
/// File name, e.g. "cpa-package-2026-10-11.zip".
|
||||||
pub file_name: String,
|
pub file_name: String,
|
||||||
/// The zip, base64-encoded.
|
/// The zip, base64-encoded: only when it isn't saved (`save_to`
|
||||||
pub content_base64: String,
|
/// absent, which needs include_tins false).
|
||||||
|
pub content_base64: Option<String>,
|
||||||
|
/// The path the zip was written to, when saved. Tell the human; never
|
||||||
|
/// open it, attach it, or copy it into the books.
|
||||||
|
pub saved_to: Option<String>,
|
||||||
|
/// Whether the worksheets carry full TINs from the vault.
|
||||||
|
pub tins_included: bool,
|
||||||
|
/// With TINs: each member or 1099 payee whose TIN the package lacks,
|
||||||
|
/// and why (not in the vault, the vault locked, ...). Collect them
|
||||||
|
/// with books.set_tin or books.set_w9.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tin_gaps: Vec<String>,
|
||||||
|
/// What to tell the human.
|
||||||
|
pub note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2624,6 +2709,35 @@ impl<'de> Deserialize<'de> for SecretTin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parameters for `books.set_tin`: a party's full taxpayer
|
||||||
|
/// identification number, for the TIN vault only.
|
||||||
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
|
pub struct SetTinParams {
|
||||||
|
/// The party: id or name. Any role (a member, a contractor, ...).
|
||||||
|
pub party: String,
|
||||||
|
/// The full TIN (nine digits, hyphens allowed), stored only in the
|
||||||
|
/// encrypted TIN vault; the books keep its type and last four. Never
|
||||||
|
/// echoed back.
|
||||||
|
pub tin: SecretTin,
|
||||||
|
/// The TIN's type: ssn, ein, or itin.
|
||||||
|
pub tin_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A party's taxpayer-ID facts: never more of the number than its last
|
||||||
|
/// four.
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct TinOut {
|
||||||
|
/// ssn, ein, or itin.
|
||||||
|
pub tin_type: String,
|
||||||
|
/// The last four digits.
|
||||||
|
pub last4: String,
|
||||||
|
/// True while the TIN vault holds the full number (by the books'
|
||||||
|
/// record of it).
|
||||||
|
pub in_vault: bool,
|
||||||
|
/// When and why the vault's copy was purged, if it was.
|
||||||
|
pub purged: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.get_w9`.
|
/// Parameters for `books.get_w9`.
|
||||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
pub struct GetW9Params {
|
pub struct GetW9Params {
|
||||||
@@ -2678,9 +2792,10 @@ pub struct PurgeTinOut {
|
|||||||
pub w9_file_in_vault: bool,
|
pub w9_file_in_vault: bool,
|
||||||
/// The TIN's last four digits, which the books keep.
|
/// The TIN's last four digits, which the books keep.
|
||||||
pub tin_last4: Option<String>,
|
pub tin_last4: Option<String>,
|
||||||
/// True when the purge is journaled (`party.w9_set` with the vault
|
/// True when the purge is journaled: `party.tin_set` with the vault
|
||||||
/// record cleared and kept_at saying why); false when the party has no
|
/// record cleared and `purged` saying when and why, and `party.w9_set`
|
||||||
/// W-9 facts to update.
|
/// likewise (kept_at) when the party has W-9 facts; false when the
|
||||||
|
/// books have no facts to update.
|
||||||
pub journaled: bool,
|
pub journaled: bool,
|
||||||
/// What happens (preview) or happened.
|
/// What happens (preview) or happened.
|
||||||
pub note: String,
|
pub note: String,
|
||||||
@@ -2737,6 +2852,16 @@ pub struct SetEntryPartyParams {
|
|||||||
/// in `books.vendor_1099`); one without, or clearing, clears the vendor
|
/// in `books.vendor_1099`); one without, or clearing, clears the vendor
|
||||||
/// tag too.
|
/// tag too.
|
||||||
pub party: Option<String>,
|
pub party: Option<String>,
|
||||||
|
/// Several parties, each with their share, in place of `party`
|
||||||
|
/// (journaled as `entry.parties_set`; an empty list clears). Name both
|
||||||
|
/// members on a joint member transaction and let the shares derive;
|
||||||
|
/// give amounts (or percents) when a split is uneven or a party isn't a
|
||||||
|
/// member. Refused: a party named twice, an unknown party, mixed kinds
|
||||||
|
/// of share, percents not summing to 100, two parties with vendor
|
||||||
|
/// records (a 1099 counts a payment toward one vendor; the one vendor
|
||||||
|
/// party's record is tagged), and, once the entry has balanced lines,
|
||||||
|
/// shares that don't add up to what it moved.
|
||||||
|
pub parties: Option<Vec<PartyShareParam>>,
|
||||||
/// With `party` null: mark the entry as having no counterparty, with
|
/// With `party` null: mark the entry as having no counterparty, with
|
||||||
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
|
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
|
||||||
/// "require"`.
|
/// "require"`.
|
||||||
@@ -2804,6 +2929,8 @@ pub struct PartyOut {
|
|||||||
pub w9: Option<W9Out>,
|
pub w9: Option<W9Out>,
|
||||||
/// W-4 facts, if on file.
|
/// W-4 facts, if on file.
|
||||||
pub w4: Option<W4Out>,
|
pub w4: Option<W4Out>,
|
||||||
|
/// Taxpayer-ID facts (any role), if on file: type and last four.
|
||||||
|
pub tin: Option<TinOut>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One party in `books.parties`.
|
/// One party in `books.parties`.
|
||||||
@@ -2864,8 +2991,12 @@ pub struct ListPartiesOut {
|
|||||||
/// negative received from it; a reversal counts against its original's
|
/// negative received from it; a reversal counts against its original's
|
||||||
/// side. A transfer between the company's own cash accounts nets to zero.
|
/// side. A transfer between the company's own cash accounts nets to zero.
|
||||||
/// A bill is not paid until its payment is.
|
/// A bill is not paid until its payment is.
|
||||||
/// (`ledger_core::parties::activity` computes it; the viewer shows the
|
/// An entry naming several parties counts each one's share of that money
|
||||||
/// same numbers.)
|
/// (its amount, its percent, or its derived share: see
|
||||||
|
/// `books.set_entry_party` `parties`); an entry whose shares don't resolve
|
||||||
|
/// counts for none of them.
|
||||||
|
/// (`ledger_core::parties::activity_with`, with the `[[company.members]]`,
|
||||||
|
/// computes it; the viewer shows the same numbers.)
|
||||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
pub struct PartyTotalsOut {
|
pub struct PartyTotalsOut {
|
||||||
/// Paid to the party, decimal string.
|
/// Paid to the party, decimal string.
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
// Tool listing: the narrow tools; chart is read-only.
|
// Tool listing: the narrow tools; chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
let tools = list["result"]["tools"].as_array().unwrap();
|
||||||
assert_eq!(tools.len(), 91, "expected 91 tools, got {}", tools.len());
|
assert_eq!(tools.len(), 92, "expected 92 tools, got {}", tools.len());
|
||||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -875,11 +875,14 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
|||||||
assert!(err.contains("not pdf"), "{err}");
|
assert!(err.contains("not pdf"), "{err}");
|
||||||
|
|
||||||
// The CPA package carries all three statements.
|
// The CPA package carries all three statements.
|
||||||
let err = mcp.call_err("books.export_cpa_package", json!({"as_of": "2026-10-31"}));
|
let err = mcp.call_err(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-10-31", "include_tins": false}),
|
||||||
|
);
|
||||||
assert!(err.contains("cash_accounts"), "{err}");
|
assert!(err.contains("cash_accounts"), "{err}");
|
||||||
let pkg = mcp.call(
|
let pkg = mcp.call(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"]}),
|
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||||
);
|
);
|
||||||
let zip_bytes = b64.decode(pkg["content_base64"].as_str().unwrap()).unwrap();
|
let zip_bytes = b64.decode(pkg["content_base64"].as_str().unwrap()).unwrap();
|
||||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
|
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
|
||||||
@@ -931,7 +934,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
|||||||
// Without receipts, a lighter package.
|
// Without receipts, a lighter package.
|
||||||
let light = mcp.call(
|
let light = mcp.call(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false}),
|
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
|
||||||
);
|
);
|
||||||
let light = zip::ZipArchive::new(std::io::Cursor::new(
|
let light = zip::ZipArchive::new(std::io::Cursor::new(
|
||||||
b64.decode(light["content_base64"].as_str().unwrap())
|
b64.decode(light["content_base64"].as_str().unwrap())
|
||||||
|
|||||||
@@ -2598,7 +2598,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
|||||||
let mut m = Mcp::start(&dir, "1000000.00");
|
let mut m = Mcp::start(&dir, "1000000.00");
|
||||||
let out = m.ok(
|
let out = m.ok(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": END, "cash_accounts": ["1000"]}),
|
json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false}),
|
||||||
);
|
);
|
||||||
assert_eq!(out["file_name"], format!("cpa-package-{END}.zip"));
|
assert_eq!(out["file_name"], format!("cpa-package-{END}.zip"));
|
||||||
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
|
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
|
||||||
@@ -2707,7 +2707,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
|||||||
// Without receipts: no evidence at all.
|
// Without receipts: no evidence at all.
|
||||||
let out = m.ok(
|
let out = m.ok(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false}),
|
json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
|
||||||
);
|
);
|
||||||
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
|
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -681,6 +681,10 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
"books.set_w4",
|
"books.set_w4",
|
||||||
json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}),
|
json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}),
|
||||||
);
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": "Pat", "tin": "571-93-2846", "tin_type": "ssn"}),
|
||||||
|
);
|
||||||
m.ok(
|
m.ok(
|
||||||
"books.set_party_roles",
|
"books.set_party_roles",
|
||||||
json!({"party": "Landlord", "add": ["landlord"]}),
|
json!({"party": "Landlord", "add": ["landlord"]}),
|
||||||
@@ -1069,7 +1073,12 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
}
|
}
|
||||||
m.ok(
|
m.ok(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": "2026-12-31", "include_receipts": false}),
|
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}),
|
||||||
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "include_receipts": false,
|
||||||
|
"save_to": outside(&dir).join("cpa.zip").to_str().unwrap()}),
|
||||||
);
|
);
|
||||||
|
|
||||||
m
|
m
|
||||||
|
|||||||
@@ -1313,3 +1313,253 @@ fn off_never_raises_it() {
|
|||||||
json!({"entry_id": id, "confirmed": true}),
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------- party shares
|
||||||
|
//
|
||||||
|
// Oracles: SetEntryPartyParams.parties and PartyShareParam (the declared
|
||||||
|
// share rules), EntryPartyOut, PostEntryOut.parties / parties_error (code
|
||||||
|
// party_shares), PartyTotalsOut (each party's share), and
|
||||||
|
// docs/mcp-interface.md "Several parties". Values worked by hand.
|
||||||
|
|
||||||
|
/// Books with both members (50/50, capital 3000 and 3001) in the settings
|
||||||
|
/// and the directory; `extra` goes under `[books]`.
|
||||||
|
fn member_books(name: &str, extra: &str) -> Mcp {
|
||||||
|
let dir = test_dir(name);
|
||||||
|
let toml = books_toml(
|
||||||
|
&dir,
|
||||||
|
"Spec Co",
|
||||||
|
&format!(
|
||||||
|
"{extra}\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||||
|
[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
|
m.accounts(CHART);
|
||||||
|
m.accounts(&[("3001", "Capital K", "equity")]);
|
||||||
|
m.add_party("Stephen Waits", &["member"]);
|
||||||
|
m.add_party("Katherine Pendergast", &["member"]);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn both() -> Value {
|
||||||
|
json!({"parties": [{"party": "Stephen Waits"}, {"party": "Katherine Pendergast"}]})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved(v: &Value) -> Vec<(String, String)> {
|
||||||
|
v["parties"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
(
|
||||||
|
p["party"].as_str().unwrap().to_string(),
|
||||||
|
p["resolved"].as_str().unwrap_or("null").to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_joint_reimbursement_names_both_members_and_splits_by_share() {
|
||||||
|
let mut m = member_books("shares-joint", "");
|
||||||
|
let id = m.draft(
|
||||||
|
"2026-04-01",
|
||||||
|
both(),
|
||||||
|
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||||
|
);
|
||||||
|
let preview = m.ok("books.post_entry", json!({"entry_id": id}));
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&preview),
|
||||||
|
[
|
||||||
|
("Stephen Waits".to_string(), "50.00".to_string()),
|
||||||
|
("Katherine Pendergast".to_string(), "50.00".to_string())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(preview["parties"][0]["share"], "derived");
|
||||||
|
assert!(preview["party_id"].is_null(), "no sole party");
|
||||||
|
assert!(preview["parties_error"].is_null());
|
||||||
|
assert_eq!(preview["party_missing"], false);
|
||||||
|
m.ok(
|
||||||
|
"books.post_entry",
|
||||||
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.count("entry.parties_set"), 1);
|
||||||
|
for who in ["Stephen Waits", "Katherine Pendergast"] {
|
||||||
|
let t = m.party(who, 2026)["totals"].clone();
|
||||||
|
assert_eq!(t["paid"], "50.00", "{who}'s half");
|
||||||
|
}
|
||||||
|
let list = m.read_only("books.parties", json!({"year": 2026}));
|
||||||
|
let paid: Vec<&str> = list["parties"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p["paid"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(paid, ["50.00", "50.00"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_joint_contribution_derives_from_the_capital_lines() {
|
||||||
|
let mut m = member_books("shares-capital", "");
|
||||||
|
let id = m.post(
|
||||||
|
"2026-01-02",
|
||||||
|
both(),
|
||||||
|
&[
|
||||||
|
("1000", "debit", "300.00"),
|
||||||
|
("3000", "credit", "200.00"),
|
||||||
|
("3001", "credit", "100.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let e = m.read_only("books.get_entry", json!({"entry_id": id}));
|
||||||
|
assert_eq!(
|
||||||
|
resolved(&e),
|
||||||
|
[
|
||||||
|
("Stephen Waits".to_string(), "-200.00".to_string()),
|
||||||
|
("Katherine Pendergast".to_string(), "-100.00".to_string())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
m.party("Stephen Waits", 2026)["totals"]["received"],
|
||||||
|
"200.00"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
m.party("Katherine Pendergast", 2026)["totals"]["received"],
|
||||||
|
"100.00"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_amounts_and_percents_apportion_exactly() {
|
||||||
|
let mut m = member_books("shares-explicit", "");
|
||||||
|
m.add_party("Riley", &["contractor"]);
|
||||||
|
let id = m.post(
|
||||||
|
"2026-05-01",
|
||||||
|
json!({"party": "Riley"}),
|
||||||
|
&[("6000", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||||
|
);
|
||||||
|
let e = m.ok(
|
||||||
|
"books.set_entry_party",
|
||||||
|
json!({"entry_id": id, "parties": [
|
||||||
|
{"party": "Riley", "amount": "70.00"},
|
||||||
|
{"party": "Stephen Waits", "amount": "30.00"},
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
assert_eq!(e["parties"][0]["share"], "amount");
|
||||||
|
assert_eq!(e["parties"][0]["amount"], "70.00");
|
||||||
|
assert_eq!(e["parties"][1]["resolved"], "30.00");
|
||||||
|
assert_eq!(m.party("Riley", 2026)["totals"]["paid"], "70.00");
|
||||||
|
let e = m.ok(
|
||||||
|
"books.set_entry_party",
|
||||||
|
json!({"entry_id": id, "parties": [
|
||||||
|
{"party": "Riley", "percent": "33.333"},
|
||||||
|
{"party": "Stephen Waits", "percent": "66.667"},
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
// 33.333 → 33.33, 66.667 → 66.66; the leftover cent to the first.
|
||||||
|
assert_eq!(e["parties"][0]["resolved"], "33.34");
|
||||||
|
assert_eq!(e["parties"][1]["resolved"], "66.66");
|
||||||
|
assert_eq!(e["parties"][0]["percent"], "33.333");
|
||||||
|
assert_eq!(m.count("entry.parties_set"), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shares_that_dont_add_up_are_refused_changing_nothing() {
|
||||||
|
let mut m = member_books("shares-refused", "");
|
||||||
|
m.add_party("Riley", &["contractor"]);
|
||||||
|
let id = m.post(
|
||||||
|
"2026-05-01",
|
||||||
|
json!({}),
|
||||||
|
&[("6000", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||||
|
);
|
||||||
|
let set = |parties: Value| json!({"entry_id": id, "parties": parties});
|
||||||
|
let why = m.refuse(
|
||||||
|
"books.set_entry_party",
|
||||||
|
set(json!([{"party": "Riley", "amount": "60.00"}, {"party": "Stephen Waits", "amount": "30.00"}])),
|
||||||
|
);
|
||||||
|
assert!(why.contains("sum"), "{why}");
|
||||||
|
m.refuse(
|
||||||
|
"books.set_entry_party",
|
||||||
|
set(json!([{"party": "Riley", "percent": "50"}, {"party": "Stephen Waits", "percent": "40"}])),
|
||||||
|
);
|
||||||
|
m.refuse(
|
||||||
|
"books.set_entry_party",
|
||||||
|
set(json!([{"party": "Riley"}, {"party": "Riley"}])),
|
||||||
|
);
|
||||||
|
m.refuse(
|
||||||
|
"books.set_entry_party",
|
||||||
|
set(json!([{"party": "Riley", "amount": "50.00", "percent": "50"}])),
|
||||||
|
);
|
||||||
|
m.refuse(
|
||||||
|
"books.set_entry_party",
|
||||||
|
set(json!([{"party": "Riley", "amount": "50.00"}, {"party": "Stephen Waits"}])),
|
||||||
|
);
|
||||||
|
// Derived for a non-member among several.
|
||||||
|
m.refuse(
|
||||||
|
"books.set_entry_party",
|
||||||
|
set(json!([{"party": "Riley"}, {"party": "Stephen Waits"}])),
|
||||||
|
);
|
||||||
|
m.refuse(
|
||||||
|
"books.set_entry_party",
|
||||||
|
json!({"entry_id": id, "party": "Riley", "parties": [{"party": "Riley"}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.count("entry.parties_set"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_draft_whose_shares_dont_add_up_previews_the_error_and_wont_post() {
|
||||||
|
let mut m = member_books("shares-draft", "");
|
||||||
|
let id = m.draft(
|
||||||
|
"2026-06-01",
|
||||||
|
json!({"parties": [
|
||||||
|
{"party": "Stephen Waits", "amount": "10.00"},
|
||||||
|
{"party": "Katherine Pendergast", "amount": "10.00"},
|
||||||
|
]}),
|
||||||
|
&[("2100", "debit", "25.00"), ("1000", "credit", "25.00")],
|
||||||
|
);
|
||||||
|
let preview = m.ok("books.post_entry", json!({"entry_id": id}));
|
||||||
|
assert!(preview["parties_error"].is_string(), "{preview}");
|
||||||
|
assert!(preview["parties"][0]["resolved"].is_null());
|
||||||
|
let why = m.refuse(
|
||||||
|
"books.post_entry",
|
||||||
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert!(why.contains("party_shares"), "{why}");
|
||||||
|
m.ok(
|
||||||
|
"books.set_entry_party",
|
||||||
|
json!({"entry_id": id, "parties": [
|
||||||
|
{"party": "Stephen Waits", "amount": "15.00"},
|
||||||
|
{"party": "Katherine Pendergast", "amount": "10.00"},
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.post_entry",
|
||||||
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.party("Stephen Waits", 2026)["totals"]["paid"], "15.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn under_require_several_parties_satisfy_the_policy() {
|
||||||
|
let mut m = member_books("shares-require", "parties = \"require\"");
|
||||||
|
let id = m.draft(
|
||||||
|
"2026-07-01",
|
||||||
|
both(),
|
||||||
|
&[("2100", "debit", "40.00"), ("1000", "credit", "40.00")],
|
||||||
|
);
|
||||||
|
let preview = m.ok("books.post_entry", json!({"entry_id": id}));
|
||||||
|
assert_eq!(preview["party_missing"], false);
|
||||||
|
m.ok(
|
||||||
|
"books.post_entry",
|
||||||
|
json!({"entry_id": id, "confirmed": true}),
|
||||||
|
);
|
||||||
|
// The reversal carries both shares.
|
||||||
|
m.ok(
|
||||||
|
"books.reverse_entry",
|
||||||
|
json!({"entry_id": id, "date": "2026-07-02", "description": "undo", "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
m.party("Katherine Pendergast", 2026)["totals"]["paid"],
|
||||||
|
"0.00"
|
||||||
|
);
|
||||||
|
m.read_only("books.verify", json!({}));
|
||||||
|
}
|
||||||
@@ -8,7 +8,11 @@
|
|||||||
//! (DECISION, Stephen, 2026-10-15): a full TIN is never in the journal, the
|
//! (DECISION, Stephen, 2026-10-15): a full TIN is never in the journal, the
|
||||||
//! SQLite projection, the viewer, or any tool output, except the 1099
|
//! SQLite projection, the viewer, or any tool output, except the 1099
|
||||||
//! worksheet for the CPA when asked (`books.vendor_1099` with
|
//! worksheet for the CPA when asked (`books.vendor_1099` with
|
||||||
//! `include_tins`). The harness is `tests/spec_parties.rs`'s, with a
|
//! `include_tins`); and the vault holds everyone's, members included, for
|
||||||
|
//! the CPA package, which carries them only in a zip saved outside the
|
||||||
|
//! books dir (DECISION, Stephen, 2026-10-15: "In the vault, that's the
|
||||||
|
//! WHOLE reason we built it. And it needs to be in the damn CPA
|
||||||
|
//! package."). The harness is `tests/spec_parties.rs`'s, with a
|
||||||
//! transcript of every response and the server's stderr kept, and the vault
|
//! transcript of every response and the server's stderr kept, and the vault
|
||||||
//! key in a temp file outside the books dir (never `~/.config`).
|
//! key in a temp file outside the books dir (never `~/.config`).
|
||||||
|
|
||||||
@@ -355,8 +359,13 @@ const CHART: &[(&str, &str, &str)] = &[
|
|||||||
/// Books with the chart (no receipts needed), a contractor "Acme Design"
|
/// Books with the chart (no receipts needed), a contractor "Acme Design"
|
||||||
/// with a vendor record marked NEC, and a landlord "Rent Co".
|
/// with a vendor record marked NEC, and a landlord "Rent Co".
|
||||||
fn books(name: &str) -> Mcp {
|
fn books(name: &str) -> Mcp {
|
||||||
|
books_with(name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `books`, with `extra` TOML after `[books]` (members, say).
|
||||||
|
fn books_with(name: &str, extra: &str) -> Mcp {
|
||||||
let dir = test_dir(name);
|
let dir = test_dir(name);
|
||||||
let toml = books_toml(&dir, "1000000.00", "");
|
let toml = books_toml(&dir, "1000000.00", extra);
|
||||||
let mut m = Mcp::start(&dir, &toml);
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
m.accounts(CHART);
|
m.accounts(CHART);
|
||||||
m.add_party("Acme Design", &["contractor"]);
|
m.add_party("Acme Design", &["contractor"]);
|
||||||
@@ -374,6 +383,54 @@ const SSN: &str = "571-93-2846";
|
|||||||
const SSN_DIGITS: &str = "571932846";
|
const SSN_DIGITS: &str = "571932846";
|
||||||
const EIN: &str = "47-3829105";
|
const EIN: &str = "47-3829105";
|
||||||
const EIN_DIGITS: &str = "473829105";
|
const EIN_DIGITS: &str = "473829105";
|
||||||
|
/// A member's SSN, unlike the others.
|
||||||
|
const MEMBER_SSN: &str = "638-27-4915";
|
||||||
|
const MEMBER_SSN_DIGITS: &str = "638274915";
|
||||||
|
|
||||||
|
/// Every file in a zip, by name.
|
||||||
|
fn unzip(path: &Path) -> HashMap<String, Vec<u8>> {
|
||||||
|
let mut z = zip::ZipArchive::new(std::fs::File::open(path).unwrap()).unwrap();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
for i in 0..z.len() {
|
||||||
|
let mut f = z.by_index(i).unwrap();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
std::io::Read::read_to_end(&mut f, &mut bytes).unwrap();
|
||||||
|
out.insert(f.name().unwrap().to_string(), bytes);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CSV's rows (header first) as maps from column name to cell, cells
|
||||||
|
/// split on commas outside double quotes.
|
||||||
|
fn csv_rows(bytes: &[u8]) -> Vec<HashMap<String, String>> {
|
||||||
|
let split = |l: &str| {
|
||||||
|
let (mut cells, mut cur, mut quoted) = (Vec::new(), String::new(), false);
|
||||||
|
let mut chars = l.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'"' if quoted && chars.peek() == Some(&'"') => {
|
||||||
|
cur.push('"');
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
'"' => quoted = !quoted,
|
||||||
|
',' if !quoted => cells.push(std::mem::take(&mut cur)),
|
||||||
|
c => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(cur);
|
||||||
|
cells
|
||||||
|
};
|
||||||
|
let text = String::from_utf8(bytes.to_vec()).unwrap();
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let head = split(lines.next().expect("a header"));
|
||||||
|
lines
|
||||||
|
.map(|l| {
|
||||||
|
let cells = split(l);
|
||||||
|
assert_eq!(cells.len(), head.len(), "{l}");
|
||||||
|
head.iter().cloned().zip(cells).collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
impl Mcp {
|
impl Mcp {
|
||||||
fn books_dir(&self) -> PathBuf {
|
fn books_dir(&self) -> PathBuf {
|
||||||
@@ -921,7 +978,10 @@ fn purge_deletes_the_vault_records_and_journals_why() {
|
|||||||
/// encrypted.
|
/// encrypted.
|
||||||
#[test]
|
#[test]
|
||||||
fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||||
let mut m = books("no-leak");
|
let mut m = books_with(
|
||||||
|
"no-leak",
|
||||||
|
"[[company.members]]\nname = \"Kat\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
|
||||||
|
);
|
||||||
let src = m.w9_file("acme-w9.pdf", SSN);
|
let src = m.w9_file("acme-w9.pdf", SSN);
|
||||||
m.w9(
|
m.w9(
|
||||||
"Acme Design",
|
"Acme Design",
|
||||||
@@ -958,6 +1018,21 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
|||||||
json!({"party": "Acme Design", "notes": format!("ssn {SSN}")}),
|
json!({"party": "Acme Design", "notes": format!("ssn {SSN}")}),
|
||||||
);
|
);
|
||||||
m.refuse("books.add_party", json!({"name": format!("Acme {EIN}")}));
|
m.refuse("books.add_party", json!({"name": format!("Acme {EIN}")}));
|
||||||
|
// A member's SSN, by books.set_tin, and refusals of it.
|
||||||
|
m.add_party("Kat", &["member"]);
|
||||||
|
m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||||
|
);
|
||||||
|
for args in [
|
||||||
|
json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "itin"}),
|
||||||
|
json!({"party": "Kat", "tin": MEMBER_SSN_DIGITS.parse::<u64>().unwrap() as f64,
|
||||||
|
"tin_type": "ssn"}),
|
||||||
|
json!({"party": "Nobody", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||||
|
json!({"party": "Rent Co", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||||
|
] {
|
||||||
|
m.refuse("books.set_tin", args);
|
||||||
|
}
|
||||||
// Everything else, read and write.
|
// Everything else, read and write.
|
||||||
for (tool, args) in [
|
for (tool, args) in [
|
||||||
("books.parties", json!({})),
|
("books.parties", json!({})),
|
||||||
@@ -988,8 +1063,15 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||||
|
"save_to": m.outside().join("cpa.zip").to_str().unwrap()}),
|
||||||
|
),
|
||||||
|
("books.party", json!({"party": "Kat"})),
|
||||||
|
("books.parties", json!({"role": "member"})),
|
||||||
(
|
(
|
||||||
"books.purge_tin",
|
"books.purge_tin",
|
||||||
json!({"party": "Rent Co", "reason": "preview only"}),
|
json!({"party": "Rent Co", "reason": "preview only"}),
|
||||||
@@ -1029,7 +1111,8 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
|||||||
tool == "books.vendor_1099" && resp.contains("\"warning\":\"This worksheet");
|
tool == "books.vendor_1099" && resp.contains("\"warning\":\"This worksheet");
|
||||||
!worksheet
|
!worksheet
|
||||||
&& (holds_tin(resp.as_bytes(), SSN_DIGITS)
|
&& (holds_tin(resp.as_bytes(), SSN_DIGITS)
|
||||||
|| holds_tin(resp.as_bytes(), EIN_DIGITS))
|
|| holds_tin(resp.as_bytes(), EIN_DIGITS)
|
||||||
|
|| holds_tin(resp.as_bytes(), MEMBER_SSN_DIGITS))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}");
|
assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}");
|
||||||
@@ -1037,6 +1120,23 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
|||||||
// The server's log.
|
// The server's log.
|
||||||
let log = std::fs::read(m.outside().join("stderr.log")).unwrap();
|
let log = std::fs::read(m.outside().join("stderr.log")).unwrap();
|
||||||
assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS));
|
assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS));
|
||||||
|
assert!(!holds_tin(&log, MEMBER_SSN_DIGITS));
|
||||||
|
// The package saved outside the books carries them all: the member's
|
||||||
|
// in the K-1 worksheet, the payees' in the 1099 worksheet.
|
||||||
|
let pkg = unzip(&m.outside().join("cpa.zip"));
|
||||||
|
assert!(holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS));
|
||||||
|
let w1099 = &pkg["1099-worksheet-2026.csv"];
|
||||||
|
assert!(holds_tin(w1099, SSN_DIGITS) && holds_tin(w1099, EIN_DIGITS));
|
||||||
|
for (name, bytes) in &pkg {
|
||||||
|
if name != "k1-members.csv" && name != "1099-worksheet-2026.csv" {
|
||||||
|
assert!(
|
||||||
|
!holds_tin(bytes, SSN_DIGITS)
|
||||||
|
&& !holds_tin(bytes, EIN_DIGITS)
|
||||||
|
&& !holds_tin(bytes, MEMBER_SSN_DIGITS),
|
||||||
|
"{name} in the package holds a TIN"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Every byte in the books dir outside the vault: the journal, the
|
// Every byte in the books dir outside the vault: the journal, the
|
||||||
// database (and its WAL), the stored files, the settings.
|
// database (and its WAL), the stored files, the settings.
|
||||||
let books = m.books_dir();
|
let books = m.books_dir();
|
||||||
@@ -1047,7 +1147,9 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
|||||||
}
|
}
|
||||||
let bytes = std::fs::read(&f).unwrap();
|
let bytes = std::fs::read(&f).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
!holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS),
|
!holds_tin(&bytes, SSN_DIGITS)
|
||||||
|
&& !holds_tin(&bytes, EIN_DIGITS)
|
||||||
|
&& !holds_tin(&bytes, MEMBER_SSN_DIGITS),
|
||||||
"{} holds a full TIN",
|
"{} holds a full TIN",
|
||||||
f.display()
|
f.display()
|
||||||
);
|
);
|
||||||
@@ -1058,11 +1160,13 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
|||||||
}
|
}
|
||||||
// The vault holds them encrypted only.
|
// The vault holds them encrypted only.
|
||||||
let vault_files = files_under(&m.vault());
|
let vault_files = files_under(&m.vault());
|
||||||
assert!(vault_files.len() >= 3, "{vault_files:?}");
|
assert!(vault_files.len() >= 4, "{vault_files:?}");
|
||||||
for f in vault_files {
|
for f in vault_files {
|
||||||
let bytes = std::fs::read(&f).unwrap();
|
let bytes = std::fs::read(&f).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
!holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS),
|
!holds_tin(&bytes, SSN_DIGITS)
|
||||||
|
&& !holds_tin(&bytes, EIN_DIGITS)
|
||||||
|
&& !holds_tin(&bytes, MEMBER_SSN_DIGITS),
|
||||||
"{} holds a TIN in the clear",
|
"{} holds a TIN in the clear",
|
||||||
f.display()
|
f.display()
|
||||||
);
|
);
|
||||||
@@ -1094,3 +1198,638 @@ fn books_in_git_must_ignore_the_vault() {
|
|||||||
.refused()
|
.refused()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------- books.set_tin, any role
|
||||||
|
|
||||||
|
/// The TIN facts a party's last `party.tin_set` journaled.
|
||||||
|
impl Mcp {
|
||||||
|
fn journaled_tin(&self, party: u64) -> Value {
|
||||||
|
String::from_utf8(self.journal())
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
|
||||||
|
.filter(|v| v["type"] == "party.tin_set" || v["line_type"] == "party.tin_set")
|
||||||
|
.rfind(|v| v["payload"]["id"] == json!(party))
|
||||||
|
.map(|v| v["payload"]["facts"].clone())
|
||||||
|
.expect("a party.tin_set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any role may have its TIN in the vault: a member (for the K-1), an
|
||||||
|
/// employee, a customer, a party with no role at all. The books journal
|
||||||
|
/// its type, last four, and record hash; the output shows the last four.
|
||||||
|
#[test]
|
||||||
|
fn set_tin_takes_any_party_members_included() {
|
||||||
|
let mut m = books("set-tin");
|
||||||
|
for (name, roles) in [
|
||||||
|
("Kat", vec!["member"]),
|
||||||
|
("Pat", vec!["employee"]),
|
||||||
|
("Nobody In Particular", vec![]),
|
||||||
|
] {
|
||||||
|
let id = m.add_party(name, &roles);
|
||||||
|
let p = m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": name, "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||||
|
);
|
||||||
|
assert_eq!(p["tin"]["last4"], "4915", "{p}");
|
||||||
|
assert_eq!(p["tin"]["tin_type"], "ssn");
|
||||||
|
assert_eq!(p["tin"]["in_vault"], true);
|
||||||
|
assert!(!holds_tin(p.to_string().as_bytes(), MEMBER_SSN_DIGITS));
|
||||||
|
assert!(m.vault().join(format!("tin-{id}.bin")).exists());
|
||||||
|
let facts = m.journaled_tin(id);
|
||||||
|
assert_eq!(facts["last4"], "4915");
|
||||||
|
assert_eq!(facts["tin_type"], "ssn");
|
||||||
|
assert_eq!(facts["vault_record"].as_str().unwrap().len(), 64);
|
||||||
|
assert_eq!(m.party(name, 2026)["party"]["tin"]["last4"], "4915");
|
||||||
|
}
|
||||||
|
// A customer (its role comes with its record).
|
||||||
|
m.ok("books.add_customer", json!({"name": "Client"}));
|
||||||
|
let p = m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": "Client", "tin": EIN, "tin_type": "ein"}),
|
||||||
|
);
|
||||||
|
assert_eq!(p["tin"]["last4"], "9105");
|
||||||
|
assert!(p["roles"].as_array().unwrap().contains(&json!("customer")));
|
||||||
|
// An EIN for a member that is a company; a replacement TIN.
|
||||||
|
let kat = m.party_id("Kat");
|
||||||
|
let first = m.journaled_tin(kat)["vault_record"].clone();
|
||||||
|
m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": "Kat", "tin": EIN, "tin_type": "ein"}),
|
||||||
|
);
|
||||||
|
let facts = m.journaled_tin(kat);
|
||||||
|
assert_eq!(
|
||||||
|
(facts["last4"].clone(), facts["tin_type"].clone()),
|
||||||
|
(json!("9105"), json!("ein"))
|
||||||
|
);
|
||||||
|
assert_ne!(facts["vault_record"], first);
|
||||||
|
assert!(!holds_tin(&m.journal(), EIN_DIGITS));
|
||||||
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_tin_refusals_write_nothing() {
|
||||||
|
let mut m = books("set-tin-refusals");
|
||||||
|
m.add_party("Kat", &["member"]);
|
||||||
|
// A W-9 payee whose W-9 facts name another last four.
|
||||||
|
m.w9(
|
||||||
|
"Acme Design",
|
||||||
|
json!({"tin_last4": "1111", "kept_at": "binder"}),
|
||||||
|
);
|
||||||
|
let acme = m.party_id("Acme Design");
|
||||||
|
for (party, tin, kind) in [
|
||||||
|
("Kat", json!("57193284"), "ssn"), // eight digits
|
||||||
|
("Kat", json!("971-93-2846"), "ssn"), // SSN starting 9
|
||||||
|
("Kat", json!(SSN), "itin"), // not an ITIN
|
||||||
|
("Kat", json!(SSN), "passport"), // no such type
|
||||||
|
("Kat", json!(571932846.0), "ssn"), // a float
|
||||||
|
("Nobody", json!(SSN), "ssn"), // no such party
|
||||||
|
("Acme Design", json!(SSN), "ssn"), // W-9 says 1111
|
||||||
|
] {
|
||||||
|
let why = m.refuse(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": party, "tin": tin, "tin_type": kind}),
|
||||||
|
);
|
||||||
|
assert!(!holds_tin(why.as_bytes(), SSN_DIGITS), "{why}");
|
||||||
|
}
|
||||||
|
m.refuse("books.set_tin", json!({"party": "Kat", "tin_type": "ssn"}));
|
||||||
|
assert!(!m.vault().join(format!("tin-{acme}.bin")).exists());
|
||||||
|
assert!(!m.key().exists(), "no key made for a refusal");
|
||||||
|
assert_eq!(m.count("party.tin_set"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A W-9 with a TIN records the party's TIN facts too, and the two never
|
||||||
|
/// disagree: set_tin agreeing with the W-9 moves its vault record along; a
|
||||||
|
/// W-9 with a new last four replaces the TIN facts.
|
||||||
|
#[test]
|
||||||
|
fn w9_and_tin_facts_agree() {
|
||||||
|
let mut m = books("w9-tin");
|
||||||
|
let acme = m.party_id("Acme Design");
|
||||||
|
m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"}));
|
||||||
|
let p = m.party("Acme Design", 2026);
|
||||||
|
assert_eq!(p["party"]["tin"]["last4"], "2846");
|
||||||
|
assert_eq!(p["party"]["tin"]["in_vault"], true);
|
||||||
|
let (w9, tin) = (m.journaled_w9(acme), m.journaled_tin(acme));
|
||||||
|
assert_eq!(w9["vault_record"], tin["vault_record"]);
|
||||||
|
// books.set_tin with the same number: both facts follow the new record.
|
||||||
|
m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": "Acme Design", "tin": SSN, "tin_type": "ssn"}),
|
||||||
|
);
|
||||||
|
let (w9b, tinb) = (m.journaled_w9(acme), m.journaled_tin(acme));
|
||||||
|
assert_ne!(tinb["vault_record"], tin["vault_record"], "a fresh nonce");
|
||||||
|
assert_eq!(w9b["vault_record"], tinb["vault_record"]);
|
||||||
|
// W-9 facts without a TIN that disagree with the TIN facts: refused.
|
||||||
|
m.refuse(
|
||||||
|
"books.set_w9",
|
||||||
|
json!({"party": "Acme Design", "received_on": "2026-01-05",
|
||||||
|
"entity_type": "x", "tin_last4": "1111", "kept_at": "binder"}),
|
||||||
|
);
|
||||||
|
// A new W-9 with a new TIN: the TIN facts are replaced (cleared first).
|
||||||
|
let before = m.count("party.tin_set");
|
||||||
|
m.w9(
|
||||||
|
"Acme Design",
|
||||||
|
json!({"tin": "571-93-1111", "tin_type": "ssn"}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.count("party.tin_set"), before + 2, "cleared, then set");
|
||||||
|
assert_eq!(m.journaled_tin(acme)["last4"], "1111");
|
||||||
|
assert_eq!(m.journaled_w9(acme)["tin_last4"], "1111");
|
||||||
|
m.pay("Acme Design", "2500.00");
|
||||||
|
let v = m.read_only(
|
||||||
|
"books.vendor_1099",
|
||||||
|
json!({"year": 2026, "include_tins": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(v["vendors"][0]["tin"], "571-93-1111");
|
||||||
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// books.purge_tin covers TIN facts: a member's record goes, the books
|
||||||
|
/// keep the last four and say when and why.
|
||||||
|
#[test]
|
||||||
|
fn purge_covers_tin_facts() {
|
||||||
|
let mut m = books("purge-tin-facts");
|
||||||
|
let kat = m.add_party("Kat", &["member"]);
|
||||||
|
m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": "Kat", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||||
|
);
|
||||||
|
let p = m.ok(
|
||||||
|
"books.purge_tin",
|
||||||
|
json!({"party": "Kat", "reason": "left the company"}),
|
||||||
|
);
|
||||||
|
assert_eq!(p["status"], "preview");
|
||||||
|
assert_eq!(p["tin_in_vault"], true);
|
||||||
|
assert_eq!(p["tin_last4"], "4915");
|
||||||
|
let p = m.ok(
|
||||||
|
"books.purge_tin",
|
||||||
|
json!({"party": "Kat", "reason": "left the company", "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(p["journaled"], true);
|
||||||
|
assert!(!m.vault().join(format!("tin-{kat}.bin")).exists());
|
||||||
|
let facts = m.journaled_tin(kat);
|
||||||
|
assert!(facts["vault_record"].is_null(), "{facts}");
|
||||||
|
assert_eq!(facts["last4"], "4915");
|
||||||
|
assert!(
|
||||||
|
facts["purged"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("left the company")
|
||||||
|
);
|
||||||
|
let party = m.party("Kat", 2026);
|
||||||
|
assert_eq!(party["party"]["tin"]["in_vault"], false);
|
||||||
|
m.refuse(
|
||||||
|
"books.purge_tin",
|
||||||
|
json!({"party": "Kat", "reason": "again", "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------- the CPA package
|
||||||
|
|
||||||
|
const MEMBERS: &str = "[[company.members]]\nname = \"Stephen Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||||
|
[[company.members]]\nname = \"Kat Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\n";
|
||||||
|
|
||||||
|
/// Books for the K-1 worksheet, hand-worked:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 2025-06-01 Dr 1000 2000 / Cr 3000 1000, Cr 3001 1000 both contribute cash
|
||||||
|
/// 2025-07-01 Dr 1000 400 / Cr 4000 400 2025 income, 200 each
|
||||||
|
/// 2026-02-01 Dr 1500 600 / Cr 3000 600 Stephen: equipment
|
||||||
|
/// 2026-03-01 Dr 1000 500 / Cr 3001 500 Kat: cash
|
||||||
|
/// 2026-03-15 Dr 6999 100 / Cr 3000 100 Stephen paid an expense
|
||||||
|
/// 2026-04-01 Dr 1000 1000 / Cr 4000 1000 revenue
|
||||||
|
/// 2026-05-01 Dr 3000 150, Dr 3001 150 / Cr 1000 300 distributions
|
||||||
|
/// 2026-03-01 Dr 6000 2500 / Cr 1000 2500 Acme Design (m.pay)
|
||||||
|
/// 2026: net income 1000 - 100 - 2500 = -1600, -800 each.
|
||||||
|
/// Stephen: beginning 1200, cash 100, property 600, income -800,
|
||||||
|
/// distributions 150, ending 950.
|
||||||
|
/// Kat: beginning 1200, cash 500, property 0, income -800,
|
||||||
|
/// distributions 150, ending 750.
|
||||||
|
/// Check: bank 1100 + equipment 600 = 1700 = 950 + 750.
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Stephen's party has his SSN in the vault; Kat's has none. Acme Design
|
||||||
|
/// (NEC, an EIN in the vault) is paid 2,500.00 in 2026.
|
||||||
|
fn k1_books(name: &str) -> Mcp {
|
||||||
|
let mut m = books_with(name, MEMBERS);
|
||||||
|
m.accounts(&[
|
||||||
|
("1500", "Equipment", "asset"),
|
||||||
|
("3001", "Capital Kat", "equity"),
|
||||||
|
("4000", "App Revenue", "revenue"),
|
||||||
|
]);
|
||||||
|
m.add_party("Stephen Example", &["member"]);
|
||||||
|
m.add_party("Kat Example", &["member"]);
|
||||||
|
m.ok(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": "Stephen Example", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||||
|
);
|
||||||
|
m.w9("Acme Design", json!({"tin": EIN, "tin_type": "ein"}));
|
||||||
|
let none = json!({});
|
||||||
|
m.post(
|
||||||
|
"2025-06-01",
|
||||||
|
none.clone(),
|
||||||
|
&[
|
||||||
|
("1000", "debit", "2000.00"),
|
||||||
|
("3000", "credit", "1000.00"),
|
||||||
|
("3001", "credit", "1000.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
m.post(
|
||||||
|
"2025-07-01",
|
||||||
|
none.clone(),
|
||||||
|
&[("1000", "debit", "400.00"), ("4000", "credit", "400.00")],
|
||||||
|
);
|
||||||
|
m.post(
|
||||||
|
"2026-02-01",
|
||||||
|
none.clone(),
|
||||||
|
&[("1500", "debit", "600.00"), ("3000", "credit", "600.00")],
|
||||||
|
);
|
||||||
|
m.post(
|
||||||
|
"2026-03-01",
|
||||||
|
none.clone(),
|
||||||
|
&[("1000", "debit", "500.00"), ("3001", "credit", "500.00")],
|
||||||
|
);
|
||||||
|
m.post(
|
||||||
|
"2026-03-15",
|
||||||
|
none.clone(),
|
||||||
|
&[("6999", "debit", "100.00"), ("3000", "credit", "100.00")],
|
||||||
|
);
|
||||||
|
m.post(
|
||||||
|
"2026-04-01",
|
||||||
|
none.clone(),
|
||||||
|
&[("1000", "debit", "1000.00"), ("4000", "credit", "1000.00")],
|
||||||
|
);
|
||||||
|
m.post(
|
||||||
|
"2026-05-01",
|
||||||
|
none,
|
||||||
|
&[
|
||||||
|
("3000", "debit", "150.00"),
|
||||||
|
("3001", "debit", "150.00"),
|
||||||
|
("1000", "credit", "300.00"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
m.pay("Acme Design", "2500.00");
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The package carries the K-1 worksheet (one row per member: party,
|
||||||
|
/// share, capital account, TIN, and the hand-worked capital figures) and
|
||||||
|
/// the 1099 worksheet with full TINs, saved outside the books only; a
|
||||||
|
/// missing TIN is a gap, never a failure.
|
||||||
|
#[test]
|
||||||
|
fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||||
|
let mut m = k1_books("k1");
|
||||||
|
let to = m.outside().join("cpa-2026.zip");
|
||||||
|
let out = m.read_only(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||||
|
"save_to": to.to_str().unwrap()}),
|
||||||
|
);
|
||||||
|
assert_eq!(out["saved_to"], json!(to.display().to_string()));
|
||||||
|
assert!(out["content_base64"].is_null(), "never the bytes: {out}");
|
||||||
|
assert_eq!(out["tins_included"], true);
|
||||||
|
assert!(
|
||||||
|
out["note"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&to.display().to_string())
|
||||||
|
);
|
||||||
|
let gaps: Vec<&str> = out["tin_gaps"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||||
|
assert!(gaps[0].contains("Kat Example") && gaps[0].contains("TIN not in the vault"));
|
||||||
|
assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS));
|
||||||
|
assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS));
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(&to).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600, "the package is the owner's only");
|
||||||
|
}
|
||||||
|
let pkg = unzip(&to);
|
||||||
|
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||||
|
assert_eq!(k1.len(), 3, "two members and their total");
|
||||||
|
let (stephen, kat) = (&k1[0], &k1[1]);
|
||||||
|
// The total row: not a K-1; ties to the balance sheet (1,700.00).
|
||||||
|
let total = &k1[2];
|
||||||
|
assert_eq!(total["member"], "Total, all members (not a K-1)");
|
||||||
|
assert_eq!(total["tin"], "");
|
||||||
|
for (col, v) in [
|
||||||
|
("beginning_capital", "2400.00"),
|
||||||
|
("contributions_cash", "600.00"),
|
||||||
|
("contributions_property", "600.00"),
|
||||||
|
("net_income", "-1600.00"),
|
||||||
|
("distributions", "300.00"),
|
||||||
|
("other", "0.00"),
|
||||||
|
("ending_capital", "1700.00"),
|
||||||
|
] {
|
||||||
|
assert_eq!(total[col], v, "{col}");
|
||||||
|
}
|
||||||
|
let sid = m.party_id("Stephen Example");
|
||||||
|
let kid = m.party_id("Kat Example");
|
||||||
|
for (row, want) in [
|
||||||
|
(
|
||||||
|
stephen,
|
||||||
|
[
|
||||||
|
("member", "Stephen Example"),
|
||||||
|
("party_id", sid.to_string().as_str()),
|
||||||
|
("share", "0.50"),
|
||||||
|
("capital_account", "3000"),
|
||||||
|
("tin", MEMBER_SSN),
|
||||||
|
("tin_type", "ssn"),
|
||||||
|
("tin_note", ""),
|
||||||
|
("beginning_capital", "1200.00"),
|
||||||
|
("contributions_cash", "100.00"),
|
||||||
|
("contributions_property", "600.00"),
|
||||||
|
("net_income", "-800.00"),
|
||||||
|
("distributions", "150.00"),
|
||||||
|
("other", "0.00"),
|
||||||
|
("ending_capital", "950.00"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
kat,
|
||||||
|
[
|
||||||
|
("member", "Kat Example"),
|
||||||
|
("party_id", kid.to_string().as_str()),
|
||||||
|
("share", "0.50"),
|
||||||
|
("capital_account", "3001"),
|
||||||
|
("tin", ""),
|
||||||
|
("tin_type", ""),
|
||||||
|
("tin_note", "TIN not in the vault"),
|
||||||
|
("beginning_capital", "1200.00"),
|
||||||
|
("contributions_cash", "500.00"),
|
||||||
|
("contributions_property", "0.00"),
|
||||||
|
("net_income", "-800.00"),
|
||||||
|
("distributions", "150.00"),
|
||||||
|
("other", "0.00"),
|
||||||
|
("ending_capital", "750.00"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
for (col, v) in want {
|
||||||
|
let got = row
|
||||||
|
.get(col)
|
||||||
|
.unwrap_or_else(|| panic!("no column {col}: {row:?}"));
|
||||||
|
if col == "tin_note" && !v.is_empty() {
|
||||||
|
assert!(got.starts_with(v), "{col}: {got}");
|
||||||
|
} else if col == "share" {
|
||||||
|
assert_eq!(
|
||||||
|
got.trim_end_matches('0').trim_end_matches('.'),
|
||||||
|
"0.5",
|
||||||
|
"{col}"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(got, v, "{col} of {row:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The 1099 worksheet: the payee's full TIN.
|
||||||
|
let w = csv_rows(&pkg["1099-worksheet-2026.csv"]);
|
||||||
|
let acme = w.iter().find(|r| r["vendor"] == "Acme Design").unwrap();
|
||||||
|
assert_eq!(acme["tin"], EIN);
|
||||||
|
assert_eq!(acme["tin_type"], "ein");
|
||||||
|
assert_eq!(acme["tin_last4"], "9105");
|
||||||
|
assert_eq!(acme["reportable"], "true");
|
||||||
|
let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap();
|
||||||
|
assert!(
|
||||||
|
readme.contains("k1-members.csv") && readme.contains("Kat Example"),
|
||||||
|
"{readme}"
|
||||||
|
);
|
||||||
|
assert!(!holds_tin(readme.as_bytes(), MEMBER_SSN_DIGITS));
|
||||||
|
// Nothing of it in the books dir.
|
||||||
|
for f in files_under(&m.books_dir()) {
|
||||||
|
if f.starts_with(m.vault()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let b = std::fs::read(&f).unwrap();
|
||||||
|
assert!(
|
||||||
|
!holds_tin(&b, MEMBER_SSN_DIGITS) && !holds_tin(&b, EIN_DIGITS),
|
||||||
|
"{}",
|
||||||
|
f.display()
|
||||||
|
);
|
||||||
|
assert!(!f.to_string_lossy().ends_with(".zip"), "{}", f.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder: the zip goes in it, by its name.
|
||||||
|
let folder = m.outside().join("for-krista");
|
||||||
|
std::fs::create_dir_all(&folder).unwrap();
|
||||||
|
let out = m.read_only(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||||
|
"save_to": folder.to_str().unwrap()}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out["saved_to"],
|
||||||
|
json!(
|
||||||
|
folder
|
||||||
|
.join("cpa-package-2026-12-31.zip")
|
||||||
|
.display()
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Without TINs: last fours only, returned as base64.
|
||||||
|
let out = m.read_only(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||||
|
);
|
||||||
|
assert_eq!(out["tins_included"], false);
|
||||||
|
let zip = base64::Engine::decode(
|
||||||
|
&base64::engine::general_purpose::STANDARD,
|
||||||
|
out["content_base64"].as_str().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let plain_path = m.outside().join("plain.zip");
|
||||||
|
std::fs::write(&plain_path, &zip).unwrap();
|
||||||
|
let plain = unzip(&plain_path);
|
||||||
|
let k1 = csv_rows(&plain["k1-members.csv"]);
|
||||||
|
assert_eq!(k1[0]["tin_last4"], "4915");
|
||||||
|
assert!(!k1[0].contains_key("tin"));
|
||||||
|
assert_eq!(k1[0]["ending_capital"], "950.00");
|
||||||
|
for bytes in plain.values() {
|
||||||
|
assert!(!holds_tin(bytes, MEMBER_SSN_DIGITS) && !holds_tin(bytes, EIN_DIGITS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With TINs the package is never returned and never lands in the books:
|
||||||
|
/// no save_to, a path in the books dir or its exports/, an existing file,
|
||||||
|
/// or a path that is neither a folder nor a .zip is refused.
|
||||||
|
#[test]
|
||||||
|
fn a_package_with_tins_goes_only_outside_the_books() {
|
||||||
|
let mut m = k1_books("k1-where");
|
||||||
|
let why = m.refuse(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||||
|
);
|
||||||
|
assert!(why.contains("save_to"), "{why}");
|
||||||
|
let books = m.books_dir();
|
||||||
|
std::fs::create_dir_all(books.join("exports")).unwrap();
|
||||||
|
let existing = m.outside().join("taken.zip");
|
||||||
|
std::fs::write(&existing, b"x").unwrap();
|
||||||
|
for to in [
|
||||||
|
books.join("cpa.zip"),
|
||||||
|
books.join("exports"),
|
||||||
|
books.join("exports/cpa.zip"),
|
||||||
|
m.data.join("cpa.zip"),
|
||||||
|
books.join("vault/../cpa.zip"),
|
||||||
|
existing.clone(),
|
||||||
|
m.outside().join("cpa.txt"),
|
||||||
|
m.outside().join("no/such/dir/cpa.zip"),
|
||||||
|
] {
|
||||||
|
m.refuse(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||||
|
"save_to": to.to_str().unwrap()}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(std::fs::read(&existing).unwrap(), b"x");
|
||||||
|
assert!(
|
||||||
|
std::fs::read_dir(books.join("exports"))
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(!books.join("cpa.zip").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A locked vault never fails the package: every TIN row says why, and
|
||||||
|
/// tin_gaps lists each member and payee.
|
||||||
|
#[test]
|
||||||
|
fn a_locked_vault_still_builds_the_package() {
|
||||||
|
let mut m = k1_books("k1-locked");
|
||||||
|
std::fs::remove_file(m.key()).unwrap();
|
||||||
|
let to = m.outside().join("cpa.zip");
|
||||||
|
let out = m.read_only(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||||
|
"save_to": to.to_str().unwrap()}),
|
||||||
|
);
|
||||||
|
let gaps = out["tin_gaps"].as_array().unwrap();
|
||||||
|
assert_eq!(gaps.len(), 3, "two members and Acme: {gaps:?}");
|
||||||
|
assert!(
|
||||||
|
gaps.iter().all(|g| g.as_str().unwrap().contains("locked")),
|
||||||
|
"{gaps:?}"
|
||||||
|
);
|
||||||
|
assert!(!m.key().exists(), "reading never makes a key");
|
||||||
|
let pkg = unzip(&to);
|
||||||
|
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||||
|
let members: Vec<_> = k1
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !r["member"].starts_with("Total"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(members.len(), 2);
|
||||||
|
assert!(
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.all(|r| r["tin"].is_empty() && r["tin_note"].contains("locked"))
|
||||||
|
);
|
||||||
|
assert_eq!(k1[0]["ending_capital"], "950.00", "the numbers are there");
|
||||||
|
let w = csv_rows(&pkg["1099-worksheet-2026.csv"]);
|
||||||
|
assert!(w.iter().all(|r| r["tin"].is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B-26 ("No tool output, error, or log holds a full TIN"): a TIN put in
|
||||||
|
/// the wrong field is refused, and the refusal never repeats it, however
|
||||||
|
/// it was written: as a party name, a bare number where a string goes, or
|
||||||
|
/// a share's percent.
|
||||||
|
#[test]
|
||||||
|
fn refusals_never_echo_a_tin() {
|
||||||
|
let mut m = books("echo");
|
||||||
|
let id = m.post(
|
||||||
|
"2026-02-01",
|
||||||
|
json!({}),
|
||||||
|
&[("6999", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||||
|
);
|
||||||
|
for (tool, args) in [
|
||||||
|
(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": MEMBER_SSN, "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"books.set_tin",
|
||||||
|
json!({"party": MEMBER_SSN_DIGITS.parse::<u64>().unwrap(), "tin": MEMBER_SSN,
|
||||||
|
"tin_type": "ssn"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"books.purge_tin",
|
||||||
|
json!({"party": "638.27.4915", "reason": "x", "confirmed": true}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"books.set_entry_party",
|
||||||
|
json!({"entry_id": id, "parties": [
|
||||||
|
{"party": "Acme Design", "percent": MEMBER_SSN},
|
||||||
|
{"party": MEMBER_SSN, "amount": "1.00"},
|
||||||
|
]}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let why = m.refuse(tool, args.clone());
|
||||||
|
assert!(
|
||||||
|
!holds_tin(why.as_bytes(), MEMBER_SSN_DIGITS),
|
||||||
|
"{tool} {args} echoed the TIN: {why}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// k1::member_party: a vendor whose display name is a member's name, with
|
||||||
|
/// a W-9 EIN in the vault, never puts its TIN on the member's K-1 row:
|
||||||
|
/// the member's TIN is a gap.
|
||||||
|
#[test]
|
||||||
|
fn a_k1_row_never_carries_another_partys_tin() {
|
||||||
|
let mut m = k1_books("k1-lookalike");
|
||||||
|
m.ok(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": "Kat Example", "archived": true}),
|
||||||
|
);
|
||||||
|
m.add_party("KE Consulting LLC", &["contractor"]);
|
||||||
|
m.ok(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": "KE Consulting LLC", "display_name": "Kat Example"}),
|
||||||
|
);
|
||||||
|
m.w9(
|
||||||
|
"KE Consulting LLC",
|
||||||
|
json!({"tin": "12-3456789", "tin_type": "ein"}),
|
||||||
|
);
|
||||||
|
let to = m.outside().join("cpa-lookalike.zip");
|
||||||
|
let out = m.read_only(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||||
|
"save_to": to.to_str().unwrap()}),
|
||||||
|
);
|
||||||
|
let gaps = out["tin_gaps"].to_string();
|
||||||
|
assert!(gaps.contains("member Kat Example"), "{gaps}");
|
||||||
|
let pkg = unzip(&to);
|
||||||
|
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||||
|
let kat = k1.iter().find(|r| r["member"] == "Kat Example").unwrap();
|
||||||
|
assert_eq!(kat["tin"], "", "{kat:?}");
|
||||||
|
assert_eq!(kat["party_id"], "", "{kat:?}");
|
||||||
|
assert!(!holds_tin(&pkg["k1-members.csv"], "123456789"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Members who file a joint individual return (files_jointly_with on
|
||||||
|
/// both): the README says each gets their own K-1 and both go on one Form
|
||||||
|
/// 1040; books.settings reports it; a one-sided setting is a settings error.
|
||||||
|
#[test]
|
||||||
|
fn joint_filers_are_noted_in_the_package() {
|
||||||
|
let joint = "[[company.members]]\nname = \"Stephen Example\"\nshare = \"50%\"\ncapital_account = \"3000\"\nfiles_jointly_with = \"Kat Example\"\n\
|
||||||
|
[[company.members]]\nname = \"Kat Example\"\nshare = \"50%\"\ncapital_account = \"3001\"\nfiles_jointly_with = \"Stephen Example\"\n";
|
||||||
|
let mut m = books_with("joint", joint);
|
||||||
|
m.accounts(&[("3001", "Capital Kat", "equity")]);
|
||||||
|
let s = m.read_only("books.settings", json!({}));
|
||||||
|
assert_eq!(s["members"][0]["files_jointly_with"], "Kat Example");
|
||||||
|
assert_eq!(s["members"][1]["files_jointly_with"], "Stephen Example");
|
||||||
|
let to = m.outside().join("joint.zip");
|
||||||
|
m.read_only(
|
||||||
|
"books.export_cpa_package",
|
||||||
|
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||||
|
"save_to": to.to_str().unwrap()}),
|
||||||
|
);
|
||||||
|
let pkg = unzip(&to);
|
||||||
|
let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap();
|
||||||
|
let note = "Stephen Example and Kat Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).";
|
||||||
|
assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}");
|
||||||
|
}
|
||||||
@@ -790,6 +790,21 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record or clear a party's taxpayer-ID facts (the TIN's type, last
|
||||||
|
/// four, and vault record; never the number). Journaled as
|
||||||
|
/// `party.tin_set`.
|
||||||
|
pub fn set_party_tin(
|
||||||
|
&mut self,
|
||||||
|
id: ledger_core::PartyId,
|
||||||
|
facts: Option<ledger_core::TinFacts>,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ledger.check_set_party_tin(id, facts.as_ref())?;
|
||||||
|
let line = self.append_journal("party.tin_set", json!({"id": id, "facts": facts}))?;
|
||||||
|
self.ledger.set_party_tin(id, facts)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Record or clear a party's W-4 facts. Journaled as `party.w4_set`.
|
/// Record or clear a party's W-4 facts. Journaled as `party.w4_set`.
|
||||||
pub fn set_party_w4(
|
pub fn set_party_w4(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -821,6 +836,28 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Name the parties an entry was with, each with their share, or clear
|
||||||
|
/// them (an empty list): `Ledger::set_entry_parties`. Checked before
|
||||||
|
/// journaling, so a refusal journals nothing. Journaled as
|
||||||
|
/// `entry.parties_set {entry_id, parties: [{party_id, share}]}`, a share
|
||||||
|
/// being `{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`.
|
||||||
|
/// Whether the shares resolve against the entry's lines depends on the
|
||||||
|
/// settings: callers check `ledger_core::shares::resolve` first.
|
||||||
|
pub fn set_entry_parties(
|
||||||
|
&mut self,
|
||||||
|
entry: EntryId,
|
||||||
|
parties: &[ledger_core::PartyShare],
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.ledger.check_set_entry_parties(entry, parties)?;
|
||||||
|
let line = self.append_journal(
|
||||||
|
"entry.parties_set",
|
||||||
|
json!({"entry_id": entry, "parties": parties}),
|
||||||
|
)?;
|
||||||
|
self.ledger.set_entry_parties(entry, parties)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark an entry as having no counterparty, with why
|
/// Mark an entry as having no counterparty, with why
|
||||||
/// (`Ledger::set_entry_no_party`). Journaled as `entry.no_party
|
/// (`Ledger::set_entry_no_party`). Journaled as `entry.no_party
|
||||||
/// {entry_id, reason}`.
|
/// {entry_id, reason}`.
|
||||||
@@ -4072,6 +4109,12 @@ pub(crate) fn replay_line_into_ledger(
|
|||||||
.map_err(|_| StoreError::JournalCorrupt("party.w4_set facts".into()))?;
|
.map_err(|_| StoreError::JournalCorrupt("party.w4_set facts".into()))?;
|
||||||
ledger.set_party_w4(get_u64(p, "id")?, facts)?;
|
ledger.set_party_w4(get_u64(p, "id")?, facts)?;
|
||||||
}
|
}
|
||||||
|
"party.tin_set" => {
|
||||||
|
let facts: Option<ledger_core::TinFacts> =
|
||||||
|
serde_json::from_value(p["facts"].clone())
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("party.tin_set facts".into()))?;
|
||||||
|
ledger.set_party_tin(get_u64(p, "id")?, facts)?;
|
||||||
|
}
|
||||||
"entry.party_set" => {
|
"entry.party_set" => {
|
||||||
let party = match p.get("party_id") {
|
let party = match p.get("party_id") {
|
||||||
None | Some(Value::Null) => None,
|
None | Some(Value::Null) => None,
|
||||||
@@ -4081,6 +4124,12 @@ pub(crate) fn replay_line_into_ledger(
|
|||||||
};
|
};
|
||||||
ledger.set_entry_party(get_u64(p, "entry_id")?, party)?;
|
ledger.set_entry_party(get_u64(p, "entry_id")?, party)?;
|
||||||
}
|
}
|
||||||
|
"entry.parties_set" => {
|
||||||
|
let parties: Vec<ledger_core::PartyShare> =
|
||||||
|
serde_json::from_value(p["parties"].clone())
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt("entry.parties_set parties".into()))?;
|
||||||
|
ledger.set_entry_parties(get_u64(p, "entry_id")?, &parties)?;
|
||||||
|
}
|
||||||
"entry.no_party" => {
|
"entry.no_party" => {
|
||||||
ledger.set_entry_no_party(get_u64(p, "entry_id")?, get_str(p, "reason")?)?;
|
ledger.set_entry_no_party(get_u64(p, "entry_id")?, get_str(p, "reason")?)?;
|
||||||
}
|
}
|
||||||
@@ -4656,7 +4705,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
|
"role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
|
||||||
| "party.w4_set" | "entry.party_set" | "entry.no_party" => {
|
| "party.w4_set" | "party.tin_set" | "entry.party_set" | "entry.parties_set"
|
||||||
|
| "entry.no_party" => {
|
||||||
// Control roles and the party directory live in the ledger
|
// Control roles and the party directory live in the ledger
|
||||||
// (rebuilt by replay); the events mirror below keeps the
|
// (rebuilt by replay); the events mirror below keeps the
|
||||||
// history.
|
// history.
|
||||||
|
|||||||
@@ -752,3 +752,190 @@ fn books_from_before_parties_open_and_get_parties_by_replay() {
|
|||||||
let s = Store::open(&dst).unwrap();
|
let s = Store::open(&dst).unwrap();
|
||||||
assert_eq!(view(s.ledger()), before);
|
assert_eq!(view(s.ledger()), before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- party shares
|
||||||
|
|
||||||
|
/// Oracle: Store::set_entry_parties doc (`entry.parties_set {entry_id,
|
||||||
|
/// parties: [{party_id, share}]}`, a share `{"amount": "30.00"}`,
|
||||||
|
/// `{"percent": "60"}`, or `"derived"`; refusals journal nothing) and
|
||||||
|
/// Store::verify (a fresh replay equals the live ledger).
|
||||||
|
mod shares {
|
||||||
|
use super::*;
|
||||||
|
use ledger_core::{PartyShare, Share};
|
||||||
|
|
||||||
|
fn share(party: PartyId, share: Share) -> PartyShare {
|
||||||
|
PartyShare { party, share }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_entry_parties_journals_one_event_with_each_share() {
|
||||||
|
let dir = test_dir("shares-event");
|
||||||
|
let (mut s, fx) = fixture(&dir);
|
||||||
|
let a = s.add_party("Stephen Waits", &[Role::Member]).unwrap();
|
||||||
|
let b = s
|
||||||
|
.add_party("Katherine Pendergast", &[Role::Member])
|
||||||
|
.unwrap();
|
||||||
|
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
|
||||||
|
s.set_entry_parties(fx.entry, &[PartyShare::whole(a), PartyShare::whole(b)])
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
pl,
|
||||||
|
json!({"entry_id": fx.entry, "parties": [
|
||||||
|
{"party_id": a, "share": "derived"},
|
||||||
|
{"party_id": b, "share": "derived"},
|
||||||
|
]})
|
||||||
|
);
|
||||||
|
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
|
||||||
|
s.set_entry_parties(
|
||||||
|
fx.entry,
|
||||||
|
&[
|
||||||
|
share(a, Share::Amount(dec("300.00"))),
|
||||||
|
share(b, Share::Amount(dec("200.00"))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
pl,
|
||||||
|
json!({"entry_id": fx.entry, "parties": [
|
||||||
|
{"party_id": a, "share": {"amount": "300.00"}},
|
||||||
|
{"party_id": b, "share": {"amount": "200.00"}},
|
||||||
|
]})
|
||||||
|
);
|
||||||
|
let ((), pl) = one_event(&mut s, &dir, "entry.parties_set", |s| {
|
||||||
|
s.set_entry_parties(
|
||||||
|
fx.entry,
|
||||||
|
&[
|
||||||
|
share(a, Share::Percent(dec("60"))),
|
||||||
|
share(b, Share::Percent(dec("40"))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
pl["parties"][0]["share"],
|
||||||
|
json!({"percent": "60"}),
|
||||||
|
"percent shares journal as strings"
|
||||||
|
);
|
||||||
|
assert_eq!(s.ledger().entry_parties(fx.entry).len(), 2);
|
||||||
|
assert_eq!(s.ledger().entry_party(fx.entry), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refusals_journal_nothing() {
|
||||||
|
let dir = test_dir("shares-refused");
|
||||||
|
let (mut s, fx) = fixture(&dir);
|
||||||
|
let a = s.add_party("A", &[Role::Member]).unwrap();
|
||||||
|
let b = s.add_party("B", &[Role::Member]).unwrap();
|
||||||
|
let v1 = s.add_vendor("V1").unwrap();
|
||||||
|
let v2 = s.add_vendor("V2").unwrap();
|
||||||
|
let (p1, p2) = (
|
||||||
|
s.ledger().party_of_vendor(v1).unwrap(),
|
||||||
|
s.ledger().party_of_vendor(v2).unwrap(),
|
||||||
|
);
|
||||||
|
let cases: Vec<(&str, EntryId, Vec<PartyShare>)> = vec![
|
||||||
|
("unknown entry", fx.entry + 99, vec![PartyShare::whole(a)]),
|
||||||
|
("unknown party", fx.entry, vec![PartyShare::whole(999)]),
|
||||||
|
(
|
||||||
|
"duplicate",
|
||||||
|
fx.entry,
|
||||||
|
vec![PartyShare::whole(a), PartyShare::whole(a)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mixed",
|
||||||
|
fx.entry,
|
||||||
|
vec![share(a, Share::Percent(dec("50"))), PartyShare::whole(b)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"percent sum",
|
||||||
|
fx.entry,
|
||||||
|
vec![
|
||||||
|
share(a, Share::Percent(dec("50"))),
|
||||||
|
share(b, Share::Percent(dec("40"))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sub-cent",
|
||||||
|
fx.entry,
|
||||||
|
vec![
|
||||||
|
share(a, Share::Amount(dec("250.001"))),
|
||||||
|
share(b, Share::Amount(dec("249.999"))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"two vendors",
|
||||||
|
fx.entry,
|
||||||
|
vec![
|
||||||
|
share(p1, Share::Amount(dec("250.00"))),
|
||||||
|
share(p2, Share::Amount(dec("250.00"))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (what, e, shares) in cases {
|
||||||
|
refused_nothing_journaled(&mut s, &dir, what, |s| s.set_entry_parties(e, &shares));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_gives_equal_state() {
|
||||||
|
let dir = test_dir("shares-replay");
|
||||||
|
let (mut s, fx) = fixture(&dir);
|
||||||
|
let a = s.add_party("Stephen Waits", &[Role::Member]).unwrap();
|
||||||
|
let b = s
|
||||||
|
.add_party("Katherine Pendergast", &[Role::Member])
|
||||||
|
.unwrap();
|
||||||
|
let v = s.add_vendor("Contractor").unwrap();
|
||||||
|
let pv = s.ledger().party_of_vendor(v).unwrap();
|
||||||
|
s.set_entry_parties(
|
||||||
|
fx.entry,
|
||||||
|
&[
|
||||||
|
share(a, Share::Amount(dec("300.00"))),
|
||||||
|
share(b, Share::Amount(dec("200.00"))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// A second entry shared with a vendor party, then reversed.
|
||||||
|
let other = s.create_entry(date(2026, 2, 1), "Split work").unwrap();
|
||||||
|
let exp = s
|
||||||
|
.add_account("6000", "Work", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
s.add_line(other, exp, Side::Debit, usd("90.00")).unwrap();
|
||||||
|
s.add_line(other, fx.cash, Side::Credit, usd("90.00"))
|
||||||
|
.unwrap();
|
||||||
|
s.post_entry(other).unwrap();
|
||||||
|
s.set_entry_parties(
|
||||||
|
other,
|
||||||
|
&[
|
||||||
|
share(pv, Share::Percent(dec("66.67"))),
|
||||||
|
share(a, Share::Percent(dec("33.33"))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(s.ledger().entry_vendor(other), Some(v));
|
||||||
|
let r = s.reverse_entry(other, date(2026, 2, 2), "undo").unwrap();
|
||||||
|
assert_eq!(s.ledger().entry_parties(r), s.ledger().entry_parties(other));
|
||||||
|
// An old-style single tag after a multi-party one.
|
||||||
|
let third = s.create_entry(date(2026, 3, 1), "Draft").unwrap();
|
||||||
|
s.set_entry_parties(third, &[PartyShare::whole(a), PartyShare::whole(b)])
|
||||||
|
.unwrap();
|
||||||
|
s.set_entry_party(third, Some(b)).unwrap();
|
||||||
|
s.verify().unwrap();
|
||||||
|
let live = s.ledger().clone();
|
||||||
|
drop(s);
|
||||||
|
for db in [true, false] {
|
||||||
|
if !db {
|
||||||
|
delete_db(&dir);
|
||||||
|
}
|
||||||
|
let s = Store::open(&dir).unwrap();
|
||||||
|
assert!(
|
||||||
|
*s.ledger() == live,
|
||||||
|
"replay (db {db}): {:?}",
|
||||||
|
s.ledger().differs_from(&live)
|
||||||
|
);
|
||||||
|
assert_eq!(s.ledger().entry_parties(r).len(), 2);
|
||||||
|
assert_eq!(s.ledger().entry_party(third), Some(b));
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//! Spec-derived tests for a party's taxpayer-ID facts in the store.
|
||||||
|
//!
|
||||||
|
//! Oracles: `Store::set_party_tin` ("journaled as `party.tin_set`" {id,
|
||||||
|
//! facts}), the `ledger_core::parties` spec (INVARIANTS 1, 2, 2a: any role,
|
||||||
|
//! never a full TIN, the TIN and W-9 facts agree), and `Store::verify` (a
|
||||||
|
//! fresh replay equals the live ledger). Values worked by hand.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::{Currency, Role, TinFacts, TinType, W9Facts};
|
||||||
|
use ledger_store::journal::read_and_verify_journal;
|
||||||
|
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn test_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wb-party-tin-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
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 tin(last4: &str) -> TinFacts {
|
||||||
|
TinFacts {
|
||||||
|
tin_type: TinType::Ssn,
|
||||||
|
last4: last4.into(),
|
||||||
|
vault_record: Some("ab".repeat(32)),
|
||||||
|
purged: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn w9(last4: &str) -> W9Facts {
|
||||||
|
W9Facts {
|
||||||
|
received: NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_db(dir: &Path) {
|
||||||
|
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||||
|
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any role, journaled as one `party.tin_set {id, facts}` line, replayed
|
||||||
|
/// on open (with and without the database), and cleared with null.
|
||||||
|
#[test]
|
||||||
|
fn tin_facts_journal_and_replay_for_any_role() {
|
||||||
|
let dir = test_dir("replay");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let member = s.add_party("Kat", &[Role::Member]).unwrap();
|
||||||
|
let nobody = s.add_party("Plain", &[]).unwrap();
|
||||||
|
s.set_party_tin(member, Some(tin("4915"))).unwrap();
|
||||||
|
s.set_party_tin(nobody, Some(tin("1234"))).unwrap();
|
||||||
|
s.set_party_tin(nobody, None).unwrap();
|
||||||
|
let j = journal(&dir);
|
||||||
|
let tins: Vec<&(String, Value)> = j.iter().filter(|(t, _)| t == "party.tin_set").collect();
|
||||||
|
assert_eq!(tins.len(), 3);
|
||||||
|
assert_eq!(tins[0].1["id"], json!(member));
|
||||||
|
assert_eq!(tins[0].1["facts"]["last4"], "4915");
|
||||||
|
assert_eq!(tins[0].1["facts"]["tin_type"], "ssn");
|
||||||
|
assert!(tins[2].1["facts"].is_null());
|
||||||
|
s.verify().unwrap();
|
||||||
|
drop(s);
|
||||||
|
for without_db in [false, true] {
|
||||||
|
if without_db {
|
||||||
|
delete_db(&dir);
|
||||||
|
}
|
||||||
|
let s = Store::open(&dir).unwrap();
|
||||||
|
assert_eq!(s.ledger().get_party(member).unwrap().tin, Some(tin("4915")));
|
||||||
|
assert_eq!(s.ledger().get_party(nobody).unwrap().tin, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TIN and W-9 facts never disagree (INVARIANT 2a), and a refusal
|
||||||
|
/// journals nothing.
|
||||||
|
#[test]
|
||||||
|
fn tin_and_w9_facts_agree() {
|
||||||
|
let dir = test_dir("agree");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let c = s.add_party("Acme", &[Role::Contractor]).unwrap();
|
||||||
|
s.set_party_w9(c, Some(w9("2846"))).unwrap();
|
||||||
|
let before = journal(&dir).len();
|
||||||
|
assert!(s.set_party_tin(c, Some(tin("1111"))).is_err());
|
||||||
|
assert!(s.set_party_tin(c, Some(tin("12"))).is_err(), "four digits");
|
||||||
|
assert!(
|
||||||
|
s.set_party_tin(
|
||||||
|
c,
|
||||||
|
Some(TinFacts {
|
||||||
|
purged: Some("571932846".into()),
|
||||||
|
..tin("2846")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"never a full TIN"
|
||||||
|
);
|
||||||
|
assert!(s.set_party_tin(99, Some(tin("2846"))).is_err());
|
||||||
|
assert_eq!(journal(&dir).len(), before);
|
||||||
|
s.set_party_tin(c, Some(tin("2846"))).unwrap();
|
||||||
|
assert!(s.set_party_w9(c, Some(w9("1111"))).is_err());
|
||||||
|
// Cleared first, the W-9 may change, then the TIN facts follow.
|
||||||
|
s.set_party_tin(c, None).unwrap();
|
||||||
|
s.set_party_w9(c, Some(w9("1111"))).unwrap();
|
||||||
|
s.set_party_tin(c, Some(tin("1111"))).unwrap();
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `party.tin_set` line that can't replay is refused on open.
|
||||||
|
#[test]
|
||||||
|
fn corrupt_tin_payloads_are_refused_on_open() {
|
||||||
|
for (i, facts) in [
|
||||||
|
json!({"tin_type": "passport", "last4": "1234"}),
|
||||||
|
json!({"tin_type": "ssn", "last4": "123456789"}),
|
||||||
|
json!({"tin_type": "ssn", "last4": "1234", "vault_record": "nothex"}),
|
||||||
|
json!({"last4": "1234"}),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let dir = test_dir(&format!("corrupt-{i}"));
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let id = s.add_party("A", &[Role::Member]).unwrap();
|
||||||
|
drop(s);
|
||||||
|
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||||
|
w.append("party.tin_set", json!({"id": id, "facts": facts}))
|
||||||
|
.unwrap();
|
||||||
|
drop(w);
|
||||||
|
assert!(Store::open(&dir).is_err(), "{facts} opened");
|
||||||
|
delete_db(&dir);
|
||||||
|
assert!(Store::open(&dir).is_err(), "{facts} opened without the db");
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-26
@@ -33,7 +33,10 @@ to 100%) drives the statement of members' equity, `[books]
|
|||||||
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
||||||
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
||||||
`config.json` sets `receipt_policy`. Members may name a
|
`config.json` sets `receipt_policy`. Members may name a
|
||||||
`distribution_account`; single-owner businesses may set `[books] close_into`
|
`distribution_account`, and `files_jointly_with` (spouses filing one joint
|
||||||
|
return; the CPA package's README notes their two K-1s go on one Form 1040,
|
||||||
|
and k1-members.csv ends with a members' total, not a K-1, that ties to
|
||||||
|
Schedule L); single-owner businesses may set `[books] close_into`
|
||||||
(the equity account a close puts net income in; default Retained Earnings)
|
(the equity account a close puts net income in; default Retained Earnings)
|
||||||
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
|
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
|
||||||
The cash flow statement's account roles are `[books] cash_accounts` (what
|
The cash flow statement's account roles are `[books] cash_accounts` (what
|
||||||
@@ -112,21 +115,31 @@ below.
|
|||||||
- `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder,
|
- `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder,
|
||||||
balance }] }`. Balances included: one call answers "what do we have."
|
balance }] }`. Balances included: one call answers "what do we have."
|
||||||
- `books.draft_entry { date: "2026-10-10", description: "...", vendor?,
|
- `books.draft_entry { date: "2026-10-10", description: "...", vendor?,
|
||||||
party? }` → `{ entry_id, status: "draft" }`. `party` tags the
|
party?, parties?, no_party_reason? }` → `{ entry_id, status: "draft" }`.
|
||||||
counterparty (see "Tools — parties"); given with `vendor`, both must name
|
`party` tags the counterparty (see "Tools — parties"); given with
|
||||||
the same vendor record, else nothing is drafted.
|
`vendor`, both must name the same vendor record, else nothing is
|
||||||
|
drafted. `parties` names several, each with a share (as
|
||||||
|
`books.set_entry_party`), in place of `party` and `vendor`.
|
||||||
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
||||||
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
|
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
|
||||||
class, bad amount) adds nothing.
|
class, bad amount) adds nothing.
|
||||||
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
|
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
|
||||||
description, total, lines (with resulting balances), receipts,
|
description, total, lines (with resulting balances), receipts,
|
||||||
receipt_required, receipt_missing, split_detected }`. Without
|
receipt_required, receipt_missing, split_detected, party_policy,
|
||||||
`confirmed: true`, `status` is `"preview"` and nothing changes; with it,
|
party_id?, party?, parties, parties_error?, no_party_reason?,
|
||||||
the receipt policy is enforced and `status` is `"posted"`.
|
party_missing, party_suggestions }`. Without `confirmed: true`, `status`
|
||||||
|
is `"preview"` and nothing changes; with it, the receipt policy is
|
||||||
|
enforced and `status` is `"posted"`. `parties` lists every party the
|
||||||
|
entry names with its share resolved to the cent (`{ party_id, party,
|
||||||
|
share: "amount" | "percent" | "derived", amount?, percent?, resolved }`);
|
||||||
|
when the shares don't add up to what the entry moved, `parties_error`
|
||||||
|
says why and confirming fails with code `party_shares`.
|
||||||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||||||
preview/confirm pattern as post.
|
preview/confirm pattern as post.
|
||||||
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
|
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
|
||||||
its `vendor`, `party_id`, and `party` (the party's label) tags.
|
its `vendor`, `party_id`, and `party` (the party's label, when it names
|
||||||
|
exactly one) tags, and `parties` / `parties_error` as in the
|
||||||
|
`post_entry` preview.
|
||||||
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
||||||
→ entries with lines, newest first. `limit` caps at 100.
|
→ entries with lines, newest first. `limit` caps at 100.
|
||||||
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
||||||
@@ -290,14 +303,22 @@ is on file and the TIN's last four; full TINs live only in the TIN vault
|
|||||||
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
|
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
|
||||||
takes `vendor` too; bills and their payments are tagged automatically.
|
takes `vendor` too; bills and their payments are tagged automatically.
|
||||||
`books.set_entry_party` tags the vendor too when the party has a vendor
|
`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.
|
record, so tagging by party counts for 1099s the same way. On an entry
|
||||||
|
naming several parties the parties are `books.set_entry_party`'s:
|
||||||
|
tagging the vendor of a party already named keeps every share as it
|
||||||
|
is; tagging a vendor whose party isn't named, or clearing the tag while
|
||||||
|
its party is named, is refused (drop or add the party with `parties`
|
||||||
|
instead). An entry and its reversal always carry the same tags.
|
||||||
- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` →
|
- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` →
|
||||||
`{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file,
|
`{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file,
|
||||||
paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }],
|
paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }],
|
||||||
missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold
|
missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold
|
||||||
wanderbooks doesn't know (after 2026 until the indexed amount is added)
|
wanderbooks doesn't know (after 2026 until the indexed amount is added)
|
||||||
it refuses with what to do; pass `threshold` explicitly once the figure
|
it refuses with what to do; pass `threshold` explicitly once the figure
|
||||||
is confirmed. `tin_last4` comes from the party's W-9 facts. With
|
is confirmed. On an entry naming several parties, `paid` counts only
|
||||||
|
the vendor's resolved share of the payment (rules.md B-19); an entry
|
||||||
|
whose shares don't resolve counts in full and `notes` names it.
|
||||||
|
`tin_last4` comes from the party's W-9 facts. With
|
||||||
`include_tins: true` (default false) each line carries `tin`, the full
|
`include_tins: true` (default false) each line carries `tin`, the full
|
||||||
TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an
|
TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an
|
||||||
EIN), and `tin_type`, read from the vault, or `tin_note` saying why not
|
EIN), and `tin_type`, read from the vault, or `tin_note` saying why not
|
||||||
@@ -359,11 +380,44 @@ texts are at most 500 characters, no control characters.
|
|||||||
`"wanderbooks vault"`.
|
`"wanderbooks vault"`.
|
||||||
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
|
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
|
||||||
true }` → the party. Journaled as `party.w4_set`.
|
true }` → the party. Journaled as `party.w4_set`.
|
||||||
- `books.set_entry_party { entry_id, party? }` → the entry (as
|
- `books.set_entry_party { entry_id, party?, parties?, no_party_reason? }`
|
||||||
`books.get_entry`). `party` null or omitted clears the tag. A party with
|
→ the entry (as `books.get_entry`). `party` null or omitted clears the
|
||||||
a vendor record also tags the entry's vendor; one without, or clearing,
|
tag. A party with a vendor record also tags the entry's vendor; one
|
||||||
clears the vendor tag. Metadata only; posted entries may be tagged.
|
without, or clearing, clears the vendor tag. Metadata only; posted
|
||||||
Journaled as `entry.party_set`.
|
entries may be tagged. Tagging an entry that was reversed, or a
|
||||||
|
reversal, tags the other too (the whole reversal chain), so each
|
||||||
|
party's part of the pair still nets to zero. Journaled as
|
||||||
|
`entry.party_set`.
|
||||||
|
`no_party_reason` (with no party) marks an entry as having no
|
||||||
|
counterparty (`entry.no_party`).
|
||||||
|
- **Several parties:** `parties: [{ party, amount? | percent? }]`, in
|
||||||
|
place of `party`, names every counterparty with its share; `[]` clears.
|
||||||
|
Journaled as `entry.parties_set { entry_id, parties: [{ party_id, share
|
||||||
|
}] }`, `share` being `{"amount": "30.00"}`, `{"percent": "60"}`, or
|
||||||
|
`"derived"`. Each share, of what the entry moved (its `entry_flow` net;
|
||||||
|
for an entry moving no money, like property contributed, its total):
|
||||||
|
- `amount`: exactly that, positive, in cents; the amounts must sum to
|
||||||
|
what it moved.
|
||||||
|
- `percent`: that percent; the percents must sum to exactly 100. Each
|
||||||
|
part is rounded down to the cent and the leftover cents go to the
|
||||||
|
first party listed.
|
||||||
|
- neither (derived): a sole party gets all of it. Members named
|
||||||
|
together (parties with the member role named in `[[company.members]]`)
|
||||||
|
get what their own capital and distributions lines on the entry say,
|
||||||
|
when it has any (these must account for all it moved); else their
|
||||||
|
`[[company.members]]` shares, rescaled to the members named, rounded as
|
||||||
|
percents are. A derived share for a non-member among several is
|
||||||
|
refused.
|
||||||
|
Every party gives the same kind of share. Refused, journaling nothing: a
|
||||||
|
party named twice, an unknown party, mixed kinds, percents not summing
|
||||||
|
to 100, an amount finer than a cent, two parties with vendor records (a
|
||||||
|
1099 counts a payment toward one vendor; the one vendor party's record
|
||||||
|
is tagged and counts the whole payment), and, once the entry has
|
||||||
|
balanced lines, shares that don't resolve. A draft's shares are checked
|
||||||
|
against its lines again at `post_entry`. Under `[books] parties =
|
||||||
|
"require"`, any party (or the no-party mark) satisfies the check.
|
||||||
|
Reversals carry every share. There is no "joint" party: a joint member
|
||||||
|
transaction names both members and lets the shares derive.
|
||||||
- `books.parties { role?, include_archived?, year? }` → `{ year, parties:
|
- `books.parties { role?, include_archived?, year? }` → `{ year, parties:
|
||||||
[{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file,
|
[{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file,
|
||||||
tin_last4?, w4_on_file, archived, paid, received }] }`, by id.
|
tin_last4?, w4_on_file, archived, paid, received }] }`, by id.
|
||||||
@@ -397,7 +451,8 @@ include vendor and customer when it has those records.
|
|||||||
positive net was paid to the party, a negative one received from it; 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
|
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
|
payment is; an entry only moving money between money accounts nets to
|
||||||
zero.
|
zero. An entry naming several parties counts this party's resolved
|
||||||
|
share only (an entry whose shares don't resolve counts for none).
|
||||||
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
|
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
|
||||||
`owed` / `receivable`: its open bills' and invoices' balances now (every
|
`owed` / `receivable`: its open bills' and invoices' balances now (every
|
||||||
year).
|
year).
|
||||||
@@ -408,9 +463,12 @@ include vendor and customer when it has those records.
|
|||||||
|
|
||||||
## Tools — the TIN vault
|
## Tools — the TIN vault
|
||||||
|
|
||||||
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so the CPA's 1099
|
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so what the CPA
|
||||||
worksheet is complete, never in the books (`ledger-vault`; design in
|
gets is complete (every member's TIN for their K-1, every payee's for
|
||||||
`docs/parties-and-attachments.md` "The TIN vault"; B-26).
|
their 1099), never in the books (`ledger-vault`; design in
|
||||||
|
`docs/parties-and-attachments.md` "The TIN vault"; B-26). DECISION
|
||||||
|
(Stephen, 2026-10-15): the vault holds everyone's TIN, members included,
|
||||||
|
and the CPA package carries them.
|
||||||
|
|
||||||
- **Where:** `vault/` in the books dir (the parent of the data dir, beside
|
- **Where:** `vault/` in the books dir (the parent of the data dir, beside
|
||||||
`wanderbooks.toml`; git-ignored), one file per record, each encrypted
|
`wanderbooks.toml`; git-ignored), one file per record, each encrypted
|
||||||
@@ -424,14 +482,31 @@ worksheet is complete, never in the books (`ledger-vault`; design in
|
|||||||
locked: the books open and verify; the worksheet says the TINs are
|
locked: the books open and verify; the worksheet says the TINs are
|
||||||
missing.
|
missing.
|
||||||
- **Never out:** no tool output, error, or log holds more than a TIN's
|
- **Never out:** no tool output, error, or log holds more than a TIN's
|
||||||
last four, except `books.vendor_1099 { include_tins: true }`. The tool
|
last four, except `books.vendor_1099 { include_tins: true }`; the CPA
|
||||||
arguments that take a TIN never echo it, even when refused.
|
package carries them only in its zip, written outside the books dir.
|
||||||
|
The tool arguments that take a TIN never echo it, even when refused.
|
||||||
|
- **TIN facts:** any party (any role) may have `tin` facts:
|
||||||
|
`{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`,
|
||||||
|
journaled as `party.tin_set {id, facts|null}` and shown in every party
|
||||||
|
output as `tin: { tin_type, last4, in_vault, purged? }`. They never
|
||||||
|
disagree with the party's W-9 facts: both on file means the same last
|
||||||
|
four (the engine refuses otherwise).
|
||||||
|
- `books.set_tin { party, tin, tin_type }` → the party. Validates the TIN
|
||||||
|
as `books.set_w9` does, checks the facts on a copy of the ledger, writes
|
||||||
|
the TIN to the vault (the same key and git-ignore checks), and journals
|
||||||
|
`party.tin_set` with the record's hash. Any role. A party whose W-9
|
||||||
|
facts name another last four is refused (a payee's new TIN comes with
|
||||||
|
its W-9: `books.set_w9`); one whose W-9 facts agree gets their
|
||||||
|
`vault_record` updated too (`party.w9_set`).
|
||||||
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates
|
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates
|
||||||
the TIN against its type (nine digits, hyphens allowed; an ITIN starts
|
the TIN against its type (nine digits, hyphens allowed; an ITIN starts
|
||||||
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start
|
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start
|
||||||
with 9), checks the facts on a copy of the ledger, then writes the vault
|
with 9), checks the facts on a copy of the ledger, then writes the vault
|
||||||
and journals the facts with `vault_record`, the SHA-256 of the TIN
|
and journals the facts with `vault_record`, the SHA-256 of the TIN
|
||||||
record (of the form's record when only a form is given). The form (PDF,
|
record (of the form's record when only a form is given). With a TIN it
|
||||||
|
also journals the party's TIN facts (`party.tin_set`, as
|
||||||
|
`books.set_tin`); a TIN with a new last four clears the old TIN facts
|
||||||
|
first (`party.tin_set` null), so the two never disagree. The form (PDF,
|
||||||
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment
|
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment
|
||||||
store. Facts given later without a TIN keep the vault record when their
|
store. Facts given later without a TIN keep the vault record when their
|
||||||
last four agree with the vault's TIN, and are refused when they don't
|
last four agree with the vault's TIN, and are refused when they don't
|
||||||
@@ -447,8 +522,10 @@ worksheet is complete, never in the books (`ledger-vault`; design in
|
|||||||
journaled, note }`. Without `confirmed`, a preview that changes nothing.
|
journaled, note }`. Without `confirmed`, a preview that changes nothing.
|
||||||
Confirmed, deletes the party's vault records (needs no key) and, when the
|
Confirmed, deletes the party's vault records (needs no key) and, when the
|
||||||
party has W-9 facts, journals `party.w9_set` with `vault_record` cleared,
|
party has W-9 facts, journals `party.w9_set` with `vault_record` cleared,
|
||||||
the last four kept, and `kept_at` saying when and why it was purged.
|
the last four kept, and `kept_at` saying when and why it was purged;
|
||||||
Refused when the reason is blank or there is nothing to purge.
|
when it has TIN facts, `party.tin_set` likewise, with `purged` saying
|
||||||
|
when and why. Refused when the reason is blank or there is nothing to
|
||||||
|
purge.
|
||||||
|
|
||||||
## Tools — attachments
|
## Tools — attachments
|
||||||
|
|
||||||
@@ -794,8 +871,38 @@ entries.
|
|||||||
|
|
||||||
## Tools — CPA handoff
|
## Tools — CPA handoff
|
||||||
|
|
||||||
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts? }`
|
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
|
||||||
→ `{ file_name, content_base64 }`. A zip with the full set, each
|
investing_accounts?, include_receipts?, include_tins?, save_to? }` →
|
||||||
|
`{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`.
|
||||||
|
**TINs** (`include_tins`, default true; DECISION, Stephen, 2026-10-15):
|
||||||
|
the K-1 worksheet and the 1099 worksheet carry every member's and
|
||||||
|
payee's full TIN from the vault, so the zip is written to `save_to` (a
|
||||||
|
new `.zip` path, or an existing folder it goes in as
|
||||||
|
`cpa-package-<as_of>.zip`; outside the books dir, `exports/` included;
|
||||||
|
mode 0600) and the output is its path, never its bytes; without
|
||||||
|
`save_to` it is refused. A locked vault or a missing TIN never fails the
|
||||||
|
package: the row says why ("TIN not in the vault", "the TIN vault is
|
||||||
|
locked", a record that disagrees with the books), the README lists it,
|
||||||
|
and `tin_gaps` names each member and each reportable payee without one.
|
||||||
|
`include_tins: false`: last fours only, returned as `content_base64`
|
||||||
|
unless `save_to` is given. **K-1 worksheet** (`k1-members.csv`, with
|
||||||
|
`[[company.members]]`): one row per member, in order: `member,
|
||||||
|
party_id, share, capital_account, tin, tin_type, tin_note` (without
|
||||||
|
TINs: `tin_last4, tin_type`), then `beginning_capital,
|
||||||
|
contributions_cash, contributions_property, net_income, distributions,
|
||||||
|
other, ending_capital` for the fiscal year to date: the statement of
|
||||||
|
members' equity's figures (`ledger_core::k1`), contributions split into
|
||||||
|
property (what a contribution debits to asset accounts other than cash,
|
||||||
|
receivables, and prepaid accounts: a prepaid schedule's account, or one
|
||||||
|
named "prepaid") and cash (the rest, including a service the members
|
||||||
|
paid ahead). Property in an entry crediting several members is shared
|
||||||
|
in proportion to their credits. An entry and its reversal both in the
|
||||||
|
year cancel: neither is a contribution or a distribution. A member's
|
||||||
|
party is the party with the member's name (not a display name) and the
|
||||||
|
member role, as the viewer's capital statement finds it; with none, the
|
||||||
|
member's TIN is a gap.
|
||||||
|
**1099 worksheet** with TINs adds `party_id, tin_last4, tin, tin_type,
|
||||||
|
tin_note`. A zip with the full set, each
|
||||||
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
||||||
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
|
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
|
||||||
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
|
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
|
||||||
|
|||||||
+127
-15
@@ -62,7 +62,8 @@ Rules (to vendor as B-26 with sources):
|
|||||||
### Links
|
### Links
|
||||||
|
|
||||||
- Entries: `entry.party_set {entry_id, party_id|null}` generalizes
|
- Entries: `entry.party_set {entry_id, party_id|null}` generalizes
|
||||||
`entry.vendor_set`; an entry has one counterparty.
|
`entry.vendor_set` (one counterparty); `entry.parties_set` names
|
||||||
|
several, each with a share (below).
|
||||||
- Invoices and bills already name a customer or vendor id: those are
|
- Invoices and bills already name a customer or vendor id: those are
|
||||||
party ids after migration.
|
party ids after migration.
|
||||||
- Payroll lines name an employee party id (old runs keep their name
|
- Payroll lines name an employee party id (old runs keep their name
|
||||||
@@ -74,9 +75,94 @@ Rules (to vendor as B-26 with sources):
|
|||||||
`PartyField` per event, `{field, value}`: name, display_name, website,
|
`PartyField` per event, `{field, value}`: name, display_name, website,
|
||||||
address, notes, archived), `party.role_set {id, role, on}`,
|
address, notes, archived), `party.role_set {id, role, on}`,
|
||||||
`party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`,
|
`party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`,
|
||||||
`entry.party_set {entry_id, party_id|null}`. Replayed and projected like
|
`party.tin_set {id, facts|null}` (TIN facts: type, last four, vault
|
||||||
|
record, when purged), `entry.party_set {entry_id, party_id|null}`,
|
||||||
|
`entry.parties_set {entry_id, parties: [{party_id, share}]}` (a share is
|
||||||
|
`{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`; `[]`
|
||||||
|
clears). Replayed and projected like
|
||||||
the rest; every event gets viewer journal text (the GUI test enforces it).
|
the rest; every event gets viewer journal text (the GUI test enforces it).
|
||||||
|
|
||||||
|
### Shares: an entry with several parties
|
||||||
|
|
||||||
|
DECISION (Stephen, 2026-10-15): there is no "joint" party. A joint
|
||||||
|
transaction is with both members and shows on both members' pages, each
|
||||||
|
with their share; apportioning is explicit and exact when it isn't
|
||||||
|
derived. Spec: `ledger_core::shares`.
|
||||||
|
|
||||||
|
- An entry names a list of `PartyShare {party, share}`; `Share` is
|
||||||
|
`Amount(d)`, `Percent(p)`, or `Derived`. Tagging one party
|
||||||
|
(`set_entry_party`) is one derived share: all of it.
|
||||||
|
`Ledger::entry_party` means "the sole party"; `entry_parties` lists
|
||||||
|
them all.
|
||||||
|
- **What a share is of:** the entry's `entry_flow` net (what it moved
|
||||||
|
with its counterparties), or its total debits when the net is zero (a
|
||||||
|
member contributing property moves no money but is still theirs).
|
||||||
|
- **Amount:** exactly that, positive, in cents; the amounts must sum to
|
||||||
|
what the entry moved.
|
||||||
|
- **Percent:** more than 0, at most 100, summing to exactly 100. Each part
|
||||||
|
is rounded toward zero to the cent and the leftover cents (fewer than
|
||||||
|
one per party) go to the first party in order.
|
||||||
|
- **Derived:** a sole party gets all of it. Several parties must all be
|
||||||
|
members (`[[company.members]]` matched by name to a party with the
|
||||||
|
member role; core is handed them as `PartyMember`s). If the entry has
|
||||||
|
lines on a named member's capital or distributions account, each
|
||||||
|
member gets what their own lines say (and those must account for all
|
||||||
|
it moved); otherwise each gets their income share, rescaled to the
|
||||||
|
members named, rounded as percents are.
|
||||||
|
- **Refused** (journaling nothing): a party named twice, an unknown
|
||||||
|
party, mixed kinds of share, percents not summing to 100, an amount
|
||||||
|
finer than a cent, and two parties with vendor records. A 1099 counts
|
||||||
|
a payment toward a single vendor: the vendor tag follows the one party
|
||||||
|
with a vendor record, and the 1099 worksheet counts the whole payment
|
||||||
|
toward it, so name a vendor among others only when the whole payment
|
||||||
|
was its (otherwise record its part as its own entry). Whether shares
|
||||||
|
add up depends on the settings (cash accounts, members), so core
|
||||||
|
checks only their form; the MCP server checks they resolve once the
|
||||||
|
entry has balanced lines, and `post_entry` refuses (`party_shares`)
|
||||||
|
until they do. Replay never consults settings.
|
||||||
|
- **Money:** `parties::activity_with` counts each party's resolved share;
|
||||||
|
an entry moving no money counts nothing; an entry whose shares don't
|
||||||
|
resolve counts for no one (listed as unresolved). Reversals carry
|
||||||
|
every share, so a reversal subtracts each party's part; re-tagging
|
||||||
|
either one (parties, shares, or vendor) re-tags the other, so the pair
|
||||||
|
always nets to zero.
|
||||||
|
- **For the agent:** a joint member transaction (a reimbursement on Due
|
||||||
|
to Shareholders, a contribution or distribution from joint funds)
|
||||||
|
names both members with no shares and lets them derive; an uneven
|
||||||
|
split gives amounts.
|
||||||
|
|
||||||
|
### A member's capital statement
|
||||||
|
|
||||||
|
A member's party page (role member, named in `[[company.members]]`,
|
||||||
|
whose capital account is in the chart) is their capital account
|
||||||
|
statement over the page's period (`Books::capital_statement`). Every
|
||||||
|
figure is the CPA package's K-1 worksheet row for that member and span
|
||||||
|
(`ledger_core::k1::k1_detail`, itself the statement of changes in
|
||||||
|
members' equity split into cash and property), so the page and the
|
||||||
|
K-1 never disagree:
|
||||||
|
|
||||||
|
- capital at the start of the period (including their share of earnings
|
||||||
|
not yet closed into capital);
|
||||||
|
- add contributions of cash (deposits, and what they paid for the
|
||||||
|
company) and of property (debits to asset accounts that are neither
|
||||||
|
cash nor receivables, at the value booked: fair market value);
|
||||||
|
- add their share of the period's net income, closed or not;
|
||||||
|
- less distributions;
|
||||||
|
- any other change in their capital, when there is one;
|
||||||
|
- capital at the end of the period (always opening plus the movements,
|
||||||
|
exactly);
|
||||||
|
- their share of profit, loss, and capital; and their part of what the
|
||||||
|
company owes the members jointly, split by the members' shares: the
|
||||||
|
balance of the `[books] financing_accounts` liabilities (the
|
||||||
|
liabilities members advance), or, only when that is not set, of the
|
||||||
|
liability accounts named "due to" / "owed to" (such as 2100 Due to
|
||||||
|
Shareholders).
|
||||||
|
|
||||||
|
Cash and property are told apart by `[books] cash_accounts`. Without it
|
||||||
|
the statement reads money as the engine does (every asset account but
|
||||||
|
receivables), so every contribution reads as cash; set `cash_accounts`
|
||||||
|
to see property apart. (The Cash page keeps its own name-based guess.)
|
||||||
|
|
||||||
### Migration
|
### Migration
|
||||||
|
|
||||||
Old journals replay unchanged. `vendor.added` and `customer.added` keep
|
Old journals replay unchanged. `vendor.added` and `customer.added` keep
|
||||||
@@ -87,7 +173,13 @@ 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
|
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
|
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
|
entry's party, and `entry.party_set` to a party with a vendor record sets
|
||||||
the vendor tag: one counterparty.
|
the vendor tag: one counterparty. On an entry naming several parties the
|
||||||
|
vendor tag follows the parties, never the other way: `entry.vendor_set`
|
||||||
|
for the vendor of a party already named keeps every share; for any other
|
||||||
|
vendor, or clearing the tag while its party is named, it is refused. A
|
||||||
|
vendor record can't link to a party named beside another party with a
|
||||||
|
vendor record (refused: the entry would name two vendors); linking one
|
||||||
|
tags the entries naming the party that have no vendor tag yet.
|
||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
|
|
||||||
@@ -115,13 +207,22 @@ year. The directory shows the same columns, sortable.
|
|||||||
|
|
||||||
## The TIN vault
|
## The TIN vault
|
||||||
|
|
||||||
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so the 1099
|
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so what it
|
||||||
worksheet it hands the CPA is complete, but never in the journal.
|
hands the CPA is complete, but never in the journal. The vault holds
|
||||||
|
everyone's, members included, and the CPA package carries them: "In the
|
||||||
|
vault, that's the WHOLE reason we built it. And it needs to be in the
|
||||||
|
damn CPA package."
|
||||||
|
|
||||||
- **Where:** `vault/` in the books dir, listed in `.gitignore`. Never in
|
- **Where:** `vault/` in the books dir, listed in `.gitignore`. Never in
|
||||||
the hash-chained journal, the SQLite projection, git, an export other
|
the hash-chained journal, the SQLite projection, git, an export other
|
||||||
than the 1099 worksheet, or the viewer. Nothing outside the vault holds
|
than the 1099 worksheet and the CPA package (saved outside the books
|
||||||
more than the last four digits.
|
dir), or the viewer. Nothing outside the vault holds more than the last
|
||||||
|
four digits.
|
||||||
|
- **Who:** any party, whatever its roles: every member (their K-1 needs
|
||||||
|
it) and every 1099 payee. The books keep the party's TIN facts (type,
|
||||||
|
last four, the vault record's hash; `party.tin_set`), which never
|
||||||
|
disagree with its W-9 facts' last four. The viewer shows "TIN on file
|
||||||
|
••••1234 (SSN)" on the party's page, never more.
|
||||||
- **What:** per party, the full TIN, its type (SSN, EIN, ITIN), and the
|
- **What:** per party, the full TIN, its type (SSN, EIN, ITIN), and the
|
||||||
W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random
|
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
|
nonce per record; the file format carries a version and the party id as
|
||||||
@@ -139,24 +240,35 @@ worksheet it hands the CPA is complete, but never in the journal.
|
|||||||
- **Purge:** `books.purge_tin {party, reason}` deletes the vault record
|
- **Purge:** `books.purge_tin {party, reason}` deletes the vault record
|
||||||
(after the retention period, or on request) and journals that it was
|
(after the retention period, or on request) and journals that it was
|
||||||
purged; the journal's facts stay.
|
purged; the journal's facts stay.
|
||||||
- **Use:** only `books.vendor_1099` with `include_tins: true` (and the CPA
|
- **Use:** `books.vendor_1099` with `include_tins: true` and
|
||||||
package, when asked) decrypts them. The MCP server never returns a TIN
|
`books.export_cpa_package` (its K-1 worksheet, `k1-members.csv`, and its
|
||||||
in any other tool, log, or error. Tests check that no other output
|
1099 worksheet; `include_tins` defaults to true there) decrypt them.
|
||||||
contains a nine-digit run.
|
The package is written only to `save_to`, outside the books dir, and
|
||||||
|
the tool returns its path; it is refused without one. The MCP server
|
||||||
|
never returns a TIN in any other tool, log, or error. Tests check that
|
||||||
|
no other output contains a nine-digit run.
|
||||||
- **Books without a vault** (another machine, a restored backup) still
|
- **Books without a vault** (another machine, a restored backup) still
|
||||||
open and verify; the 1099 worksheet then says which TINs are missing.
|
open and verify; the 1099 worksheet and the CPA package then say which
|
||||||
|
TINs are missing (`tin_gaps`), and the package is still built.
|
||||||
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
|
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
|
||||||
`books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form
|
`books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form
|
||||||
goes to the vault, never the attachment store) and journals the last
|
goes to the vault, never the attachment store) and journals the last
|
||||||
four with `vault_record`; facts that disagree with the vault's TIN are
|
four with `vault_record`; facts that disagree with the vault's TIN are
|
||||||
refused, and clearing facts with a vault record needs a purge first.
|
refused, and clearing facts with a vault record needs a purge first.
|
||||||
|
With a TIN it also records the party's TIN facts. `books.set_tin
|
||||||
|
{party, tin, tin_type}` stores any party's TIN (a member's SSN, say) the
|
||||||
|
same way and journals `party.tin_set`; a party whose W-9 facts name
|
||||||
|
another last four is refused (a payee's new TIN comes with its W-9).
|
||||||
`books.get_w9 {party, save_to}` writes the form to a new file outside the
|
`books.get_w9 {party, save_to}` writes the form to a new file outside the
|
||||||
books dir and returns only the path. `books.purge_tin {party, reason,
|
books dir and returns only the path. `books.purge_tin {party, reason,
|
||||||
confirmed?}` previews, then deletes the records (no key needed) and
|
confirmed?}` previews, then deletes the records (no key needed) and
|
||||||
journals the purge as `party.w9_set` with `vault_record` cleared and
|
journals the purge as `party.w9_set` with `vault_record` cleared and
|
||||||
`kept_at` saying when and why. `books.vendor_1099 {include_tins: true}`
|
`kept_at` saying when and why, and `party.tin_set` with `vault_record`
|
||||||
is the one output with full TINs (with a warning; `save_csv_to` writes
|
cleared and `purged` saying when and why. `books.vendor_1099 {include_tins: true}`
|
||||||
the CSV outside the books dir). A key file inside the books dir is
|
is the one tool output with full TINs (with a warning; `save_csv_to`
|
||||||
|
writes the CSV outside the books dir); `books.export_cpa_package
|
||||||
|
{save_to}` writes them in its zip, outside the books dir, and returns
|
||||||
|
only the path. A key file inside the books dir is
|
||||||
refused. `tests/spec_vault.rs` scans every tool response, the server's
|
refused. `tests/spec_vault.rs` scans every tool response, the server's
|
||||||
log, and every file in the books dir outside `vault/` for the TINs in
|
log, and every file in the books dir outside `vault/` for the TINs in
|
||||||
any formatting.
|
any formatting.
|
||||||
|
|||||||
+66
-11
@@ -234,6 +234,14 @@ closed period).
|
|||||||
period at a time: a member's ending on one statement is their beginning
|
period at a time: a member's ending on one statement is their beginning
|
||||||
on the next, to the cent, and a member's period income is the
|
on the next, to the cent, and a member's period income is the
|
||||||
roll-forward's remainder (their share within a rounding cent).
|
roll-forward's remainder (their share within a rounding cent).
|
||||||
|
- A correction undoes what it corrects (B-9): an entry and its reversal
|
||||||
|
both in a statement's span are neither contributions nor
|
||||||
|
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
|
||||||
|
the same). The K-1 worksheet's contributed property (Item L) is what
|
||||||
|
the member owned and handed over, with its own basis (§723): never a
|
||||||
|
prepaid service they paid for the company (cash), and shared among
|
||||||
|
members credited in one entry in proportion to their credits
|
||||||
|
(`k1` INVARIANTS 3, 4, 6).
|
||||||
|
|
||||||
**Enforced:** `CloseTo::Members`, `periods::allocate`,
|
**Enforced:** `CloseTo::Members`, `periods::allocate`,
|
||||||
`kernel::apportion`, `members_equity`. **Checked:** Kani
|
`kernel::apportion`, `members_equity`. **Checked:** Kani
|
||||||
@@ -387,6 +395,38 @@ 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
|
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.
|
file by the vendor's flag or its party's W-9 facts.
|
||||||
|
|
||||||
|
An entry naming several parties (a payment shared between a contractor
|
||||||
|
and a member; `ledger_core::shares`) counts only the vendor's share
|
||||||
|
toward its 1099: what was paid to the payee (§6041(a)), not what the
|
||||||
|
entry moved for others. The share is resolved (`shares::resolve`)
|
||||||
|
against the entry's flow, the same target the MCP server checks shares
|
||||||
|
against, then scaled to the 1099 payment: the share unchanged when the
|
||||||
|
payment equals the target (the usual case, an expense paid from cash or
|
||||||
|
by a member), else payment × share ÷ target, rounded to the nearest cent,
|
||||||
|
half away from zero (so a part charged to a card scales out, 1099-K). A
|
||||||
|
percent split's leftover cent goes to the party named first (shares
|
||||||
|
INVARIANT 8). A reversal carries the shares and subtracts the same part,
|
||||||
|
and re-apportioning either one re-apportions the other
|
||||||
|
(`Ledger::set_entry_parties` INVARIANT 5), so the pair nets to zero. The
|
||||||
|
vendor tag follows the parties: on an entry naming several,
|
||||||
|
`set_entry_vendor` never replaces their shares (refused unless the
|
||||||
|
vendor's party is named), and a vendor record never links to a party
|
||||||
|
named beside another vendor (`add_vendor`), checked by
|
||||||
|
`tagging_the_vendor_keeps_a_shared_entrys_parties`,
|
||||||
|
`clearing_the_vendor_on_a_shared_entry_is_refused`,
|
||||||
|
`re_apportioning_a_reversed_entry_re_apportions_its_reversal`,
|
||||||
|
`a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry`.
|
||||||
|
Shares that don't resolve count the whole payment toward the vendor (the
|
||||||
|
conservative answer) and the entry is listed (`Vendor1099Report::
|
||||||
|
unresolved`; a `books.vendor_1099` note). An entry naming one party
|
||||||
|
counts as before (`form1099::vendor_1099_report_with_members`, checked
|
||||||
|
by `a_shared_payment_counts_the_vendors_share`,
|
||||||
|
`a_percent_split_gives_the_leftover_cent_to_the_first`,
|
||||||
|
`a_reversed_shared_payment_nets_to_zero`,
|
||||||
|
`a_shared_payment_partly_on_a_card_scales_to_the_payment`,
|
||||||
|
`unresolved_shares_count_in_full_and_are_listed`). DECISION (Stephen,
|
||||||
|
2026-10-15: shares).
|
||||||
|
|
||||||
**Enforced:** `form1099::default_threshold`, a dated rule
|
**Enforced:** `form1099::default_threshold`, a dated rule
|
||||||
(`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from
|
(`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from
|
||||||
2026-11-01). For a later year the engine refuses with `RuleNotKnown`
|
2026-11-01). For a later year the engine refuses with `RuleNotKnown`
|
||||||
@@ -525,20 +565,35 @@ The full TIN, when the books need it for the 1099 worksheet, lives in the
|
|||||||
TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key
|
TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key
|
||||||
file outside the books dir): `books.set_w9` `tin` writes it there and
|
file outside the books dir): `books.set_w9` `tin` writes it there and
|
||||||
journals only the last four and the vault record's hash; the signed W-9
|
journals only the last four and the vault record's hash; the signed W-9
|
||||||
goes there too, never to the attachment store. No tool output, error, or
|
goes there too, never to the attachment store. Any party may have its
|
||||||
log holds a full TIN except `books.vendor_1099` with `include_tins`
|
TIN in the vault, whatever its roles: every member's (for their K-1) and
|
||||||
(DECISION, Stephen, 2026-10-15: for the CPA, when asked); files holding
|
every 1099 payee's belongs there (DECISION, Stephen, 2026-10-15: "In the
|
||||||
one (`books.get_w9`, `save_csv_to`) are written only outside the books
|
vault, that's the WHOLE reason we built it. And it needs to be in the
|
||||||
dir. `books.purge_tin` deletes a party's vault records and journals that
|
damn CPA package."). `books.set_tin` stores one for any party; the books
|
||||||
it did.
|
journal its TIN facts (type, last four, the record's hash:
|
||||||
|
`party.tin_set`), which never disagree with its W-9 facts' last four. No
|
||||||
|
tool output, error, or log holds a full TIN except `books.vendor_1099`
|
||||||
|
with `include_tins` (DECISION, Stephen, 2026-10-15: for the CPA, when
|
||||||
|
asked): a refusal that quotes its input masks any TIN in it
|
||||||
|
(`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`),
|
||||||
|
and a party text field refuses nine digits however they are separated
|
||||||
|
(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA
|
||||||
|
package, whose K-1 and 1099 worksheets carry every member's and payee's
|
||||||
|
TIN by default) are written only outside the books dir, and the package
|
||||||
|
comes back only as its path. A locked vault or a missing TIN never fails
|
||||||
|
the package: it says which TINs are missing. `books.purge_tin` deletes a
|
||||||
|
party's vault records and journals that it did.
|
||||||
|
|
||||||
**Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check,
|
**Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check,
|
||||||
W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`,
|
W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`,
|
||||||
`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`;
|
`set_party_w9`, `set_party_w4`, `set_party_tin` (TinFacts::check; TIN and
|
||||||
`ledger-vault` (Tin::new, the encrypted records, records_held,
|
W-9 facts agree), `set_entry_party`, `validate_payroll`; `ledger-core`
|
||||||
purge_records); `ledger-mcp` `books.set_w9`, `books.get_w9`,
|
`k1` (the K-1 worksheet); `ledger-vault` (Tin::new, the encrypted
|
||||||
`books.purge_tin`, `books.vendor_1099` (open_vault refuses a key file in
|
records, records_held, purge_records); `ledger-mcp` `books.set_w9`,
|
||||||
the books dir; outside_books for files with a TIN).
|
`books.set_tin`, `books.get_w9`, `books.purge_tin`, `books.vendor_1099`,
|
||||||
|
`books.export_cpa_package` (open_vault refuses a key file in the books
|
||||||
|
dir; outside_books for files with a TIN; vault_tin checks the vault
|
||||||
|
against the books).
|
||||||
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
|
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
|
||||||
`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp`
|
`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp`
|
||||||
`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log,
|
`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log,
|
||||||
|
|||||||
Reference in new issue
Block a user