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
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 9;
|
||||
pub const SCHEMA_VERSION: u32 = 10;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -220,5 +220,12 @@ CREATE TABLE IF NOT EXISTS asset_tags_log(
|
||||
asset_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
r#"
|
||||
-- v10: each asset's federal tax basis: the rule (cost, carryover,
|
||||
-- personal_use_conversion) and the contributing member's adjusted basis
|
||||
-- (TEXT, exact decimal, never REAL).
|
||||
ALTER TABLE assets ADD COLUMN tax_rule TEXT;
|
||||
ALTER TABLE assets ADD COLUMN contributor_basis TEXT;
|
||||
"#,
|
||||
];
|
||||
@@ -725,6 +725,27 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record an asset's federal tax basis (see
|
||||
/// `Ledger::set_asset_tax_basis`). Journaled as `asset.tax_basis_set`.
|
||||
pub fn set_asset_tax_basis(
|
||||
&mut self,
|
||||
id: AssetId,
|
||||
tax: ledger_core::TaxBasis,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ledger.check_asset_tax_basis(id, tax)?;
|
||||
let line = self.append_journal(
|
||||
"asset.tax_basis_set",
|
||||
json!({
|
||||
"asset_id": id,
|
||||
"rule": tax.rule.key(),
|
||||
"contributor_basis": tax.contributor_basis.map(|b| b.to_string()),
|
||||
}),
|
||||
)?;
|
||||
self.ledger.set_asset_tax_basis(id, tax)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change an asset's description (see `Ledger::set_asset_description`).
|
||||
/// Journaled as `asset.described`, with the description it had.
|
||||
pub fn set_asset_description(
|
||||
@@ -2897,6 +2918,21 @@ impl Store {
|
||||
let (id, serial) = row?;
|
||||
ledger.set_asset_serial(id as u64, Some(&serial))?;
|
||||
}
|
||||
let mut xstmt = self.conn.prepare(
|
||||
"SELECT id, tax_rule, contributor_basis FROM assets WHERE tax_rule IS NOT NULL ORDER BY id",
|
||||
)?;
|
||||
let taxes = xstmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in taxes {
|
||||
let (id, rule, basis) = row?;
|
||||
let tax = tax_basis_from_journal(&json!({"rule": rule, "contributor_basis": basis}))?;
|
||||
ledger.set_asset_tax_basis(id as u64, tax)?;
|
||||
}
|
||||
let mut qstmt = self.conn.prepare(
|
||||
"SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id",
|
||||
)?;
|
||||
@@ -3452,6 +3488,10 @@ pub(crate) fn replay_line_into_ledger(
|
||||
"asset.tagged" => {
|
||||
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?;
|
||||
}
|
||||
"asset.tax_basis_set" => {
|
||||
let tax = tax_basis_from_journal(p)?;
|
||||
ledger.set_asset_tax_basis(get_u64(p, "asset_id")?, tax)?;
|
||||
}
|
||||
"asset.described" => {
|
||||
ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?;
|
||||
}
|
||||
@@ -3680,6 +3720,26 @@ fn apply_document_payment(
|
||||
/// The close target recorded in a `period.closed` line. Absent or
|
||||
/// `"retained_earnings"`: Retained Earnings with no sweeps (every journal
|
||||
/// written before targets existed).
|
||||
/// An `asset.tax_basis_set` payload's rule and contributor basis, strictly:
|
||||
/// an unknown rule or a basis that isn't a decimal string is corrupt.
|
||||
fn tax_basis_from_journal(p: &Value) -> Result<ledger_core::TaxBasis, StoreError> {
|
||||
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("asset.tax_basis_set {what}"));
|
||||
let rule =
|
||||
ledger_core::TaxBasisRule::parse(get_str(p, "rule")?).ok_or_else(|| corrupt("rule"))?;
|
||||
let contributor_basis = match p.get("contributor_basis") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_str()
|
||||
.and_then(|s| s.parse::<Decimal>().ok())
|
||||
.ok_or_else(|| corrupt("contributor_basis"))?,
|
||||
),
|
||||
};
|
||||
Ok(ledger_core::TaxBasis {
|
||||
rule,
|
||||
contributor_basis,
|
||||
})
|
||||
}
|
||||
|
||||
fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
||||
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
|
||||
let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
|
||||
@@ -3929,6 +3989,16 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
params![id, tag],
|
||||
)?;
|
||||
}
|
||||
"asset.tax_basis_set" => {
|
||||
conn.execute(
|
||||
"UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?",
|
||||
params![
|
||||
get_str(p, "rule")?,
|
||||
p.get("contributor_basis").and_then(|v| v.as_str()),
|
||||
get_u64(p, "asset_id")? as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"asset.described" => {
|
||||
conn.execute(
|
||||
"UPDATE assets SET description = ? WHERE id = ?",
|
||||
|
||||
@@ -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