P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
check / check (push) Canceled after 0s

A vendor's 1099 decision is its own dated fact (files NEC or MISC, not
reportable with its reason, or undetermined), applied by each payment's
date. Payment methods are recorded; card and network payments are left
out and listed; a member's check through financing counts; a bank payment
with no method counts (D-40); $2,000 for payments after 2025. One
worksheet for the tool, the viewer and the CPA package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 12:12:26 -06:00
1 parent c2ceabbae9
commit 1304b9c0ad
77 files changed
+11455 -970

No files matched your search

+22 -3
View File
@@ -42,16 +42,35 @@ pub struct Vendor {
pub id: VendorId,
/// Display name.
pub name: String,
/// The information return this vendor gets, if any (see
/// [`crate::form1099`]).
/// Its 1099 decisions, each from the date it takes effect, in date
/// order (one from the beginning first); none when nobody has decided
/// (see [`crate::form1099`], D-40).
#[serde(default)]
pub form_1099: Option<crate::form1099::Form1099>,
pub decisions_1099: Vec<crate::form1099::Dated1099>,
/// A W-9 (taxpayer identification number) is on file. The number
/// itself is never stored here.
#[serde(default)]
pub tin_on_file: bool,
}
impl Vendor {
/// The 1099 decision in force on `date`, if any was recorded by then.
pub fn decision_on(&self, date: NaiveDate) -> Option<&crate::form1099::Decision1099> {
crate::form1099::in_force(&self.decisions_1099, date).map(|d| &d.decision)
}
/// The latest 1099 decision recorded (the one in force from the last
/// effective date on), if any.
pub fn latest_1099(&self) -> Option<&crate::form1099::Dated1099> {
self.decisions_1099.last()
}
/// The return its latest decision files, if it files one.
pub fn form_1099(&self) -> Option<crate::form1099::Form1099> {
self.latest_1099().and_then(|d| d.decision.form())
}
}
/// Payment status of an invoice or bill.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DocumentStatus {
+13
View File
@@ -522,6 +522,19 @@ pub enum LedgerError {
#[error("unknown vendor id {0}")]
UnknownVendor(u64),
/// A 1099 decision, or the 1099 report, was refused: the message says
/// why and what to do.
#[error("{0}")]
Invalid1099(String),
/// The payment method named does not exist.
#[error("unknown payment method (instrument) id {0}")]
UnknownInstrument(u64),
/// A payment method (instrument) was refused: the message says why.
#[error("{0}")]
InvalidInstrument(String),
/// The referenced invoice does not exist.
#[error("unknown invoice id {0}")]
UnknownInvoice(u64),
File diff suppressed because it is too large. Load diff
+202
View File
@@ -0,0 +1,202 @@
//! Payment instruments: how the company (or a member, for it) paid.
//!
//! A payment to a contractor by check counts toward its 1099; the same
//! payment by card or through a payment network is the card processor's
//! to report, on Form 1099-K, and never the company's (Treas. Reg.
//! §1.6041-1(a)(1)(iv)). The books record each way of paying once
//! ([`Instrument`]: the company's bank, its business card, a member's own
//! card, a member's own cash or check, a payment platform), each with the
//! ledger account it pays through, and tag the entries paid with one
//! ([`crate::Ledger::set_entry_instrument`]). The 1099 worksheet reads the
//! tag ([`crate::form1099`]), and the business cards' liability accounts
//! are the books' card accounts ([`crate::Ledger::card_accounts`]), known
//! from what the books record rather than an account's name.
//!
//! ```text
//! GOAL: every payment's method is recorded once, plainly, so the 1099
//! worksheet never guesses whether a payment was by card.
//! SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv),
//! §1.6050W-1(b)(1), (b)(3), (c)(3); Instructions for Form 1099-K:
//! automated clearing houses are not payment networks;
//! sources/irs-1099-payment-method.md); plan/served.md 4.9, 5.2.
//! INVARIANTS:
//! 1. A label is one line, at most settings::MAX_TEXT characters, holds
//! no full TIN, and is unique among instruments (ignoring case and
//! surrounding space).
//! 2. last4, when given, is exactly four ASCII digits: no full card or
//! account number ever enters the books.
//! 3. The account is a real asset or liability account, not a header,
//! and not the payables or receivables control account (a bill is not
//! paid until it is paid).
//! 4. The owner party, when given, exists. It is informational: it never
//! classifies a payment (the payment-card exception follows the
//! instrument, whoever's card it is).
//! ```
use crate::{AccountId, PartyId};
use serde::{Deserialize, Serialize};
/// Opaque instrument handle.
pub type InstrumentId = u64;
/// What kind of payment an instrument makes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstrumentKind {
/// The company's own bank account: its checks, ACH, wires, bill-pay.
Bank,
/// A company credit or debit card the payee charged.
BusinessCard,
/// A member's personal credit or debit card the payee charged.
MemberCard,
/// A member's own cash, check, ACH, Zelle, or a friends-and-family
/// transfer from a member's own app or card.
MemberCashOrCheck,
/// A payment the payee received as a seller through a payment network
/// that settles it (PayPal or Venmo goods and services).
Platform,
}
impl InstrumentKind {
/// Every kind, in order.
pub const ALL: [InstrumentKind; 5] = [
InstrumentKind::Bank,
InstrumentKind::BusinessCard,
InstrumentKind::MemberCard,
InstrumentKind::MemberCashOrCheck,
InstrumentKind::Platform,
];
/// Parse a key ("bank", "business_card", ...), ignoring case and
/// surrounding space.
pub fn parse(s: &str) -> Option<InstrumentKind> {
let s = s.trim().to_ascii_lowercase();
InstrumentKind::ALL.into_iter().find(|k| k.key() == s)
}
/// Its key: "bank", "business_card", "member_card",
/// "member_cash_or_check", "platform".
pub fn key(self) -> &'static str {
match self {
InstrumentKind::Bank => "bank",
InstrumentKind::BusinessCard => "business_card",
InstrumentKind::MemberCard => "member_card",
InstrumentKind::MemberCashOrCheck => "member_cash_or_check",
InstrumentKind::Platform => "platform",
}
}
/// In words, for people.
pub fn label(self) -> &'static str {
match self {
InstrumentKind::Bank => "the company's bank (check, ACH, wire)",
InstrumentKind::BusinessCard => "a company card",
InstrumentKind::MemberCard => "a member's personal card",
InstrumentKind::MemberCashOrCheck => "a member's own cash or check",
InstrumentKind::Platform => "a payment network (goods and services)",
}
}
/// Whether a payment made with it is a payment-card or third-party
/// network transaction: reported by the processor on Form 1099-K and
/// excluded from 1099-NEC and 1099-MISC (Treas. Reg.
/// §1.6041-1(a)(1)(iv)), whoever's card it was.
pub fn is_card_or_network(self) -> bool {
matches!(
self,
InstrumentKind::BusinessCard | InstrumentKind::MemberCard | InstrumentKind::Platform
)
}
}
/// One way the company pays.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instrument {
/// Opaque handle.
pub id: InstrumentId,
/// What kind of payment it makes.
pub kind: InstrumentKind,
/// Its name ("Harbor Bank checking", "Riley's Visa"), unique.
pub label: String,
/// The last four digits of its card or account number, if given.
pub last4: Option<String>,
/// Whose it is (a member's card), if given. Never used to classify.
pub owner_party: Option<PartyId>,
/// The ledger account it pays through: the bank account, the card's
/// liability, or the members' financing liability (Due to Members).
pub account: AccountId,
}
/// Check a label: one line, at most `MAX_TEXT` characters, no full TIN
/// (INVARIANT 1, all but uniqueness). Returns it trimmed.
pub fn check_label(label: &str) -> Result<String, String> {
crate::settings::line_text("label", label, crate::settings::MAX_TEXT)
}
/// Check a last four (INVARIANT 2).
pub fn check_last4(last4: &str) -> Result<(), String> {
if last4.len() == 4 && last4.bytes().all(|b| b.is_ascii_digit()) {
Ok(())
} else {
Err(format!(
"last4 {:?} must be exactly four digits (never a whole card or account number)",
crate::parties::redact_full_tins(last4)
))
}
}
/// The key two labels are compared by: trimmed, lower case.
pub fn label_key(label: &str) -> String {
label.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_round_trip_and_only_cards_and_networks_are_excluded() {
for k in InstrumentKind::ALL {
assert_eq!(InstrumentKind::parse(k.key()), Some(k));
assert_eq!(
InstrumentKind::parse(&format!(" {} ", k.key().to_uppercase())),
Some(k)
);
assert!(!k.label().is_empty());
}
assert_eq!(InstrumentKind::parse("card"), None);
let excluded: Vec<_> = InstrumentKind::ALL
.into_iter()
.filter(|k| k.is_card_or_network())
.collect();
assert_eq!(
excluded,
[
InstrumentKind::BusinessCard,
InstrumentKind::MemberCard,
InstrumentKind::Platform
]
);
}
#[test]
fn last4_is_four_digits_only() {
assert!(check_last4("0042").is_ok());
for bad in ["", "123", "12345", "12a4", "1234", "4111111111111111"] {
assert!(check_last4(bad).is_err(), "{bad:?}");
}
// A refusal never echoes a full TIN-shaped number.
let why = check_last4("123-45-6789").unwrap_err();
assert!(!why.contains("6789"), "{why}");
}
#[test]
fn labels_are_one_bounded_line() {
assert_eq!(check_label(" Harbor Bank ").unwrap(), "Harbor Bank");
assert!(check_label("").is_err());
assert!(check_label("a\nb").is_err());
assert!(check_label(&"x".repeat(crate::settings::MAX_TEXT + 1)).is_err());
assert!(check_label("card 123-45-6789").is_err());
assert_eq!(label_key(" Riley's Visa "), "riley's visa");
}
}
+1 -1
View File
@@ -577,7 +577,7 @@ mod tests {
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 cash = add("1000", "Quillmere", 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);
+159
View File
@@ -25,6 +25,9 @@
//! * a trial balance puts each movement in exactly one column, so its
//! columns differ by the net of all movements (B-6);
//! * a closing line zeroes the account it closes (B-11);
//! * a payment to a vendor splits, line by line, into what counts toward
//! its 1099, what a card or payment network paid, and what is unknown,
//! and money moved between the company's own sources nets out (B-19);
//! * an allocation's parts sum to the whole, to the last unit, by largest
//! remainder (B-13);
//! * a cumulative allocation hands out exactly the new amount, and leaves
@@ -273,6 +276,162 @@ pub fn credit_line<T: Amount>(credit: T) -> Option<(Side, T)> {
closing_line(credit)
}
// ---------------------------------------------------------------------------
// 1099 payments: where each payment line goes (rules.md B-19)
// ---------------------------------------------------------------------------
/// A line's role in a payment to a vendor, from its account.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PayRole {
/// One of the books' cash accounts: bank, cash on hand.
Cash,
/// One of the books' financing liabilities (what members or lenders
/// paid on the company's behalf), or an account a member's card or
/// cash, or a payment platform, pays through: how a payment through
/// it was made, only the entry's method says.
Financing,
/// The account of a company card (a business card's liability).
CardAccount,
/// None of these (an expense, payables, prepaid): not a payment line.
Other,
}
/// What an entry's payment-method tag says about one of its lines.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tag {
/// The entry has no payment method recorded.
None,
/// The method pays through this line's account and is a card or a
/// payment network.
ThisCard,
/// The method pays through this line's account and is neither (the
/// company's bank, a member's own cash or check).
ThisOther,
/// The method pays through another account: it doesn't describe this
/// line.
Elsewhere,
}
/// Where a payment line's amount goes on the 1099 worksheet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bucket {
/// Counts toward the payee's 1099.
Counts,
/// Paid by card or payment network: Form 1099-K's, never 1099-NEC or
/// 1099-MISC's.
Card,
/// Can't be classified until the payment method is recorded.
Undetermined,
}
impl Bucket {
/// Every bucket, in [`split_payment`]'s order.
pub const ALL: [Bucket; 3] = [Bucket::Counts, Bucket::Card, Bucket::Undetermined];
/// Its place in [`split_payment`]'s array.
pub const fn index(self) -> usize {
match self {
Bucket::Counts => 0,
Bucket::Card => 1,
Bucket::Undetermined => 2,
}
}
}
/// The bucket a payment line goes in, from its role and the entry's tag.
///
/// ```text
/// GOAL: every payment line to a vendor counts toward its 1099, is a card
/// or payment-network payment (Form 1099-K), or is unknown; never
/// guessed.
/// SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv): a
/// transaction reportable under §6050W is not reported under
/// §6041; §1.6041-1(e)(1): a member writing a check at the
/// company's direction doesn't make the member the payor;
/// sources/irs-1099-payment-method.md); DECISION D-40 (owner,
/// 2026-10-20: a bank payment with no method recorded counts).
/// INVARIANTS (first match wins):
/// 1. The tag names this line's account: Card for a card or network,
/// else Counts.
/// 2. Otherwise the role decides: Cash counts (presumed check, ACH or
/// transfer, D-40); Financing is Undetermined (a member's card or
/// their check, unknown until tagged); CardAccount is Card; Other is
/// no payment line (None).
/// ```
pub fn payment_bucket(role: PayRole, tag: Tag) -> Option<Bucket> {
match (tag, role) {
(Tag::ThisCard, _) => Some(Bucket::Card),
(Tag::ThisOther, _) => Some(Bucket::Counts),
(Tag::None | Tag::Elsewhere, PayRole::Cash) => Some(Bucket::Counts),
(Tag::None | Tag::Elsewhere, PayRole::Financing) => Some(Bucket::Undetermined),
(Tag::None | Tag::Elsewhere, PayRole::CardAccount) => Some(Bucket::Card),
(Tag::None | Tag::Elsewhere, PayRole::Other) => None,
}
}
/// What an entry paid, per bucket: credits minus debits of the lines in
/// each (a line in no bucket is not a payment line). `None` on overflow.
pub fn split_payment<T: Amount>(lines: &[(Option<Bucket>, Side, T)]) -> Option<[T; 3]> {
let mut out = [T::ZERO; 3];
for &(bucket, side, amount) in lines {
if let Some(b) = bucket {
let i = b.index();
out[i] = match side {
Side::Credit => out[i].checked_add(amount)?,
Side::Debit => out[i].checked_sub(amount)?,
};
}
}
Some(out)
}
/// Net one entry's buckets ([`split_payment`]): money moved between the
/// company's own payment sources pays no one.
///
/// ```text
/// GOAL: a card bill paid from the bank, a member reimbursed, or a loan
/// paid down never counts toward a vendor's 1099.
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made to
/// the payee; P3 review R1, 2026-10-20).
/// INVARIANTS:
/// 1. The buckets' sum P is kept exactly.
/// 2. No bucket ends opposite in sign to P.
/// 3. Buckets all of one sign (zeros aside) come back unchanged.
/// SCENARIOS:
/// 1. IF the signs differ and P = 0 (Dr 2050 / Cr 1000, the card bill):
/// SHOULD be [0, 0, 0].
/// 2. IF the signs differ and exactly one bucket has P's sign: SHOULD
/// give it P and the others 0.
/// 3. IF two buckets have P's sign: SHOULD put P in Undetermined
/// BECAUSE which source the other side offsets is unknown (split
/// the entry).
/// ```
pub fn net_buckets<T: Amount>(b: [T; 3]) -> Option<[T; 3]> {
let positive = b.iter().any(|&x| x > T::ZERO);
let negative = b.iter().any(|&x| x < T::ZERO);
if !(positive && negative) {
return Some(b);
}
let p = b[0].checked_add(b[1])?.checked_add(b[2])?;
let mut out = [T::ZERO; 3];
if p == T::ZERO {
return Some(out);
}
let mut taker = Bucket::Undetermined.index();
let mut takers = 0;
for (i, &x) in b.iter().enumerate() {
if x != T::ZERO && (x > T::ZERO) == (p > T::ZERO) {
taker = i;
takers += 1;
}
}
if takers != 1 {
taker = Bucket::Undetermined.index();
}
out[taker] = p;
Some(out)
}
/// Make rounded parts sum to the whole: the last part becomes `whole`
/// minus the others, absorbing every unit of rounding. Returns `None` on
/// overflow, leaving `parts` unchanged.
+255 -6
View File
@@ -255,6 +255,11 @@ pub struct Ledger {
next_recurring_id: RecurringId,
/// Who each entry paid or was billed by, for 1099 reporting.
entry_vendors: HashMap<EntryId, VendorId>,
/// The payment methods ([`crate::instruments`]), by id.
instruments: std::collections::BTreeMap<crate::InstrumentId, crate::Instrument>,
next_instrument_id: crate::InstrumentId,
/// The payment method each entry was paid with, for 1099 reporting.
entry_instruments: HashMap<EntryId, crate::InstrumentId>,
/// Each invoice's payments (entry, amount), for aging as of a date.
invoice_payments: HashMap<InvoiceId, Vec<(EntryId, Decimal)>>,
/// Each bill's payments (entry, amount), for aging as of a date.
@@ -313,6 +318,9 @@ impl Ledger {
recurring: std::collections::BTreeMap::new(),
next_recurring_id: 1,
entry_vendors: HashMap::new(),
instruments: std::collections::BTreeMap::new(),
next_instrument_id: 1,
entry_instruments: HashMap::new(),
invoice_payments: HashMap::new(),
bill_payments: HashMap::new(),
roles: std::collections::BTreeMap::new(),
@@ -630,6 +638,15 @@ impl Ledger {
self.next_recurring_id == other.next_recurring_id,
),
("entry_vendors", self.entry_vendors == other.entry_vendors),
("instruments", self.instruments == other.instruments),
(
"next_instrument_id",
self.next_instrument_id == other.next_instrument_id,
),
(
"entry_instruments",
self.entry_instruments == other.entry_instruments,
),
(
"invoice_payments",
self.invoice_payments == other.invoice_payments,
@@ -1665,6 +1682,10 @@ impl Ledger {
if let Some(v) = self.entry_vendor(entry) {
self.entry_vendors.insert(id, v);
}
// Paid the same way: its 1099 buckets net to zero too.
if let Some(i) = self.entry_instrument(entry) {
self.entry_instruments.insert(id, i);
}
// 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);
@@ -3030,7 +3051,7 @@ impl Ledger {
Vendor {
id,
name: name.to_string(),
form_1099: None,
decisions_1099: Vec::new(),
tin_on_file: false,
},
);
@@ -3062,20 +3083,248 @@ impl Ledger {
self.vendors.get(&id)
}
/// Set which information return a vendor gets (or none), and whether
/// their W-9 is on file.
/// Check a 1099 decision for a vendor before it is recorded
/// ([`crate::form1099::check_new_decision`]): the vendor exists, the
/// decision, its source and its date are sound, and a dated decision
/// records a change. Returns what recording it would do.
pub fn check_vendor_1099(
&self,
id: VendorId,
new: &crate::form1099::Dated1099,
) -> Result<crate::form1099::Applied, LedgerError> {
let v = self
.vendors
.get(&id)
.ok_or(LedgerError::UnknownVendor(id))?;
crate::form1099::check_new_decision(&v.decisions_1099, new)
.map_err(LedgerError::Invalid1099)
}
/// Record a vendor's 1099 decision from the date it takes effect (or
/// from the beginning), with who decided, and whether its W-9 is on
/// file, checked ([`Self::check_vendor_1099`]).
///
/// ```text
/// GOAL: "no 1099" is a decision kept with its reason and who made it,
/// never the same as "never decided" (plan defects 18, 23), and
/// each payment takes the decision in force on its date (D-40).
/// SOURCE: docs/spec/rules.md B-19; crate::form1099::plan_decision.
/// SCENARIOS:
/// 1. IF the vendor is unknown, or the decision fails its checks:
/// SHOULD refuse, changing nothing.
/// 2. Otherwise SHOULD record it (crate::form1099::apply_decision)
/// and set the W-9 flag, returning what it did.
/// ```
pub fn set_vendor_1099(
&mut self,
id: VendorId,
form: Option<crate::form1099::Form1099>,
new: crate::form1099::Dated1099,
tin_on_file: bool,
) -> Result<(), LedgerError> {
) -> Result<crate::form1099::Applied, LedgerError> {
self.check_vendor_1099(id, &new)?;
self.replay_vendor_1099(id, new, tin_on_file)
}
/// [`Self::set_vendor_1099`] as journaled: the vendor must exist, and
/// the decision is recorded as the line says, without the checks a
/// new decision passes (rules.md B-28). A line written before
/// decisions were kept replays as a decision from the beginning with
/// no source (a null as undetermined: plan 5.5).
pub fn replay_vendor_1099(
&mut self,
id: VendorId,
new: crate::form1099::Dated1099,
tin_on_file: bool,
) -> Result<crate::form1099::Applied, LedgerError> {
let v = self
.vendors
.get_mut(&id)
.ok_or(LedgerError::UnknownVendor(id))?;
v.form_1099 = form;
let applied = crate::form1099::apply_decision(&mut v.decisions_1099, new);
v.tin_on_file = tin_on_file;
Ok(applied)
}
/// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions
/// and who made them (P3 review R8). Returns whether it changed.
pub fn set_vendor_w9_flag(&mut self, id: VendorId, on_file: bool) -> Result<bool, LedgerError> {
let v = self
.vendors
.get_mut(&id)
.ok_or(LedgerError::UnknownVendor(id))?;
let changed = v.tin_on_file != on_file;
v.tin_on_file = on_file;
Ok(changed)
}
// ------------------------------------------------------------------
// Payment methods (crate::instruments)
// ------------------------------------------------------------------
/// The id the next payment method gets.
pub fn next_instrument_id(&self) -> crate::InstrumentId {
self.next_instrument_id
}
/// Check a new payment method (crate::instruments INVARIANTS 1-4).
/// Returns its label, trimmed.
pub fn check_add_instrument(
&self,
label: &str,
last4: Option<&str>,
owner_party: Option<crate::PartyId>,
account: AccountId,
) -> Result<String, LedgerError> {
let label =
crate::instruments::check_label(label).map_err(LedgerError::InvalidInstrument)?;
let key = crate::instruments::label_key(&label);
if let Some(i) = self
.instruments
.values()
.find(|i| crate::instruments::label_key(&i.label) == key)
{
return Err(LedgerError::InvalidInstrument(format!(
"payment method {} is already named {:?}: give this one another name",
i.id, i.label
)));
}
if let Some(l) = last4 {
crate::instruments::check_last4(l).map_err(LedgerError::InvalidInstrument)?;
}
if let Some(p) = owner_party
&& !self.parties.contains_key(&p)
{
return Err(LedgerError::UnknownParty(p));
}
let a = self
.chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if a.placeholder {
return Err(LedgerError::PlaceholderAccount(a.code.clone()));
}
if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) {
return Err(LedgerError::InvalidInstrument(format!(
"account {} {:?} is {:?}: a payment method pays through an asset (a bank account) or a liability (a card, or what's owed to the members)",
a.code, a.name, a.account_type
)));
}
let control = [&crate::PAYABLES, &crate::RECEIVABLES]
.into_iter()
.find(|c| self.role_account(c) == Some(account));
if let Some(c) = control {
return Err(LedgerError::InvalidInstrument(format!(
"account {} is the {} control account, which no payment is made from: name the bank, card, or members' account the money left",
a.code, c.role
)));
}
Ok(label)
}
/// Add a payment method ([`Self::check_add_instrument`]). Returns its
/// id.
pub fn add_instrument(
&mut self,
kind: crate::InstrumentKind,
label: &str,
last4: Option<&str>,
owner_party: Option<crate::PartyId>,
account: AccountId,
) -> Result<crate::InstrumentId, LedgerError> {
let label = self.check_add_instrument(label, last4, owner_party, account)?;
let id = self.next_instrument_id;
self.next_instrument_id += 1;
self.instruments.insert(
id,
crate::Instrument {
id,
kind,
label,
last4: last4.map(str::to_string),
owner_party,
account,
},
);
Ok(id)
}
/// Every payment method, in id order.
pub fn instruments(&self) -> impl Iterator<Item = &crate::Instrument> + '_ {
self.instruments.values()
}
/// One payment method.
pub fn get_instrument(&self, id: crate::InstrumentId) -> Option<&crate::Instrument> {
self.instruments.get(&id)
}
/// The liability accounts that are company cards: the account of each
/// business-card payment method that is a liability (a credit card's
/// balance; a debit card draws on an asset and isn't one). A member's
/// card pays through the members' financing account, and a platform
/// through its own balance: neither is a card account. For whatever
/// needs to tell a card from other liabilities (party money, the cash
/// flow's sections), from what the books record rather than an
/// account's name.
pub fn card_accounts(&self) -> std::collections::BTreeSet<AccountId> {
self.instruments
.values()
.filter(|i| i.kind == crate::InstrumentKind::BusinessCard)
.filter(|i| {
self.chart
.get(i.account)
.is_some_and(|a| a.account_type == AccountType::Liability)
})
.map(|i| i.account)
.collect()
}
/// The payment method with this label (ignoring case and surrounding
/// space).
pub fn instrument_by_label(&self, label: &str) -> Option<&crate::Instrument> {
let key = crate::instruments::label_key(label);
self.instruments
.values()
.find(|i| crate::instruments::label_key(&i.label) == key)
}
/// The payment method an entry is tagged with.
pub fn entry_instrument(&self, entry: EntryId) -> Option<crate::InstrumentId> {
self.entry_instruments.get(&entry).copied()
}
/// Tag an entry (draft or posted) with the payment method it was paid
/// with, or clear the tag.
///
/// ```text
/// GOAL: the 1099 worksheet knows how each payment was made.
/// SOURCE: docs/spec/rules.md B-19; crate::instruments.
/// SCENARIOS:
/// 1. IF the entry or the payment method is unknown: SHOULD refuse.
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
/// entries, like the vendor tag).
/// 3. The entry's reversal chain carries the same tag, so a payment
/// and its reversal always sit in the same bucket and net to zero.
/// ```
pub fn set_entry_instrument(
&mut self,
entry: EntryId,
instrument: Option<crate::InstrumentId>,
) -> Result<(), LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
if let Some(i) = instrument
&& !self.instruments.contains_key(&i)
{
return Err(LedgerError::UnknownInstrument(i));
}
for e in self.reversal_chain(entry) {
match instrument {
Some(i) => self.entry_instruments.insert(e, i),
None => self.entry_instruments.remove(&e),
};
}
Ok(())
}
+4 -2
View File
@@ -26,6 +26,7 @@ pub mod classes;
pub mod conventions;
pub mod error;
pub mod form1099;
pub mod instruments;
pub mod journal;
pub mod k1;
pub mod kernel;
@@ -63,9 +64,10 @@ pub use cashflow::{
pub use classes::{Class, ClassId};
pub use error::LedgerError;
pub use form1099::{
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
vendor_1099_report_with, vendor_1099_report_with_members,
Dated1099, Decision1099, ExemptReason, Form1099, Payment1099, PaymentAccounts, Status1099,
Vendor1099Line, Vendor1099Report, vendor_1099_report,
};
pub use instruments::{Instrument, InstrumentId, InstrumentKind};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use kernel::{EntryFlows, Flow};
pub use ledger::{
+41 -10
View File
@@ -693,15 +693,42 @@ impl MoneyAccounts {
}
}
/// The company's cards: the liability accounts whose names say "card"
/// (ignoring case), but not "gift card" (gift cards the company sold are
/// deferred revenue it owes its customers, a promise, not a card it pays
/// with), the rule the monthly reconciliation reminders read
/// (`ledger_store::reconcilable`), so the two never disagree on what a
/// card is. CONVENTION until payment instruments name the card accounts
/// (plan/served.md P3 defect 12).
/// The company's cards: the one rule for what a card is, which party
/// money, the monthly reconciliation reminders
/// (`ledger_store::reconcilable`) and the viewer all read, so none
/// disagrees on it.
///
/// ```text
/// GOAL: a card is known from what the books record, and a promise (a
/// gift card sold) or a member's own card is never the company's.
/// SOURCE: rules.md B-35 item 5 (DECISION, owner, 2026-10-20: money is
/// cash, cards, the members' financing and loans), B-19 (payment
/// methods, plan P3 defect 12).
/// INVARIANTS:
/// 1. When the books record a business-card payment method: exactly
/// the liability accounts of those methods
/// (crate::Ledger::card_accounts), whatever the accounts are named.
/// 2. Else (books from before payment methods, or that never record
/// one; CONVENTION): the liability accounts whose names say "card"
/// (ignoring case) but not "gift" (gift cards the company sold are
/// deferred revenue it owes its customers, a promise, not a card it
/// pays with).
/// 3. Never the account of a member's card, a member's cash or check,
/// or a payment platform: a member's card pays through the members'
/// financing account, a platform through its own balance.
/// 4. In chart order (ascending account id), each once.
/// ```
pub fn card_accounts(ledger: &crate::Ledger) -> Vec<crate::AccountId> {
ledger
let recorded = ledger.card_accounts();
if !recorded.is_empty() {
return recorded.into_iter().collect();
}
let not_cards: std::collections::BTreeSet<crate::AccountId> = ledger
.instruments()
.filter(|i| i.kind != crate::InstrumentKind::BusinessCard)
.map(|i| i.account)
.collect();
let mut cards: Vec<crate::AccountId> = ledger
.chart()
.all()
.filter(|a| {
@@ -709,9 +736,13 @@ pub fn card_accounts(ledger: &crate::Ledger) -> Vec<crate::AccountId> {
a.account_type == crate::AccountType::Liability
&& name.contains("card")
&& !name.contains("gift")
&& !not_cards.contains(&a.id)
})
.map(|a| a.id)
.collect()
.collect();
cards.sort_unstable();
cards.dedup();
cards
}
/// The [`MoneyAccounts`] of `ledger`: `cash` the books' cash accounts
@@ -1042,7 +1073,7 @@ pub fn activity(
/// 6. A card charge counts as paid (the company paid them, by card); a
/// bill doesn't until it is paid; a member's property contribution is
/// received in kind. Not the 1099 amount: that is
/// form1099::vendor_1099_report_with.
/// form1099::vendor_1099_report.
/// ```
pub fn activity_with(
ledger: &crate::Ledger,
+125
View File
@@ -442,6 +442,131 @@ fn closing_entry_balances() {
kani::cover!(len == NOMINAL + 2, "three accounts closed to two members");
}
// ---------------------------------------------------------------------------
// 1099 payments (rules.md B-19)
// ---------------------------------------------------------------------------
fn any_role() -> kernel::PayRole {
let r: u8 = kani::any();
kani::assume(r < 4);
match r {
0 => kernel::PayRole::Cash,
1 => kernel::PayRole::Financing,
2 => kernel::PayRole::CardAccount,
_ => kernel::PayRole::Other,
}
}
fn any_tag() -> kernel::Tag {
let t: u8 = kani::any();
kani::assume(t < 4);
match t {
0 => kernel::Tag::None,
1 => kernel::Tag::ThisCard,
2 => kernel::Tag::ThisOther,
_ => kernel::Tag::Elsewhere,
}
}
/// `kernel::payment_bucket` is the B-19 table, all 16 cases: a tag naming
/// the line's account decides (card or network: Card; otherwise Counts);
/// else the role does, and with no tag naming its account a financing line
/// never counts and a card account's line is always Card.
#[kani::proof]
fn payment_bucket_is_the_table() {
use kernel::{Bucket, PayRole, Tag};
let (role, tag) = (any_role(), any_tag());
let got = kernel::payment_bucket(role, tag);
let want = match tag {
Tag::ThisCard => Some(Bucket::Card),
Tag::ThisOther => Some(Bucket::Counts),
Tag::None | Tag::Elsewhere => match role {
PayRole::Cash => Some(Bucket::Counts),
PayRole::Financing => Some(Bucket::Undetermined),
PayRole::CardAccount => Some(Bucket::Card),
PayRole::Other => None,
},
};
assert_eq!(got, want);
if matches!(tag, Tag::None | Tag::Elsewhere) {
assert!(role != PayRole::Financing || got != Some(Bucket::Counts));
assert!(role != PayRole::CardAccount || got == Some(Bucket::Card));
}
}
/// `kernel::split_payment` never fails on journal-line amounts, and each
/// bucket is the exact credits-minus-debits of its lines: the buckets
/// together are the whole payment, so nothing is lost or counted twice.
#[kani::proof]
#[kani::unwind(6)]
fn payment_split_is_exact() {
let n = any_len();
let lines: [(Option<kernel::Bucket>, Side, i64); N] = core::array::from_fn(|_| {
let b: u8 = kani::any();
kani::assume(b < 4);
let bucket = match b {
0 => Some(kernel::Bucket::Counts),
1 => Some(kernel::Bucket::Card),
2 => Some(kernel::Bucket::Undetermined),
_ => None,
};
(bucket, any_side(), any_amount())
});
let got = kernel::split_payment(&lines[..n]).expect("never fails in range");
for b in kernel::Bucket::ALL {
let mut want = 0i64;
for &(lb, side, amount) in &lines[..n] {
if lb == Some(b) {
want -= wide(side, amount);
}
}
assert_eq!(got[b.index()], want);
}
kani::cover!(n == N, "four lines");
}
/// `kernel::net_buckets` keeps the payment's total, never leaves a bucket
/// opposite the total, and leaves buckets of one sign alone (money moved
/// between the company's own sources pays no one: P3 review R1); with
/// mixed signs the whole net goes to the one bucket of its sign, or to
/// Undetermined when two share it (its SCENARIOS 1-3).
#[kani::proof]
fn net_buckets_keeps_the_total() {
let b: [i64; 3] = core::array::from_fn(|_| any_amount());
let p = b[0] + b[1] + b[2];
let out = kernel::net_buckets(b).expect("never fails in range");
assert_eq!(out[0] + out[1] + out[2], p);
for x in out {
assert!(x == 0 || (x > 0) == (p > 0));
}
let one_sign = b.iter().all(|&x| x >= 0) || b.iter().all(|&x| x <= 0);
if one_sign {
assert_eq!(out, b);
} else {
// Mixed signs: the whole net lands in one bucket, the one with its
// sign when there is exactly one, else Undetermined.
let with_sign: [bool; 3] = core::array::from_fn(|i| b[i] != 0 && (b[i] > 0) == (p > 0));
let takers = with_sign.iter().filter(|&&s| s).count();
let mut taker = kernel::Bucket::Undetermined.index();
if takers == 1 {
for (i, &s) in with_sign.iter().enumerate() {
if s {
taker = i;
}
}
}
for (i, &x) in out.iter().enumerate() {
assert_eq!(x, if i == taker { p } else { 0 });
}
}
kani::cover!(b[0] > 0 && b[2] < 0 && p > 0, "a payment net of a refund");
kani::cover!(
b[0] > 0 && b[1] > 0 && b[2] < 0 && p > 0,
"two buckets share the net's sign"
);
kani::cover!(!one_sign && p == 0, "money moved between own sources");
}
/// An allocation's parts sum to the whole exactly, whatever the rounding
/// of the others, and only the last part changes.
#[kani::proof]