Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md) - store: asset.tax_basis_set journaled, replayed, projected (schema v10) - mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset - import: the register's XLSX and PDF gain the federal tax basis table with citations - viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one - just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp) - docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md) - tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests 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
49c49aef33
commit
6e6f9d65b2
30 files changed
+3240
-93
No files matched your search
@@ -3,7 +3,9 @@
|
||||
//! `asset.serial_set` {asset_id, serial | null}), replay on reopen, the v9
|
||||
//! SQLite projection agreeing with the journal (`Store::verify`), books
|
||||
//! written before tags existed, refusals journaling nothing, and corrupt
|
||||
//! `asset.serial_set` lines refused on open.
|
||||
//! `asset.serial_set` lines refused on open; and tax basis (B-25:
|
||||
//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the
|
||||
//! v10 migration, corrupt payloads).
|
||||
//!
|
||||
//! Expectations come from the doc comments on `Store::set_asset_tag`,
|
||||
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
|
||||
@@ -349,8 +351,10 @@ fn an_older_asset_registered_line_still_replays() {
|
||||
}
|
||||
|
||||
/// Downgrade the database to schema v8 (no assets.tag / assets.serial, no
|
||||
/// asset_tags_log), as an older wanderbooks left it.
|
||||
/// asset_tags_log, and none of v10's tax basis columns), as an older
|
||||
/// wanderbooks left it.
|
||||
fn downgrade_db_to_v8(dir: &Path) {
|
||||
downgrade_db_to_v9(dir);
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE assets DROP COLUMN tag;
|
||||
@@ -524,3 +528,232 @@ fn a_description_change_replays_and_verifies() {
|
||||
);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- tax basis (rules.md B-25)
|
||||
//
|
||||
// Expectations from `Store::set_asset_tax_basis` (journaled as
|
||||
// `asset.tax_basis_set`), `Ledger::set_asset_tax_basis` INVARIANTS and
|
||||
// SCENARIOS, and the schema v10 migration comment (tax_rule and
|
||||
// contributor_basis as TEXT, exact decimal).
|
||||
|
||||
fn tax(rule: ledger_core::TaxBasisRule, basis: Option<&str>) -> ledger_core::TaxBasis {
|
||||
ledger_core::TaxBasis {
|
||||
rule,
|
||||
contributor_basis: basis.map(|b| b.parse().unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
fn tax_of(s: &Store, id: AssetId) -> Option<ledger_core::TaxBasis> {
|
||||
s.ledger().get_asset(id).unwrap().tax
|
||||
}
|
||||
|
||||
/// Journaled as `asset.tax_basis_set` {asset_id, rule, contributor_basis}:
|
||||
/// the basis a decimal string, or null for a purchase.
|
||||
#[test]
|
||||
fn tax_basis_journals_asset_tax_basis_set() {
|
||||
use ledger_core::TaxBasisRule::*;
|
||||
let (dir, mut s, _, x, y) = fixture("journal-tax");
|
||||
s.set_asset_tax_basis(x, tax(Cost, None)).unwrap();
|
||||
s.set_asset_tax_basis(y, tax(PersonalUseConversion, Some("1500.25")))
|
||||
.unwrap();
|
||||
s.set_asset_tax_basis(x, tax(Carryover, Some("800.00")))
|
||||
.unwrap();
|
||||
let j = journal(&dir);
|
||||
let set: Vec<&Value> = j
|
||||
.iter()
|
||||
.filter(|(t, _)| t == "asset.tax_basis_set")
|
||||
.map(|(_, p)| p)
|
||||
.collect();
|
||||
assert_eq!(set.len(), 3);
|
||||
assert_eq!(
|
||||
set[0],
|
||||
&json!({"asset_id": x, "rule": "cost", "contributor_basis": null})
|
||||
);
|
||||
assert_eq!(set[1]["asset_id"], json!(y));
|
||||
assert_eq!(set[1]["rule"], "personal_use_conversion");
|
||||
let b = set[1]["contributor_basis"]
|
||||
.as_str()
|
||||
.expect("a decimal string");
|
||||
assert_eq!(
|
||||
b.parse::<rust_decimal::Decimal>().unwrap(),
|
||||
"1500.25".parse().unwrap()
|
||||
);
|
||||
assert_eq!(set[2]["rule"], "carryover");
|
||||
let b = set[2]["contributor_basis"]
|
||||
.as_str()
|
||||
.expect("a decimal string");
|
||||
assert_eq!(
|
||||
b.parse::<rust_decimal::Decimal>().unwrap(),
|
||||
"800.00".parse().unwrap()
|
||||
);
|
||||
// Set again replaces (the journal keeps both).
|
||||
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("800.00"))));
|
||||
assert_eq!(
|
||||
s.ledger().get_asset(y).unwrap().tax_basis(),
|
||||
Some("1200.00".parse().unwrap()),
|
||||
"lesser of the 1200.00 book cost and the 1500.25 basis"
|
||||
);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A refused tax basis (Ledger::set_asset_tax_basis INVARIANT 3) journals
|
||||
/// nothing and changes nothing, now or on reopen.
|
||||
#[test]
|
||||
fn a_refused_tax_basis_journals_nothing() {
|
||||
use ledger_core::TaxBasisRule::*;
|
||||
let (dir, mut s, _, x, _) = fixture("refused-tax");
|
||||
s.set_asset_tax_basis(x, tax(Carryover, Some("100.00")))
|
||||
.unwrap();
|
||||
let lines = journal(&dir).len();
|
||||
let before = s.ledger().clone();
|
||||
for bad in [
|
||||
tax(Cost, Some("100.00")),
|
||||
tax(Carryover, None),
|
||||
tax(PersonalUseConversion, None),
|
||||
tax(Carryover, Some("-0.01")),
|
||||
tax(PersonalUseConversion, Some("10.001")),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
s.set_asset_tax_basis(x, bad),
|
||||
Err(StoreError::Ledger(LedgerError::InvalidTaxBasis(_)))
|
||||
),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
s.set_asset_tax_basis(x + 99, tax(Cost, None)),
|
||||
Err(StoreError::Ledger(LedgerError::UnknownAsset(_)))
|
||||
));
|
||||
assert_eq!(journal(&dir).len(), lines, "nothing journaled");
|
||||
assert_eq!(s.ledger(), &before);
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(s.ledger(), &before);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// Tax basis replays on reopen, with the database and rebuilt from the
|
||||
/// journal alone, and verifies each time.
|
||||
#[test]
|
||||
fn tax_basis_replays_on_reopen() {
|
||||
use ledger_core::TaxBasisRule::*;
|
||||
let (dir, mut s, a, x, y) = fixture("replay-tax");
|
||||
let z = laptop(&mut s, &a, "Laptop C");
|
||||
s.set_asset_tax_basis(x, tax(Carryover, Some("900.00")))
|
||||
.unwrap();
|
||||
s.set_asset_tax_basis(x, tax(PersonalUseConversion, Some("2000.00")))
|
||||
.unwrap();
|
||||
s.set_asset_tax_basis(y, tax(Cost, None)).unwrap();
|
||||
s.verify().unwrap();
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
for rebuild in [false, true] {
|
||||
if rebuild {
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.ledger(), &live, "rebuild={rebuild}");
|
||||
assert_eq!(
|
||||
tax_of(&s, x),
|
||||
Some(tax(PersonalUseConversion, Some("2000.00")))
|
||||
);
|
||||
assert_eq!(tax_of(&s, y), Some(tax(Cost, None)));
|
||||
assert_eq!(tax_of(&s, z), None);
|
||||
let ax = s.ledger().get_asset(x).unwrap();
|
||||
assert_eq!(ax.tax_basis(), Some("1200.00".parse().unwrap()));
|
||||
assert_eq!(ax.built_in_gain(), Some("-800.00".parse().unwrap()));
|
||||
drop(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Downgrade the database to schema v9 (no assets.tax_rule /
|
||||
/// assets.contributor_basis), as an older wanderbooks left it.
|
||||
fn downgrade_db_to_v9(dir: &Path) {
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE assets DROP COLUMN tax_rule;
|
||||
ALTER TABLE assets DROP COLUMN contributor_basis;
|
||||
UPDATE meta SET value = '9' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An existing v9 database migrates on open, verifies, and takes tax bases.
|
||||
#[test]
|
||||
fn a_v9_database_migrates_and_verifies() {
|
||||
use ledger_core::TaxBasisRule::*;
|
||||
let (dir, mut s, _, x, y) = fixture("v9");
|
||||
s.set_asset_tag(x, "WL-0001").unwrap();
|
||||
drop(s);
|
||||
downgrade_db_to_v9(&dir);
|
||||
let mut s = Store::open(&dir).expect("a v9 database opens");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0001"));
|
||||
assert_eq!(tax_of(&s, x), None);
|
||||
s.set_asset_tax_basis(y, tax(Carryover, Some("640.00")))
|
||||
.unwrap();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tax_of(&s, y), Some(tax(Carryover, Some("640.00"))));
|
||||
}
|
||||
|
||||
/// A v9 database behind a journal with a tax basis line migrates, applies
|
||||
/// it (Store::open SCENARIO 7), and verifies.
|
||||
#[test]
|
||||
fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
|
||||
use ledger_core::TaxBasisRule::*;
|
||||
let (dir, s, _, x, _) = fixture("v9-behind");
|
||||
drop(s);
|
||||
downgrade_db_to_v9(&dir);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append(
|
||||
"asset.tax_basis_set",
|
||||
json!({"asset_id": x, "rule": "carryover", "contributor_basis": "300.00"}),
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let s = Store::open(&dir).expect("opens and catches up");
|
||||
s.verify().unwrap();
|
||||
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("300.00"))));
|
||||
}
|
||||
|
||||
/// An `asset.tax_basis_set` line with an unknown rule, or a contributor
|
||||
/// basis that isn't a decimal string (or null), is corrupt: refused on
|
||||
/// open with JournalCorrupt, and on a rebuild from the journal alone.
|
||||
#[test]
|
||||
fn an_asset_tax_basis_set_line_with_a_bad_payload_is_corrupt() {
|
||||
let bads = [
|
||||
json!({"rule": "fmv", "contributor_basis": null}),
|
||||
json!({"rule": "Cost", "contributor_basis": null}),
|
||||
json!({"rule": "carryover", "contributor_basis": 300}),
|
||||
json!({"rule": "carryover", "contributor_basis": 300.5}),
|
||||
json!({"rule": "carryover", "contributor_basis": "three hundred"}),
|
||||
json!({"rule": "personal_use_conversion", "contributor_basis": ""}),
|
||||
json!({"rule": "personal_use_conversion", "contributor_basis": ["300.00"]}),
|
||||
];
|
||||
for bad in bads {
|
||||
let (dir, s, _, x, _) = fixture("corrupt-tax");
|
||||
drop(s);
|
||||
let mut p = bad.clone();
|
||||
p["asset_id"] = json!(x);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append("asset.tax_basis_set", p).unwrap();
|
||||
drop(w);
|
||||
match Store::open(&dir) {
|
||||
Err(StoreError::JournalCorrupt(_)) => {}
|
||||
Err(other) => panic!("{bad}: expected JournalCorrupt, got {other:?}"),
|
||||
Ok(_) => panic!("{bad}: a corrupt asset.tax_basis_set line opened"),
|
||||
}
|
||||
let _ = std::fs::remove_file(dir.join(DB_FILE));
|
||||
match Store::open(&dir) {
|
||||
Err(StoreError::JournalCorrupt(_)) => {}
|
||||
Err(other) => panic!("{bad} rebuild: expected JournalCorrupt, got {other:?}"),
|
||||
Ok(_) => panic!("{bad} rebuild: a corrupt asset.tax_basis_set line opened"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user