P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
check / check (push) Canceled after 0s
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:
77 files changed
+11455
-970
No files matched your search
@@ -0,0 +1,368 @@
|
||||
//! Journal payloads of 1099 decisions and payment methods (plan P3,
|
||||
//! defects 12, 18 and 23; D-40). One encoder and one decoder per event,
|
||||
//! so the writer and every reader (replay, the projection, the viewer)
|
||||
//! read a line the same way.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every event round-trips: decode(encode(x)) == x, and every line
|
||||
//! written before decisions were kept reads as it always meant.
|
||||
//! SOURCE: plan/served.md 5.2, 5.5 (a legacy null is undetermined);
|
||||
//! rules.md B-27 (an older reader says "newer", never "corrupt").
|
||||
//! INVARIANTS:
|
||||
//! 1. vendor.1099_set {vendor_id, form_1099: "nec"|"misc"|
|
||||
//! "not_reportable"|null, reason_kind?, reason?, effective?,
|
||||
//! tin_on_file, source?}. A line with no source is one written before
|
||||
//! decisions were kept: undated, read as its form or undetermined.
|
||||
//! A line with a source is the vendor_1099_decisions feature's.
|
||||
//! 2. not_reportable needs a source, a reason_kind and a reason; a date
|
||||
//! needs a source; a reason or reason_kind beside anything else is
|
||||
//! corrupt. A form or reason kind this version doesn't know is newer
|
||||
//! (format::newer_content), never corrupt.
|
||||
//! 3. vendor.w9_flag_set {vendor_id, tin_on_file}.
|
||||
//! 4. instrument.added {instrument_id, kind, label, last4?,
|
||||
//! owner_party_id?, account_id}; entry.instrument_set {entry_id,
|
||||
//! instrument_id|null}. Optional keys are left out when absent.
|
||||
//! 5. Decoding checks shape only (rules.md B-28): what a command checks
|
||||
//! (limits, the chart) stays with the command.
|
||||
//! ```
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::JournalLine;
|
||||
use crate::meta::{source_json, source_of};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::form1099::{Dated1099, Decision1099, ExemptReason, Form1099};
|
||||
use ledger_core::{Instrument, InstrumentId, InstrumentKind, VendorId};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const SET: &str = "vendor.1099_set";
|
||||
|
||||
fn corrupt(event: &str, what: &str) -> StoreError {
|
||||
StoreError::JournalCorrupt(format!("{event}: missing or invalid '{what}'"))
|
||||
}
|
||||
|
||||
fn u64_of(p: &Value, event: &str, k: &str) -> Result<u64, StoreError> {
|
||||
p.get(k)
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| corrupt(event, k))
|
||||
}
|
||||
|
||||
fn opt_u64(p: &Value, event: &str, k: &str) -> Result<Option<u64>, StoreError> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| corrupt(event, k)),
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_str<'a>(p: &'a Value, event: &str, k: &str) -> Result<Option<&'a str>, StoreError> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s)),
|
||||
Some(_) => Err(corrupt(event, k)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `vendor.1099_set` for a decision with its source (INVARIANT 1).
|
||||
pub fn decision_set(vendor: VendorId, d: &Dated1099, tin_on_file: bool) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("vendor_id".into(), json!(vendor));
|
||||
match &d.decision {
|
||||
Decision1099::Undetermined => {
|
||||
o.insert("form_1099".into(), Value::Null);
|
||||
}
|
||||
Decision1099::Files(f) => {
|
||||
o.insert("form_1099".into(), json!(f.key()));
|
||||
}
|
||||
Decision1099::NotReportable { kind, reason } => {
|
||||
o.insert("form_1099".into(), json!("not_reportable"));
|
||||
o.insert("reason_kind".into(), json!(kind.key()));
|
||||
o.insert("reason".into(), json!(reason));
|
||||
}
|
||||
}
|
||||
if let Some(e) = d.effective {
|
||||
o.insert("effective".into(), json!(e.to_string()));
|
||||
}
|
||||
o.insert("tin_on_file".into(), json!(tin_on_file));
|
||||
if let Some(s) = &d.source {
|
||||
o.insert("source".into(), source_json(s));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read a `vendor.1099_set` (INVARIANTS 1-2): the vendor, its decision,
|
||||
/// and its W-9 flag.
|
||||
pub fn decision_of(line: &JournalLine) -> Result<(VendorId, Dated1099, bool), StoreError> {
|
||||
let p = &line.payload;
|
||||
let vendor = u64_of(p, SET, "vendor_id")?;
|
||||
// Every line has carried the flag; a missing one was false.
|
||||
let tin_on_file = match p.get("tin_on_file") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| corrupt(SET, "tin_on_file"))?,
|
||||
};
|
||||
let source = match p.get("source") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(source_of(p, SET, "source")?),
|
||||
};
|
||||
let effective = match opt_str(p, SET, "effective")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
Some(NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| corrupt(SET, "effective"))?)
|
||||
}
|
||||
};
|
||||
let kind = opt_str(p, SET, "reason_kind")?;
|
||||
let reason = opt_str(p, SET, "reason")?;
|
||||
let decision = match opt_str(p, SET, "form_1099")? {
|
||||
None => Decision1099::Undetermined,
|
||||
Some("not_reportable") => {
|
||||
let kind = kind.ok_or_else(|| corrupt(SET, "reason_kind"))?;
|
||||
let reason = reason
|
||||
.filter(|r| !r.trim().is_empty())
|
||||
.ok_or_else(|| corrupt(SET, "reason"))?;
|
||||
Decision1099::NotReportable {
|
||||
kind: ExemptReason::parse(kind).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the 1099 reason kind {kind:?}"))
|
||||
})?,
|
||||
reason: reason.to_string(),
|
||||
}
|
||||
}
|
||||
Some(s) => Decision1099::Files(Form1099::parse(s).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the 1099 decision {s:?}"))
|
||||
})?),
|
||||
};
|
||||
let not_reportable = matches!(decision, Decision1099::NotReportable { .. });
|
||||
if !not_reportable && (kind.is_some() || reason.is_some()) {
|
||||
return Err(corrupt(SET, "reason"));
|
||||
}
|
||||
if source.is_none() && (not_reportable || effective.is_some()) {
|
||||
return Err(corrupt(SET, "source"));
|
||||
}
|
||||
Ok((
|
||||
vendor,
|
||||
Dated1099 {
|
||||
effective,
|
||||
decision,
|
||||
source,
|
||||
},
|
||||
tin_on_file,
|
||||
))
|
||||
}
|
||||
|
||||
/// `vendor.w9_flag_set {vendor_id, tin_on_file}` (INVARIANT 3).
|
||||
pub fn w9_flag_set(vendor: VendorId, tin_on_file: bool) -> Value {
|
||||
json!({ "vendor_id": vendor, "tin_on_file": tin_on_file })
|
||||
}
|
||||
|
||||
/// Read a `vendor.w9_flag_set`.
|
||||
pub fn w9_flag_of(p: &Value) -> Result<(VendorId, bool), StoreError> {
|
||||
const E: &str = "vendor.w9_flag_set";
|
||||
Ok((
|
||||
u64_of(p, E, "vendor_id")?,
|
||||
p.get("tin_on_file")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| corrupt(E, "tin_on_file"))?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `instrument.added` (INVARIANT 4).
|
||||
pub fn instrument_added(i: &Instrument) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("instrument_id".into(), json!(i.id));
|
||||
o.insert("kind".into(), json!(i.kind.key()));
|
||||
o.insert("label".into(), json!(i.label));
|
||||
if let Some(l) = &i.last4 {
|
||||
o.insert("last4".into(), json!(l));
|
||||
}
|
||||
if let Some(p) = i.owner_party {
|
||||
o.insert("owner_party_id".into(), json!(p));
|
||||
}
|
||||
o.insert("account_id".into(), json!(i.account));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read an `instrument.added`.
|
||||
pub fn instrument_of(line: &JournalLine) -> Result<Instrument, StoreError> {
|
||||
const E: &str = "instrument.added";
|
||||
let p = &line.payload;
|
||||
let kind = opt_str(p, E, "kind")?.ok_or_else(|| corrupt(E, "kind"))?;
|
||||
Ok(Instrument {
|
||||
id: u64_of(p, E, "instrument_id")?,
|
||||
kind: InstrumentKind::parse(kind).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the payment method kind {kind:?}"))
|
||||
})?,
|
||||
label: opt_str(p, E, "label")?
|
||||
.ok_or_else(|| corrupt(E, "label"))?
|
||||
.to_string(),
|
||||
last4: opt_str(p, E, "last4")?.map(str::to_string),
|
||||
owner_party: opt_u64(p, E, "owner_party_id")?,
|
||||
account: u64_of(p, E, "account_id")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `entry.instrument_set {entry_id, instrument_id|null}` (INVARIANT 4).
|
||||
pub fn entry_instrument_set(
|
||||
entry: ledger_core::EntryId,
|
||||
instrument: Option<InstrumentId>,
|
||||
) -> Value {
|
||||
json!({ "entry_id": entry, "instrument_id": instrument })
|
||||
}
|
||||
|
||||
/// Read an `entry.instrument_set`.
|
||||
pub fn entry_instrument_of(
|
||||
p: &Value,
|
||||
) -> Result<(ledger_core::EntryId, Option<InstrumentId>), StoreError> {
|
||||
const E: &str = "entry.instrument_set";
|
||||
Ok((u64_of(p, E, "entry_id")?, opt_u64(p, E, "instrument_id")?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::settings::Source;
|
||||
|
||||
fn line(line_type: &str, payload: Value) -> JournalLine {
|
||||
JournalLine {
|
||||
seq: 7,
|
||||
ts: "2026-10-20T00:00:00Z".into(),
|
||||
line_type: line_type.into(),
|
||||
payload,
|
||||
prev: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn source() -> Source {
|
||||
Source {
|
||||
decided_by: "Riley Chen".into(),
|
||||
decided_on: "2026-10-16".parse().unwrap(),
|
||||
citation: Some("Treas. Reg. §1.6041-3(p)(1)".into()),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 1: every decision round-trips.
|
||||
#[test]
|
||||
fn decisions_round_trip() {
|
||||
for (effective, decision) in [
|
||||
(None, Decision1099::Undetermined),
|
||||
(None, Decision1099::Files(Form1099::Nec)),
|
||||
(
|
||||
Some("2026-12-30".parse().unwrap()),
|
||||
Decision1099::not_reportable(ExemptReason::Corporation, "a corporation by name")
|
||||
.unwrap(),
|
||||
),
|
||||
] {
|
||||
let d = Dated1099 {
|
||||
effective,
|
||||
decision,
|
||||
source: Some(source()),
|
||||
};
|
||||
let got = decision_of(&line(SET, decision_set(4, &d, true))).unwrap();
|
||||
assert_eq!(got, (4, d, true));
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 1, plan 5.5: lines written before decisions were kept
|
||||
/// read as their form, undated, with no source; a null (or no form)
|
||||
/// as undetermined.
|
||||
#[test]
|
||||
fn legacy_lines_read_as_they_always_meant() {
|
||||
let legacy = |form: Value| {
|
||||
decision_of(&line(
|
||||
SET,
|
||||
json!({"vendor_id": 2, "form_1099": form, "tin_on_file": true}),
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let nec = legacy(json!("nec"));
|
||||
assert_eq!(
|
||||
nec,
|
||||
(
|
||||
2,
|
||||
Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), None),
|
||||
true
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
legacy(Value::Null).1,
|
||||
Dated1099::from_beginning(Decision1099::Undetermined, None)
|
||||
);
|
||||
let bare = decision_of(&line(SET, json!({"vendor_id": 2}))).unwrap();
|
||||
assert_eq!(
|
||||
bare,
|
||||
(
|
||||
2,
|
||||
Dated1099::from_beginning(Decision1099::Undetermined, None),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 2.
|
||||
#[test]
|
||||
fn malformed_decisions_are_corrupt_and_newer_ones_newer() {
|
||||
let src = source_json(&source());
|
||||
let bad = [
|
||||
// not_reportable without a reason, its kind, or a source.
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason": "x", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "reason": "x", "tin_on_file": false}),
|
||||
// A date without a source; a reason beside a form.
|
||||
json!({"vendor_id": 1, "form_1099": "nec", "effective": "2026-01-01", "tin_on_file": false}),
|
||||
json!({"vendor_id": 1, "form_1099": "nec", "reason": "x", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": 7, "tin_on_file": false}),
|
||||
json!({"vendor_id": 1, "form_1099": "nec", "effective": "2026-13-01", "tin_on_file": false, "source": src}),
|
||||
];
|
||||
for p in bad {
|
||||
let e = decision_of(&line(SET, p.clone())).unwrap_err();
|
||||
assert!(matches!(e, StoreError::JournalCorrupt(_)), "{p}: {e:?}");
|
||||
}
|
||||
let newer = [
|
||||
json!({"vendor_id": 1, "form_1099": "k", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "martian", "reason": "x", "tin_on_file": false, "source": src}),
|
||||
];
|
||||
for p in newer {
|
||||
let e = decision_of(&line(SET, p.clone())).unwrap_err();
|
||||
assert!(matches!(e, StoreError::NewerJournal { .. }), "{p}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 4.
|
||||
#[test]
|
||||
fn instruments_round_trip() {
|
||||
for i in [
|
||||
Instrument {
|
||||
id: 3,
|
||||
kind: InstrumentKind::BusinessCard,
|
||||
label: "Harbor Visa".into(),
|
||||
last4: Some("0042".into()),
|
||||
owner_party: None,
|
||||
account: 14,
|
||||
},
|
||||
Instrument {
|
||||
id: 4,
|
||||
kind: InstrumentKind::MemberCashOrCheck,
|
||||
label: "Riley's checks".into(),
|
||||
last4: None,
|
||||
owner_party: Some(2),
|
||||
account: 9,
|
||||
},
|
||||
] {
|
||||
let p = instrument_added(&i);
|
||||
assert!(p.get("last4").is_none() || i.last4.is_some());
|
||||
assert_eq!(instrument_of(&line("instrument.added", p)).unwrap(), i);
|
||||
}
|
||||
let e = instrument_of(&line(
|
||||
"instrument.added",
|
||||
json!({"instrument_id": 1, "kind": "crypto", "label": "x", "account_id": 1}),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, StoreError::NewerJournal { .. }), "{e:?}");
|
||||
assert_eq!(
|
||||
entry_instrument_of(&entry_instrument_set(5, None)).unwrap(),
|
||||
(5, None)
|
||||
);
|
||||
assert_eq!(
|
||||
entry_instrument_of(&entry_instrument_set(5, Some(2))).unwrap(),
|
||||
(5, Some(2))
|
||||
);
|
||||
assert_eq!(w9_flag_of(&w9_flag_set(3, true)).unwrap(), (3, true));
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,17 @@ pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
|
||||
/// older version can't read: a new event type (which it would refuse as
|
||||
/// unknown), or a field or value on an event type it knows (which it
|
||||
/// would misread silently). A writer declares the feature
|
||||
/// (`journal.format_set`) before its first such line, so the older
|
||||
/// version stops there instead (rules.md B-27). Books that never use one
|
||||
/// stay readable by versions before it.
|
||||
/// (`journal.format_set`) before its first such line
|
||||
/// ([`crate::Store`]'s `ensure_feature`), so the older version stops
|
||||
/// there instead (rules.md B-27). Books that never use one stay readable
|
||||
/// by versions before it.
|
||||
pub const KNOWN_FEATURES: &[&str] = &[
|
||||
MEMBERS_CUMULATIVE,
|
||||
FEATURE_ACCOUNT_RENAMED,
|
||||
FEATURE_REQUIRE_TAX_CATEGORY,
|
||||
PARTY_CREDITORS,
|
||||
PAYMENT_INSTRUMENTS,
|
||||
VENDOR_1099_DECISIONS,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -75,6 +78,16 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
|
||||
/// creditor of a liability (rules.md B-35, P3 defect 24).
|
||||
pub const PARTY_CREDITORS: &str = "party_creditors";
|
||||
|
||||
/// Payment methods (`instrument.added`) and the entries tagged with one
|
||||
/// (`entry.instrument_set`): plan P3, defect 12.
|
||||
pub const PAYMENT_INSTRUMENTS: &str = "payment_instruments";
|
||||
|
||||
/// 1099 decisions kept as decisions (`vendor.1099_set` with
|
||||
/// `not_reportable`, its reason, the date it takes effect and who decided:
|
||||
/// plan P3, defects 18 and 23, D-40) and W-9 flags kept apart from them
|
||||
/// (`vendor.w9_flag_set`).
|
||||
pub const VENDOR_1099_DECISIONS: &str = "vendor_1099_decisions";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -107,6 +120,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"document.filed",
|
||||
"entry.approved",
|
||||
"entry.created",
|
||||
"entry.instrument_set",
|
||||
"entry.line_added",
|
||||
"entry.line_class_set",
|
||||
"entry.no_party",
|
||||
@@ -118,6 +132,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"fx.rate_set",
|
||||
"invoice.created",
|
||||
"invoice.payment_received",
|
||||
"instrument.added",
|
||||
FORMAT_SET,
|
||||
"journal.tail_repaired",
|
||||
"line.cleared",
|
||||
@@ -150,6 +165,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"store.created",
|
||||
"vendor.1099_set",
|
||||
"vendor.added",
|
||||
"vendor.w9_flag_set",
|
||||
];
|
||||
|
||||
/// The journal format in force: the last `journal.format_set` read, or
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod blob;
|
||||
pub mod chain;
|
||||
pub mod error;
|
||||
pub mod exports;
|
||||
pub mod form1099;
|
||||
pub mod format;
|
||||
pub mod journal;
|
||||
pub mod meta;
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
//! cash accounts: `[books] cash_accounts`, resolved; when none are set,
|
||||
//! the asset accounts (not placeholders) whose names say they hold
|
||||
//! cash ("cash", "bank", "checking", "savings", "money market").
|
||||
//! card accounts: liability accounts whose names contain "card".
|
||||
//! card accounts: `ledger_core::parties::card_accounts`: the
|
||||
//! liabilities of the business-card payment methods the books record,
|
||||
//! else (none recorded) the liabilities whose names say "card" but
|
||||
//! not "gift"; never a member's card or a platform's account.
|
||||
//! INVARIANTS:
|
||||
//! 1. reconcilable_accounts: the cash accounts, then the card accounts,
|
||||
//! then every other liability with a recorded statement, each once.
|
||||
@@ -55,9 +58,10 @@ pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The card accounts: liabilities whose names contain "card"
|
||||
/// (`ledger_core::parties::card_accounts`, the same rule party money
|
||||
/// reads, so the two never disagree on what a card is).
|
||||
/// The card accounts (`ledger_core::parties::card_accounts`: the
|
||||
/// business-card payment methods' liabilities, else the liabilities named
|
||||
/// "card", not "gift"), the same rule party money and the viewer read, so
|
||||
/// none disagrees on what a card is.
|
||||
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
|
||||
ledger_core::parties::card_accounts(ledger)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 12;
|
||||
pub const SCHEMA_VERSION: u32 = 13;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -323,5 +323,38 @@ CREATE TABLE IF NOT EXISTS entry_approvals(
|
||||
note TEXT NULL,
|
||||
source_json TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
r#"
|
||||
-- v13: 1099 decisions and payment methods (plan P3, defects 12, 18 and
|
||||
-- 23; D-40). Each 1099 decision recorded with who decided (journal
|
||||
-- feature vendor_1099_decisions), line by line: the date it takes effect
|
||||
-- (NULL: from the beginning), nec, misc, not_reportable or NULL
|
||||
-- (undetermined), and for not_reportable its kind and reason. A decision
|
||||
-- from the beginning also sets vendors.form_1099, as every decision
|
||||
-- written before these did. Payment methods (feature payment_instruments)
|
||||
-- and the method each entry was paid with. Rows only come from lines
|
||||
-- written under the features, so books written before them project the
|
||||
-- same as before.
|
||||
CREATE TABLE IF NOT EXISTS vendor_1099_decisions(
|
||||
seq INTEGER PRIMARY KEY,
|
||||
vendor_id INTEGER NOT NULL,
|
||||
effective TEXT NULL,
|
||||
form_1099 TEXT NULL,
|
||||
reason_kind TEXT NULL,
|
||||
reason TEXT NULL,
|
||||
source_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS instruments(
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
last4 TEXT NULL,
|
||||
owner_party INTEGER NULL,
|
||||
account_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entry_instruments(
|
||||
entry_id INTEGER PRIMARY KEY,
|
||||
instrument_id INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -587,16 +587,25 @@ impl Store {
|
||||
/// first line that needs it: a new event type, or a field or value on
|
||||
/// a known event type that an older version would otherwise misread
|
||||
/// silently. Declared with the format in force (at least format 2,
|
||||
/// crate::meta::FORMAT) and every feature already in force; nothing is
|
||||
/// crate::meta::FORMAT) and every feature already in force, so an
|
||||
/// older reader stops at the declaration (rules.md B-27); nothing is
|
||||
/// journaled when it is already declared. Books that never use the
|
||||
/// feature stay readable by older versions.
|
||||
///
|
||||
/// Each command here that writes a feature's line declares it first.
|
||||
/// A caller that journals several commands as one (a tool drafting an
|
||||
/// entry and tagging it) calls this before the first of them, once
|
||||
/// every check has passed: a declaration never lands after another
|
||||
/// line of the same command (where a refusal to upgrade the format
|
||||
/// would leave the command half done), and a refused command leaves
|
||||
/// none.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
|
||||
/// instead of misreading, or failing on, a line it can't read.
|
||||
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
|
||||
/// ```
|
||||
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
|
||||
pub fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
|
||||
self.ensure_features(&[feature])
|
||||
}
|
||||
|
||||
@@ -3079,25 +3088,149 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set which 1099 a vendor gets (or none) and whether its W-9 is on
|
||||
/// file. Journaled as `vendor.1099_set`.
|
||||
/// Record a vendor's 1099 decision, from the date it takes effect (or
|
||||
/// from the beginning), with who decided; and its W-9 flag when given
|
||||
/// (`None` keeps it). Journaled as `vendor.1099_set` under the
|
||||
/// `vendor_1099_decisions` feature. Returns what it did to the
|
||||
/// vendor's decisions.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the decision ("no 1099" included) is kept as a decision, with
|
||||
/// who made it and when (plan defects 18, 23; D-40).
|
||||
/// SOURCE: ledger_core::form1099 (plan_decision, check_new_decision);
|
||||
/// P3 review R8.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the decision has no source: SHOULD refuse
|
||||
/// BECAUSE every new decision names who made it.
|
||||
/// 2. IF the core refuses it: SHOULD refuse, journaling nothing.
|
||||
/// 3. IF it changes no decision: SHOULD journal nothing but a
|
||||
/// changed W-9 flag (Store::set_vendor_w9_flag).
|
||||
/// 4. Otherwise: SHOULD declare the feature once, then journal it.
|
||||
/// ```
|
||||
pub fn set_vendor_1099(
|
||||
&mut self,
|
||||
vendor: VendorId,
|
||||
form: Option<ledger_core::Form1099>,
|
||||
tin_on_file: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
self.rehearse(|l| l.set_vendor_1099(vendor, form, tin_on_file))?;
|
||||
self.ledger
|
||||
decision: &ledger_core::Dated1099,
|
||||
tin_on_file: Option<bool>,
|
||||
) -> Result<ledger_core::form1099::Applied, StoreError> {
|
||||
if decision.source.is_none() {
|
||||
return Err(LedgerError::Invalid1099(
|
||||
"a 1099 decision names who made it (decided_by)".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let applied = self.ledger.check_vendor_1099(vendor, decision)?;
|
||||
let current = self
|
||||
.ledger
|
||||
.get_vendor(vendor)
|
||||
.ok_or(LedgerError::UnknownVendor(vendor))?;
|
||||
.ok_or(LedgerError::UnknownVendor(vendor))?
|
||||
.tin_on_file;
|
||||
let flag = tin_on_file.unwrap_or(current);
|
||||
if applied == ledger_core::form1099::Applied::Unchanged {
|
||||
self.set_vendor_w9_flag(vendor, flag)?;
|
||||
return Ok(applied);
|
||||
}
|
||||
self.ensure_feature(crate::format::VENDOR_1099_DECISIONS)?;
|
||||
let line = self.append_journal(
|
||||
"vendor.1099_set",
|
||||
json!({ "vendor_id": vendor, "form_1099": form, "tin_on_file": tin_on_file }),
|
||||
crate::form1099::decision_set(vendor, decision, flag),
|
||||
)?;
|
||||
self.ledger.set_vendor_1099(vendor, form, tin_on_file)?;
|
||||
let got = self
|
||||
.ledger
|
||||
.set_vendor_1099(vendor, decision.clone(), flag)?;
|
||||
debug_assert_eq!(got, applied, "check and record diverged");
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
/// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions
|
||||
/// and who made them (P3 review R8). Journaled as
|
||||
/// `vendor.w9_flag_set` under the `vendor_1099_decisions` feature;
|
||||
/// nothing when the flag is already so. Returns whether it changed.
|
||||
pub fn set_vendor_w9_flag(
|
||||
&mut self,
|
||||
vendor: VendorId,
|
||||
on_file: bool,
|
||||
) -> Result<bool, StoreError> {
|
||||
let v = self
|
||||
.ledger
|
||||
.get_vendor(vendor)
|
||||
.ok_or(LedgerError::UnknownVendor(vendor))?;
|
||||
if v.tin_on_file == on_file {
|
||||
return Ok(false);
|
||||
}
|
||||
self.ensure_feature(crate::format::VENDOR_1099_DECISIONS)?;
|
||||
let line = self.append_journal(
|
||||
"vendor.w9_flag_set",
|
||||
crate::form1099::w9_flag_set(vendor, on_file),
|
||||
)?;
|
||||
self.ledger.set_vendor_w9_flag(vendor, on_file)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Add a payment method (ledger_core::instruments), journaled as
|
||||
/// `instrument.added` under the `payment_instruments` feature. Returns
|
||||
/// its id.
|
||||
pub fn add_instrument(
|
||||
&mut self,
|
||||
kind: ledger_core::InstrumentKind,
|
||||
label: &str,
|
||||
last4: Option<&str>,
|
||||
owner_party: Option<ledger_core::PartyId>,
|
||||
account: AccountId,
|
||||
) -> Result<ledger_core::InstrumentId, StoreError> {
|
||||
let label = self
|
||||
.ledger
|
||||
.check_add_instrument(label, last4, owner_party, account)?;
|
||||
let id = self.ledger.next_instrument_id();
|
||||
let instrument = ledger_core::Instrument {
|
||||
id,
|
||||
kind,
|
||||
label,
|
||||
last4: last4.map(str::to_string),
|
||||
owner_party,
|
||||
account,
|
||||
};
|
||||
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
|
||||
let line = self.append_journal(
|
||||
"instrument.added",
|
||||
crate::form1099::instrument_added(&instrument),
|
||||
)?;
|
||||
let live =
|
||||
self.ledger
|
||||
.add_instrument(kind, &instrument.label, last4, owner_party, account)?;
|
||||
debug_assert_eq!(live, id, "id allocator diverged from journal");
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Tag an entry (and its reversal chain) with the payment method it
|
||||
/// was paid with, or clear it. Journaled as `entry.instrument_set`
|
||||
/// under the `payment_instruments` feature; nothing when every entry
|
||||
/// of the chain already says so. Returns whether it changed.
|
||||
pub fn set_entry_instrument(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
instrument: Option<ledger_core::InstrumentId>,
|
||||
) -> Result<bool, StoreError> {
|
||||
self.rehearse(|l| l.set_entry_instrument(entry, instrument))?;
|
||||
if self
|
||||
.ledger
|
||||
.reversal_chain(entry)
|
||||
.into_iter()
|
||||
.all(|e| self.ledger.entry_instrument(e) == instrument)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
|
||||
let line = self.append_journal(
|
||||
"entry.instrument_set",
|
||||
crate::form1099::entry_instrument_set(entry, instrument),
|
||||
)?;
|
||||
self.ledger.set_entry_instrument(entry, instrument)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Tag an entry with the vendor it paid, or clear the tag. Journaled as
|
||||
@@ -4743,19 +4876,32 @@ pub(crate) fn replay_line_into_ledger(
|
||||
}
|
||||
}
|
||||
"vendor.1099_set" => {
|
||||
let form = match p.get("form_1099").and_then(|v| v.as_str()) {
|
||||
Some(s) => Some(ledger_core::Form1099::parse(s).ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("vendor.1099_set form_1099".into())
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
ledger.set_vendor_1099(
|
||||
get_u64(p, "vendor_id")?,
|
||||
form,
|
||||
p.get("tin_on_file")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
let (vendor, decision, tin_on_file) = crate::form1099::decision_of(line)?;
|
||||
ledger.replay_vendor_1099(vendor, decision, tin_on_file)?;
|
||||
}
|
||||
"vendor.w9_flag_set" => {
|
||||
let (vendor, on_file) = crate::form1099::w9_flag_of(p)?;
|
||||
ledger.set_vendor_w9_flag(vendor, on_file)?;
|
||||
}
|
||||
"instrument.added" => {
|
||||
let i = crate::form1099::instrument_of(line)?;
|
||||
let got = ledger.add_instrument(
|
||||
i.kind,
|
||||
&i.label,
|
||||
i.last4.as_deref(),
|
||||
i.owner_party,
|
||||
i.account,
|
||||
)?;
|
||||
if got != i.id {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"payment method id: journal says {}, replay got {got}",
|
||||
i.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
"entry.instrument_set" => {
|
||||
let (entry, instrument) = crate::form1099::entry_instrument_of(p)?;
|
||||
ledger.set_entry_instrument(entry, instrument)?;
|
||||
}
|
||||
"entry.vendor_set" => {
|
||||
ledger.set_entry_vendor(
|
||||
@@ -5488,6 +5634,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
SELECT ?, vendor_id FROM entry_vendors WHERE entry_id = ?",
|
||||
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
|
||||
)?;
|
||||
// And the way it was paid.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_instruments(entry_id, instrument_id) \
|
||||
SELECT ?, instrument_id FROM entry_instruments WHERE entry_id = ?",
|
||||
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
|
||||
params![
|
||||
@@ -5698,17 +5850,81 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
)?;
|
||||
}
|
||||
"vendor.1099_set" => {
|
||||
let (vendor, d, tin_on_file) = crate::form1099::decision_of(line)?;
|
||||
let key = match &d.decision {
|
||||
ledger_core::Decision1099::Undetermined => None,
|
||||
other => Some(other.key()),
|
||||
};
|
||||
// A decision from the beginning is the vendor's form, as every
|
||||
// line written before decisions were dated was.
|
||||
if d.effective.is_none() {
|
||||
conn.execute(
|
||||
"UPDATE vendors SET form_1099 = ? WHERE id = ?",
|
||||
params![key, vendor as i64],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE vendors SET form_1099 = ?, tin_on_file = ? WHERE id = ?",
|
||||
"UPDATE vendors SET tin_on_file = ? WHERE id = ?",
|
||||
params![tin_on_file, vendor as i64],
|
||||
)?;
|
||||
if let Some(s) = &d.source {
|
||||
let (kind, reason) = match &d.decision {
|
||||
ledger_core::Decision1099::NotReportable { kind, reason } => {
|
||||
(Some(kind.key()), Some(reason.as_str()))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO vendor_1099_decisions(seq, vendor_id, effective, form_1099, reason_kind, reason, source_json) VALUES (?,?,?,?,?,?,?)",
|
||||
params![
|
||||
line.seq as i64,
|
||||
vendor as i64,
|
||||
d.effective.map(|e| e.to_string()),
|
||||
key,
|
||||
kind,
|
||||
reason,
|
||||
crate::meta::source_json(s).to_string(),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"vendor.w9_flag_set" => {
|
||||
let (vendor, on_file) = crate::form1099::w9_flag_of(p)?;
|
||||
conn.execute(
|
||||
"UPDATE vendors SET tin_on_file = ? WHERE id = ?",
|
||||
params![on_file, vendor as i64],
|
||||
)?;
|
||||
}
|
||||
"instrument.added" => {
|
||||
let i = crate::form1099::instrument_of(line)?;
|
||||
conn.execute(
|
||||
"INSERT INTO instruments(id, kind, label, last4, owner_party, account_id) VALUES (?,?,?,?,?,?)",
|
||||
params![
|
||||
p.get("form_1099").and_then(|v| v.as_str()),
|
||||
p.get("tin_on_file")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
get_u64(p, "vendor_id")? as i64,
|
||||
i.id as i64,
|
||||
i.kind.key(),
|
||||
i.label,
|
||||
i.last4,
|
||||
i.owner_party.map(|p| p as i64),
|
||||
i.account as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"entry.instrument_set" => {
|
||||
let (entry, instrument) = crate::form1099::entry_instrument_of(p)?;
|
||||
// The whole reversal chain, as the ledger tags it.
|
||||
for e in sqlite_reversal_chain(conn, entry as i64)? {
|
||||
match instrument {
|
||||
Some(i) => conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_instruments(entry_id, instrument_id) VALUES (?, ?)",
|
||||
params![e, i as i64],
|
||||
)?,
|
||||
None => conn.execute(
|
||||
"DELETE FROM entry_instruments WHERE entry_id = ?",
|
||||
params![e],
|
||||
)?,
|
||||
};
|
||||
}
|
||||
}
|
||||
"entry.vendor_set" => match p.get("vendor_id").and_then(|v| v.as_u64()) {
|
||||
Some(v) => {
|
||||
conn.execute(
|
||||
@@ -6108,6 +6324,46 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An entry's reversal chain in the projection, as
|
||||
/// `Ledger::reversal_chain` walks it: up to the original, then down
|
||||
/// through each posted reversal. A draft (not projected) is its own chain.
|
||||
fn sqlite_reversal_chain(conn: &Connection, entry: i64) -> Result<Vec<i64>, StoreError> {
|
||||
let up = |e: i64| -> Result<Option<i64>, StoreError> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT reversal_of FROM entries WHERE id = ?",
|
||||
params![e],
|
||||
|r| r.get::<_, Option<i64>>(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten())
|
||||
};
|
||||
let mut top = entry;
|
||||
while let Some(o) = up(top)? {
|
||||
if o == entry {
|
||||
break;
|
||||
}
|
||||
top = o;
|
||||
}
|
||||
let mut chain = vec![top];
|
||||
let mut cur = top;
|
||||
while let Some(r) = conn
|
||||
.query_row(
|
||||
"SELECT id FROM entries WHERE reversal_of = ? ORDER BY id LIMIT 1",
|
||||
params![cur],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?
|
||||
{
|
||||
if chain.contains(&r) {
|
||||
break;
|
||||
}
|
||||
chain.push(r);
|
||||
cur = r;
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// What [`Store::verify_and_heal`] found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifyReport {
|
||||
|
||||
Reference in new issue
Block a user