diff --git a/bookspec/README.md b/bookspec/README.md index c36cca5..ae5cdb3 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -124,11 +124,14 @@ the side gives the direction. 1099-K; rules.md B-19); with the cards, what party money is, for `party_part` and `party_totals` (B-35 item 5: no `cash_accounts`, every asset account but receivables); the cash accounts are what the K-1 - worksheet reads as money (`k1`) and an IIF file's bank accounts (B-34). + worksheet reads as money (`k1`, `k1_tax_basis`, `k1_reconciliation`) + and an IIF file's bank accounts (B-34). - `[[setup.fixed_assets]]` `{ cost_account, accumulated_account, expense_account }`: accounts that hold property and equipment (their cost and accumulated depreciation are investing; ASC 230-10-45-12(c), - 45-13(c)). + 45-13(c)), for an example no asset's own figures matter to. An asset + with its figures is a `[[setup.assets]]` entry (below), whose accounts + hold property and equipment too. - `[[setup.assets]]`: fixed assets registered before the first event, each named by its `id` in the events and queries that ask about it (rules.md B-15): @@ -145,10 +148,24 @@ the side gives the direction. cost_account = "1500" accumulated_account = "1510" expense_account = "6200" + acquired_by = 2 # optional: the event whose entry put it + # on the books (B-15 rule 7) + basis = { rule = "personal_use_conversion", + contributor_adjusted_basis = "1688.40", + fmv_at_contribution = "1440.00", + sec704c_method = "traditional" } # optional (B-25) ``` Registering an asset posts nothing: the entry that buys it is an event - of its own. + of its own. `acquired_by = N` links that entry, event N's, once the + event is applied: its date is the asset's acquired date, which puts it + on the register (before it, the in-service date does). `basis` records + how the company got it and the facts its tax figures follow from, as + the asset is registered: `rule` `"purchase"`, `"carryover_723"` or + `"personal_use_conversion"`; a contribution's + `contributor_adjusted_basis` and `fmv_at_contribution`; the §704(c) + method if chosen (`"traditional"`, `"traditional_with_curative"`, + `"remedial"`). - `[[setup.schedules]]`, and `register_schedule = { ... }` as an event: a prepaid expense or deferred revenue recognized over its months (rules.md B-16), named by its `id`: @@ -172,8 +189,9 @@ the side gives the direction. distributions_account? }`: the members of a multi-member LLC. `share` is the member's share of income, an exact decimal fraction (`"0.5"`, `"0.3333"`); every member has one or none does, and the shares sum to - 1. A close to the members, `members_equity` and `k1` need them. - `distributions_account`: the member's distributions account (B-13). A + 1. A close to the members, `members_equity`, `k1`, `k1_tax_basis` and + `k1_reconciliation` need them. `distributions_account`: the member's + distributions account (B-13). A member whose `name` is a party's in `setup.parties` with the `member` role is that party, as the company's settings name them: an entry's parties' shares resolve with those members (B-35), and so does the @@ -239,10 +257,14 @@ the side gives the direction. - `refused = ""` on a reversal or a command: the books refuse it, for that reason (below); it changes nothing, posts nothing, and the example goes on without it. -- `[[changes]]` `{ after, rename?, require_tax_category? }`: a change - that posts no entry, exactly one of `rename = { code, name }` (rename - the account `code`, B-31) and `require_tax_category = "warn"` or - `"refuse"` (the policy, B-32; the books start at `warn`). It is made +- `[[changes]]` `{ after, rename?, require_tax_category?, correct_asset? + }`: a change that posts no entry, exactly one of `rename = { code, name + }` (rename the account `code`, B-31), `require_tax_category = "warn"` + or `"refuse"` (the policy, B-32; the books start at `warn`) and + `correct_asset = { asset, cost?, salvage?, life_months?, in_service?, + reason }` (a registration mistake corrected: each figure given replaces + the asset's, the rest stay; refused while the books carry depreciation + for it). It is made right after event `after` (1 to the number of events), once the expectations asked after that event are checked, in the order listed. - `after = N` on an expectation: asked of the books after event N (before @@ -276,6 +298,8 @@ answers is refused, never skipped. | `balances` | `as_of` | each account named by code, its balance on its normal side (a member's capital after a loss is negative); the example names the accounts it shows | `ledger-core` `tests/bookspec.rs` | | `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `".beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `k1` | `from`, `to` | the K-1 worksheet (B-13; Schedule K-1 Item L): `".beginning"`, `.contributions_cash`, `.contributions_property`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | +| `k1_tax_basis` | `from`, `to` | the K-1 worksheet on the tax basis (B-36; Schedule K-1 Item L, tax-basis method), beside `k1`'s book figures: `".beginning"`, `.contributions_cash`, `.contributions_property`, `.net_income`, `.other`, `.distributions`, `.ending`; a line the books can't know is `"open"`, with what they know so far as `".known_"`; the example names the figures it shows (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | +| `k1_reconciliation` | `from`, `to` | each member's book capital less tax-basis capital at `to`, by cause (B-36): `".beginning"`, `.built_in_gain_704c`, `.property_without_basis`, `.property_distributed`, `.total`; the example names the members it shows, each with every cause (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | | `vendor_1099_paid` | `from` (January 1 of `to`'s year), `to` | each payee's payments that count toward its 1099, by name: out of the cash and financing accounts (`cash_accounts` required), a card's or payment network's left out (B-19), every payee listed | `ledger-core` `tests/bookspec.rs` | | `vendor_1099` | `payee`; `from` (January 1 of `to`'s year), `to` | one payee's line of the 1099 worksheet (B-19): `status` (`reportable`, `under_threshold`, `not_reportable` or `undetermined`), `threshold`, `paid` (counts toward its 1099), `excluded_card` (a card's or payment network's: Form 1099-K), `undetermined` (through a financing account, its payment method not recorded), `not_reportable` (paid while decided not reportable), every one given | `ledger-core` `tests/bookspec.rs` | | `party_part` | `entry = N`, `party`, no dates | entry N's part for the party (B-35): `resolved`, signed as a line on the party's own account would be (paid or given positive, received negative), the entry's `direction` (`paid`, `received` or `neither`), and what of it moved as money (`cash`) and as property (`in_kind`), every one given | `ledger-core` `tests/bookspec.rs` | @@ -289,6 +313,9 @@ answers is refused, never skipped. | `run` | `event = N`, no dates | what the `run_depreciation` event N posted: `amount` (zero when nothing was due, or the month had its run already), and `caught_up`, what of it is beyond the month's own depreciation (B-15); both listed | `ledger-core` `tests/bookspec.rs` | | `disposal` | `asset`, no dates | what the asset's disposal took (B-15), every figure listed: `final_depreciation` (posted in its own entry), `catch_up_depreciation` and `excess_depreciation` (charged, and credited back, in the disposal entry), `accumulated` (cleared), `book_value`, `gain_loss` (a gain positive) | `ledger-core` `tests/bookspec.rs` | | `depreciation_schedule` | `asset`, `as_of` | the asset's schedule through the day (B-15): each month named (`"YYYY-MM"`) its depreciation, `"0.00"` for a month it doesn't reach; `accumulated` and `book_value` through the day; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | +| `register_row` | `asset`, `as_of` | the asset's row of the fixed asset register (B-15 rules 7 and 8, B-24): `acquired`, `in_service` (`YYYY-MM-DD`), `cost`, `accumulated_scheduled` (the schedule's), `accumulated_posted` (what the books carry), `unposted` (the one less the other), `book_value` (cost less posted), each `"0.00"` once disposed; the example names the figures it shows; an asset acquired after the day is refused | `ledger-core` `tests/bookspec.rs` | +| `register_totals` | `as_of` | the register's totals over the assets held (B-24, B-25): `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`, `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`, `missing_basis` (a count), and how it ties to the books: `unregistered_cost`, `unposted_depreciation`, `unattributed_accumulated`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | +| `asset_basis` | `asset`, no dates | the asset's basis facts and the tax figures they give (B-25): `rule`, `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`, `depreciable_basis`, `built_in_gain_704c` (a loss negative), each `"none"` where it has none, and `sec704c_1c` (`"true"` for a built-in loss, which counts only for the contributor); the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `schedule_plan` | `schedule`, no dates | the schedule's plan (B-16): what it recognizes at each month end named (`"YYYY-MM-DD"`), `"0.00"` at one it doesn't post; `total`, all it recognizes; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `iif_lines` | `as_of` | formats appendix: the file's lines in order, each its fields joined by tabs, without the CR LF that ends each | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | | `iif_changes` | `as_of` | formats appendix: each text the file writes differently from the books, in file order, as `[what, books' text, file's text]` (the example's header gives `what`) | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | @@ -297,7 +324,9 @@ A figure query is a table (`[expect.values]`, or `values = { ... }`); a format query, and `uncategorized_accounts`, an array (`values = [ ... ]`). `period`, `day` and `calendar_year` give dates and a year, `account_names` names, `party_part`'s `direction`, `vendor_1099`'s -`status` and `party_tin_last4` words, not figures. +`status` and `party_tin_last4` words, not figures; `register_row` gives +dates beside its figures, `asset_basis` a rule's name, `"none"` and +`"true"` or `"false"`, and a `k1_tax_basis` line `"open"`, beside them. A command is tried on a copy of the books, as the books' own command does it, and changes nothing checked after it. It shows no values: it is diff --git a/bookspec/examples/assets-723-basis-apart-from-depreciable-basis.toml b/bookspec/examples/assets-723-basis-apart-from-depreciable-basis.toml new file mode 100644 index 0000000..c72c4bd --- /dev/null +++ b/bookspec/examples/assets-723-basis-apart-from-depreciable-basis.toml @@ -0,0 +1,72 @@ +# P3 defect 13, named regression 4 +# (personal_use_contribution_keeps_its_723_basis_apart_from_depreciable_basis). +id = "assets-723-basis-apart-from-depreciable-basis" +title = "Property contributed from personal use: the §723 basis apart from the depreciable basis" +rule = "The partnership's basis in property a partner contributes is the partner's adjusted basis (IRC §723). Its depreciable basis, for property converted from personal use, is the lesser of fair market value and that adjusted basis at conversion (CHOICE recorded by the owner as D-41, the CPA to confirm). The §704(c) built-in gain or loss is fair market value less the §723 basis at contribution; a built-in loss counts only for the contributing partner (IRC §704(c)(1)(C))." +status = "AUTHORITY" +sources = [ + "26 U.S.C. §723", + "Treas. Reg. §1.168(i)-4(b)(1): 'the lesser of its fair market value or its adjusted depreciable basis'", + "IRS Pub. 946 (2025), Property changed from personal use", + "Treas. Reg. §1.704-3(a)(3)(ii)", + "26 U.S.C. §704(c)(1)(C)", +] +tags = ["fixed-assets", "tax-basis", "partnerships", "704c"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-away-from-zero" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# A laptop a member contributes from personal use: worth 1,440.00, held by +# the member at an adjusted basis of 1,688.40. (Nothing rounds here.) +[[setup.assets]] +id = "laptop" +description = "Laptop" +cost = "1440.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1688.40", fmv_at_contribution = "1440.00" } + +[[events]] +date = "2026-01-01" +description = "Laptop contributed at its value" +lines = [ + { account = "1500", side = "debit", amount = "1440.00" }, + { account = "3000", side = "credit", amount = "1440.00" }, +] + +# Hand check: §723 basis 1,688.40 (the member's adjusted basis). +# Depreciable: the lesser of 1,440.00 and 1,688.40 = 1,440.00. Built-in +# gain: 1,440.00 - 1,688.40 = -248.40, a loss (§704(c)(1)(C)). +[[expect]] +query = "asset_basis" +asset = "laptop" +values = { tax_basis_723 = "1688.40", depreciable_basis = "1440.00", built_in_gain_704c = "-248.40", sec704c_1c = "true" } diff --git a/bookspec/examples/assets-a-reversed-run-is-not-posted.toml b/bookspec/examples/assets-a-reversed-run-is-not-posted.toml new file mode 100644 index 0000000..044c8d0 --- /dev/null +++ b/bookspec/examples/assets-a-reversed-run-is-not-posted.toml @@ -0,0 +1,95 @@ +# P3 defect 13, named regression 2 (a_reversed_depreciation_run_is_not_posted), its +# posted figure; a reversal of the reversal reinstates the run. +id = "assets-a-reversed-run-is-not-posted" +title = "A reversed depreciation run is no longer posted; reversing the reversal reinstates it" +rule = "The accumulated depreciation the books carry for an asset counts each run while its entry stands: a reversal takes the run back from the reversal's date, and a reversal of that reversal puts it back from its own date." +status = "AUTHORITY" +sources = [ + "wanderbooks docs/spec/rules.md B-8, B-9 (a correction is an opposite entry; reversing the reversal reinstates the original)", + "wanderbooks docs/spec/rules.md B-24", +] +tags = ["fixed-assets", "depreciation", "reversal"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-away-from-zero" + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# 1,200.00, no salvage, 12 months from 2026-01-01: 100.00 a month. +[[setup.assets]] +id = "printer" +description = "Printer" +cost = "1200.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 + +[[events]] +date = "2026-01-01" +description = "Printer bought" +lines = [ + { account = "1500", side = "debit", amount = "1200.00" }, + { account = "1000", side = "credit", amount = "1200.00" }, +] + +# January's run: 100.00, dated Jan 31. +[[events]] +run_depreciation = { asset = "printer", period = "2026-01" } + +[[events]] +date = "2026-02-01" +description = "Reversal of the January run" +reverses = 2 + +[[events]] +date = "2026-02-02" +description = "Reversal of the reversal" +reverses = 3 + +# Hand check: on Jan 31 the books carry January's run, 100.00; on Feb 1 its +# reversal takes it back, 0.00, while the schedule through Feb 1 is +# 100.00 + 100.00 x 1/28 = 103.57 (all of it unposted); on Feb 2 the +# reversal of the reversal puts the run back, 100.00. +[[expect]] +query = "register_row" +asset = "printer" +as_of = "2026-01-31" +values = { accumulated_posted = "100.00", book_value = "1100.00" } + +[[expect]] +query = "register_row" +asset = "printer" +as_of = "2026-02-01" +values = { accumulated_posted = "0.00", accumulated_scheduled = "103.57", unposted = "103.57", book_value = "1200.00" } + +[[expect]] +query = "register_row" +asset = "printer" +as_of = "2026-02-02" +values = { accumulated_posted = "100.00", book_value = "1100.00" } diff --git a/bookspec/examples/assets-acquired-before-in-service.toml b/bookspec/examples/assets-acquired-before-in-service.toml new file mode 100644 index 0000000..da8c2e1 --- /dev/null +++ b/bookspec/examples/assets-acquired-before-in-service.toml @@ -0,0 +1,83 @@ +# P3 defect 13, named regression 3 +# (an_asset_bought_before_it_is_in_service_is_on_the_register_from_acquisition). +id = "assets-acquired-before-in-service" +title = "An asset is on the register from acquisition and depreciates from its in-service date" +rule = "Depreciation starts on the day the asset is placed in service; the asset is on the register (and the books carry its cost) from the day the company acquired it, the date of the entry that put it on the books." +status = "AUTHORITY" +sources = [ + "ASC 360-10-35-4 (cost allocated over the periods the asset serves)", + "IRS Pub. 946 (2025), Placed in Service: 'ready and available for a specific use' (for tax depreciation, the CPA's)", + "wanderbooks docs/spec/rules.md B-15, B-24", +] +tags = ["fixed-assets", "dates"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-away-from-zero" + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# A kiln bought 2026-03-10 for 3,000.00, placed in service 2026-04-01, 36 +# months, no salvage. +[[setup.assets]] +id = "kiln" +description = "Kiln" +cost = "3000.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-04-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 + +[[events]] +date = "2026-03-10" +description = "Kiln bought" +lines = [ + { account = "1500", side = "debit", amount = "3000.00" }, + { account = "1000", side = "credit", amount = "3000.00" }, +] + +# Hand check: on the register from Mar 10 (its entry's date), nothing +# accrued before Apr 1 (a March charge from the purchase date would have +# been 3,000.00 / 36 x 22/31 = 59.14: none is due), and the register +# carries the cost the books do. +[[expect]] +query = "register_row" +asset = "kiln" +as_of = "2026-03-31" +values = { acquired = "2026-03-10", in_service = "2026-04-01", accumulated_scheduled = "0.00", accumulated_posted = "0.00", book_value = "3000.00" } + +[[expect]] +query = "register_totals" +as_of = "2026-03-31" +values = { total_cost = "3000.00", unregistered_cost = "0.00" } + +# April: 3,000.00 / 36 = 83.333... -> 83.33. +[[expect]] +query = "depreciation_schedule" +asset = "kiln" +as_of = "2026-04-30" +values = { "2026-03" = "0.00", "2026-04" = "83.33" } diff --git a/bookspec/examples/assets-book-value-is-what-the-books-carry.toml b/bookspec/examples/assets-book-value-is-what-the-books-carry.toml new file mode 100644 index 0000000..9edc9d9 --- /dev/null +++ b/bookspec/examples/assets-book-value-is-what-the-books-carry.toml @@ -0,0 +1,86 @@ +# P3 defect 13, named regression 1 (list_assets_book_value_is_what_the_books_carry). +id = "assets-book-value-is-what-the-books-carry" +title = "Book value is cost less the depreciation posted, never the schedule's" +rule = "An asset's carrying amount is its cost less the accumulated depreciation the books have recorded for it; what the depreciation schedule says is due through a date is a second figure, reported beside it, and their difference is depreciation not yet posted." +status = "AUTHORITY" +sources = [ + "ASC 360-10-35-4 (cost less salvage allocated over the periods the asset serves)", + "wanderbooks docs/spec/rules.md B-24 (the register reconciles to the general ledger)", +] +tags = ["fixed-assets", "depreciation", "posted-vs-scheduled"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-away-from-zero" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# A laptop contributed 2026-05-15 at 1,440.00, in service that day, 36 +# months, no salvage: 40.00 a month. +[[setup.assets]] +id = "laptop" +description = "Laptop" +cost = "1440.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-05-15" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 + +[[events]] +date = "2026-05-15" +description = "Laptop contributed" +lines = [ + { account = "1500", side = "debit", amount = "1440.00" }, + { account = "3000", side = "credit", amount = "1440.00" }, +] + +# May's run, dated the month's last day: 40.00 x 17/31 = 21.9355 -> 21.94. +[[events]] +run_depreciation = { asset = "laptop", period = "2026-05" } + +[[expect]] +query = "run" +event = 2 + +[expect.values] +amount = "21.94" +caught_up = "0.00" + +# Hand check: through May 20, 6 days of May, 40.00 x 6/31 = 7.7419 -> +# 7.74 is due; the run is dated May 31, so nothing is posted yet, and the +# book value is the cost (before P3 the tool called 1,432.26 book value). +[[expect]] +query = "register_row" +asset = "laptop" +as_of = "2026-05-20" +values = { accumulated_posted = "0.00", accumulated_scheduled = "7.74", unposted = "7.74", book_value = "1440.00" } + +[[expect]] +query = "register_row" +asset = "laptop" +as_of = "2026-05-31" +values = { accumulated_posted = "21.94", accumulated_scheduled = "21.94", unposted = "0.00", book_value = "1418.06" } diff --git a/bookspec/examples/assets-built-in-gain-fixed-at-contribution.toml b/bookspec/examples/assets-built-in-gain-fixed-at-contribution.toml new file mode 100644 index 0000000..efe5fc4 --- /dev/null +++ b/bookspec/examples/assets-built-in-gain-fixed-at-contribution.toml @@ -0,0 +1,78 @@ +# P3 defect 13, named regression 5 (built_in_gain_is_fixed_at_contribution_not_book_cost). +id = "assets-built-in-gain-fixed-at-contribution" +title = "The §704(c) built-in gain is fixed at contribution, whatever the book cost becomes" +rule = "The built-in gain on contributed property is the excess of its book value (its fair market value) over the contributing partner's adjusted tax basis upon contribution; correcting the book cost later never changes the figure at contribution." +status = "AUTHORITY" +sources = [ + "Treas. Reg. §1.704-3(a)(3)(i)-(ii)", +] +tags = ["fixed-assets", "tax-basis", "704c"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-away-from-zero" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# A press contributed from business use: worth 1,000.00, held by the member +# at an adjusted basis of 400.00. (Nothing rounds here.) +[[setup.assets]] +id = "press" +description = "Press" +cost = "1000.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 +basis = { rule = "carryover_723", contributor_adjusted_basis = "400.00", fmv_at_contribution = "1000.00" } + +[[events]] +date = "2026-01-01" +description = "Press contributed at its value" +lines = [ + { account = "1500", side = "debit", amount = "1000.00" }, + { account = "3000", side = "credit", amount = "1000.00" }, +] + +# Hand check: §723 basis 400.00, depreciated from (carryover); built-in +# gain 1,000.00 - 400.00 = 600.00. +[[expect]] +query = "asset_basis" +asset = "press" +after = 1 +values = { tax_basis_723 = "400.00", depreciable_basis = "400.00", built_in_gain_704c = "600.00" } + +# The book cost corrected to 1,100.00 (costs to bring it to use): the gain +# at contribution stays 1,000.00 - 400.00 = 600.00, not 700.00. +[[changes]] +after = 1 +correct_asset = { asset = "press", cost = "1100.00", reason = "costs to bring it to use" } + +[[expect]] +query = "asset_basis" +asset = "press" +values = { tax_basis_723 = "400.00", fmv_at_contribution = "1000.00", built_in_gain_704c = "600.00" } diff --git a/bookspec/examples/assets-four-contributed-devices.toml b/bookspec/examples/assets-four-contributed-devices.toml new file mode 100644 index 0000000..f79fc5b --- /dev/null +++ b/bookspec/examples/assets-four-contributed-devices.toml @@ -0,0 +1,155 @@ +# P3 defect 13: four devices two members contribute from personal use, each at the +# lower of its value and its basis. +id = "assets-four-contributed-devices" +title = "Each contributed device takes the lower of its value and its basis; gains and losses are per property" +rule = "Each item of contributed property is its own: depreciable basis is the lesser of its fair market value and the contributor's adjusted basis; its built-in gain or loss is its own, and gains and losses of different properties are never netted." +status = "AUTHORITY" +sources = [ + "Treas. Reg. §1.168(i)-4(b)(1)", + "Treas. Reg. §1.704-3(a)(2): section 704(c) applies 'on a property-by-property basis'", + "Treas. Reg. §1.704-3(a)(3)(ii)", + "26 U.S.C. §704(c)(1)(C)", +] +tags = ["fixed-assets", "tax-basis", "partnerships", "704c"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-away-from-zero" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital, Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital, Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.members]] +name = "Tobin Harlow" +capital_account = "3000" +share = "0.5" + +[[setup.members]] +name = "Morgan Ellis" +capital_account = "3001" +share = "0.5" + +# Four devices the two members contribute jointly from personal use, each +# at its value. (Nothing rounds here.) +[[setup.assets]] +id = "laptop" +description = "Laptop" +cost = "1400.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1750.40", fmv_at_contribution = "1400.00", sec704c_method = "traditional" } + +[[setup.assets]] +id = "phone" +description = "Phone" +cost = "900.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1129.99", fmv_at_contribution = "900.00", sec704c_method = "traditional" } + +[[setup.assets]] +id = "camera" +description = "Camera" +cost = "1300.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1080.00", fmv_at_contribution = "1300.00", sec704c_method = "traditional" } + +[[setup.assets]] +id = "monitor" +description = "Monitor" +cost = "500.00" +salvage = "0.00" +life_months = 36 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 1 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "500.00", fmv_at_contribution = "500.00", sec704c_method = "traditional" } + +[[events]] +date = "2026-01-01" +description = "Four devices contributed jointly" +lines = [ + { account = "1500", side = "debit", amount = "1400.00" }, + { account = "1500", side = "debit", amount = "900.00" }, + { account = "1500", side = "debit", amount = "1300.00" }, + { account = "1500", side = "debit", amount = "500.00" }, + { account = "3000", side = "credit", amount = "2050.00" }, + { account = "3001", side = "credit", amount = "2050.00" }, +] + +# Hand check, each device its own: the lesser of value and basis, and the +# value less the basis. +[[expect]] +query = "asset_basis" +asset = "laptop" +values = { tax_basis_723 = "1750.40", depreciable_basis = "1400.00", built_in_gain_704c = "-350.40", sec704c_1c = "true" } + +[[expect]] +query = "asset_basis" +asset = "phone" +values = { tax_basis_723 = "1129.99", depreciable_basis = "900.00", built_in_gain_704c = "-229.99", sec704c_1c = "true" } + +[[expect]] +query = "asset_basis" +asset = "camera" +values = { tax_basis_723 = "1080.00", depreciable_basis = "1080.00", built_in_gain_704c = "220.00", sec704c_1c = "false" } + +[[expect]] +query = "asset_basis" +asset = "monitor" +values = { tax_basis_723 = "500.00", depreciable_basis = "500.00", built_in_gain_704c = "0.00", sec704c_1c = "false" } + +# Totals: cost 1,400.00 + 900.00 + 1,300.00 + 500.00 = 4,100.00; §723 +# 1,750.40 + 1,129.99 + 1,080.00 + 500.00 = 4,460.39; depreciable +# 1,400.00 + 900.00 + 1,080.00 + 500.00 = 3,880.00. The gains and losses +# are each property's own: the register totals none of them. +[[expect]] +query = "register_totals" +as_of = "2026-06-30" +values = { total_cost = "4100.00", total_tax_basis_723 = "4460.39", total_depreciable_basis = "3880.00", missing_basis = "0" } diff --git a/bookspec/examples/partnership-k1-tax-basis-capital.toml b/bookspec/examples/partnership-k1-tax-basis-capital.toml new file mode 100644 index 0000000..7f9b6d0 --- /dev/null +++ b/bookspec/examples/partnership-k1-tax-basis-capital.toml @@ -0,0 +1,172 @@ +# P3 (D-41): the K-1 worksheet reports tax-basis capital beside book capital. +id = "partnership-k1-tax-basis-capital" +title = "Schedule K-1 Item L on the tax basis: property contributed at its adjusted basis" +rule = "Each partner's capital account is reported on the tax basis: capital contributed is cash plus the adjusted tax basis of property contributed; the share of income is as figured for tax; distributions are cash plus the adjusted tax basis of property distributed. Book capital (property at fair market value) differs by the partner's share of the built-in gain or loss at contribution, and by every book-tax difference after; a figure the books cannot know (tax depreciation) is left to the preparer, never guessed." +status = "AUTHORITY" +sources = [ + "Instructions for Form 1065 (2025), Item L, Tax-basis method: Capital contributed during the year; Current year net income (loss); Withdrawals and distributions", + "Instructions for Form 1065 (2020), Item L: 'you must use the tax basis method this year'", + "26 U.S.C. §723", + "Treas. Reg. §1.704-3(a)(3)(ii)", +] +tags = ["partnerships", "k1", "tax-basis", "capital"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "largest-remainder" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital, Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital, Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.members]] +name = "Tobin Harlow" +capital_account = "3000" +share = "0.5" + +[[setup.members]] +name = "Morgan Ellis" +capital_account = "3001" +share = "0.5" + +# Two devices the members contribute jointly from personal use, 24-month +# lives: a laptop worth 1,440.00 (adjusted basis 1,688.40) and a camera +# worth 1,300.00 (adjusted basis 1,080.00). +[[setup.assets]] +id = "laptop" +description = "Laptop" +cost = "1440.00" +salvage = "0.00" +life_months = 24 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 2 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1688.40", fmv_at_contribution = "1440.00", sec704c_method = "traditional" } + +[[setup.assets]] +id = "camera" +description = "Camera" +cost = "1300.00" +salvage = "0.00" +life_months = 24 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" +acquired_by = 2 +basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1080.00", fmv_at_contribution = "1300.00", sec704c_method = "traditional" } + +[[events]] +date = "2026-01-01" +description = "Cash contributed" +lines = [ + { account = "1000", side = "debit", amount = "2000.00" }, + { account = "3000", side = "credit", amount = "1000.00" }, + { account = "3001", side = "credit", amount = "1000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Devices contributed jointly, at their values" +lines = [ + { account = "1500", side = "debit", amount = "1440.00" }, + { account = "1500", side = "debit", amount = "1300.00" }, + { account = "3000", side = "credit", amount = "1370.00" }, + { account = "3001", side = "credit", amount = "1370.00" }, +] + +[[events]] +date = "2026-02-01" +description = "Sale" +lines = [ + { account = "1000", side = "debit", amount = "600.00" }, + { account = "4000", side = "credit", amount = "600.00" }, +] + +# December's runs catch the year up (rules.md B-15): 1,440.00 x 12/24 = +# 720.00 and 1,300.00 x 12/24 = 650.00, dated Dec 31. +[[events]] +run_depreciation = { asset = "laptop", period = "2026-12" } + +[[events]] +run_depreciation = { asset = "camera", period = "2026-12" } + +[[events]] +date = "2026-12-15" +description = "Distributions" +lines = [ + { account = "3000", side = "debit", amount = "300.00" }, + { account = "3001", side = "debit", amount = "300.00" }, + { account = "1000", side = "credit", amount = "600.00" }, +] + +# Hand check, book, each: 1,000.00 cash + 1,370.00 property + (600.00 - +# 720.00 - 650.00) / 2 = -385.00 - 300.00 = 1,685.00. +[[expect]] +query = "k1" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.contributions_cash" = "1000.00", "Tobin Harlow.contributions_property" = "1370.00", "Tobin Harlow.net_income" = "-385.00", "Tobin Harlow.distributions" = "300.00", "Tobin Harlow.ending" = "1685.00" } + +# Tax, each: property at its §723 basis, (1,688.40 + 1,080.00) / 2 = +# 1,384.20; income open (tax depreciation is the preparer's), known so far +# -385.00; ending known so far 1,000.00 + 1,384.20 - 385.00 - 300.00 = +# 1,699.20. +[[expect]] +query = "k1_tax_basis" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.contributions_cash" = "1000.00", "Tobin Harlow.contributions_property" = "1384.20", "Tobin Harlow.net_income" = "open", "Tobin Harlow.known_net_income" = "-385.00", "Tobin Harlow.distributions" = "300.00", "Tobin Harlow.ending" = "open", "Tobin Harlow.known_ending" = "1699.20" } + +# Book less tax: the built-in gain share, ((1,440.00 - 1,688.40) + +# (1,300.00 - 1,080.00)) / 2 = (-248.40 + 220.00) / 2 = -14.20 = +# 1,685.00 - 1,699.20. +[[expect]] +query = "k1_reconciliation" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.built_in_gain_704c" = "-14.20", "Tobin Harlow.property_without_basis" = "0.00", "Tobin Harlow.property_distributed" = "0.00", "Tobin Harlow.total" = "-14.20" } + +[[expect]] +query = "k1_tax_basis" +from = "2026-01-01" +to = "2026-12-31" +values = { "Morgan Ellis.contributions_property" = "1384.20", "Morgan Ellis.known_ending" = "1699.20" } diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs index 4caadd8..10440fb 100644 --- a/crates/ledger-core/src/assets.rs +++ b/crates/ledger-core/src/assets.rs @@ -10,6 +10,16 @@ //! for replay. Depreciation itself is pure math — [`depreciation_schedule`] //! — and postings go through the normal entry machinery, so every //! accounting invariant holds. +//! +//! Two figures of accumulated depreciation, never confused (rules.md B-24): +//! what the schedule says is due through a date +//! ([`accumulated_scheduled_through`]) and what the books carry, the runs +//! posted and not reversed ([`crate::Ledger::accumulated_posted`]). Book +//! value is cost less the posted figure. Two dates: the in-service date starts depreciation; the +//! acquired date (the acquisition entry's) puts the asset on the register. +//! Three tax figures beside book value, each named for what it is +//! ([`Asset::basis_figures`]): the §723 basis, the depreciable basis, and +//! the §704(c) built-in gain or loss at contribution. use crate::money::Steps; use crate::{ @@ -51,14 +61,22 @@ pub enum AssetStatus { } /// A fixed asset on the books. +/// +/// Its serde form is internal and frozen: the books an earlier version +/// wrote are compared with it field for field +/// (`ledger-store/tests/fixtures/old-books`), so the in-service date keeps +/// the name `purchase_date` and the basis the name `tax` there. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Asset { /// Opaque handle. pub id: AssetId, /// What it is, e.g. "2023 Sprinter van". pub description: String, - /// Purchase date. Depreciation starts here. - pub purchase_date: NaiveDate, + /// The day it was placed in service: depreciation starts here (B-15). + /// The day the company acquired it is its acquisition entry's + /// ([`crate::Ledger::asset_acquired_date`]), never later than this. + #[serde(rename = "purchase_date")] + pub in_service_date: NaiveDate, /// Capitalized cost. pub cost: Money, /// Expected residual value at end of life. @@ -84,14 +102,16 @@ pub struct Asset { pub serial: Option, /// The posted entry that put it on the books (its purchase, or a /// member's contribution of it), if linked - /// ([`crate::Ledger::set_asset_acquisition`]). + /// ([`crate::Ledger::set_asset_acquisition`]). Its date is the asset's + /// acquired date. #[serde(default)] pub acquired_by: Option, - /// Its federal tax basis, if recorded ([`crate::Ledger::set_asset_tax_basis`]). - /// Book cost is [`Asset::cost`]; tax basis can differ (a member's - /// contribution carries the member's basis, not its market value). - #[serde(default)] - pub tax: Option, + /// How the company got it and the facts its tax figures follow from, + /// if recorded ([`crate::Ledger::set_asset_basis`]). Book cost is + /// [`Asset::cost`]; the tax figures can differ (a member's contribution + /// carries the member's basis, not its market value). + #[serde(default, rename = "tax")] + pub basis: Option, } /// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]). @@ -104,88 +124,255 @@ pub struct AssetCorrection { /// Useful life in months. pub useful_life_months: u32, /// In-service date (depreciation starts here). - pub purchase_date: NaiveDate, + pub in_service_date: NaiveDate, } -/// Which rule sets an asset's federal tax basis. +/// How the company got an asset, which sets its tax figures +/// ([`crate::kernel::basis_figures`]). +/// +/// Its wire names ([`Self::key`]) are `purchase`, `carryover_723` and +/// `personal_use_conversion`. Books written before P3 named the first two +/// `cost` and `carryover` ([`Self::parse_legacy`]); the serde names stay +/// those (internal and frozen, see [`Asset`]). /// /// ```text /// SOURCE: docs/spec/sources/irs-partnership-basis.md: IRC §1012(a) (cost), /// IRC §723 (property a partner contributes keeps the partner's /// adjusted basis), Treas. Reg. §1.168(i)-4(b) (property converted /// from personal use depreciates from the lesser of its fair market -/// value and its adjusted basis), IRC §704(c)(1)(C) (built-in loss). +/// value and its adjusted basis), IRC §704(c) (built-in gain and +/// loss). /// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TaxBasisRule { - /// Bought by the company: its cost (IRC §1012(a)), the book cost. - Cost, - /// Contributed by a member from business use: the member's adjusted - /// basis carries over (IRC §723). - Carryover, - /// Contributed by a member from personal use: depreciable basis is the - /// lesser of fair market value (the book cost) and the member's - /// adjusted basis (Treas. Reg. §1.168(i)-4(b), with IRC §723). +pub enum BasisRule { + /// Bought by the company: its basis is its cost (IRC §1012(a)), the + /// book cost. + #[serde(rename = "cost")] + Purchase, + /// Contributed by a member from business use: the company's basis is + /// the member's adjusted basis (IRC §723), and depreciation starts from + /// it. + #[serde(rename = "carryover")] + Carryover723, + /// Contributed by a member from personal use: the §723 basis as for + /// carryover, and a depreciable basis of the lesser of fair market + /// value and that basis (Treas. Reg. §1.168(i)-4(b); DECISION D-41, + /// owner 2026-10-20, the CPA to confirm at year end). + #[serde(rename = "personal_use_conversion")] PersonalUseConversion, } -impl TaxBasisRule { - /// Its wire name: `cost`, `carryover`, `personal_use_conversion`. +impl BasisRule { + /// Every rule. + pub const ALL: [Self; 3] = [ + Self::Purchase, + Self::Carryover723, + Self::PersonalUseConversion, + ]; + + /// Its wire name: `purchase`, `carryover_723`, `personal_use_conversion`. pub fn key(self) -> &'static str { match self { - TaxBasisRule::Cost => "cost", - TaxBasisRule::Carryover => "carryover", - TaxBasisRule::PersonalUseConversion => "personal_use_conversion", + BasisRule::Purchase => "purchase", + BasisRule::Carryover723 => "carryover_723", + BasisRule::PersonalUseConversion => "personal_use_conversion", } } - /// The rule named `key`. + /// The rule named `key` (a wire name). pub fn parse(key: &str) -> Option { - [Self::Cost, Self::Carryover, Self::PersonalUseConversion] - .into_iter() - .find(|r| r.key() == key) + Self::ALL.into_iter().find(|r| r.key() == key) + } + + /// The rule a pre-P3 name means: `cost`, `carryover` or + /// `personal_use_conversion`. Only for replaying `asset.tax_basis_set`, + /// reading the projection, and the tools' aliases (one release). + pub fn parse_legacy(key: &str) -> Option { + match key { + "cost" => Some(Self::Purchase), + "carryover" => Some(Self::Carryover723), + "personal_use_conversion" => Some(Self::PersonalUseConversion), + _ => None, + } + } + + /// Whether a member contributed it (§723 and §704(c) apply). + pub fn is_contribution(self) -> bool { + self != Self::Purchase } } -/// An asset's federal tax basis: the rule and, for a member's -/// contribution, the member's adjusted basis at the time. +/// How the company allocates the tax items of a contributed property +/// among the members, IRC §704(c): chosen property by property +/// (Treas. Reg. §1.704-3(a)(2)). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct TaxBasis { - /// The rule. - pub rule: TaxBasisRule, - /// The contributing member's adjusted basis at contribution (cost less - /// any depreciation they took). `None` for [`TaxBasisRule::Cost`]. - pub contributor_basis: Option, +#[serde(rename_all = "snake_case")] +pub enum Sec704cMethod { + /// The traditional method, Treas. Reg. §1.704-3(b): tax items follow + /// the book allocation to the noncontributing members as far as the + /// property's tax items allow (the ceiling rule). + Traditional, + /// The traditional method with curative allocations, Treas. Reg. + /// §1.704-3(c): other tax items correct the ceiling rule's distortion. + TraditionalWithCurative, + /// The remedial allocation method, Treas. Reg. §1.704-3(d). + Remedial, } -impl TaxBasis { - /// Whether the record fits its rule: Cost takes no contributor basis; - /// Carryover and PersonalUseConversion require a non-negative one in - /// cents at most, and no larger than a cost may be - /// (kernel::MAX_LINE_AMOUNT: the register sums them; P0 review round 3, - /// S1). Returns why not. Needs no asset, so a caller can check before - /// registering one. Replay checks only [`Self::check_journaled`]. +impl Sec704cMethod { + /// Every method. + pub const ALL: [Self; 3] = [ + Self::Traditional, + Self::TraditionalWithCurative, + Self::Remedial, + ]; + + /// Its wire name: `traditional`, `traditional_with_curative`, + /// `remedial`. + pub fn key(self) -> &'static str { + match self { + Self::Traditional => "traditional", + Self::TraditionalWithCurative => "traditional_with_curative", + Self::Remedial => "remedial", + } + } + + /// The method named `key`. + pub fn parse(key: &str) -> Option { + Self::ALL.into_iter().find(|m| m.key() == key) + } + + /// The paragraph of Treas. Reg. §1.704-3 that describes it. + pub fn citation(self) -> &'static str { + match self { + Self::Traditional => "Treas. Reg. §1.704-3(b)", + Self::TraditionalWithCurative => "Treas. Reg. §1.704-3(c)", + Self::Remedial => "Treas. Reg. §1.704-3(d)", + } + } +} + +/// How the company got an asset and the facts its tax figures follow +/// from (facts only: the figures are derived, [`Asset::basis_figures`]). +/// +/// Its serde form keeps the pre-P3 shape for a record written then +/// (`{rule, contributor_basis}`): the newer facts are left out when absent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetBasis { + /// The rule. + pub rule: BasisRule, + /// The contributing member's adjusted basis at contribution (cost plus + /// improvements, less depreciation allowed or allowable, IRC + /// §1016(a)). `None` for a purchase. + #[serde(rename = "contributor_basis")] + pub contributor_adjusted_basis: Option, + /// Fair market value at contribution. `None` for a purchase, and for a + /// contribution recorded before P3, whose value is read as the book + /// cost ([`Asset::fmv_at_contribution`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fmv_at_contribution: Option, + /// The §704(c) method chosen for it, if chosen (contributions only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sec704c_method: Option, + /// Who gave the facts, when, and on what (rules.md B-29). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +impl AssetBasis { + /// A purchase's record. + pub fn purchase() -> Self { + Self { + rule: BasisRule::Purchase, + contributor_adjusted_basis: None, + fmv_at_contribution: None, + sec704c_method: None, + source: None, + } + } + + /// A contribution's record: the member's adjusted basis and the value + /// at contribution. + pub fn contribution(rule: BasisRule, adjusted: Decimal, fmv: Decimal) -> Self { + Self { + rule, + contributor_adjusted_basis: Some(adjusted), + fmv_at_contribution: Some(fmv), + sec704c_method: None, + source: None, + } + } + + /// Whether a new record fits its rule (for `asset.basis_set`): a + /// purchase takes no contributor basis, value or method; a + /// contribution needs the member's adjusted basis and the value at + /// contribution, each non-negative, in cents at most, and no larger + /// than a cost may be (kernel::MAX_LINE_AMOUNT); its source, if given, + /// passes Source::check. Returns why not. Needs no asset, so a caller + /// can check before registering one. Replay checks only + /// [`Self::check_journaled`]. pub fn check(&self) -> Result<(), String> { self.check_journaled()?; - match self.contributor_basis { - Some(b) if b > crate::kernel::MAX_LINE_AMOUNT => { - Err("a basis is at most one quadrillion".into()) + if let Some(source) = &self.source { + source.check().map_err(|why| format!("source: {why}"))?; + } + for (what, v) in [ + ("an adjusted basis", self.contributor_adjusted_basis), + ("a fair market value", self.fmv_at_contribution), + ] { + match v { + Some(v) if v.normalize().scale() > 2 => { + return Err(format!("{what} is in cents at most")); + } + Some(v) if v > crate::kernel::MAX_LINE_AMOUNT => { + return Err(format!("{what} is at most one quadrillion")); + } + _ => {} + } + } + Ok(()) + } + + /// The checks every journaled `asset.basis_set` passed, which replay + /// applies (rules.md B-28): the facts fit the rule and none is + /// negative. No bound on size or places. + pub fn check_journaled(&self) -> Result<(), String> { + if self.rule == BasisRule::Purchase { + if self.contributor_adjusted_basis.is_some() + || self.fmv_at_contribution.is_some() + || self.sec704c_method.is_some() + { + return Err("a purchase's basis is its cost: give no contributor basis, value at contribution or §704(c) method".into()); + } + return Ok(()); + } + match (self.contributor_adjusted_basis, self.fmv_at_contribution) { + (None, _) => { + Err("a contribution needs the contributing member's adjusted basis".into()) + } + (_, None) => Err("a contribution needs its fair market value at contribution".into()), + (Some(b), _) if b.is_sign_negative() && !b.is_zero() => { + Err("a basis can't be negative".into()) + } + (_, Some(v)) if v.is_sign_negative() && !v.is_zero() => { + Err("a value can't be negative".into()) } _ => Ok(()), } } /// The checks every journaled `asset.tax_basis_set` passed when it was - /// written (the rule through 2026-10-16): [`Self::check`] without the - /// upper bound. Replay applies exactly these (rules.md B-28). - pub fn check_journaled(&self) -> Result<(), String> { - use TaxBasisRule::*; - match (self.rule, self.contributor_basis) { - (Cost, Some(_)) => { + /// written (the rule through 2026-10-16), for a record with no value + /// at contribution: a purchase takes no contributor basis; a + /// contribution needs one, non-negative, in cents at most. Replay of + /// that event applies exactly these (rules.md B-28). + pub fn check_legacy_journaled(&self) -> Result<(), String> { + use BasisRule::*; + match (self.rule, self.contributor_adjusted_basis) { + (Purchase, Some(_)) => { Err("a purchase's tax basis is its cost; give no contributor basis".into()) } - (Carryover | PersonalUseConversion, None) => { + (Carryover723 | PersonalUseConversion, None) => { Err("a contribution needs the contributing member's adjusted basis".into()) } (_, Some(b)) if b.is_sign_negative() && !b.is_zero() => { @@ -198,34 +385,83 @@ impl TaxBasis { } impl Asset { - /// Its depreciable federal tax basis, by its rule, or `None` if not - /// recorded. + /// Its fair market value at contribution: the value recorded, or for a + /// contribution recorded before P3 (no value recorded), the book cost, + /// which is what that version assumed. `None` for a purchase or when + /// no basis is recorded. + pub fn fmv_at_contribution(&self) -> Option { + let b = self.basis.as_ref()?; + if !b.rule.is_contribution() { + return None; + } + Some(b.fmv_at_contribution.unwrap_or(self.cost.amount)) + } + + /// Whether its value at contribution was recorded as a fact (false for + /// a record from before P3, read as the book cost, and for a purchase). + pub fn fmv_is_recorded(&self) -> bool { + self.basis + .as_ref() + .is_some_and(|b| b.fmv_at_contribution.is_some()) + } + + /// Its three tax figures (kernel::basis_figures), or `None` when no + /// basis is recorded. The figures are at contribution (or purchase): + /// the CPA adjusts them for tax depreciation (IRC §1016(a)(2)), and + /// the §704(c) gain or loss as Treas. Reg. §1.704-3(a)(3)(ii) goes on + /// to reduce it. /// /// ```text /// INVARIANTS: - /// 1. Cost: the book cost. - /// 2. Carryover: the contributor's adjusted basis. - /// 3. PersonalUseConversion: min(book cost, contributor's basis); the - /// book cost of a contribution is its fair market value. + /// 1. A purchase's depreciable basis is the book cost now. + /// 2. A contribution's figures follow its facts: the member's + /// adjusted basis and the value at contribution (the book cost for + /// a record from before P3, which reproduces that version's + /// figures exactly). + /// 3. NEVER panics: a figure past exact arithmetic is refused + /// (TooLargeToCompute). /// ``` - pub fn tax_basis(&self) -> Option { - let t = self.tax?; - Some(match (t.rule, t.contributor_basis) { - (TaxBasisRule::Cost, _) => self.cost.amount, - (TaxBasisRule::Carryover, Some(b)) => b, - (TaxBasisRule::PersonalUseConversion, Some(b)) => b.min(self.cost.amount), - (_, None) => return None, - }) + pub fn basis_figures( + &self, + ) -> Result>, LedgerError> { + let Some(b) = &self.basis else { + return Ok(None); + }; + let adjusted = b.contributor_adjusted_basis.unwrap_or(Decimal::ZERO); + let fmv = self.fmv_at_contribution().unwrap_or(Decimal::ZERO); + if b.rule.is_contribution() && b.contributor_adjusted_basis.is_none() { + // A contribution without its basis never passes a check; one + // can't be reported. + return Ok(None); + } + crate::kernel::basis_figures(b.rule, self.cost.amount, adjusted, fmv) + .map(Some) + .ok_or_else(|| { + LedgerError::TooLargeToCompute(format!("asset {}'s basis figures", self.id)) + }) } - /// Built-in gain (positive) or loss (negative) at contribution, for - /// IRC §704(c): fair market value (the book cost) less the contributor's - /// adjusted basis. `None` for a purchase or when not recorded. A - /// built-in loss counts only for the contributing member - /// (§704(c)(1)(C)). - pub fn built_in_gain(&self) -> Option { - let b = self.tax?.contributor_basis?; - Some(self.cost.amount - b) + /// Its §723 basis (a contribution: the member's adjusted basis), if + /// recorded. + pub fn tax_basis_723(&self) -> Option { + self.basis_figures().ok().flatten()?.tax_basis_723 + } + + /// Its depreciable tax basis, if recorded. + pub fn depreciable_basis(&self) -> Option { + Some(self.basis_figures().ok().flatten()?.depreciable_basis) + } + + /// Its §704(c) built-in gain (+) or loss (−) at contribution, if a + /// contribution is recorded. + pub fn built_in_gain_704c(&self) -> Option { + self.basis_figures().ok().flatten()?.built_in_gain_704c + } + + /// Whether it is contributed property with a built-in loss, which + /// counts only for the contributing member(s): IRC §704(c)(1)(C). + pub fn sec704c_1c(&self) -> bool { + self.built_in_gain_704c().is_some_and(|g| g < Decimal::ZERO) } } @@ -300,8 +536,10 @@ pub fn tag_key(tag: &str) -> String { pub struct NewAsset { /// What it is. pub description: String, - /// Purchase date. - pub purchase_date: NaiveDate, + /// The day it was placed in service: depreciation starts here. The + /// acquired date is its acquisition entry's, linked after + /// (`Ledger::set_asset_acquisition`). + pub in_service_date: NaiveDate, /// Capitalized cost. Must be positive, in the ledger's base currency. pub cost: Money, /// Residual value. Non-negative, less than cost, base currency. @@ -316,6 +554,21 @@ pub struct NewAsset { pub expense_account: AccountId, } +/// One depreciation run the books recorded for an asset +/// (`asset.depreciation_posted`): the month, the entry that posted it, and +/// the amount that entry credited to accumulated depreciation. A run counts +/// while its entry stands: a reversal takes it back, and a reversal of the +/// reversal reinstates it ([`crate::Ledger::accumulated_posted`]). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DepreciationPosting { + /// The month run, "YYYY-MM" as journaled. + pub period: String, + /// The entry that posted it. + pub entry: crate::EntryId, + /// What it credited to accumulated depreciation. + pub amount: Decimal, +} + /// One month of a depreciation schedule. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ScheduleLine { @@ -331,15 +584,15 @@ pub struct ScheduleLine { /// The end of an asset's life, exclusive: its in-service date plus its /// life in months (chrono clamps a day past a month's end, so Jan 31 plus -/// one month is Feb 28). The life is `[purchase_date, life_end)`. +/// one month is Feb 28). The life is `[in_service_date, life_end)`. /// Refused (DateOutOfRange), never a panic, past chrono's last day: replay /// validation keeps every journaled asset's end a date, so this is for /// figures asked of a value no command took. pub fn life_end(asset: &Asset) -> Result { asset - .purchase_date + .in_service_date .checked_add_months(Months::new(asset.useful_life_months)) - .ok_or(LedgerError::DateOutOfRange(asset.purchase_date)) + .ok_or(LedgerError::DateOutOfRange(asset.in_service_date)) } /// One month of a schedule, with its month as numbers (the line names it @@ -350,7 +603,7 @@ struct MonthLine { line: ScheduleLine, } -/// Straight-line depreciation schedule from the purchase month through +/// Straight-line depreciation schedule from the in-service month through /// `through`, inclusive, and through the disposal day of a disposed asset: /// one line per month, the last prorated by days when the schedule stops /// mid-month. @@ -365,8 +618,8 @@ struct MonthLine { /// date prorates that month by days, as partial first and last /// months do. /// INVARIANTS: -/// 1. The life is [purchase, purchase + life months), cut into calendar -/// months (calendar::month_parts). Through day k of a month but the +/// 1. The life is [in service, in service + life months), cut into +/// calendar months (calendar::month_parts). Through day k of a month but the /// last, accumulated is (cost − salvage) / life × (the months held /// before it, each as days held over days in the month, + k / days /// in this month): the monthly rate by days. @@ -426,7 +679,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result, AssetStatus::Active => through, AssetStatus::Disposed { date, .. } => through.min(*date), }; - if stop < asset.purchase_date { + if stop < asset.in_service_date { return Ok(Vec::new()); } let too_large = @@ -443,7 +696,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result, let steps = Steps::of(depreciable, asset.cost.currency).ok_or_else(too_large)?; let mut out = Vec::new(); let (mut before, mut charged) = (0i128, 0i128); - for p in crate::calendar::month_parts(asset.purchase_date, end) { + for p in crate::calendar::month_parts(asset.in_service_date, end) { if p.first > stop { break; } @@ -486,21 +739,31 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result, Ok(out) } -/// Accumulated depreciation through `as_of` (inclusive), exact to the -/// minor unit ([`depreciation_schedule`]). -pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Result { +/// Accumulated depreciation the schedule says is due through `as_of` +/// (inclusive), exact to the minor unit ([`depreciation_schedule`]): the +/// tools' `accumulated_scheduled_through`. What the books carry is +/// [`crate::Ledger::accumulated_posted`]; the two agree once every month +/// due is posted (rules.md B-24). +pub fn accumulated_scheduled_through( + asset: &Asset, + as_of: NaiveDate, +) -> Result { Ok(depreciation_schedule(asset, as_of)? .last() .map(|l| l.accumulated) .unwrap_or(Decimal::ZERO)) } -/// Book value (cost − accumulated depreciation) as of a date. -pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Result { +/// Cost less the depreciation the *schedule* says is due through `as_of`: +/// the schedule's own book value (as [`ScheduleLine::book_value`]), never +/// what the books carry, which is cost less +/// [`crate::Ledger::accumulated_posted`] (the register's and the tools' +/// `book_value`, rules.md B-24). +pub fn scheduled_book_value(asset: &Asset, as_of: NaiveDate) -> Result { asset .cost .amount - .checked_sub(accumulated_depreciation(asset, as_of)?) + .checked_sub(accumulated_scheduled_through(asset, as_of)?) .ok_or_else(|| LedgerError::TooLargeToCompute(format!("asset {}'s book value", asset.id))) } @@ -542,23 +805,44 @@ pub struct PostedRun { /// 2. A run in effect sums to its amount; one reversed, to zero. /// ``` pub fn depreciation_effects(ledger: &Ledger, runs: &[(EntryId, Decimal)]) -> Vec { + run_chain_entries(ledger, runs, &ledger.posted_reversals()) + .into_iter() + .map(|(d, amount)| PostedRun { + year: d.year(), + month: d.month(), + amount, + }) + .collect() +} + +/// Every entry of each run's reversal chain the books carry (posted), in +/// order, with its date and what it adds to what the books carry for the +/// asset: the run its amount, a reversal of it the amount taken away, a +/// reversal of that reversal the amount back, and so on (`reversals`: +/// each posted reversal's original, [`Ledger::posted_reversals`], read +/// once for many runs). The one reading of what the books carry for an +/// asset's depreciation: [`depreciation_effects`] by month, and +/// [`Ledger::accumulated_posted`] by day. +pub(crate) fn run_chain_entries( + ledger: &Ledger, + runs: &[(EntryId, Decimal)], + reversals: &std::collections::HashMap, +) -> Vec<(NaiveDate, Decimal)> { let mut out = Vec::new(); for &(run, amount) in runs { - let chain = ledger.reversal_chain(run); - let from = chain.iter().position(|&e| e == run).unwrap_or(0); - for (i, e) in chain[from..].iter().enumerate() { - let Some(entry) = ledger - .get_entry(*e) + let (mut link, mut adds) = (Some(run), true); + // A chain has no more links than there are reversals: the bound + // is a guard, never reached. + for _ in 0..=reversals.len() { + let Some(id) = link else { break }; + if let Some(e) = ledger + .get_entry(id) .filter(|x| x.status() == EntryStatus::Posted) - else { - continue; - }; - let d = entry.date(); - out.push(PostedRun { - year: d.year(), - month: d.month(), - amount: if i % 2 == 0 { amount } else { -amount }, - }); + { + out.push((e.date(), if adds { amount } else { -amount })); + } + link = reversals.get(&id).copied(); + adds = !adds; } } out @@ -598,7 +882,7 @@ pub fn run_amounts( through: (i32, u32), posted: &[PostedRun], ) -> Result, LedgerError> { - let first = (asset.purchase_date.year(), asset.purchase_date.month()); + let first = (asset.in_service_date.year(), asset.in_service_date.month()); if through < first { return Ok(Vec::new()); } @@ -763,10 +1047,10 @@ pub fn run_amount( posted: &[PostedRun], ) -> Result { let period = format!("{year:04}-{month:02}"); - if month_end_of(year, month)? < asset.purchase_date { + if month_end_of(year, month)? < asset.in_service_date { return Err(LedgerError::InvalidDepreciationPeriod(format!( "{period} ends before asset {} was placed in service on {}", - asset.id, asset.purchase_date + asset.id, asset.in_service_date ))); } catch_up_room(asset, year, month, posted) @@ -838,38 +1122,37 @@ pub struct DisposalAmounts { pub gain_loss: Decimal, } -/// Whether an asset can be disposed of on `date` for `proceeds`: it is -/// active, the proceeds aren't negative, and the date isn't before it was -/// placed in service (a disposal on that date holds it one day, accrual -/// being inclusive, B-15). The checks only: no depreciation is computed, -/// so replaying a journaled disposal never depends on a figure (rules.md -/// B-28). -pub fn check_disposal(asset: &Asset, date: NaiveDate, proceeds: Money) -> Result<(), LedgerError> { +/// Whether an asset can be disposed of for `proceeds`: it is active and +/// the proceeds aren't negative. The checks every journaled +/// disposal passed, so replaying one never depends on a figure (rules.md +/// B-28). The date's floor, the day the company acquired the asset, needs +/// its acquisition entry, so the ledger checks it for new disposals only +/// (`Ledger::dispose_asset`): a disposal may fall on the in-service date +/// (one day held, accrual being inclusive, B-15), or before it once the +/// asset is acquired (nothing accrued yet). +pub fn check_disposal(asset: &Asset, proceeds: Money) -> Result<(), LedgerError> { if asset.status != AssetStatus::Active { return Err(LedgerError::AssetAlreadyDisposed(asset.id)); } if proceeds.amount < Decimal::ZERO { return Err(LedgerError::NegativeProceeds(proceeds.amount)); } - if date < asset.purchase_date { - return Err(LedgerError::DisposalBeforePurchase(date.to_string())); - } Ok(()) } -/// Compute disposal amounts. Pure math; posting is the caller's job. -/// A disposal may fall on the purchase date (one day held, accrual being -/// inclusive, B-15 in docs/spec/rules.md); one before it is an error -/// ([`check_disposal`]). Accumulated depreciation is the schedule through -/// the disposal day, inclusive: what the schedule of the asset, once -/// disposed, shows ([`depreciation_schedule`] INVARIANT 4). +/// Compute disposal amounts by the schedule. Pure math; posting is the +/// caller's job. The checks are [`check_disposal`]'s (the acquired-date +/// floor is the ledger's, for new disposals). Accumulated depreciation is +/// the schedule through the disposal day, inclusive: what the schedule of +/// the asset, once disposed, shows ([`depreciation_schedule`] INVARIANT 4); +/// nothing before the in-service date. pub fn disposal_amounts( asset: &Asset, date: NaiveDate, proceeds: Money, ) -> Result { - check_disposal(asset, date, proceeds)?; - let accumulated = accumulated_depreciation(asset, date)?; + check_disposal(asset, proceeds)?; + let accumulated = accumulated_scheduled_through(asset, date)?; let too_large = || LedgerError::TooLargeToCompute(format!("asset {}'s disposal", asset.id)); let book = asset .cost diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index f640747..a634fb1 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -166,11 +166,35 @@ pub enum LedgerError { why: String, }, - /// An asset's tax basis record doesn't fit its rule (see - /// `Ledger::set_asset_tax_basis`). + /// An asset's basis record doesn't fit its rule (see + /// `Ledger::set_asset_basis`). #[error("bad tax basis: {0}")] InvalidTaxBasis(String), + /// An asset can't be in service before the company acquired it: its + /// in-service date is before its acquisition entry's date. + #[error( + "asset {asset} can't be in service on {in_service}, before it was acquired on {acquired} (its acquisition entry's date): correct the in-service date, or the entry" + )] + InServiceBeforeAcquisition { + /// The asset. + asset: u64, + /// The in-service date given. + in_service: String, + /// The acquisition entry's date. + acquired: String, + }, + + /// A depreciation run that doesn't fit its asset or entry (see + /// `Ledger::check_depreciation_run`). + #[error("bad depreciation run for asset {asset}: {why}")] + InvalidDepreciationRun { + /// The asset. + asset: u64, + /// Why not. + why: String, + }, + /// No party has this id. #[error("unknown party {0}")] UnknownParty(u64), @@ -411,17 +435,24 @@ pub enum LedgerError { #[error("disposal proceeds must not be negative, got {0}")] NegativeProceeds(Decimal), - /// Disposal date must not precede the purchase date. - #[error("disposal date {0} is before the purchase date")] - DisposalBeforePurchase(String), + /// A disposal can't come before the company acquired the asset (its + /// acquisition entry's date, or its in-service date when none is + /// linked). + #[error("disposal date {date} is before the asset was acquired ({acquired})")] + DisposalBeforeAcquisition { + /// The disposal date given. + date: String, + /// The acquired date. + acquired: String, + }, /// The three asset account links must be three distinct accounts. #[error("asset account links must be distinct; account id {0} is linked twice")] DuplicateAssetAccount(u64), - /// purchase_date + useful life overflows the date range: a new asset's - /// last day of depreciation is 9999-12-31 at the latest. - #[error("purchase date plus useful life overflows the calendar")] + /// in-service date + useful life overflows the date range: a new + /// asset's last day of depreciation is 9999-12-31 at the latest. + #[error("in-service date plus useful life overflows the calendar")] AssetDateOverflow, /// A depreciation run for a month that can't be run: not a month, or diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs index f933887..7030763 100644 --- a/crates/ledger-core/src/k1.rs +++ b/crates/ledger-core/src/k1.rs @@ -169,17 +169,9 @@ pub fn k1_detail( } let me = members_equity(ledger, start, end, members)?; let places = ledger.base_currency().allocation_places(); - let receivables = ledger.role_account(&crate::RECEIVABLES); let closings = ledger.closing_entry_ids(); let cancelled = crate::reports::cancelled_in_span(ledger, start, end); - let property_account = |id: AccountId| { - !cash.contains(&id) - && Some(id) != receivables - && ledger - .chart() - .get(id) - .is_some_and(|a| a.account_type == AccountType::Asset) - }; + let property_account = |id: AccountId| is_property_account(ledger, cash, id); let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal { e.lines() .iter() @@ -274,6 +266,18 @@ pub fn k1_detail( Ok(K1Detail { rows, lines }) } +/// Whether an account holds property (INVARIANT 3): an asset account that +/// is neither cash (`cash`) nor receivables. The tax-basis capital +/// worksheet (crate::tax_capital) reads property the same way. +pub(crate) fn is_property_account(ledger: &Ledger, cash: &[AccountId], id: AccountId) -> bool { + !cash.contains(&id) + && Some(id) != ledger.role_account(&crate::RECEIVABLES) + && ledger + .chart() + .get(id) + .is_some_and(|a| a.account_type == AccountType::Asset) +} + /// One entry's property shared among the members' credits in it /// (INVARIANT 4): at most their total credit, in proportion to each /// credit, rounded toward zero to `places` (the base currency's diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 600705d..5331d33 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -48,8 +48,14 @@ //! times the monthly rate within one unit, the last the remainder; //! * a catch-up run brings the books to the schedule without ever carrying //! more than it says at a month end already posted (B-15), and a -//! disposal trues what the books carry up or down to the schedule. +//! disposal trues what the books carry up or down to the schedule; +//! * an asset's basis figures follow from its basis rule and facts: the +//! §723 basis is the contributor's adjusted basis, the depreciable basis +//! the cost, that basis, or the lesser of it and fair market value, and +//! the §704(c) built-in gain is fair market value less the §723 basis +//! (B-25). +use crate::assets::BasisRule; use crate::{AccountType, Side}; use rust_decimal::Decimal; @@ -1402,6 +1408,72 @@ pub fn true_up(target: T, posted: T) -> Option<(T, T)> { columns(target.checked_sub(posted)?) } +/// An asset's three basis figures ([`basis_figures`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BasisFigures { + /// The partnership's basis in contributed property, IRC §723: the + /// contributor's adjusted basis. `None` for a purchase. + pub tax_basis_723: Option, + /// The basis tax depreciation starts from. + pub depreciable_basis: T, + /// Fair market value less the §723 basis at contribution, IRC §704(c): + /// positive a built-in gain, negative a built-in loss. `None` for a + /// purchase (not property contributed). + pub built_in_gain_704c: Option, +} + +/// An asset's basis figures from its rule and facts: `cost` the book cost +/// (a purchase's basis), `adjusted` the contributing member's adjusted +/// basis and `fmv` the fair market value at contribution (both ignored for +/// a purchase). +/// +/// ```text +/// GOAL: the three tax figures of an asset, each named for what it is, +/// from one function the register, the tools and the viewer share. +/// SOURCE: docs/spec/rules.md B-25: IRC §1012(a) (cost); IRC §723 (the +/// contributor's adjusted basis carries over); Treas. Reg. +/// §1.168(i)-4(b)(1) (property converted from personal use: "the +/// lesser of its fair market value or its adjusted depreciable +/// basis"; DECISION D-41, owner 2026-10-20); Treas. Reg. +/// §1.704-3(a)(3)(ii) (built-in gain: "the excess of the +/// property's book value over the contributing partner's adjusted +/// tax basis upon contribution", book value being fair market +/// value at contribution, §1.704-3(a)(3)(i)). +/// INVARIANTS: +/// 1. Purchase: depreciable = cost; no §723 basis, no built-in gain. +/// 2. Contribution: tax_basis_723 = adjusted; built_in_gain_704c = +/// fmv − adjusted, negative exactly when adjusted > fmv (a built-in +/// loss, IRC §704(c)(1)(C)). +/// 3. Carryover723: depreciable = adjusted. +/// 4. PersonalUseConversion: depreciable = min(fmv, adjusted), at most +/// each of them and equal to one. +/// 5. NEVER wraps: None when a figure can't be represented. +/// ``` +pub fn basis_figures( + rule: BasisRule, + cost: T, + adjusted: T, + fmv: T, +) -> Option> { + Some(match rule { + BasisRule::Purchase => BasisFigures { + tax_basis_723: None, + depreciable_basis: cost, + built_in_gain_704c: None, + }, + BasisRule::Carryover723 => BasisFigures { + tax_basis_723: Some(adjusted), + depreciable_basis: adjusted, + built_in_gain_704c: Some(fmv.checked_sub(adjusted)?), + }, + BasisRule::PersonalUseConversion => BasisFigures { + tax_basis_723: Some(adjusted), + depreciable_basis: if fmv < adjusted { fmv } else { adjusted }, + built_in_gain_704c: Some(fmv.checked_sub(adjusted)?), + }, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1750,6 +1822,46 @@ mod tests { assert_eq!(true_up(5_500i64, 5_500), Some((0, 0))); } + /// basis_figures INVARIANTS 1-4, by hand: a 1,440.00 laptop the member + /// holds at 1,688.40 (personal use): §723 1,688.40, depreciable + /// 1,440.00, built-in loss 248.40; as business property, depreciable + /// 1,688.40; a purchase at 900.00: depreciable 900.00 and nothing else. + #[test] + fn basis_figures_by_rule() { + let (fmv, adj) = (144_000i64, 168_840i64); + let p = basis_figures(BasisRule::PersonalUseConversion, fmv, adj, fmv).unwrap(); + assert_eq!( + p, + BasisFigures { + tax_basis_723: Some(adj), + depreciable_basis: fmv, + built_in_gain_704c: Some(-24_840), + } + ); + let c = basis_figures(BasisRule::Carryover723, fmv, adj, fmv).unwrap(); + assert_eq!(c.depreciable_basis, adj); + assert_eq!(c.built_in_gain_704c, Some(-24_840)); + // Value above basis: depreciable is the basis, a built-in gain. + let g = basis_figures(BasisRule::PersonalUseConversion, 1000, 400, 1000).unwrap(); + assert_eq!( + (g.depreciable_basis, g.built_in_gain_704c), + (400, Some(600)) + ); + let b = basis_figures(BasisRule::Purchase, 90_000i64, 7, 8).unwrap(); + assert_eq!( + b, + BasisFigures { + tax_basis_723: None, + depreciable_basis: 90_000, + built_in_gain_704c: None, + } + ); + assert_eq!( + basis_figures(BasisRule::Carryover723, 0, i64::MAX, i64::MIN), + None + ); + } + #[test] fn max_line_amount_is_one_quadrillion() { assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64)); diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 99b5e24..2ba1254 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -270,6 +270,10 @@ pub struct Ledger { /// Every asset tag ever held ([`assets::tag_key`] → the asset that /// held it): a tag never moves to another asset. asset_tags: std::collections::BTreeMap, + /// Each asset's depreciation runs (`asset.depreciation_posted`), in + /// the order recorded: what the books carry for it + /// ([`Ledger::accumulated_posted`]). + asset_runs: std::collections::BTreeMap>, /// The party directory ([`crate::parties`]). parties: std::collections::BTreeMap, next_party_id: crate::PartyId, @@ -325,6 +329,7 @@ impl Ledger { bill_payments: HashMap::new(), roles: std::collections::BTreeMap::new(), asset_tags: std::collections::BTreeMap::new(), + asset_runs: std::collections::BTreeMap::new(), parties: std::collections::BTreeMap::new(), next_party_id: 1, entry_parties: HashMap::new(), @@ -720,6 +725,7 @@ impl Ledger { ("bill_payments", self.bill_payments == other.bill_payments), ("roles", self.roles == other.roles), ("asset_tags", self.asset_tags == other.asset_tags), + ("asset_runs", self.asset_runs == other.asset_runs), ("parties", self.parties == other.parties), ("next_party_id", self.next_party_id == other.next_party_id), ("entry_parties", self.entry_parties == other.entry_parties), @@ -1927,10 +1933,10 @@ impl Ledger { // its exclusive end) inside the calendar the books keep, so every // month of the life is a month the books can post. if strict { - crate::calendar::check(new.purchase_date)?; + crate::calendar::check(new.in_service_date)?; } let end = new - .purchase_date + .in_service_date .checked_add_months(Months::new(new.useful_life_months)); if !end .is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar)) @@ -1981,7 +1987,7 @@ impl Ledger { Asset { id, description: new.description, - purchase_date: new.purchase_date, + in_service_date: new.in_service_date, cost: new.cost, salvage_value: new.salvage_value, useful_life_months: new.useful_life_months, @@ -1993,7 +1999,7 @@ impl Ledger { tag: None, serial: None, acquired_by: None, - tax: None, + basis: None, }, ); Ok(id) @@ -2092,19 +2098,25 @@ impl Ledger { } /// Link an asset to the posted entry that put it on the books: its - /// purchase, or a member's contribution of it. + /// purchase, or a member's contribution of it. The entry's date is the + /// asset's acquired date ([`Self::asset_acquired_date`]). /// /// ```text /// GOAL: from an asset, its source document and capital entry; from - /// the entry, the asset. - /// SOURCE: DECISION (owner, 2026-10-15). + /// the entry, the asset; and the day the company acquired it. + /// SOURCE: DECISION (owner, 2026-10-15); rules.md B-15 (P3, settled in + /// design 2026-10-20: an asset's acquired date comes from the + /// entry it is linked to). /// INVARIANTS: /// 1. The linked entry is posted, is not a reversal, has not been /// reversed, and debits the asset's cost account. - /// 2. A refusal MUST leave the books unchanged. + /// 2. The linked entry is dated on or before the asset's in-service + /// date, and on or before its disposal: the register then holds + /// the asset exactly while the books carry its cost (B-24). + /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: - /// 1. IF the asset or entry is unknown, or the entry fails 1: SHOULD - /// refuse with AcquisitionEntryMismatch (or UnknownAsset / + /// 1. IF the asset or entry is unknown, or the entry fails 1 or 2: + /// SHOULD refuse with AcquisitionEntryMismatch (or UnknownAsset / /// UnknownEntry). /// 2. IF a later correction reverses the linked entry: link the /// replacement (a reversed link stays as history in the journal). @@ -2119,6 +2131,19 @@ impl Ledger { Ok(()) } + /// [`Self::set_asset_acquisition`] with the checks every journaled + /// `asset.acquisition_linked` passed when it was written (INVARIANT 1, + /// without the dates of INVARIANT 2), for replay only (rules.md B-28). + pub fn replay_set_asset_acquisition( + &mut self, + id: AssetId, + entry: EntryId, + ) -> Result<(), LedgerError> { + self.check_asset_acquisition_journaled(id, entry)?; + self.assets.get_mut(&id).expect("checked").acquired_by = Some(entry); + Ok(()) + } + /// Restore a link recorded earlier (a projection read-back): it was /// checked when made, and a later correction (reversing that entry) /// doesn't undo it; it only becomes a link to fix, by linking the @@ -2149,8 +2174,67 @@ impl Ledger { .is_some_and(|e| self.entries.values().any(|x| x.reversal_of() == Some(e))) } - /// The validation behind [`Self::set_asset_acquisition`]. + /// The day the company acquired an asset: its acquisition entry's date, + /// or its in-service date when no entry is linked (every asset + /// registered before P3 without a link), never later than the + /// in-service date. `None` for an unknown asset. + /// + /// ```text + /// GOAL: the register holds an asset from the day the books carry its + /// cost, which can be before it serves (B-24). + /// SOURCE: rules.md B-15, B-24; P3, settled in design (2026-10-20): + /// "an asset's acquired date comes from the entry it is linked + /// to". + /// INVARIANTS: + /// 1. acquired = min(in_service_date, the linked entry's date); the + /// in-service date with no link. One source of truth: no stored + /// date to disagree with the entry. + /// ``` + pub fn asset_acquired_date(&self, id: AssetId) -> Option { + let a = self.assets.get(&id)?; + Some( + a.acquired_by + .and_then(|e| self.entries.get(&e)) + .map_or(a.in_service_date, |e| e.date().min(a.in_service_date)), + ) + } + + /// The validation behind [`Self::set_asset_acquisition`]: INVARIANTS 1 + /// and 2. pub fn check_asset_acquisition(&self, id: AssetId, entry: EntryId) -> Result<(), LedgerError> { + self.check_asset_acquisition_journaled(id, entry)?; + let asset = self.assets.get(&id).expect("checked"); + let date = self.entries.get(&entry).expect("checked").date(); + let why = if date > asset.in_service_date { + Some(format!( + "it is dated {date}, after the asset's in-service date {} (an asset is acquired before it serves: correct the in-service date with books.correct_asset, or the entry)", + asset.in_service_date + )) + } else { + match &asset.status { + AssetStatus::Disposed { date: gone, .. } if date > *gone => Some(format!( + "it is dated {date}, after the asset's disposal on {gone}" + )), + _ => None, + } + }; + match why { + Some(why) => Err(LedgerError::AcquisitionEntryMismatch { + asset: id, + entry, + why, + }), + None => Ok(()), + } + } + + /// INVARIANT 1 of [`Self::set_asset_acquisition`]: the checks every + /// journaled `asset.acquisition_linked` passed. + fn check_asset_acquisition_journaled( + &self, + id: AssetId, + entry: EntryId, + ) -> Result<(), LedgerError> { let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; let e = self .entries @@ -2221,69 +2305,109 @@ impl Ledger { Ok(()) } - /// Record an asset's federal tax basis: the rule, and for a member's - /// contribution the member's adjusted basis. Book figures don't change. + /// Record how the company got an asset and the facts its tax figures + /// follow from ([`assets::AssetBasis`]): the rule, and for a member's + /// contribution the member's adjusted basis, the fair market value at + /// contribution, the §704(c) method if chosen, and the source. Book + /// figures don't change. /// /// ```text - /// GOAL: the register carries tax basis beside book value, so book and - /// tax never get confused and the CPA sees both. + /// GOAL: the register carries the tax figures beside book value, each + /// named for what it is, so book and tax never get confused and + /// the CPA sees both. /// SOURCE: docs/spec/rules.md B-25; docs/spec/sources/ - /// irs-partnership-basis.md. + /// irs-partnership-basis.md; DECISION D-41 (owner, 2026-10-20). /// INVARIANTS: - /// 1. Cost takes no contributor basis; Carryover and - /// PersonalUseConversion require one. - /// 2. A contributor basis is non-negative, in cents at most. + /// 1. The record passes AssetBasis::check: a purchase takes no + /// contribution facts; a contribution needs the adjusted basis and + /// the value at contribution, each non-negative, in cents, at + /// most one quadrillion. + /// 2. Only facts are kept: the figures are derived when read + /// (Asset::basis_figures), under the rule in force. /// 3. A refusal MUST leave the books unchanged. /// SCENARIOS: - /// 1. IF the asset is unknown, or 1 or 2 fails: SHOULD refuse with + /// 1. IF the asset is unknown, or 1 fails: SHOULD refuse with /// UnknownAsset or InvalidTaxBasis. /// 2. IF set again: the new record replaces the old (the journal /// keeps both). /// ``` - pub fn set_asset_tax_basis( + pub fn set_asset_basis( &mut self, id: AssetId, - tax: assets::TaxBasis, + basis: assets::AssetBasis, ) -> Result<(), LedgerError> { - self.check_asset_tax_basis(id, tax)?; - self.assets.get_mut(&id).expect("checked").tax = Some(tax); + self.check_asset_basis(id, &basis)?; + self.assets.get_mut(&id).expect("checked").basis = Some(basis); Ok(()) } - /// [`Self::set_asset_tax_basis`] with the checks every journaled - /// `asset.tax_basis_set` passed when it was written - /// (TaxBasis::check_journaled: no upper bound), for replay only - /// (rules.md B-28). Never for new work. - pub fn replay_set_asset_tax_basis( + /// The validation behind [`Self::set_asset_basis`]. + pub fn check_asset_basis( + &self, + id: AssetId, + basis: &assets::AssetBasis, + ) -> Result<(), LedgerError> { + if !self.assets.contains_key(&id) { + return Err(LedgerError::UnknownAsset(id)); + } + basis.check().map_err(LedgerError::InvalidTaxBasis) + } + + /// [`Self::set_asset_basis`] with the checks every journaled + /// `asset.basis_set` passed (AssetBasis::check_journaled: the facts fit + /// the rule, none negative, no bound on size), for replay and the + /// projection's read-back only (rules.md B-28). The figures the line + /// carries are for its readers: replay never recomputes them, so a + /// later change to how a figure is derived never refuses books an + /// earlier version wrote. Never for new work. + pub fn replay_set_asset_basis( &mut self, id: AssetId, - tax: assets::TaxBasis, + basis: assets::AssetBasis, ) -> Result<(), LedgerError> { let asset = self .assets .get_mut(&id) .ok_or(LedgerError::UnknownAsset(id))?; - tax.check_journaled() + basis + .check_journaled() .map_err(LedgerError::InvalidTaxBasis)?; - asset.tax = Some(tax); + asset.basis = Some(basis); Ok(()) } - /// The validation behind [`Self::set_asset_tax_basis`]. - pub fn check_asset_tax_basis( - &self, + /// Replay a pre-P3 `asset.tax_basis_set` (rule and contributor basis, + /// no value at contribution, which reads as the book cost): the checks + /// it passed when written (AssetBasis::check_legacy_journaled), for + /// replay and the projection's read-back only (rules.md B-28). Never for + /// new work. + pub fn replay_set_asset_tax_basis( + &mut self, id: AssetId, - tax: assets::TaxBasis, + rule: assets::BasisRule, + contributor_basis: Option, ) -> Result<(), LedgerError> { - if !self.assets.contains_key(&id) { - return Err(LedgerError::UnknownAsset(id)); - } - tax.check().map_err(LedgerError::InvalidTaxBasis) + let asset = self + .assets + .get_mut(&id) + .ok_or(LedgerError::UnknownAsset(id))?; + let basis = assets::AssetBasis { + rule, + contributor_adjusted_basis: contributor_basis, + fmv_at_contribution: None, + sec704c_method: None, + source: None, + }; + basis + .check_legacy_journaled() + .map_err(LedgerError::InvalidTaxBasis)?; + asset.basis = Some(basis); + Ok(()) } /// Correct a registered asset's economics (cost, salvage, useful life, /// in-service date) when it was registered wrong. Its accounts, tag, - /// serial, links, and tax basis record stay. + /// serial, links, and basis record stay. /// /// ```text /// GOAL: fix a registration mistake without re-registering (which would @@ -2295,7 +2419,12 @@ impl Ledger { /// 2. Only an active asset is corrected; a store also refuses once any /// depreciation for it is posted (the posted entries would disagree /// with the schedule; correct with entries instead). - /// 3. A refusal MUST leave the books unchanged. + /// 3. The in-service date is never before the linked acquisition + /// entry's date (set_asset_acquisition INVARIANT 2). + /// 4. A contribution's value at contribution and built-in gain stay + /// as recorded: they were fixed when it was contributed (Treas. + /// Reg. §1.704-3(a)(3)(ii)), whatever the book cost becomes. + /// 5. A refusal MUST leave the books unchanged. /// ``` pub fn correct_asset( &mut self, @@ -2328,16 +2457,31 @@ impl Ledger { a.cost = fix.cost; a.salvage_value = fix.salvage_value; a.useful_life_months = fix.useful_life_months; - a.purchase_date = fix.purchase_date; + a.in_service_date = fix.in_service_date; } - /// The validation behind [`Self::correct_asset`]. + /// The validation behind [`Self::correct_asset`] (INVARIANTS 1-3). pub fn check_correct_asset( &self, id: AssetId, fix: &assets::AssetCorrection, ) -> Result<(), LedgerError> { - self.validate_new_asset(&self.corrected_asset(id, fix)?) + self.validate_new_asset(&self.corrected_asset(id, fix)?)?; + if let Some(linked) = self + .assets + .get(&id) + .and_then(|a| a.acquired_by) + .and_then(|e| self.entries.get(&e)) + .map(|e| e.date()) + && fix.in_service_date < linked + { + return Err(LedgerError::InServiceBeforeAcquisition { + asset: id, + in_service: fix.in_service_date.to_string(), + acquired: linked.to_string(), + }); + } + Ok(()) } /// The active asset `id` as `fix` would leave it, as a registration to @@ -2353,7 +2497,7 @@ impl Ledger { } Ok(NewAsset { description: a.description.clone(), - purchase_date: fix.purchase_date, + in_service_date: fix.in_service_date, cost: fix.cost, salvage_value: fix.salvage_value, useful_life_months: fix.useful_life_months, @@ -2381,6 +2525,232 @@ impl Ledger { .expect("some number is free") } + /// Whether a depreciation run can be recorded, for a run a store + /// journals now: the asset is known; the period is a month, "YYYY-MM"; + /// the amount is positive and in cents; and the entry is posted and + /// credits the asset's accumulated depreciation account by exactly the + /// amount. + /// + /// ```text + /// GOAL: a run the books record is the entry that posted it, no more + /// and no less, so accumulated_posted is what the books carry. + /// SOURCE: rules.md B-24; B-28 (new commands are bounded; replay checks + /// only what every journaled run passed: + /// record_depreciation_run). + /// ``` + pub fn check_depreciation_run( + &self, + asset: AssetId, + period: &str, + entry: EntryId, + amount: Decimal, + ) -> Result<(), LedgerError> { + let a = self + .assets + .get(&asset) + .ok_or(LedgerError::UnknownAsset(asset))?; + let bad = |why: String| LedgerError::InvalidDepreciationRun { asset, why }; + let month_ok = period.len() == 7 + && period.as_bytes()[4] == b'-' + && NaiveDate::parse_from_str(&format!("{period}-01"), "%Y-%m-%d").is_ok(); + if !month_ok { + return Err(bad(format!("period {period:?} isn't a month (YYYY-MM)"))); + } + if amount <= Decimal::ZERO || amount.normalize().scale() > 2 { + return Err(bad(format!( + "amount {amount} isn't a positive number of cents" + ))); + } + let e = self + .entries + .get(&entry) + .ok_or(LedgerError::UnknownEntry(entry))?; + if e.status() != crate::EntryStatus::Posted { + return Err(bad(format!("entry {entry} isn't posted"))); + } + let credited = kernel::net( + e.lines() + .iter() + .filter(|l| l.account == a.accum_depr_account) + .map(|l| (kernel::flip(l.side), l.amount.amount)), + ) + .ok_or_else(|| bad("its entry's lines are too large".into()))?; + if credited != amount { + return Err(bad(format!( + "entry {entry} credits accumulated depreciation {credited}, not {amount}" + ))); + } + Ok(()) + } + + /// Record a depreciation run (`asset.depreciation_posted`). Checks only + /// that the asset and the entry exist: every run an earlier version + /// journaled replays, whatever its period or amount (rules.md B-28; a + /// store checks new runs first, [`Self::check_depreciation_run`]). + pub fn record_depreciation_run( + &mut self, + asset: AssetId, + period: &str, + entry: EntryId, + amount: Decimal, + ) -> Result<(), LedgerError> { + if !self.assets.contains_key(&asset) { + return Err(LedgerError::UnknownAsset(asset)); + } + if !self.entries.contains_key(&entry) { + return Err(LedgerError::UnknownEntry(entry)); + } + self.asset_runs + .entry(asset) + .or_default() + .push(assets::DepreciationPosting { + period: period.to_string(), + entry, + amount, + }); + Ok(()) + } + + /// An asset's depreciation runs, in the order recorded. + pub fn depreciation_runs(&self, asset: AssetId) -> &[assets::DepreciationPosting] { + self.asset_runs.get(&asset).map_or(&[], Vec::as_slice) + } + + /// Each posted reversal's original: original entry → its reversal (an + /// entry is reversed at most once, B-10). Read once for many runs + /// ([`assets::depreciation_effects`], [`Self::accumulated_posted_all`]), + /// never per run: the viewer asks it of every asset. + pub(crate) fn posted_reversals(&self) -> HashMap { + self.posted_entries() + .filter_map(|e| e.reversal_of().map(|orig| (orig, e.id()))) + .collect() + } + + /// An asset's runs as the catch-up rule reads them: (entry, amount), + /// in the order recorded ([`assets::depreciation_effects`]). + pub fn depreciation_run_entries(&self, asset: AssetId) -> Vec<(EntryId, Decimal)> { + self.depreciation_runs(asset) + .iter() + .map(|r| (r.entry, r.amount)) + .collect() + } + + /// What the books carry for an asset's depreciation, entry by entry, + /// each in its own month: every run and every reversal of one + /// ([`assets::depreciation_effects`] of its runs). + pub fn depreciation_effects_of(&self, asset: AssetId) -> Vec { + assets::depreciation_effects(self, &self.depreciation_run_entries(asset)) + } + + /// Accumulated depreciation the books carry for an asset on `as_of`: + /// what its runs carry through that day, entry by entry + /// ([`assets::depreciation_effects`]' reading, by day); zero once it + /// is disposed (the disposal entry cleared it). + /// + /// ```text + /// GOAL: book value is cost less what the books carry, never the + /// schedule's figure. + /// SOURCE: rules.md B-24 (the register reconciles to the general + /// ledger); B-8, B-9, B-10 (a reversal undoes what it reverses, + /// and reversing the reversal reinstates it); B-15 (what the + /// catch-up rule reads, the same entries). + /// INVARIANTS: + /// 1. Disposed on or before as_of: 0. + /// 2. Otherwise Σ, over the asset's runs and every entry of each + /// one's reversal chain the books carry (assets::run_chain_entries: + /// the run +amount, its reversal −amount, that reversal's + /// reversal +amount, ...), of those dated on or before as_of. So + /// it moves exactly as the trial balance does, even for a + /// reversal dated before its run, and agrees with + /// depreciation_effects summed through any month's end. + /// 3. NEVER panics: a sum past exact arithmetic is refused + /// (TooLargeToCompute). + /// ``` + pub fn accumulated_posted( + &self, + id: AssetId, + as_of: NaiveDate, + ) -> Result { + self.accumulated_posted_with(id, as_of, &self.posted_reversals()) + } + + /// [`Self::accumulated_posted`] for every registered asset, the + /// reversal links read once (for the register and the viewer). + pub fn accumulated_posted_all( + &self, + as_of: NaiveDate, + ) -> Result, LedgerError> { + let reversals = self.posted_reversals(); + self.assets + .keys() + .map(|&id| Ok((id, self.accumulated_posted_with(id, as_of, &reversals)?))) + .collect() + } + + fn accumulated_posted_with( + &self, + id: AssetId, + as_of: NaiveDate, + reversals: &HashMap, + ) -> Result { + let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; + if matches!(asset.status, AssetStatus::Disposed { date, .. } if date <= as_of) { + return Ok(Decimal::ZERO); + } + self.carried_between(id, NaiveDate::MIN, as_of, reversals) + } + + /// Depreciation the books posted for an asset through entries dated in + /// `[from, to]` (its runs and their reversals, as + /// [`Self::accumulated_posted`] counts them, the disposal aside): the + /// period's depreciation expense from its runs. + pub fn depreciation_posted_between( + &self, + id: AssetId, + from: NaiveDate, + to: NaiveDate, + ) -> Result { + if !self.assets.contains_key(&id) { + return Err(LedgerError::UnknownAsset(id)); + } + self.carried_between(id, from, to, &self.posted_reversals()) + } + + /// What the entries of the asset's runs' chains dated in `[from, to]` + /// carry, summed exactly. + fn carried_between( + &self, + id: AssetId, + from: NaiveDate, + to: NaiveDate, + reversals: &HashMap, + ) -> Result { + let too_large = + || LedgerError::TooLargeToCompute(format!("asset {id}'s posted depreciation")); + assets::run_chain_entries(self, &self.depreciation_run_entries(id), reversals) + .into_iter() + .filter(|(d, _)| *d >= from && *d <= to) + .try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(a)) + .ok_or_else(too_large) + } + + /// The run the books carry for an asset's month, if any: the first one + /// recorded for `period` whose entry is in effect ([`Self::in_effect`]: + /// posted, and an even number of posted reversals after it). A month + /// whose run was reversed has none, so it can be run again; one whose + /// reversal was itself reversed has its run back. At most one run of a + /// month is in effect: a store refuses re-instating a run whose month + /// was run again. + pub fn live_depreciation_run( + &self, + id: AssetId, + period: &str, + ) -> Option<&assets::DepreciationPosting> { + self.depreciation_runs(id) + .iter() + .find(|run| run.period == period && self.in_effect(run.entry)) + } + /// Mark an asset disposed. The disposal *entry* is posted through the /// normal machinery by the caller; this records the lifecycle change. /// @@ -2439,11 +2809,22 @@ impl Ledger { }); } let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; - // Activeness, proceeds sign, and date order; no depreciation is - // computed, so a journaled disposal replays even where the figure - // can't be (rules.md B-28). - assets::check_disposal(asset, date, proceeds)?; + // Activeness and the proceeds' sign; no depreciation is computed, + // so a journaled disposal replays even where the figure can't be + // (rules.md B-28). + assets::check_disposal(asset, proceeds)?; if strict { + // Never before the company acquired it. A command's bound, not + // replay's: the acquired date needs the link, which a + // projection read-back restores after the disposal (rules.md + // B-28). + let acquired = self.asset_acquired_date(id).expect("known asset"); + if date < acquired { + return Err(LedgerError::DisposalBeforeAcquisition { + date: date.to_string(), + acquired: acquired.to_string(), + }); + } // Proceeds are a line of the disposal entry, and its date one // the books keep (P0 review round 3, S1). if !kernel::Amount::in_line_range(proceeds.amount) { diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 68c0793..1883e97 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -44,6 +44,7 @@ pub mod schedules; pub mod settings; pub mod shares; pub mod tax; +pub mod tax_capital; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use arap::{ @@ -51,10 +52,11 @@ pub use arap::{ Vendor, VendorId, }; pub use assets::{ - Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, - PostedRun, RunAmount, ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, - book_value, catch_up_room, check_disposal, depreciation_effects, depreciation_schedule, - disposal_amounts, life_end, run_amount, run_amounts, + Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod, + DepreciationPosting, DisposalAmounts, NewAsset, PostedRun, RunAmount, ScheduleLine, + Sec704cMethod, accumulated_scheduled_through, catch_up_room, check_disposal, + depreciation_effects, depreciation_schedule, disposal_amounts, life_end, run_amount, + run_amounts, scheduled_book_value, }; pub use cashflow::{ CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection, diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index 7717f11..ec10366 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -1675,6 +1675,50 @@ fn true_up_reaches_the_target() { kani::cover!(down > 0, "depreciation past the day, credited back"); } +/// `kernel::basis_figures` (rules.md B-25), for every rule and every +/// cost, adjusted basis and value within the bounds: a purchase +/// depreciates from cost with no §723 basis or built-in gain; a +/// contribution's §723 basis is the adjusted basis and its built-in gain +/// the value less it (negative exactly when the basis exceeds the value); +/// carryover depreciates from the basis, personal use from the lesser of +/// value and basis (at most each, equal to one); no overflow is reachable. +#[kani::proof] +fn basis_figures_laws() { + use crate::assets::BasisRule; + let r: u8 = kani::any(); + kani::assume(r < 3); + let rule = match r { + 0 => BasisRule::Purchase, + 1 => BasisRule::Carryover723, + _ => BasisRule::PersonalUseConversion, + }; + let (cost, adjusted, fmv) = (any_amount(), any_amount(), any_amount()); + kani::assume(cost > 0 && adjusted >= 0 && fmv >= 0); + let f = kernel::basis_figures(rule, cost, adjusted, fmv).expect("no overflow"); + match rule { + BasisRule::Purchase => { + assert_eq!(f.depreciable_basis, cost); + assert!(f.tax_basis_723.is_none() && f.built_in_gain_704c.is_none()); + } + _ => { + assert_eq!(f.tax_basis_723, Some(adjusted)); + let g = f.built_in_gain_704c.expect("a contribution's gain"); + assert_eq!(g, fmv - adjusted); + assert_eq!(g < 0, adjusted > fmv); + if rule == BasisRule::Carryover723 { + assert_eq!(f.depreciable_basis, adjusted); + } else { + assert!(f.depreciable_basis <= adjusted && f.depreciable_basis <= fmv); + assert!(f.depreciable_basis == adjusted || f.depreciable_basis == fmv); + } + } + } + kani::cover!( + rule == BasisRule::PersonalUseConversion && fmv < adjusted, + "personal use worth less than its basis: a built-in loss" + ); +} + // --------------------------------------------------------------------------- // The checker itself // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index af6ec43..7f592e7 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -1166,30 +1166,53 @@ pub struct AssetRegisterRow { pub description: String, /// The manufacturer's serial number, if recorded. pub serial: Option, - /// Placed in service (depreciation starts). - pub in_service: NaiveDate, + /// The day the company acquired it: its acquisition entry's date, or + /// the in-service date when none is linked + /// (`Ledger::asset_acquired_date`). On the register from this day. + pub acquired_date: NaiveDate, + /// Placed in service: depreciation starts. + pub in_service_date: NaiveDate, /// Capitalized cost. pub cost: Decimal, /// Salvage value. pub salvage: Decimal, /// Useful life in months. pub useful_life_months: u32, - /// Accumulated depreciation by its schedule through the date (zero once - /// disposed: it left the books with the asset). - pub accumulated: Decimal, - /// Cost less accumulated (zero once disposed). + /// Accumulated depreciation its schedule says is due through the date + /// (zero once disposed: it left the books with the asset). + pub accumulated_scheduled: Decimal, + /// Accumulated depreciation the books carry for it on the date: its + /// runs posted, less what reversals took back (`Ledger:: + /// accumulated_posted`; zero once disposed). + pub accumulated_posted: Decimal, + /// What the books carry: cost less accumulated_posted (zero once + /// disposed). pub book_value: Decimal, /// Disposal date and proceeds, if disposed by the date. pub disposed: Option<(NaiveDate, Decimal)>, - /// The federal tax basis rule, if recorded. - pub tax_rule: Option, + /// How the company got it (the basis rule), if recorded. + pub basis_rule: Option, /// The contributing member's adjusted basis (a contribution only). - pub contributor_basis: Option, - /// The depreciable federal tax basis (`Asset::tax_basis`), if recorded. - pub tax_basis: Option, - /// Built-in gain (+) or loss (−) at contribution, IRC §704(c) - /// (`Asset::built_in_gain`). - pub built_in_gain: Option, + pub contributor_adjusted_basis: Option, + /// Fair market value at contribution (a contribution only): recorded, + /// or the book cost for a record from before P3 (`fmv_recorded` + /// false). + pub fmv_at_contribution: Option, + /// Whether the value at contribution was recorded as a fact. + pub fmv_recorded: bool, + /// The §723 basis at contribution (Asset::basis_figures). + pub tax_basis_723: Option, + /// The depreciable tax basis at contribution or purchase. + pub depreciable_basis: Option, + /// The §704(c) built-in gain (+) or loss (−) at contribution. + pub built_in_gain_704c: Option, + /// A built-in loss: it counts only for the contributing member(s), + /// IRC §704(c)(1)(C). + pub sec704c_1c: bool, + /// The §704(c) method chosen for it, if chosen. + pub sec704c_method: Option, + /// Who gave its basis facts, when, and on what. + pub basis_source: Option, } /// The fixed asset register as of a date, reconciled to the general ledger. @@ -1197,20 +1220,24 @@ pub struct AssetRegisterRow { pub struct AssetRegister { /// The date. pub as_of: NaiveDate, - /// Every asset registered by the date, in registration order; disposed + /// Every asset acquired by the date, in registration order; disposed /// ones carry their disposal and no cost on the books. pub rows: Vec, /// Total cost of the assets still held. pub total_cost: Decimal, /// Their accumulated depreciation by schedule. - pub total_accumulated: Decimal, - /// Their book value. + pub total_accumulated_scheduled: Decimal, + /// Their accumulated depreciation the books carry from runs. + pub total_accumulated_posted: Decimal, + /// Their book value: total cost less total_accumulated_posted. pub total_book_value: Decimal, /// The total depreciable tax basis of the assets still held that have - /// one recorded. - pub total_tax_basis: Decimal, - /// How many assets still held have no tax basis recorded. - pub missing_tax_basis: usize, + /// a basis recorded. + pub total_depreciable_basis: Decimal, + /// The total §723 basis of the contributed assets still held. + pub total_tax_basis_723: Decimal, + /// How many assets still held have no basis recorded. + pub missing_basis: usize, /// The general ledger's balance (debit positive) of the assets' cost /// accounts on the date. pub ledger_cost: Decimal, @@ -1224,7 +1251,15 @@ impl AssetRegister { /// don't carry yet (run `run_depreciation`); negative if the books /// carry more. pub fn unposted_depreciation(&self) -> Decimal { - self.total_accumulated - self.ledger_accumulated + self.total_accumulated_scheduled - self.ledger_accumulated + } + + /// Accumulated depreciation in the accounts that no held asset's runs + /// account for: zero when every depreciation in those accounts came + /// from a run; otherwise an entry made by hand (or a run reversed + /// after its asset left the books) is in them. + pub fn unattributed_accumulated(&self) -> Decimal { + self.ledger_accumulated - self.total_accumulated_posted } /// Cost in the ledger's cost accounts that no held asset accounts for @@ -1235,31 +1270,106 @@ impl AssetRegister { } } +/// One asset's register row as of `as_of` (fixed_asset_register +/// INVARIANTS 1, 2 and 5), or `None` before it was acquired: the one +/// derivation the register and books.list_assets share. `posted` is +/// `Ledger::accumulated_posted_all(as_of)`. +pub fn register_row( + ledger: &Ledger, + a: &crate::Asset, + as_of: NaiveDate, + posted: &std::collections::BTreeMap, +) -> Result, LedgerError> { + use crate::AssetStatus; + let acquired = ledger + .asset_acquired_date(a.id) + .unwrap_or(a.in_service_date); + if acquired > as_of { + return Ok(None); + } + let disposed = match &a.status { + AssetStatus::Disposed { date, proceeds } if *date <= as_of => { + Some((*date, proceeds.amount)) + } + _ => None, + }; + let (sched, post) = if disposed.is_some() { + (Decimal::ZERO, Decimal::ZERO) + } else { + ( + crate::assets::accumulated_scheduled_through(a, as_of)?, + posted.get(&a.id).copied().unwrap_or(Decimal::ZERO), + ) + }; + let figures = a.basis_figures()?; + let book_value = if disposed.is_some() { + Decimal::ZERO + } else { + a.cost + .amount + .checked_sub(post) + .ok_or_else(|| LedgerError::TooLargeToCompute(format!("asset {}'s book value", a.id)))? + }; + Ok(Some(AssetRegisterRow { + asset_id: a.id, + tag: a.tag.clone(), + description: a.description.clone(), + serial: a.serial.clone(), + acquired_date: acquired, + in_service_date: a.in_service_date, + cost: a.cost.amount, + salvage: a.salvage_value.amount, + useful_life_months: a.useful_life_months, + accumulated_scheduled: sched, + accumulated_posted: post, + book_value, + disposed, + basis_rule: a.basis.as_ref().map(|b| b.rule), + contributor_adjusted_basis: a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis), + fmv_at_contribution: a.fmv_at_contribution(), + fmv_recorded: a.fmv_is_recorded(), + tax_basis_723: figures.and_then(|f| f.tax_basis_723), + depreciable_basis: figures.map(|f| f.depreciable_basis), + built_in_gain_704c: figures.and_then(|f| f.built_in_gain_704c), + sec704c_1c: figures + .and_then(|f| f.built_in_gain_704c) + .is_some_and(|g| g < Decimal::ZERO), + sec704c_method: a.basis.as_ref().and_then(|b| b.sec704c_method), + basis_source: a.basis.as_ref().and_then(|b| b.source.clone()), + })) +} + /// The fixed asset register as of a date. /// /// ```text /// GOAL: one list of everything the company owns as a fixed asset: what, -/// which label, cost, depreciation, book value, and whether the -/// books agree. -/// SOURCE: docs/spec/rules.md B-15 (depreciation); DECISION (owner, -/// 2026-10-15): a fully featured asset register. +/// which label, cost, depreciation due and posted, book value, tax +/// figures, and whether the books agree. +/// SOURCE: docs/spec/rules.md B-15 (depreciation), B-24 (the register +/// reconciles to the books), B-25 (tax figures beside book); +/// DECISION (owner, 2026-10-15): a fully featured asset register. /// INVARIANTS: -/// 1. Rows: the assets registered on or before as_of, in registration -/// order; an asset disposed on or before as_of shows its disposal, -/// zero accumulated and book value, and counts in no total. -/// 2. accumulated is the asset's schedule through as_of -/// (assets::accumulated_depreciation); book_value = cost − -/// accumulated. -/// 3. Totals are the sums over the held assets, exactly; tax basis -/// totals over those with one recorded, and missing_tax_basis counts -/// the rest. -/// 5. Each row's tax fields are the asset's (Asset::tax_basis, -/// Asset::built_in_gain), never re-derived here. +/// 1. Rows: the assets acquired on or before as_of +/// (Ledger::asset_acquired_date), in registration order; an asset +/// disposed on or before as_of shows its disposal, zero accumulated +/// and book value, and counts in no total. +/// 2. accumulated_scheduled is the asset's schedule through as_of +/// (assets::accumulated_scheduled_through); accumulated_posted is +/// what the books carry (Ledger::accumulated_posted); book_value = +/// cost − accumulated_posted: the books' figure, never the +/// schedule's. +/// 3. Totals are the sums over the held assets, exactly; the +/// depreciable basis total over those with a basis recorded (and +/// missing_basis counts the rest); the §723 total over the +/// contributions. /// 4. ledger_cost and ledger_accumulated are the general ledger's /// balances on as_of of the distinct cost and accumulated /// depreciation accounts of the registered assets, so -/// unposted_depreciation and unregistered_cost are zero exactly when -/// register and books agree. +/// unregistered_cost, unposted_depreciation and +/// unattributed_accumulated are zero exactly when register and books +/// agree. +/// 5. Each row's tax fields are the asset's (Asset::basis_figures), +/// never re-derived here. /// 6. NEVER panics: a contributor basis is as journaled, of any size /// (rules.md B-28), and a total past exact arithmetic, or a schedule /// that can't be computed, is refused (TooLargeToCompute, @@ -1270,66 +1380,44 @@ pub fn fixed_asset_register( as_of: NaiveDate, ) -> Result { let too_large = |what: &str| LedgerError::TooLargeToCompute(format!("the register's {what}")); - use crate::AssetStatus; + let posted = ledger.accumulated_posted_all(as_of)?; let mut rows = Vec::new(); - let (mut cost, mut accumulated) = (Decimal::ZERO, Decimal::ZERO); - let (mut tax_total, mut tax_missing) = (Decimal::ZERO, 0usize); + let (mut cost, mut scheduled, mut posted_total) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); + let (mut depreciable_total, mut total_723, mut missing) = + (Decimal::ZERO, Decimal::ZERO, 0usize); let mut cost_accounts = std::collections::BTreeSet::new(); let mut accum_accounts = std::collections::BTreeSet::new(); - for a in ledger - .registered_assets() - .filter(|a| a.purchase_date <= as_of) - { + for a in ledger.registered_assets() { + let Some(row) = register_row(ledger, a, as_of, &posted)? else { + continue; + }; cost_accounts.insert(a.cost_account); accum_accounts.insert(a.accum_depr_account); - let disposed = match &a.status { - AssetStatus::Disposed { date, proceeds } if *date <= as_of => { - Some((*date, proceeds.amount)) - } - _ => None, - }; - let acc = if disposed.is_some() { - Decimal::ZERO - } else { - crate::assets::accumulated_depreciation(a, as_of)? - }; - if disposed.is_none() { + if row.disposed.is_none() { cost = cost - .checked_add(a.cost.amount) + .checked_add(row.cost) .ok_or_else(|| too_large("cost"))?; - accumulated = accumulated - .checked_add(acc) + scheduled = scheduled + .checked_add(row.accumulated_scheduled) .ok_or_else(|| too_large("accumulated depreciation"))?; - match a.tax_basis() { - Some(t) => { - tax_total = tax_total - .checked_add(t) - .ok_or_else(|| too_large("tax basis"))? + posted_total = posted_total + .checked_add(row.accumulated_posted) + .ok_or_else(|| too_large("posted depreciation"))?; + match row.depreciable_basis { + Some(b) => { + depreciable_total = depreciable_total + .checked_add(b) + .ok_or_else(|| too_large("depreciable basis"))?; + if let Some(b) = row.tax_basis_723 { + total_723 = total_723 + .checked_add(b) + .ok_or_else(|| too_large("§723 basis"))?; + } } - None => tax_missing += 1, + None => missing += 1, } } - rows.push(AssetRegisterRow { - asset_id: a.id, - tag: a.tag.clone(), - description: a.description.clone(), - serial: a.serial.clone(), - in_service: a.purchase_date, - cost: a.cost.amount, - salvage: a.salvage_value.amount, - useful_life_months: a.useful_life_months, - accumulated: acc, - book_value: if disposed.is_some() { - Decimal::ZERO - } else { - a.cost.amount - acc - }, - disposed, - tax_rule: a.tax.map(|t| t.rule), - contributor_basis: a.tax.and_then(|t| t.contributor_basis), - tax_basis: a.tax_basis(), - built_in_gain: a.built_in_gain(), - }); + rows.push(row); } let tb = trial_balance_as_of(ledger, as_of); let net = |id: crate::AccountId| -> Decimal { @@ -1347,10 +1435,14 @@ pub fn fixed_asset_register( as_of, rows, total_cost: cost, - total_accumulated: accumulated, - total_book_value: cost - accumulated, - total_tax_basis: tax_total, - missing_tax_basis: tax_missing, + total_accumulated_scheduled: scheduled, + total_accumulated_posted: posted_total, + total_book_value: cost + .checked_sub(posted_total) + .ok_or_else(|| too_large("book value"))?, + total_depreciable_basis: depreciable_total, + total_tax_basis_723: total_723, + missing_basis: missing, ledger_cost: cost_accounts.iter().map(|&id| net(id)).sum(), ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::(), }) diff --git a/crates/ledger-core/src/tax_capital.rs b/crates/ledger-core/src/tax_capital.rs new file mode 100644 index 0000000..4fca8f7 --- /dev/null +++ b/crates/ledger-core/src/tax_capital.rs @@ -0,0 +1,565 @@ +//! Tax-basis capital: each member's capital account as Schedule K-1 Item L +//! reports it under the tax-basis method, beside the book figures of the +//! K-1 worksheet ([`crate::k1`]), with the reconciliation between them. +//! +//! ```text +//! GOAL: hand the CPA, per member, Item L on the tax basis as far as the +//! books know it, every figure the books can't know named with what +//! the books do have, and how book capital reconciles to it. +//! SOURCE: Instructions for Form 1065 (2025), Item L, "Tax-basis method" +//! (docs/spec/sources/irs-partnerships.md): capital contributed is +//! "the amount of cash plus the adjusted tax basis of all property +//! contributed"; net income is the partner's share "as figured for +//! tax purposes"; distributions are "the amount of cash plus the +//! adjusted tax basis of all property distributed"; required since +//! tax year 2020 (Instructions for Form 1065 (2020): "you must use +//! the tax basis method this year"). IRC §723 (the basis of +//! contributed property); Treas. Reg. §1.704-3(a)(3)(ii) (built-in +//! gain); DECISION D-41 (owner, 2026-10-20). +//! INVARIANTS: +//! 1. The book column is the K-1 worksheet's row (k1::k1_detail), +//! unchanged. +//! 2. Contributions: cash as on the book side; property at its §723 +//! basis. Within an entry, the property's built-in gain (its value at +//! contribution less its §723 basis, Asset::basis_figures) and the +//! book amount of property without a basis in the books are shared +//! among the members as its book value is (by their property in it, +//! to the cent, odd cents by largest remainder, ties to the earlier +//! member), so a member's tax property = book property − their share +//! of the built-in gain − their share of the property without a basis. +//! 3. A figure the books can't know is never guessed: its line carries +//! `known`, the part the books have, and `open`, each missing item +//! with the book amount it replaces. A line with no open item is the +//! tax figure. +//! - Net income: tax depreciation of every fixed asset in service +//! in the span (MACRS, and for contributed property the §704(c) +//! method's allocation) is the CPA's, as is the tax gain on a +//! disposal; `known` is the book share, the open items say so. +//! - Property distributed: its adjusted tax basis is the CPA's. +//! - A change in book capital the books can't explain ("other"). +//! - Beginning capital from earlier years whose tax figures were open. +//! 4. Each row: ending.known = beginning.known + contributions_cash + +//! contributions_property.known + net_income.known + other.known − +//! distributions.known, exactly; ending.open is every open item of +//! the row. +//! 5. Reconciliation: book ending − tax ending.known = (book beginning − +//! tax beginning.known) + built-in gain share + property without a +//! basis (book) − property distributed (book), exactly. +//! SCENARIOS: +//! 1. IF two members jointly contribute a laptop worth 1,440.00 whose +//! adjusted basis is 1,688.40, and nothing else happens: +//! - SHOULD give each book property 720.00, tax property 844.20, a +//! built-in gain share of −124.20, every line complete +//! BECAUSE Item L counts property at its adjusted tax basis (§723). +//! 2. IF a fixed asset is in service during the span: +//! - SHOULD leave the tax net income open, with the book depreciation +//! posted in the span +//! BECAUSE tax depreciation is the CPA's (rules.md B-15, B-25). +//! ``` + +use crate::k1::{K1Kind, K1Row, k1_detail}; +use crate::reports::MemberCapital; +use crate::{AccountId, AssetId, AssetStatus, EntryId, Ledger, LedgerError, Side}; +use chrono::NaiveDate; +use rust_decimal::Decimal; + +/// A figure the books can't know, with what they do have. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Open { + /// Beginning capital: earlier years left a tax figure open, so last + /// year's Item L ending (on the tax basis) is the CPA's return's. + PriorYears, + /// Property contributed in `entry` with no tax basis in the books (no + /// registered asset linked to the entry holds a contribution basis for + /// it): this member's share of its book amount, which the tax figure + /// replaces with the property's adjusted basis. + PropertyBasis { + /// The contribution entry. + entry: EntryId, + /// This member's share of that property at book. + book: Decimal, + }, + /// The assets linked to `entry` are valued at more than the property + /// the entry contributed: the books disagree with themselves, so none + /// of its property's tax basis is taken from them. + LinkedValueExceedsEntry { + /// The contribution entry. + entry: EntryId, + /// The linked assets' values at contribution. + linked: Decimal, + /// The property the entry debits. + property: Decimal, + }, + /// Property distributed in `entry`: this member's share of its book + /// amount, which the tax figure replaces with the company's adjusted + /// tax basis in it. + PropertyDistribution { + /// The distribution entry. + entry: EntryId, + /// This member's share of that property at book. + book: Decimal, + }, + /// Tax depreciation of the fixed assets in service in the span (MACRS, + /// IRC §168; for contributed property allocated by its §704(c) method, + /// Treas. Reg. §1.704-3): the CPA's. The book depreciation posted in + /// the span, all members, and the part on contributed property. + TaxDepreciation { + /// Book depreciation posted in the span from runs. + book: Decimal, + /// The part of it on contributed property. + contributed_book: Decimal, + }, + /// A fixed asset disposed in the span: its tax gain or loss (from its + /// adjusted tax basis) is the CPA's. + Disposal { + /// The asset. + asset: AssetId, + /// The proceeds. + proceeds: Decimal, + }, + /// A change in book capital the books can't explain (the statement of + /// members' equity's "other"): its tax treatment is the CPA's. + OtherChange { + /// The book amount. + book: Decimal, + }, +} + +/// One Item L line on the tax basis ([`Open`]: INVARIANT 3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaxLine { + /// The figure as far as the books know it. + pub known: Decimal, + /// What the CPA must still settle; empty when `known` is the tax + /// figure. + pub open: Vec, +} + +impl TaxLine { + fn complete(known: Decimal) -> Self { + Self { + known, + open: Vec::new(), + } + } + + /// The tax figure, when the books know all of it. + pub fn tax(&self) -> Option { + self.open.is_empty().then_some(self.known) + } +} + +/// Book capital less tax-basis capital, by cause (INVARIANT 5). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Reconciliation { + /// Beginning capital, book less tax (as far as known). + pub beginning: Decimal, + /// This member's share of the §704(c) built-in gain (+) or loss (−) at + /// contribution of the property they contributed in the span. + pub built_in_gain_704c: Decimal, + /// Their share of property contributed with no tax basis in the books, + /// at book. + pub property_without_basis: Decimal, + /// Their share of property distributed, at book (it lowers book + /// capital by its book amount, tax capital by its basis). + pub property_distributed: Decimal, +} + +impl Reconciliation { + /// Book ending less tax ending (as far as known). + pub fn total(&self) -> Option { + self.beginning + .checked_add(self.built_in_gain_704c)? + .checked_add(self.property_without_basis)? + .checked_sub(self.property_distributed) + } +} + +/// One member's capital, book beside tax basis. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaxCapitalRow { + /// The K-1 worksheet's book row (INVARIANT 1). + pub book: K1Row, + /// Beginning capital on the tax basis. + pub beginning: TaxLine, + /// Cash contributed (the same on both bases). + pub contributions_cash: Decimal, + /// Property contributed, at its adjusted tax basis (§723). + pub contributions_property: TaxLine, + /// Share of net income (loss) as figured for tax purposes. + pub net_income: TaxLine, + /// Other increase (decrease). + pub other: TaxLine, + /// Withdrawals and distributions: cash plus the adjusted tax basis of + /// property distributed. + pub distributions: TaxLine, + /// Ending capital on the tax basis (INVARIANT 4). + pub ending: TaxLine, + /// Book less tax, by cause (INVARIANT 5). + pub reconciliation: Reconciliation, +} + +/// The tax-basis capital worksheet for a span. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaxCapital { + /// One row per member, in order. + pub rows: Vec, + /// The items open for every member (tax depreciation, disposals). + pub open: Vec, +} + +/// Tax-basis capital for `start..=end` (INVARIANTS 1-5). `cash` are the +/// company's cash accounts, as for [`crate::k1::k1_worksheet`]. +pub fn tax_basis_capital( + ledger: &Ledger, + start: NaiveDate, + end: NaiveDate, + members: &[MemberCapital], + cash: &[AccountId], +) -> Result { + // Beginning: the same worksheet from the books' first entry to the day + // before, whose ending is this span's beginning. + let first = ledger.posted_entries().map(|e| e.date()).min(); + let prior = match (first, start.pred_opt()) { + (Some(first), Some(before)) if first <= before => { + Some(span(ledger, first, before, members, cash, None)?) + } + _ => None, + }; + span(ledger, start, end, members, cash, prior.as_ref()) +} + +fn too_large() -> LedgerError { + LedgerError::TooLargeToCompute("a member's tax-basis capital".into()) +} + +/// `whole` shared in proportion to `weights` (non-negative), to the cent: +/// each part rounded toward zero, leftover cents by largest remainder, +/// ties to the earlier part; a negative whole is shared as its magnitude +/// and negated. All zero when the weights sum to zero. +fn share(whole: Decimal, weights: &[Decimal]) -> Result, LedgerError> { + use rust_decimal::RoundingStrategy::ToZero; + let total = weights + .iter() + .try_fold(Decimal::ZERO, |t, w| t.checked_add(*w)) + .ok_or_else(too_large)?; + if whole.is_zero() || total <= Decimal::ZERO { + return Ok(vec![Decimal::ZERO; weights.len()]); + } + let magnitude = whole.abs(); + let exact: Vec = weights + .iter() + .map(|w| magnitude.checked_mul(*w).and_then(|x| x.checked_div(total))) + .collect::>() + .ok_or_else(too_large)?; + let mut parts: Vec = exact + .iter() + .map(|x| x.round_dp_with_strategy(2, ToZero)) + .collect(); + let remainders: Vec = exact.iter().zip(&parts).map(|(x, p)| x - p).collect(); + crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2)) + .ok_or_else(too_large)?; + if whole < Decimal::ZERO { + for p in &mut parts { + *p = -*p; + } + } + Ok(parts) +} + +/// What one contribution entry's property is on the tax basis. +struct EntryProperty { + /// Σ built-in gain of the linked assets with a contribution basis. + built_in_gain: Decimal, + /// The property's book amount with no basis in the books. + without_basis: Decimal, + /// The linked assets' values exceed the entry's property (linked, + /// property). + mismatch: Option<(Decimal, Decimal)>, +} + +fn entry_property( + ledger: &Ledger, + entry: &crate::JournalEntry, + cash: &[AccountId], +) -> Result { + let pool = entry + .lines() + .iter() + .filter(|l| { + l.side == Side::Debit && crate::k1::is_property_account(ledger, cash, l.account) + }) + .try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount)) + .ok_or_else(too_large)?; + let (mut linked, mut big, mut unknown) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); + for a in ledger + .registered_assets() + .filter(|a| a.acquired_by == Some(entry.id())) + { + let figures = a.basis_figures()?; + let contributed = figures.and_then(|f| f.built_in_gain_704c); + // Its book amount in the entry: the value at contribution, which + // the books recorded (D-41), or the book cost. + let value = a.fmv_at_contribution().unwrap_or(a.cost.amount); + linked = linked.checked_add(value).ok_or_else(too_large)?; + match contributed { + Some(g) => big = big.checked_add(g).ok_or_else(too_large)?, + // No basis, or a purchase's basis on a contribution: unknown. + None => unknown = unknown.checked_add(value).ok_or_else(too_large)?, + } + } + if linked > pool { + return Ok(EntryProperty { + built_in_gain: Decimal::ZERO, + without_basis: pool, + mismatch: Some((linked, pool)), + }); + } + let rest = pool - linked; + Ok(EntryProperty { + built_in_gain: big, + without_basis: unknown.checked_add(rest).ok_or_else(too_large)?, + mismatch: None, + }) +} + +fn span( + ledger: &Ledger, + start: NaiveDate, + end: NaiveDate, + members: &[MemberCapital], + cash: &[AccountId], + prior: Option<&TaxCapital>, +) -> Result { + let detail = k1_detail(ledger, start, end, members, cash)?; + let n = members.len(); + let mut big = vec![Decimal::ZERO; n]; + let mut without = vec![Decimal::ZERO; n]; + let mut distributed = vec![Decimal::ZERO; n]; + let mut prop_open: Vec> = vec![Vec::new(); n]; + let mut dist_open: Vec> = vec![Vec::new(); n]; + // Entries in journal order, each once. + let mut entries: Vec = Vec::new(); + for l in &detail.lines { + if !entries.contains(&l.entry) { + entries.push(l.entry); + } + } + for id in entries { + let e = ledger.get_entry(id).ok_or(LedgerError::UnknownEntry(id))?; + let lines = |kind| -> Vec { + (0..n) + .map(|i| { + detail + .lines + .iter() + .filter(|l| l.entry == id && l.member == i && l.kind == kind) + .map(|l| l.amount) + .sum() + }) + .collect() + }; + let takes = lines(K1Kind::PropertyContribution); + if takes.iter().any(|t| !t.is_zero()) { + let p = entry_property(ledger, e, cash)?; + let g = share(p.built_in_gain, &takes)?; + let w = share(p.without_basis, &takes)?; + for i in (0..n).filter(|&i| !takes[i].is_zero()) { + big[i] = big[i].checked_add(g[i]).ok_or_else(too_large)?; + without[i] = without[i].checked_add(w[i]).ok_or_else(too_large)?; + if let Some((linked, property)) = p.mismatch { + prop_open[i].push(Open::LinkedValueExceedsEntry { + entry: id, + linked, + property, + }); + } + if !w[i].is_zero() { + prop_open[i].push(Open::PropertyBasis { + entry: id, + book: w[i], + }); + } + } + } + // Distributions are negative lines; weights are what each took. + let taken: Vec = lines(K1Kind::Distribution) + .into_iter() + .map(|d| -d) + .collect(); + if taken.iter().any(|t| *t > Decimal::ZERO) { + let property = e + .lines() + .iter() + .filter(|l| { + l.side == Side::Credit + && crate::k1::is_property_account(ledger, cash, l.account) + }) + .try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount)) + .ok_or_else(too_large)?; + let total_taken = taken + .iter() + .try_fold(Decimal::ZERO, |t, x| t.checked_add(*x)) + .ok_or_else(too_large)?; + let property = property.min(total_taken); + if property > Decimal::ZERO { + let parts = share(property, &taken)?; + for i in (0..n).filter(|&i| !parts[i].is_zero()) { + distributed[i] = distributed[i].checked_add(parts[i]).ok_or_else(too_large)?; + dist_open[i].push(Open::PropertyDistribution { + entry: id, + book: parts[i], + }); + } + } + } + } + // Worksheet-level open items: tax depreciation while a fixed asset is + // in service in the span; each disposal in it. + let mut open = Vec::new(); + let (mut book_depr, mut contributed_depr) = (Decimal::ZERO, Decimal::ZERO); + let mut in_service = false; + for a in ledger.registered_assets() { + let gone_before = matches!(a.status, AssetStatus::Disposed { date, .. } if date < start); + if a.in_service_date <= end && !gone_before { + in_service = true; + } + let d = ledger.depreciation_posted_between(a.id, start, end)?; + book_depr = book_depr.checked_add(d).ok_or_else(too_large)?; + if a.basis.as_ref().is_some_and(|b| b.rule.is_contribution()) { + contributed_depr = contributed_depr.checked_add(d).ok_or_else(too_large)?; + } + } + if in_service || !book_depr.is_zero() { + open.push(Open::TaxDepreciation { + book: book_depr, + contributed_book: contributed_depr, + }); + } + for a in ledger.registered_assets() { + if let AssetStatus::Disposed { date, proceeds } = &a.status + && *date >= start + && *date <= end + { + open.push(Open::Disposal { + asset: a.id, + proceeds: proceeds.amount, + }); + } + } + let mut rows = Vec::with_capacity(n); + for (i, book) in detail.rows.into_iter().enumerate() { + let beginning = match prior.and_then(|p| p.rows.get(i)) { + Some(p) => TaxLine { + known: p.ending.known, + open: if p.ending.open.is_empty() { + Vec::new() + } else { + vec![Open::PriorYears] + }, + }, + None => TaxLine::complete(book.beginning), + }; + let contributions_property = TaxLine { + known: book + .contributions_property + .checked_sub(big[i]) + .and_then(|x| x.checked_sub(without[i])) + .ok_or_else(too_large)?, + open: std::mem::take(&mut prop_open[i]), + }; + let net_income = TaxLine { + known: book.net_income, + open: open.clone(), + }; + let other = TaxLine { + known: book.other, + open: if book.other.is_zero() { + Vec::new() + } else { + vec![Open::OtherChange { book: book.other }] + }, + }; + let distributions = TaxLine { + known: book + .distributions + .checked_sub(distributed[i]) + .ok_or_else(too_large)?, + open: std::mem::take(&mut dist_open[i]), + }; + let known = beginning + .known + .checked_add(book.contributions_cash) + .and_then(|x| x.checked_add(contributions_property.known)) + .and_then(|x| x.checked_add(net_income.known)) + .and_then(|x| x.checked_add(other.known)) + .and_then(|x| x.checked_sub(distributions.known)) + .ok_or_else(too_large)?; + let ending = TaxLine { + known, + open: [ + &beginning, + &contributions_property, + &net_income, + &other, + &distributions, + ] + .iter() + .flat_map(|l| l.open.iter().cloned()) + .collect(), + }; + let reconciliation = Reconciliation { + beginning: book + .beginning + .checked_sub(beginning.known) + .ok_or_else(too_large)?, + built_in_gain_704c: big[i], + property_without_basis: without[i], + property_distributed: distributed[i], + }; + rows.push(TaxCapitalRow { + contributions_cash: book.contributions_cash, + book, + beginning, + contributions_property, + net_income, + other, + distributions, + ending, + reconciliation, + }); + } + Ok(TaxCapital { rows, open }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn d(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() + } + + /// share: proportional, to the cent, largest remainder ties to the + /// first; negative wholes negated; zero weights give zeros. + #[test] + fn share_is_exact_and_signed() { + assert_eq!( + share(d("-1021.37"), &[d("525"), d("525")]).unwrap(), + vec![d("-510.69"), d("-510.68")] + ); + assert_eq!( + share(d("25"), &[d("525"), d("525")]).unwrap(), + vec![d("12.50"), d("12.50")] + ); + assert_eq!( + share(d("10"), &[d("0"), d("0")]).unwrap(), + vec![d("0"), d("0")] + ); + assert_eq!( + share(d("0.01"), &[d("1"), d("2")]).unwrap(), + vec![d("0.00"), d("0.01")] + ); + } +} diff --git a/crates/ledger-core/tests/assets.rs b/crates/ledger-core/tests/assets.rs index c7fb5ef..ead0454 100644 --- a/crates/ledger-core/tests/assets.rs +++ b/crates/ledger-core/tests/assets.rs @@ -3,7 +3,7 @@ use chrono::NaiveDate; use ledger_core::{ AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset, - accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts, + accumulated_scheduled_through, depreciation_schedule, disposal_amounts, scheduled_book_value, }; use rust_decimal::Decimal; @@ -53,7 +53,7 @@ fn van_asset( ledger .register_asset(NewAsset { description: "Sprinter van".to_string(), - purchase_date: date(2026, 1, 15), + in_service_date: date(2026, 1, 15), cost: usd(&dollars.to_string()), salvage_value: usd("0"), useful_life_months: 60, @@ -112,7 +112,7 @@ fn salvage_value_is_never_depreciated() { let id = ledger .register_asset(NewAsset { description: "Machine".to_string(), - purchase_date: date(2026, 3, 1), + in_service_date: date(2026, 3, 1), cost: usd("50000"), salvage_value: usd("5000"), useful_life_months: 36, @@ -133,11 +133,11 @@ fn book_value_tracks_accumulated() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = van_asset(&mut ledger, 60_000, cost, accum, expense); let asset = ledger.get_asset(id).unwrap(); - let acc = accumulated_depreciation(asset, date(2026, 12, 31)).unwrap(); + let acc = accumulated_scheduled_through(asset, date(2026, 12, 31)).unwrap(); // Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000. assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000)); assert_eq!( - book_value(asset, date(2026, 12, 31)).unwrap(), + scheduled_book_value(asset, date(2026, 12, 31)).unwrap(), Decimal::from(60_000) - acc ); } @@ -174,7 +174,7 @@ fn registration_validation() { .unwrap(); let base = NewAsset { description: "X".to_string(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: usd("1000"), salvage_value: usd("0"), useful_life_months: 12, @@ -222,7 +222,7 @@ fn registration_rejects_date_overflow() { let err = ledger .register_asset(NewAsset { description: "Time machine".to_string(), - purchase_date: NaiveDate::MAX, + in_service_date: NaiveDate::MAX, cost: usd("1000"), salvage_value: usd("0"), useful_life_months: 12, @@ -240,7 +240,7 @@ fn registration_rejects_date_overflow() { // could still reach (P0 review round 3, S1). let new = |date: NaiveDate, months: u32| NewAsset { description: "Late machine".to_string(), - purchase_date: date, + in_service_date: date, cost: usd("1000"), salvage_value: usd("0"), useful_life_months: months, @@ -270,7 +270,7 @@ fn schedule_through_purchase_date_covers_one_day() { let id = ledger .register_asset(NewAsset { description: "X".to_string(), - purchase_date: date(2026, 1, 15), + in_service_date: date(2026, 1, 15), cost: usd("62000"), salvage_value: usd("0"), useful_life_months: 62, @@ -335,7 +335,7 @@ fn registration_rejects_wrong_typed_accounts() { let (mut ledger, cost, accum, expense) = test_ledger(); let base = || NewAsset { description: "X".to_string(), - purchase_date: date(2026, 1, 15), + in_service_date: date(2026, 1, 15), cost: usd("1000"), salvage_value: usd("0"), useful_life_months: 12, @@ -415,7 +415,7 @@ fn one_month_useful_life_is_valid() { let id = ledger .register_asset(NewAsset { description: "Cheap tool".to_string(), - purchase_date: date(2026, 3, 10), + in_service_date: date(2026, 3, 10), cost: usd("300"), salvage_value: usd("0"), useful_life_months: 1, @@ -445,11 +445,12 @@ fn disposal_on_the_purchase_date_is_allowed() { assert_eq!(d.accumulated, Decimal::new(3226, 2)); assert_eq!(d.book_value, Decimal::new(5_996_774, 2)); assert_eq!(d.gain_loss, Decimal::new(3226, 2)); - // The day before purchase is not a disposal. - assert!(matches!( - disposal_amounts(&asset, date(2026, 1, 14), usd("60000")), - Err(ledger_core::LedgerError::DisposalBeforePurchase(_)) - )); + // The day before it is in service nothing has accrued (whether the + // company held it by then is its acquisition entry's: the ledger + // checks that floor, Ledger::dispose_asset). + let d = disposal_amounts(&asset, date(2026, 1, 14), usd("60000")).unwrap(); + assert_eq!(d.accumulated, Decimal::ZERO); + assert_eq!(d.book_value, asset.cost.amount); } /// An asset links to the posted entry that acquired it, if that entry diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index 5cceec9..79baa5d 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -11,24 +11,27 @@ //! due: a depreciation run, a disposal, a schedule run or registration), //! each accepted or refused as it says, with the parties, payee and //! payment method it is tagged with, and every change that posts no entry -//! (a rename, a policy). The books are the engine's own store -//! (`ledger_store::Store`, in a directory of their own), so every event -//! goes through the command the books take it by, journaled, and the -//! books must replay to what was kept (`Store::verify`). The queries the +//! (a rename, a policy, an asset's correction). The books are the +//! engine's own store (`ledger_store::Store`, in a directory of their +//! own), so every event goes through the command the books take it by, +//! journaled, and the books must replay to what was kept +//! (`Store::verify`). The queries the //! core answers (`cash_flow`, `trial_balance`, `profit_and_loss`, //! `vendor_1099_paid`, `vendor_1099`, `entry`, `balances`, -//! `members_equity`, `k1`, `party_part`, `party_totals`, -//! `party_tin_last4`, `period`, `day`, `calendar_year`, `account_names`, -//! `uncategorized_accounts`, `run`, `disposal`, `depreciation_schedule`, -//! `schedule_plan`) are then checked value by value, and the commands it -//! tries -//! (`rename_account`, `add_account`, `set_tax_category`) accepted or -//! refused on a copy of the books, each after the event it names (`after`) -//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are -//! checked by ledger-package (`the_bookspec_iif_example_matches_the_engine`), -//! and a query no crate answers is refused. A key the shape doesn't have -//! is refused too, never ignored. +//! `members_equity`, `k1`, `k1_tax_basis`, `k1_reconciliation`, +//! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`, +//! `calendar_year`, `account_names`, `uncategorized_accounts`, `run`, +//! `disposal`, `depreciation_schedule`, `schedule_plan`, `register_row`, +//! `register_totals`, `asset_basis`) are then checked value by value, and +//! the commands it tries (`rename_account`, `add_account`, +//! `set_tax_category`) accepted or refused on a copy of the books, each +//! after the event it names (`after`) or the last; the formats appendix's +//! (`iif_lines`, `iif_changes`) are checked by ledger-package +//! (`the_bookspec_iif_example_matches_the_engine`), and a query no crate +//! answers is refused. A key the shape doesn't have is refused too, never +//! ignored. +use std::borrow::Borrow; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -36,6 +39,7 @@ use chrono::NaiveDate; use ledger_core::calendar::{PeriodDefaults, period_defaults}; use ledger_core::parties::{Basis, MoneyAccounts}; use ledger_core::settings::{Company, Source}; +use ledger_core::tax_capital::TaxCapitalRow; use ledger_core::{ AccountType, AssetId, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, Dated1099, Decision1099, DocumentLine, EntryId, EntryStatus, ExemptReason, Form1099, @@ -170,6 +174,32 @@ struct AssetSpec { cost_account: String, accumulated_account: String, expense_account: String, + /// The event whose entry put it on the books (its purchase or a + /// member's contribution), linked once that event is applied: its date + /// is the asset's acquired date (rules.md B-15 rule 7). + #[serde(default)] + acquired_by: Option, + /// How the company got it and the facts its tax figures follow from + /// (rules.md B-25), recorded as it is registered. + #[serde(default)] + basis: Option, +} + +/// An asset's basis facts (rules.md B-25): `rule` `"purchase"`, +/// `"carryover_723"` or `"personal_use_conversion"`; a contribution's +/// `contributor_adjusted_basis` and `fmv_at_contribution`; its §704(c) +/// method, if chosen (`"traditional"`, `"traditional_with_curative"`, +/// `"remedial"`). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BasisSpec { + rule: String, + #[serde(default)] + contributor_adjusted_basis: Option, + #[serde(default)] + fmv_at_contribution: Option, + #[serde(default)] + sec704c_method: Option, } /// A prepaid expense or deferred revenue recognized over `months` from @@ -376,8 +406,9 @@ struct BillSpec { /// A change to the books that posts no entry, made right after event /// `after` (counting from 1) once the expectations asked after that event -/// are checked: a rename (`rename`) or the tax-category policy -/// (`require_tax_category`), exactly one of them. +/// are checked: a rename (`rename`), the tax-category policy +/// (`require_tax_category`) or an asset's correction (`correct_asset`), +/// exactly one of them. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ChangeSpec { @@ -388,6 +419,25 @@ struct ChangeSpec { /// "refuse" (rules.md B-32). #[serde(default)] require_tax_category: Option, + #[serde(default)] + correct_asset: Option, +} + +/// A registered asset's economics corrected (a registration mistake), for +/// `reason`: each figure given replaces the asset's; the rest stay. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CorrectSpec { + asset: String, + #[serde(default)] + cost: Option, + #[serde(default)] + salvage: Option, + #[serde(default)] + life_months: Option, + #[serde(default)] + in_service: Option, + reason: String, } /// Rename the account `code` to `name` (rules.md B-31). @@ -431,8 +481,9 @@ struct Expect { entry: Option, /// The `run_depreciation` event a `run` query asks about. event: Option, - /// The fixed asset (`setup.assets` id) a `disposal` or - /// `depreciation_schedule` query asks about. + /// The fixed asset (`setup.assets` id) a `disposal`, + /// `depreciation_schedule`, `register_row` or `asset_basis` query asks + /// about. asset: Option, /// The schedule (its id) a `schedule_plan` query asks about. schedule: Option, @@ -792,6 +843,22 @@ fn add_parties(ex: &Example, store: &mut Store) -> BTreeMap { out } +/// An asset's basis facts as the books record them. +fn basis(b: &BasisSpec, what: &str) -> ledger_core::AssetBasis { + let amount = |s: &Option| s.as_deref().map(|v| exact(v, what)); + ledger_core::AssetBasis { + rule: ledger_core::BasisRule::parse(&b.rule) + .unwrap_or_else(|| panic!("{what}: basis rule {:?}", b.rule)), + contributor_adjusted_basis: amount(&b.contributor_adjusted_basis), + fmv_at_contribution: amount(&b.fmv_at_contribution), + sec704c_method: b.sec704c_method.as_deref().map(|m| { + ledger_core::Sec704cMethod::parse(m) + .unwrap_or_else(|| panic!("{what}: §704(c) method {m:?}")) + }), + source: None, + } +} + /// A schedule as the books register it. fn new_schedule(l: &Ledger, s: &ScheduleSpec, currency: Currency, what: &str) -> NewSchedule { let account = |code: &str| { @@ -892,7 +959,7 @@ impl<'a> Books<'a> { for fa in &s.fixed_assets { let new = NewAsset { description: format!("Fixed asset on {}", fa.cost_account), - purchase_date: first, + in_service_date: first, cost: Money::new(Decimal::ONE, currency), salvage_value: Money::new(Decimal::ZERO, currency), useful_life_months: 12, @@ -911,7 +978,7 @@ impl<'a> Books<'a> { assert_eq!(a.method, "straight_line", "{what}: method"); let new = NewAsset { description: a.description.clone(), - purchase_date: date(&a.in_service, &what), + in_service_date: date(&a.in_service, &what), cost: Money::new(exact(&a.cost, &what), currency), salvage_value: Money::new(exact(&a.salvage, &what), currency), useful_life_months: a.life_months, @@ -924,6 +991,15 @@ impl<'a> Books<'a> { assets.insert(a.id.clone(), asset).is_none(), "{what}: id used twice" ); + if let Some(b) = &a.basis { + took(store.set_asset_basis(asset, basis(b, &what)), &what); + } + assert!( + a.acquired_by.is_none_or(|n| n >= 1 && n <= ex.events.len()), + "{what}: acquired_by = {:?}: events count from 1, to {}", + a.acquired_by, + ex.events.len() + ); } let mut schedules = BTreeMap::new(); for sc in &s.schedules { @@ -1190,6 +1266,17 @@ impl<'a> Books<'a> { assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry"); } self.posted.push(posted); + // The assets this event's entry put on the books. + for a in spec + .setup + .assets + .iter() + .filter(|a| a.acquired_by == Some(n)) + { + let entry = self.entry_of(n, &format!("{what}: asset {}'s acquired_by", a.id)); + let asset = self.asset(&a.id, &what); + took(self.store.set_asset_acquisition(asset, entry), &what); + } if !e.parties.is_empty() { let entry = self.entry_of(n, &what); let shares: Vec = e @@ -1391,12 +1478,30 @@ impl Books<'_> { let ex = &self.ex.id; let after = c.after; let what = format!("{ex} change after {after}"); - match (&c.rename, &c.require_tax_category) { - (Some(r), None) => { + match (&c.rename, &c.require_tax_category, &c.correct_asset) { + (Some(r), None, None) => { let account = self.id(&r.code); took(self.store.rename_account(account, &r.name, None), &what); } - (None, Some(policy)) => { + (None, None, Some(f)) => { + let id = self.asset(&f.asset, &what); + let a = self.l().get_asset(id).expect("registered").clone(); + let amount = |s: &Option, was: Money| { + s.as_deref() + .map_or(was, |v| Money::new(exact(v, &what), self.currency)) + }; + let fix = ledger_core::AssetCorrection { + cost: amount(&f.cost, a.cost), + salvage_value: amount(&f.salvage, a.salvage_value), + useful_life_months: f.life_months.unwrap_or(a.useful_life_months), + in_service_date: f + .in_service + .as_deref() + .map_or(a.in_service_date, |d| date(d, &what)), + }; + took(self.store.correct_asset(id, fix, &f.reason), &what); + } + (None, Some(policy), None) => { assert!( matches!(policy.as_str(), "warn" | "refuse"), "{what}: require_tax_category {policy:?}" @@ -1411,7 +1516,7 @@ impl Books<'_> { &what, ); } - _ => panic!("{what}: a change is one of rename or require_tax_category"), + _ => panic!("{what}: a change is one of rename, require_tax_category or correct_asset"), } } } @@ -2201,8 +2306,8 @@ fn check_depreciation_schedule(b: &Books<'_>, x: &Expect) { for (key, want) in x.figures(&ex.id) { let want = exact(want, &format!("{what}: {key}")); let got = match key.as_str() { - "accumulated" => ledger_core::accumulated_depreciation(asset, as_of), - "book_value" => ledger_core::book_value(asset, as_of), + "accumulated" => ledger_core::accumulated_scheduled_through(asset, as_of), + "book_value" => ledger_core::scheduled_book_value(asset, as_of), month => { month_key(month, &what); Ok(lines @@ -2255,6 +2360,254 @@ fn check_schedule_plan(b: &Books<'_>, x: &Expect) { } } +/// Check expectations whose values are named figures: each key one of +/// `figures`' (an unknown one refused), its value the figure's text. +fn named + Ord>( + ex: &Example, + x: &Expect, + what: &str, + figures: &BTreeMap, +) { + for (key, want) in x.figures(&ex.id) { + let got = figures + .get(key.as_str()) + .unwrap_or_else(|| panic!("{what}: no figure {key:?}")); + assert_eq!(got, want, "{what}: {key}"); + } +} + +/// A decimal figure as an example writes it: two places at least, as the +/// books keep money (exact; "0.00", "-14.20"). +fn figure(d: Decimal) -> String { + let mut d = d.normalize(); + if d.scale() < 2 { + d.rescale(2); + } + d.to_string() +} + +/// Check one `register_row` expectation: the asset's row of the fixed +/// asset register as of `as_of` (rules.md B-15 rules 7 and 8, B-24): +/// `acquired` and `in_service` (`YYYY-MM-DD`), `cost`, +/// `accumulated_scheduled` (the schedule's), `accumulated_posted` (what +/// the books carry), `unposted` (the one less the other), `book_value` +/// (cost less posted); each 0.00 once disposed; the example names the +/// figures it shows. An asset not on the register by `as_of` (acquired +/// later) is refused. +fn check_register_row(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + let as_of = x.day(ex); + let name = x + .asset + .as_deref() + .unwrap_or_else(|| panic!("{}: register_row names its asset", ex.id)); + let what = format!("{} register_row of {name} as of {as_of}", ex.id); + let id = b.asset(name, &what); + let r = + ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}")); + let row = r + .rows + .iter() + .find(|row| row.asset_id == id) + .unwrap_or_else(|| panic!("{what}: not on the register (acquired later)")); + let figures = BTreeMap::from([ + ("acquired", row.acquired_date.to_string()), + ("in_service", row.in_service_date.to_string()), + ("cost", figure(row.cost)), + ("accumulated_scheduled", figure(row.accumulated_scheduled)), + ("accumulated_posted", figure(row.accumulated_posted)), + ( + "unposted", + figure(row.accumulated_scheduled - row.accumulated_posted), + ), + ("book_value", figure(row.book_value)), + ]); + named(ex, x, &what, &figures); +} + +/// Check one `register_totals` expectation: the fixed asset register's +/// totals as of `as_of`, over the assets held (rules.md B-24, B-25): +/// `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`, +/// `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`, +/// `missing_basis` (how many held have none), and how it reconciles to +/// the books: `unregistered_cost`, `unposted_depreciation`, +/// `unattributed_accumulated`; the example names the figures it shows. +fn check_register_totals(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + let as_of = x.day(ex); + let what = format!("{} register_totals as of {as_of}", ex.id); + let r = + ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}")); + let figures = BTreeMap::from([ + ("total_cost", figure(r.total_cost)), + ( + "total_accumulated_scheduled", + figure(r.total_accumulated_scheduled), + ), + ( + "total_accumulated_posted", + figure(r.total_accumulated_posted), + ), + ("total_book_value", figure(r.total_book_value)), + ("total_depreciable_basis", figure(r.total_depreciable_basis)), + ("total_tax_basis_723", figure(r.total_tax_basis_723)), + ("missing_basis", r.missing_basis.to_string()), + ("unregistered_cost", figure(r.unregistered_cost())), + ("unposted_depreciation", figure(r.unposted_depreciation())), + ( + "unattributed_accumulated", + figure(r.unattributed_accumulated()), + ), + ]); + named(ex, x, &what, &figures); +} + +/// Check one `asset_basis` expectation: the asset's basis facts and the +/// tax figures they give (rules.md B-25): `rule`, +/// `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`, +/// `depreciable_basis`, `built_in_gain_704c` (a loss negative), each +/// `"none"` where the asset has none (a purchase's §723 basis), and +/// `sec704c_1c` (`"true"`: a built-in loss, which counts only for the +/// contributing member, IRC §704(c)(1)(C)); the example names the figures +/// it shows. +fn check_asset_basis(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + undated(ex, x); + let name = x + .asset + .as_deref() + .unwrap_or_else(|| panic!("{}: asset_basis names its asset", ex.id)); + let what = format!("{} asset_basis of {name}", ex.id); + let a = b + .l() + .get_asset(b.asset(name, &what)) + .expect("a registered asset"); + let f = a + .basis_figures() + .unwrap_or_else(|e| panic!("{what}: {e}")) + .unwrap_or_else(|| panic!("{what}: no basis recorded")); + let opt = |d: Option| d.map_or("none".to_string(), figure); + let rule = a.basis.as_ref().expect("a basis recorded").rule; + let figures = BTreeMap::from([ + ("rule", rule.key().to_string()), + ( + "contributor_adjusted_basis", + opt(a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis)), + ), + ("fmv_at_contribution", opt(a.fmv_at_contribution())), + ("tax_basis_723", opt(f.tax_basis_723)), + ("depreciable_basis", figure(f.depreciable_basis)), + ("built_in_gain_704c", opt(f.built_in_gain_704c)), + ("sec704c_1c", a.sec704c_1c().to_string()), + ]); + named(ex, x, &what, &figures); +} + +/// The tax-basis capital worksheet for `[from, to]` (rules.md B-36), a +/// row a member, as `k1` reads the book one: it needs the members and +/// their shares, and `cash_accounts`. +fn tax_capital_rows(b: &Books<'_>, x: &Expect, what: &str) -> Vec { + let ex = b.ex; + let (from, to) = x.period(ex); + assert!( + !b.members.is_empty() && b.members.iter().all(|m| m.share > Decimal::ZERO), + "{what}: needs the members and their shares" + ); + assert!( + !ex.setup.cash_accounts.is_empty(), + "{what}: the K-1 worksheet needs cash_accounts" + ); + let cash = codes(b.l(), &ex.setup.cash_accounts, &ex.id); + ledger_core::tax_capital::tax_basis_capital(b.l(), from, to, &b.members, &cash) + .unwrap_or_else(|e| panic!("{what}: {e}")) + .rows +} + +/// Check one `k1_tax_basis` expectation: each member's capital account +/// for `[from, to]` on the tax basis, as Schedule K-1 Item L reports it +/// (rules.md B-36), `"."` for the lines `beginning`, +/// `contributions_cash`, `contributions_property`, `net_income`, `other`, +/// `distributions` and `ending`, beside `k1`'s book figures; a line the +/// books can't know is `"open"`, with what they know so far as +/// `".known_"`. The example names the figures it shows. +fn check_k1_tax_basis(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + let what = format!("{} k1_tax_basis", ex.id); + let mut figures = BTreeMap::new(); + for row in tax_capital_rows(b, x, &what) { + let m = &row.book.name; + figures.insert( + format!("{m}.contributions_cash"), + figure(row.contributions_cash), + ); + for (line, tax) in [ + ("beginning", &row.beginning), + ("contributions_property", &row.contributions_property), + ("net_income", &row.net_income), + ("other", &row.other), + ("distributions", &row.distributions), + ("ending", &row.ending), + ] { + figures.insert( + format!("{m}.{line}"), + tax.tax().map_or("open".to_string(), figure), + ); + figures.insert(format!("{m}.known_{line}"), figure(tax.known)); + } + } + named(ex, x, &what, &figures); +} + +/// The causes `k1_reconciliation` reports, each member's every one. +const RECONCILING_CAUSES: [&str; 5] = [ + "beginning", + "built_in_gain_704c", + "property_without_basis", + "property_distributed", + "total", +]; + +/// Check one `k1_reconciliation` expectation: each member's book capital +/// less tax-basis capital at the end of `[from, to]`, by cause (rules.md +/// B-36), `"."` for `beginning`, `built_in_gain_704c` +/// (their share of the §704(c) gain or loss at contribution), +/// `property_without_basis`, `property_distributed` and `total`: the +/// example names the members it shows, each with every cause. +fn check_k1_reconciliation(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + let what = format!("{} k1_reconciliation", ex.id); + let mut figures = BTreeMap::new(); + for row in tax_capital_rows(b, x, &what) { + let r = &row.reconciliation; + let total = r + .total() + .unwrap_or_else(|| panic!("{what}: the total is too large")); + for (cause, v) in RECONCILING_CAUSES.into_iter().zip([ + r.beginning, + r.built_in_gain_704c, + r.property_without_basis, + r.property_distributed, + total, + ]) { + figures.insert(format!("{}.{cause}", row.book.name), figure(v)); + } + } + let named_members: BTreeSet<&str> = x + .figures(&ex.id) + .keys() + .map(|k| k.split_once('.').map_or(k.as_str(), |(m, _)| m)) + .collect(); + for m in named_members { + for cause in RECONCILING_CAUSES { + assert!( + x.figures(&ex.id).contains_key(&format!("{m}.{cause}")), + "{what}: {m} is shown with every cause: {m}.{cause} is missing" + ); + } + } + named(ex, x, &what, &figures); +} + /// The commands an example may try: each on a copy of the books, so it /// changes nothing that is checked after it. const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"]; @@ -2474,7 +2827,12 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { only( x.asset.is_some(), "asset", - &["disposal", "depreciation_schedule"], + &[ + "disposal", + "depreciation_schedule", + "register_row", + "asset_basis", + ], ); only(x.schedule.is_some(), "schedule", &["schedule_plan"]); only( @@ -2535,6 +2893,11 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { "disposal" => check_disposal(b, x), "depreciation_schedule" => check_depreciation_schedule(b, x), "schedule_plan" => check_schedule_plan(b, x), + "register_row" => check_register_row(b, x), + "register_totals" => check_register_totals(b, x), + "asset_basis" => check_asset_basis(b, x), + "k1_tax_basis" => check_k1_tax_basis(b, x), + "k1_reconciliation" => check_k1_reconciliation(b, x), "balances" => check_balances(ex, l, x), "members_equity" => check_members_equity(ex, l, &b.members, x), "k1" => check_k1(ex, l, &b.members, x), @@ -2632,7 +2995,8 @@ fn every_bookspec_example_holds() { // Each query the examples use ran: the cash flow's six examples at // least, the as-of and 1099 examples', the members closes', the // formats appendix's, and P3's default period, rename, tax category, - // parties, 1099, depreciation and schedules examples'. + // parties, 1099, depreciation, schedules, asset basis and K-1 tax-basis + // examples'. for (q, at_least) in [ ("cash_flow", 6), ("trial_balance", 2), @@ -2642,7 +3006,7 @@ fn every_bookspec_example_holds() { ("entry", 8), ("balances", 4), ("members_equity", 10), - ("k1", 2), + ("k1", 3), ("party_part", 28), ("party_totals", 23), ("party_tin_last4", 5), @@ -2660,6 +3024,11 @@ fn every_bookspec_example_holds() { ("disposal", 5), ("depreciation_schedule", 5), ("schedule_plan", 3), + ("register_row", 5), + ("register_totals", 1), + ("asset_basis", 6), + ("k1_tax_basis", 2), + ("k1_reconciliation", 1), ] { let n = checked.get(q).copied().unwrap_or(0); assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}"); @@ -3147,3 +3516,52 @@ fn a_schedule_month_off_by_a_cent_fails() { ) }); } + +/// An asset is on the register from its acquired date: a row asked for +/// before it is refused, never shown with figures. +#[test] +#[should_panic(expected = "not on the register (acquired later)")] +fn a_register_row_before_the_asset_was_acquired_is_refused() { + run_edited("assets-acquired-before-in-service.toml", |t| { + t.replacen("as_of = \"2026-03-31\"", "as_of = \"2026-03-09\"", 1) + }); +} + +/// `acquired_by` names an event whose entry put the asset on the books: +/// an event that posts no entry can't. +#[test] +#[should_panic(expected = "event 2 posted 0 entries")] +fn an_asset_acquired_by_an_event_with_no_entry_is_refused() { + run_edited("assets-a-reversed-run-is-not-posted.toml", |t| { + t.replacen("acquired_by = 1", "acquired_by = 2", 1) + .replacen( + "[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2026-01\" }", + "[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2025-12\" }\nrefused = \"invalid_depreciation_period\"", + 1, + ) + }); +} + +/// A tax line the books can't know is "open", never a figure: an example +/// giving the book figure for it fails. +#[test] +#[should_panic(expected = "k1_tax_basis: Tobin Harlow.net_income")] +fn a_tax_line_the_books_cant_know_is_open() { + run_edited("partnership-k1-tax-basis-capital.toml", |t| { + t.replacen( + "\"Tobin Harlow.net_income\" = \"open\"", + "\"Tobin Harlow.net_income\" = \"-385.00\"", + 1, + ) + }); +} + +/// A member's reconciliation is shown whole: an example leaving out a +/// cause fails, so a figure can't quietly go unchecked. +#[test] +#[should_panic(expected = "Tobin Harlow.property_distributed is missing")] +fn a_members_reconciliation_shows_every_cause() { + run_edited("partnership-k1-tax-basis-capital.toml", |t| { + t.replacen(", \"Tobin Harlow.property_distributed\" = \"0.00\"", "", 1) + }); +} diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index bf6d5cf..8a31f35 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -1035,7 +1035,7 @@ fn a_sale_to_a_contractor_does_not_lower_its_1099() { let depr = acct(&mut m.l, "6800", AccountType::Expense); m.l.register_asset(NewAsset { description: "Monitor".into(), - purchase_date: d(2024, 1, 1), + in_service_date: d(2024, 1, 1), cost: Money::new(dec("1000"), Currency::USD), salvage_value: Money::new(dec("0"), Currency::USD), useful_life_months: 36, diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index 4b98d28..d003e46 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -863,7 +863,7 @@ fn cash_flow_with_registered_asset_and_disposal() { let asset = ledger .register_asset(NewAsset { description: "Van".to_string(), - purchase_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), + in_service_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), cost: usd(60_000), salvage_value: usd(0), useful_life_months: 60, diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 5ead23e..c9da630 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -461,4 +461,27 @@ proptest! { ); } } + + /// kernel::basis_figures at i64 (what Kani's basis_figures_laws + /// proved) and at Decimal agree, rule by rule. + #[test] + fn basis_figures_agree( + rule in prop_oneof![ + Just(ledger_core::BasisRule::Purchase), + Just(ledger_core::BasisRule::Carryover723), + Just(ledger_core::BasisRule::PersonalUseConversion), + ], + cost in arb_cents(), + adjusted in arb_cents(), + fmv in arb_cents(), + ) { + let i = kernel::basis_figures(rule, cost, adjusted, fmv); + let d = kernel::basis_figures(rule, dec(cost), dec(adjusted), dec(fmv)); + prop_assert_eq!(i.is_some(), d.is_some()); + if let (Some(i), Some(d)) = (i, d) { + prop_assert_eq!(i.tax_basis_723.map(dec), d.tax_basis_723); + prop_assert_eq!(dec(i.depreciable_basis), d.depreciable_basis); + prop_assert_eq!(i.built_in_gain_704c.map(dec), d.built_in_gain_704c); + } + } } diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index 82703b4..fd53cb2 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -7,12 +7,13 @@ use chrono::NaiveDate; use ledger_core::{ - AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value, + AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_scheduled_through, depreciation_schedule, reports::{ balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance, unclosed_earnings, }, + scheduled_book_value, }; use proptest::prelude::*; use rust_decimal::Decimal; @@ -290,7 +291,7 @@ proptest! { let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap(); let id = ledger.register_asset(NewAsset { description: "prop".to_string(), - purchase_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(), + in_service_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(), cost: Money::new(cost, Currency::USD), salvage_value: Money::new(salvage, Currency::USD), useful_life_months: life, @@ -299,7 +300,7 @@ proptest! { expense_account: ex, }).unwrap(); let asset = ledger.get_asset(id).unwrap(); - let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap(); + let far = asset.in_service_date.checked_add_months(chrono::Months::new(life + 24)).unwrap(); let sched = depreciation_schedule(asset, far).unwrap(); // The lifetime total is exactly cost minus salvage: never a penny off. let total: Decimal = sched.iter().map(|l| l.amount).sum(); @@ -314,8 +315,8 @@ proptest! { } // Book value at any date matches the schedule. if let Some(last) = sched.last() { - prop_assert_eq!(book_value(asset, far).unwrap(), last.book_value); - prop_assert_eq!(accumulated_depreciation(asset, far).unwrap(), last.accumulated); + prop_assert_eq!(scheduled_book_value(asset, far).unwrap(), last.book_value); + prop_assert_eq!(accumulated_scheduled_through(asset, far).unwrap(), last.accumulated); } } } @@ -506,7 +507,7 @@ mod p3 { let id = ledger .register_asset(NewAsset { description: "prop".to_string(), - purchase_date: start, + in_service_date: start, cost: Money::new(Decimal::new(cost_cents as i64, 2), Currency::USD), salvage_value: Money::new(Decimal::new(salvage_cents as i64, 2), Currency::USD), useful_life_months: life, @@ -631,7 +632,7 @@ proptest! { let mut prev = Decimal::ZERO; for day in days { let s = depreciation_schedule(a, day).unwrap(); - let acc = accumulated_depreciation(a, day).unwrap(); + let acc = accumulated_scheduled_through(a, day).unwrap(); prop_assert_eq!(s.iter().map(|l| l.amount).sum::(), acc); prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO)); prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev); @@ -673,10 +674,10 @@ proptest! { let full = depreciation_schedule(a, NaiveDate::MAX).unwrap(); prop_assert_eq!(full.last().map_or(Decimal::ZERO, |l| l.accumulated), want.accumulated); prop_assert_eq!( - accumulated_depreciation(a, on + chrono::Days::new(later)).unwrap(), + accumulated_scheduled_through(a, on + chrono::Days::new(later)).unwrap(), want.accumulated ); - prop_assert_eq!(book_value(a, on).unwrap(), want.book_value); + prop_assert_eq!(scheduled_book_value(a, on).unwrap(), want.book_value); } /// B-15 rule 3 (defect 22), against arithmetic written here: through @@ -711,7 +712,7 @@ proptest! { x / y + i128::from(2 * (x % y) >= y) }; prop_assert_eq!( - accumulated_depreciation(a, through).unwrap(), + accumulated_scheduled_through(a, through).unwrap(), Decimal::new(want as i64, 2), "{} of {}", through, last_day ); @@ -882,3 +883,53 @@ proptest! { } } } + +fn arb_basis_rule() -> impl Strategy { + prop_oneof![ + Just(ledger_core::BasisRule::Purchase), + Just(ledger_core::BasisRule::Carryover723), + Just(ledger_core::BasisRule::PersonalUseConversion), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(5000))] + + /// kernel::basis_figures INVARIANTS 1-4 on Decimal money (rules.md + /// B-25): a purchase depreciates from cost and has no §723 basis or + /// built-in gain; a contribution's §723 basis is the adjusted basis, + /// its built-in gain fair market value less that basis, negative + /// exactly when the basis exceeds the value; carryover depreciates + /// from the basis, personal use from the lesser (at most each, equal + /// to one). + #[test] + fn prop_basis_figures_laws( + rule in arb_basis_rule(), + cost in 1i64..=100_000_000_000, + adjusted in 0i64..=100_000_000_000, + fmv in 0i64..=100_000_000_000, + ) { + use ledger_core::BasisRule::*; + let (cost, adjusted, fmv) = (Decimal::new(cost, 2), Decimal::new(adjusted, 2), Decimal::new(fmv, 2)); + let f = ledger_core::kernel::basis_figures(rule, cost, adjusted, fmv).unwrap(); + match rule { + Purchase => { + prop_assert_eq!(f.depreciable_basis, cost); + prop_assert_eq!(f.tax_basis_723, None); + prop_assert_eq!(f.built_in_gain_704c, None); + } + Carryover723 | PersonalUseConversion => { + prop_assert_eq!(f.tax_basis_723, Some(adjusted)); + let g = f.built_in_gain_704c.unwrap(); + prop_assert_eq!(g, fmv - adjusted); + prop_assert_eq!(g < Decimal::ZERO, adjusted > fmv); + if rule == Carryover723 { + prop_assert_eq!(f.depreciable_basis, adjusted); + } else { + prop_assert!(f.depreciable_basis <= adjusted && f.depreciable_basis <= fmv); + prop_assert!(f.depreciable_basis == adjusted || f.depreciable_basis == fmv); + } + } + } + } +} diff --git a/crates/ledger-core/tests/spec_asset_basis.proptest-regressions b/crates/ledger-core/tests/spec_asset_basis.proptest-regressions new file mode 100644 index 0000000..1542991 --- /dev/null +++ b/crates/ledger-core/tests/spec_asset_basis.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 905ded9e8e77bbcda0f9dfa53feb4e5279611111e7de70f9ab53d8b2ff6040ba # shrinks to assets = [(0.01, 0.00, 0.01, 2)] diff --git a/crates/ledger-core/tests/spec_asset_basis.rs b/crates/ledger-core/tests/spec_asset_basis.rs new file mode 100644 index 0000000..ef1310f --- /dev/null +++ b/crates/ledger-core/tests/spec_asset_basis.rs @@ -0,0 +1,1269 @@ +//! Spec-derived tests for an asset's basis facts and tax figures +//! (rules.md B-25), its two dates and its two accumulated figures +//! (rules.md B-15, B-24): `BasisRule`, `Sec704cMethod`, `AssetBasis` +//! (`check`, `check_journaled`, `check_legacy_journaled`), +//! `Ledger::set_asset_basis` / `replay_set_asset_basis` / +//! `replay_set_asset_tax_basis`, `Asset::basis_figures`, +//! `Ledger::asset_acquired_date`, the acquisition link's dates, +//! `Ledger::accumulated_posted` and the runs, and the tax fields of +//! `reports::fixed_asset_register`. +//! +//! Every expected value comes from the written specification: the doc +//! comments and INVARIANTS/SCENARIOS blocks on those items, and the +//! vendored sources (`docs/spec/sources/irs-partnership-basis.md`): cost +//! basis (IRC §1012(a)), the §723 basis (the contributor's adjusted basis), +//! the lesser of fair market value and adjusted basis on a conversion +//! from personal use (Treas. Reg. §1.168(i)-4(b)(1); D-41), and the +//! §704(c) built-in gain or loss as fair market value less the §723 basis +//! at contribution (Treas. Reg. §1.704-3(a)(3)(ii)). Amounts are worked by +//! hand. Every name and figure is invented. + +use std::str::FromStr; + +use chrono::NaiveDate; +use ledger_core::{ + AccountId, AccountType, AssetBasis, AssetCorrection, AssetId, BasisRule, Currency, Ledger, + LedgerError, Money, NewAsset, Sec704cMethod, Side, fixed_asset_register, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +// ---------------------------------------------------------------- helpers + +fn d(s: &str) -> Decimal { + Decimal::from_str(s).expect("decimal literal") +} + +fn usd(s: &str) -> Money { + Money::new(d(s), Currency::USD) +} + +fn date(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).expect("valid date") +} + +struct Accts { + cash: AccountId, + equipment: AccountId, + accum: AccountId, + capital: AccountId, + depr_expense: AccountId, +} + +/// A USD ledger with a fixed-asset chart, funded with $50,000 cash. +fn fixture() -> (Ledger, Accts) { + let mut l = Ledger::new(Currency::USD); + let mut acct = |code: &str, name: &str, ty: AccountType| { + l.add_account(code, name, ty, None, false).unwrap() + }; + let cash = acct("1000", "Cash", AccountType::Asset); + let equipment = acct("1500", "Equipment", AccountType::Asset); + let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset); + let capital = acct("3000", "Member Capital", AccountType::Equity); + let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense); + let e = l.try_create_entry(date(2026, 1, 1), "funding").unwrap(); + l.add_line(e, cash, Side::Debit, usd("50000.00")).unwrap(); + l.add_line(e, capital, Side::Credit, usd("50000.00")) + .unwrap(); + l.post_entry(e).unwrap(); + ( + l, + Accts { + cash, + equipment, + accum, + capital, + depr_expense, + }, + ) +} + +fn new_asset(a: &Accts, description: &str, cost: &str, life: u32, on: NaiveDate) -> NewAsset { + NewAsset { + description: description.to_string(), + in_service_date: on, + cost: usd(cost), + salvage_value: usd("0.00"), + useful_life_months: life, + cost_account: a.equipment, + accum_depr_account: a.accum, + expense_account: a.depr_expense, + } +} + +fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId { + l.register_asset(new_asset(a, description, cost, 36, date(2026, 1, 1))) + .unwrap() +} + +/// A ledger with one asset of book cost (fair market value) `cost`. +fn one(cost: &str) -> (Ledger, AssetId) { + let (mut l, a) = fixture(); + let id = register(&mut l, &a, "Laptop", cost); + (l, id) +} + +fn contribution(rule: BasisRule, adjusted: &str, fmv: &str) -> AssetBasis { + AssetBasis::contribution(rule, d(adjusted), d(fmv)) +} + +/// A pre-P3 record: rule and contributor basis, no value at contribution. +fn legacy(rule: BasisRule, basis: Option<&str>) -> AssetBasis { + AssetBasis { + rule, + contributor_adjusted_basis: basis.map(d), + fmv_at_contribution: None, + sec704c_method: None, + source: None, + } +} + +/// Post an entry of `lines` on `on`; returns it. +fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) -> ledger_core::EntryId { + let e = l.try_create_entry(on, "entry").unwrap(); + for (acct, side, amt) in lines { + l.add_line(e, *acct, *side, usd(amt)).unwrap(); + } + l.post_entry(e).unwrap(); + e +} + +/// One run for `asset`'s month of `on`, as a store posts and records it. +fn run( + l: &mut Ledger, + a: &Accts, + asset: AssetId, + on: NaiveDate, + amt: &str, +) -> ledger_core::EntryId { + let e = post( + l, + on, + &[ + (a.depr_expense, Side::Debit, amt), + (a.accum, Side::Credit, amt), + ], + ); + let period = on.format("%Y-%m").to_string(); + l.check_depreciation_run(asset, &period, e, d(amt)).unwrap(); + l.record_depreciation_run(asset, &period, e, d(amt)) + .unwrap(); + e +} + +const RULES: [BasisRule; 3] = [ + BasisRule::Purchase, + BasisRule::Carryover723, + BasisRule::PersonalUseConversion, +]; + +// ------------------------------------------------------------ BasisRule + +/// BasisRule::key: the wire names `purchase`, `carryover_723`, +/// `personal_use_conversion`; parse round-trips with key. +#[test] +fn rule_keys_are_the_documented_wire_names() { + assert_eq!(BasisRule::Purchase.key(), "purchase"); + assert_eq!(BasisRule::Carryover723.key(), "carryover_723"); + assert_eq!( + BasisRule::PersonalUseConversion.key(), + "personal_use_conversion" + ); + for r in RULES { + assert_eq!(BasisRule::parse(r.key()), Some(r)); + } +} + +/// parse takes the wire names only; parse_legacy the pre-P3 names (for +/// replay, the projection, and the tools' aliases). +#[test] +fn legacy_names_parse_only_as_legacy() { + assert_eq!(BasisRule::parse("cost"), None); + assert_eq!(BasisRule::parse("carryover"), None); + assert_eq!(BasisRule::parse_legacy("cost"), Some(BasisRule::Purchase)); + assert_eq!( + BasisRule::parse_legacy("carryover"), + Some(BasisRule::Carryover723) + ); + assert_eq!( + BasisRule::parse_legacy("personal_use_conversion"), + Some(BasisRule::PersonalUseConversion) + ); + assert_eq!(BasisRule::parse_legacy("purchase"), None); + assert_eq!(BasisRule::parse_legacy("carryover_723"), None); + for bad in [ + "", + "Purchase", + " purchase", + "carryover723", + "personal-use-conversion", + "fmv", + ] { + assert_eq!(BasisRule::parse(bad), None, "{bad:?}"); + assert_eq!(BasisRule::parse_legacy(bad), None, "{bad:?}"); + } +} + +/// The serde names are the pre-P3 ones, frozen: the books an earlier +/// version wrote are compared with the asset's serde form. +#[test] +fn rule_serde_names_are_frozen() { + for (r, name) in [ + (BasisRule::Purchase, "cost"), + (BasisRule::Carryover723, "carryover"), + (BasisRule::PersonalUseConversion, "personal_use_conversion"), + ] { + let j = serde_json::to_value(r).unwrap(); + assert_eq!(j, serde_json::Value::String(name.into())); + assert_eq!(serde_json::from_value::(j).unwrap(), r); + } + // A pre-P3 record serializes exactly as it did. + let v = serde_json::to_value(legacy(BasisRule::Carryover723, Some("900.00"))).unwrap(); + assert_eq!( + v, + serde_json::json!({"rule": "carryover", "contributor_basis": "900.00"}) + ); +} + +/// Sec704cMethod: the three methods of Treas. Reg. §1.704-3(b)-(d). +#[test] +fn methods_are_the_three_of_1_704_3() { + let keys: Vec<&str> = Sec704cMethod::ALL.iter().map(|m| m.key()).collect(); + assert_eq!( + keys, + ["traditional", "traditional_with_curative", "remedial"] + ); + for m in Sec704cMethod::ALL { + assert_eq!(Sec704cMethod::parse(m.key()), Some(m)); + let j = serde_json::to_value(m).unwrap(); + assert_eq!(j, serde_json::Value::String(m.key().into())); + } + assert_eq!( + Sec704cMethod::Traditional.citation(), + "Treas. Reg. §1.704-3(b)" + ); + assert_eq!(Sec704cMethod::parse("ceiling"), None); +} + +// --------------------------------------------------------- check refusals + +/// INVARIANT 1 (check): a purchase takes no contributor basis, value or +/// method. +#[test] +fn a_purchase_with_contribution_facts_is_refused() { + let (mut l, id) = one("1000.00"); + let before = l.clone(); + let mut with_basis = AssetBasis::purchase(); + with_basis.contributor_adjusted_basis = Some(d("500.00")); + let mut with_fmv = AssetBasis::purchase(); + with_fmv.fmv_at_contribution = Some(d("1000.00")); + let mut with_method = AssetBasis::purchase(); + with_method.sec704c_method = Some(Sec704cMethod::Traditional); + for b in [with_basis, with_fmv, with_method] { + assert!(matches!( + l.check_asset_basis(id, &b), + Err(LedgerError::InvalidTaxBasis(_)) + )); + assert!(matches!( + l.set_asset_basis(id, b), + Err(LedgerError::InvalidTaxBasis(_)) + )); + } + assert_eq!(l, before, "a refusal changes nothing"); +} + +/// A contribution needs both the member's adjusted basis and the value at +/// contribution (a fact the human gives, never assumed from cost). +#[test] +fn a_contribution_missing_a_fact_is_refused() { + let (mut l, id) = one("1000.00"); + let before = l.clone(); + for r in [BasisRule::Carryover723, BasisRule::PersonalUseConversion] { + let mut no_basis = contribution(r, "500.00", "1000.00"); + no_basis.contributor_adjusted_basis = None; + let mut no_fmv = contribution(r, "500.00", "1000.00"); + no_fmv.fmv_at_contribution = None; + for b in [no_basis, no_fmv] { + assert!(matches!( + l.set_asset_basis(id, b), + Err(LedgerError::InvalidTaxBasis(_)) + )); + } + } + assert_eq!(l, before); +} + +/// Each fact is non-negative, in cents, at most one quadrillion. +#[test] +fn a_negative_sub_cent_or_huge_fact_is_refused() { + let (mut l, id) = one("1000.00"); + let before = l.clone(); + let huge = (ledger_core::kernel::MAX_LINE_AMOUNT + Decimal::ONE).to_string(); + for r in [BasisRule::Carryover723, BasisRule::PersonalUseConversion] { + for (adj, fmv) in [ + ("-0.01", "100.00"), + ("100.00", "-0.01"), + ("100.001", "100.00"), + ("100.00", "0.005"), + (huge.as_str(), "100.00"), + ("100.00", huge.as_str()), + ] { + assert!( + matches!( + l.set_asset_basis(id, contribution(r, adj, fmv)), + Err(LedgerError::InvalidTaxBasis(_)) + ), + "{r:?} {adj} {fmv}" + ); + } + } + assert_eq!(l, before); +} + +/// A source must name who gave the facts (Source::check). +#[test] +fn a_source_without_a_name_is_refused() { + let (mut l, id) = one("1000.00"); + let mut b = contribution(BasisRule::Carryover723, "500.00", "1000.00"); + b.source = Some(ledger_core::settings::Source { + decided_by: " ".into(), + decided_on: date(2026, 10, 20), + citation: None, + note: None, + }); + assert!(matches!( + l.set_asset_basis(id, b), + Err(LedgerError::InvalidTaxBasis(_)) + )); +} + +/// A zero adjusted basis (fully depreciated) and a zero value (worthless +/// at contribution) are facts like any other (R21). +#[test] +fn zero_basis_and_zero_value_are_accepted() { + for (adj, fmv) in [("0.00", "300.00"), ("300.00", "0"), ("0", "0.00")] { + let (mut l, id) = one("1000.00"); + l.set_asset_basis(id, contribution(BasisRule::Carryover723, adj, fmv)) + .unwrap(); + let f = l.get_asset(id).unwrap().basis_figures().unwrap().unwrap(); + assert_eq!(f.tax_basis_723, Some(d(adj))); + assert_eq!(f.built_in_gain_704c, Some(d(fmv) - d(adj))); + } +} + +/// Valid records: check agrees with set, and a valid set changes nothing +/// but the asset's basis record (book figures don't change). +#[test] +fn valid_records_are_accepted_and_book_figures_stay() { + let mut src = contribution(BasisRule::PersonalUseConversion, "750.50", "700.00"); + src.sec704c_method = Some(Sec704cMethod::Traditional); + src.source = Some(ledger_core::settings::Source { + decided_by: "Morgan Ellis".into(), + decided_on: date(2026, 10, 20), + citation: Some("entry 2".into()), + note: None, + }); + for b in [ + AssetBasis::purchase(), + contribution(BasisRule::Carryover723, "750", "1000.00"), + contribution(BasisRule::PersonalUseConversion, "750.5", "1000"), + src, + ] { + let (mut l, id) = one("1000.00"); + l.check_asset_basis(id, &b).unwrap(); + let before = l.get_asset(id).unwrap().clone(); + l.set_asset_basis(id, b.clone()).unwrap(); + let after = l.get_asset(id).unwrap(); + assert_eq!(after.basis, Some(b.clone())); + let mut same = after.clone(); + same.basis = before.basis.clone(); + assert_eq!(same, before, "book figures don't change: {b:?}"); + } +} + +/// Set again, the new record replaces the old; a refusal keeps the old. +#[test] +fn setting_again_replaces_and_a_refusal_keeps() { + let (mut l, id) = one("1000.00"); + l.set_asset_basis(id, AssetBasis::purchase()).unwrap(); + assert_eq!( + l.get_asset(id).unwrap().depreciable_basis(), + Some(d("1000.00")) + ); + l.set_asset_basis( + id, + contribution(BasisRule::Carryover723, "400.00", "1000.00"), + ) + .unwrap(); + assert_eq!( + l.get_asset(id).unwrap().depreciable_basis(), + Some(d("400.00")) + ); + let before = l.clone(); + let mut bad = AssetBasis::purchase(); + bad.contributor_adjusted_basis = Some(d("1.00")); + assert!(l.set_asset_basis(id, bad).is_err()); + assert_eq!(l, before); +} + +// ------------------------------------------------------------ the figures + +/// No record: no figures. +#[test] +fn no_record_means_no_figures() { + let (l, id) = one("1000.00"); + let a = l.get_asset(id).unwrap(); + assert_eq!(a.basis_figures().unwrap(), None); + assert_eq!(a.tax_basis_723(), None); + assert_eq!(a.depreciable_basis(), None); + assert_eq!(a.built_in_gain_704c(), None); + assert!(!a.sec704c_1c()); + assert_eq!(a.fmv_at_contribution(), None); +} + +/// A purchase: depreciable = cost (IRC §1012(a)); no §723 basis, no +/// built-in gain (not property contributed). +#[test] +fn a_purchase_depreciates_from_cost() { + let (mut l, id) = one("1234.56"); + l.set_asset_basis(id, AssetBasis::purchase()).unwrap(); + let a = l.get_asset(id).unwrap(); + assert_eq!(a.depreciable_basis(), Some(d("1234.56"))); + assert_eq!(a.tax_basis_723(), None); + assert_eq!(a.built_in_gain_704c(), None); + assert_eq!(a.fmv_at_contribution(), None); +} + +/// Named regression 4 (the brief's reproduction 5, anonymized): a laptop +/// worth 1,440.00 that the member held at 1,688.40 from personal use: +/// §723 basis 1,688.40, depreciable basis 1,440.00 (the lesser, D-41), +/// built-in loss 248.40, which counts only for the contributor +/// (§704(c)(1)(C)). Before P3 its "tax basis" was the 1,440.00. +#[test] +fn personal_use_contribution_keeps_its_723_basis_apart_from_depreciable_basis() { + let (mut l, id) = one("1440.00"); + l.set_asset_basis( + id, + contribution(BasisRule::PersonalUseConversion, "1688.40", "1440.00"), + ) + .unwrap(); + let a = l.get_asset(id).unwrap(); + assert_eq!(a.tax_basis_723(), Some(d("1688.40"))); + assert_eq!(a.depreciable_basis(), Some(d("1440.00"))); + assert_eq!(a.built_in_gain_704c(), Some(d("-248.40"))); + assert!(a.sec704c_1c()); + assert!(a.fmv_is_recorded()); +} + +/// Named regression 5 (the brief's reproduction 4): value 1,000.00 and +/// adjusted basis 400.00 give a built-in gain of 600.00, fixed at +/// contribution (Treas. Reg. §1.704-3(a)(3)(ii)): correcting the book cost +/// to 1,100.00 leaves it 600.00. A record from before P3 (no value +/// recorded, read as the book cost) moves with the cost, as it did then. +#[test] +fn built_in_gain_is_fixed_at_contribution_not_book_cost() { + let (mut l, id) = one("1000.00"); + l.set_asset_basis( + id, + contribution(BasisRule::Carryover723, "400.00", "1000.00"), + ) + .unwrap(); + assert_eq!( + l.get_asset(id).unwrap().built_in_gain_704c(), + Some(d("600.00")) + ); + let fix = AssetCorrection { + cost: usd("1100.00"), + salvage_value: usd("0.00"), + useful_life_months: 36, + in_service_date: date(2026, 1, 1), + }; + l.correct_asset(id, fix).unwrap(); + let a = l.get_asset(id).unwrap(); + assert_eq!(a.cost.amount, d("1100.00")); + assert_eq!(a.built_in_gain_704c(), Some(d("600.00"))); + assert_eq!(a.fmv_at_contribution(), Some(d("1000.00"))); + // The pre-P3 record: value read as the book cost. + let (mut l, id) = one("1000.00"); + l.replay_set_asset_tax_basis(id, BasisRule::Carryover723, Some(d("400.00"))) + .unwrap(); + assert_eq!( + l.get_asset(id).unwrap().built_in_gain_704c(), + Some(d("600.00")) + ); + l.correct_asset(id, fix).unwrap(); + let a = l.get_asset(id).unwrap(); + assert_eq!(a.built_in_gain_704c(), Some(d("700.00"))); + assert!(!a.fmv_is_recorded()); +} + +/// The figure table (kernel::basis_figures INVARIANTS 2-4) on the ledger: +/// value equal to basis (no built-in gain: not §704(c) property); value +/// above basis for personal use (depreciable = basis); carryover above +/// and below. +#[test] +fn the_figures_follow_the_rule() { + for (rule, fmv, adj, dep, gain) in [ + ( + BasisRule::Carryover723, + "1000.00", + "600.00", + "600.00", + "400.00", + ), + ( + BasisRule::Carryover723, + "1000.00", + "1500.00", + "1500.00", + "-500.00", + ), + ( + BasisRule::Carryover723, + "1000.00", + "1000.00", + "1000.00", + "0.00", + ), + ( + BasisRule::PersonalUseConversion, + "1200.00", + "2000.00", + "1200.00", + "-800.00", + ), + ( + BasisRule::PersonalUseConversion, + "450.00", + "300.00", + "300.00", + "150.00", + ), + ( + BasisRule::PersonalUseConversion, + "999.99", + "999.99", + "999.99", + "0.00", + ), + ( + BasisRule::PersonalUseConversion, + "1000.00", + "1000.01", + "1000.00", + "-0.01", + ), + ( + BasisRule::PersonalUseConversion, + "1000.00", + "999.99", + "999.99", + "0.01", + ), + ] { + let (mut l, id) = one(fmv); + l.set_asset_basis(id, contribution(rule, adj, fmv)).unwrap(); + let a = l.get_asset(id).unwrap(); + assert_eq!(a.tax_basis_723(), Some(d(adj)), "{rule:?} {fmv} {adj}"); + assert_eq!(a.depreciable_basis(), Some(d(dep)), "{rule:?} {fmv} {adj}"); + assert_eq!( + a.built_in_gain_704c(), + Some(d(gain)), + "{rule:?} {fmv} {adj}" + ); + assert_eq!(a.sec704c_1c(), d(gain) < Decimal::ZERO); + } +} + +/// The four-contributed-devices shape: two members contribute four devices +/// from personal use, each at the lower of its value and its basis: a +/// laptop and a phone worth less than their basis (built-in losses, +/// §704(c)(1)(C)), a camera worth more (a built-in gain: depreciable is its +/// basis), a monitor at its basis (neither). Register totals by hand: +/// book cost 1,400.00 + 900.00 + 1,300.00 + 500.00 = 4,100.00; §723 basis +/// 1,750.40 + 1,129.99 + 1,080.00 + 500.00 = 4,460.39; depreciable basis +/// 1,400.00 + 900.00 + 1,080.00 + 500.00 = 3,880.00; built-in gain +/// −350.40 − 229.99 + 220.00 + 0.00 = −360.39 = 4,100.00 − 4,460.39. +#[test] +fn four_contributed_devices_each_take_the_lower_of_value_and_basis() { + let (mut l, a) = fixture(); + let devices = [ + ("Laptop", "1400.00", "1750.40", "1400.00", "-350.40", true), + ("Phone", "900.00", "1129.99", "900.00", "-229.99", true), + ("Camera", "1300.00", "1080.00", "1080.00", "220.00", false), + ("Monitor", "500.00", "500.00", "500.00", "0.00", false), + ]; + let mut ids = Vec::new(); + for (name, fmv, adj, _, _, _) in devices { + let id = register(&mut l, &a, name, fmv); + let mut b = contribution(BasisRule::PersonalUseConversion, adj, fmv); + b.sec704c_method = Some(Sec704cMethod::Traditional); + l.set_asset_basis(id, b).unwrap(); + ids.push(id); + } + for (id, (name, _, adj, dep, gain, loss)) in ids.iter().zip(devices) { + let x = l.get_asset(*id).unwrap(); + assert_eq!(x.tax_basis_723(), Some(d(adj)), "{name}"); + assert_eq!(x.depreciable_basis(), Some(d(dep)), "{name}"); + assert_eq!(x.built_in_gain_704c(), Some(d(gain)), "{name}"); + assert_eq!(x.sec704c_1c(), loss, "{name}"); + } + let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); + assert_eq!(r.total_cost, d("4100.00")); + assert_eq!(r.total_tax_basis_723, d("4460.39")); + assert_eq!(r.total_depreciable_basis, d("3880.00")); + assert_eq!(r.missing_basis, 0); + let gain: Decimal = r.rows.iter().filter_map(|x| x.built_in_gain_704c).sum(); + assert_eq!(gain, d("-360.39")); + assert_eq!(gain, r.total_cost - r.total_tax_basis_723); + assert_eq!(r.rows.iter().filter(|x| x.sec704c_1c).count(), 2); + assert!( + r.rows + .iter() + .all(|x| x.sec704c_method == Some(Sec704cMethod::Traditional)) + ); +} + +// -------------------------------------------------- legacy (pre-P3) lines + +/// A pre-P3 `asset.tax_basis_set` replays to the figures that version +/// gave: cost → the book cost; carryover → the contributor's basis; +/// personal-use conversion → min(book cost, basis); built-in gain = book +/// cost − basis; the value read as the book cost, not recorded. +#[test] +fn legacy_records_give_the_figures_they_gave() { + for (rule, basis, cost, dep, gain) in [ + (BasisRule::Purchase, None, "1234.56", "1234.56", None), + ( + BasisRule::Carryover723, + Some("650.25"), + "500.00", + "650.25", + Some("-150.25"), + ), + ( + BasisRule::PersonalUseConversion, + Some("2000.00"), + "1200.00", + "1200.00", + Some("-800.00"), + ), + ( + BasisRule::PersonalUseConversion, + Some("300.00"), + "450.00", + "300.00", + Some("150.00"), + ), + ] { + let (mut l, id) = one(cost); + l.replay_set_asset_tax_basis(id, rule, basis.map(d)) + .unwrap(); + let a = l.get_asset(id).unwrap(); + assert_eq!(a.depreciable_basis(), Some(d(dep)), "{rule:?}"); + assert_eq!(a.built_in_gain_704c(), gain.map(d), "{rule:?}"); + assert_eq!( + a.tax_basis_723(), + basis.filter(|_| rule.is_contribution()).map(d) + ); + assert!(!a.fmv_is_recorded()); + assert_eq!(a.basis, Some(legacy(rule, basis))); + } +} + +/// The legacy checks (as written through 2026-10-16) at replay: a purchase +/// takes no basis, a contribution needs one, non-negative, in cents; no +/// upper bound. +#[test] +fn legacy_replay_checks_what_it_checked() { + let (mut l, id) = one("1000.00"); + for (rule, basis) in [ + (BasisRule::Purchase, Some("1.00")), + (BasisRule::Carryover723, None), + (BasisRule::PersonalUseConversion, Some("-1.00")), + (BasisRule::Carryover723, Some("1.001")), + ] { + assert!(matches!( + l.replay_set_asset_tax_basis(id, rule, basis.map(d)), + Err(LedgerError::InvalidTaxBasis(_)) + )); + } + l.replay_set_asset_tax_basis(id, BasisRule::Carryover723, Some(Decimal::MAX)) + .unwrap(); +} + +/// An `asset.basis_set` at replay: its shape only (the facts fit the rule, +/// none negative), no bound on size or places (a later version may allow +/// more). +#[test] +fn basis_set_replay_checks_its_shape_only() { + let (mut l, id) = one("1000.00"); + let mut purchase_with_fmv = AssetBasis::purchase(); + purchase_with_fmv.fmv_at_contribution = Some(d("1.00")); + let mut no_fmv = contribution(BasisRule::Carryover723, "1.00", "1.00"); + no_fmv.fmv_at_contribution = None; + for b in [ + purchase_with_fmv, + no_fmv, + contribution(BasisRule::Carryover723, "-1.00", "1.00"), + contribution(BasisRule::Carryover723, "1.00", "-1.00"), + ] { + assert!(matches!( + l.replay_set_asset_basis(id, b), + Err(LedgerError::InvalidTaxBasis(_)) + )); + } + let wide = AssetBasis::contribution(BasisRule::Carryover723, d("1.0001"), Decimal::MAX); + l.replay_set_asset_basis(id, wide.clone()).unwrap(); + assert_eq!(l.get_asset(id).unwrap().basis, Some(wide)); +} + +// ------------------------------------------------------------- the dates + +/// Named regression 3 (the brief's reproduction 3): equipment bought +/// 2026-03-10 for 3,000.00 and placed in service 2026-04-01, 36 months. +/// Linked to its purchase, it is on the register from 2026-03-10: as of +/// 2026-03-31 its row is there with nothing accrued and the books' cost is +/// all registered; its schedule starts in April (a March charge would be +/// 3,000/36 × 22/31 = 59.14: none). +#[test] +fn an_asset_bought_before_it_is_in_service_is_on_the_register_from_acquisition() { + let (mut l, a) = fixture(); + let bought = post( + &mut l, + date(2026, 3, 10), + &[ + (a.equipment, Side::Debit, "3000.00"), + (a.cash, Side::Credit, "3000.00"), + ], + ); + let id = l + .register_asset(new_asset(&a, "Kiln", "3000.00", 36, date(2026, 4, 1))) + .unwrap(); + // Unlinked, it would join the register only on 2026-04-01, while the + // books carry its cost from 2026-03-10. + let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap(); + assert!(r.rows.is_empty()); + l.set_asset_acquisition(id, bought).unwrap(); + assert_eq!(l.asset_acquired_date(id), Some(date(2026, 3, 10))); + let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap(); + assert_eq!(r.rows.len(), 1); + let x = &r.rows[0]; + assert_eq!(x.acquired_date, date(2026, 3, 10)); + assert_eq!(x.in_service_date, date(2026, 4, 1)); + assert_eq!(x.accumulated_scheduled, Decimal::ZERO); + assert_eq!(x.accumulated_posted, Decimal::ZERO); + assert_eq!(x.book_value, d("3000.00")); + assert_eq!(r.unregistered_cost(), Decimal::ZERO); + let sched = + ledger_core::depreciation_schedule(l.get_asset(id).unwrap(), date(2026, 4, 30)).unwrap(); + assert_eq!(sched.len(), 1); + assert_eq!(sched[0].period, "2026-04"); + assert_eq!(sched[0].amount, d("83.33")); + // Before acquisition: not on the register. + assert!( + fixed_asset_register(&l, date(2026, 3, 9)) + .unwrap() + .rows + .is_empty() + ); +} + +/// With no entry linked the acquired date is the in-service date; a link +/// gives the entry's, never later than the in-service date. +#[test] +fn the_acquired_date_comes_from_the_linked_entry() { + let (mut l, a) = fixture(); + let id = l + .register_asset(new_asset(&a, "Lathe", "900.00", 12, date(2026, 2, 1))) + .unwrap(); + assert_eq!(l.asset_acquired_date(id), Some(date(2026, 2, 1))); + let e = post( + &mut l, + date(2026, 1, 20), + &[ + (a.equipment, Side::Debit, "900.00"), + (a.capital, Side::Credit, "900.00"), + ], + ); + l.set_asset_acquisition(id, e).unwrap(); + assert_eq!(l.asset_acquired_date(id), Some(date(2026, 1, 20))); + assert_eq!(l.asset_acquired_date(id + 9), None); +} + +/// set_asset_acquisition INVARIANT 2: a new link to an entry dated after +/// the in-service date, or after the disposal, is refused; replay +/// accepts what earlier versions did, and the acquired date is never +/// later than the in-service date. +#[test] +fn a_link_after_service_or_disposal_is_refused_and_replays() { + let (mut l, a) = fixture(); + let id = l + .register_asset(new_asset(&a, "Lathe", "900.00", 12, date(2026, 2, 1))) + .unwrap(); + let late = post( + &mut l, + date(2026, 2, 2), + &[ + (a.equipment, Side::Debit, "900.00"), + (a.cash, Side::Credit, "900.00"), + ], + ); + let before = l.clone(); + assert!(matches!( + l.set_asset_acquisition(id, late), + Err(LedgerError::AcquisitionEntryMismatch { .. }) + )); + assert_eq!(l, before); + l.replay_set_asset_acquisition(id, late).unwrap(); + assert_eq!(l.get_asset(id).unwrap().acquired_by, Some(late)); + assert_eq!(l.asset_acquired_date(id), Some(date(2026, 2, 1))); + // After the disposal: bought 05-01, scrapped 05-15 before its + // in-service date 06-01; a relink to an entry of 05-20 is refused. + let (mut l, a) = fixture(); + let id = l + .register_asset(new_asset(&a, "Drill", "300.00", 12, date(2026, 6, 1))) + .unwrap(); + let buy = |l: &mut Ledger, on| { + post( + l, + on, + &[ + (a.equipment, Side::Debit, "300.00"), + (a.cash, Side::Credit, "300.00"), + ], + ) + }; + let first = buy(&mut l, date(2026, 5, 1)); + l.set_asset_acquisition(id, first).unwrap(); + l.dispose_asset(id, date(2026, 5, 15), usd("0.00")).unwrap(); + let later = buy(&mut l, date(2026, 5, 20)); + assert!(matches!( + l.set_asset_acquisition(id, later), + Err(LedgerError::AcquisitionEntryMismatch { .. }) + )); + let on_the_day = buy(&mut l, date(2026, 5, 15)); + l.set_asset_acquisition(id, on_the_day).unwrap(); +} + +/// correct_asset INVARIANT 3: a new in-service date before the linked +/// entry's date is refused. +#[test] +fn an_in_service_date_before_the_acquisition_is_refused() { + let (mut l, a) = fixture(); + let id = l + .register_asset(new_asset(&a, "Lathe", "900.00", 12, date(2026, 2, 1))) + .unwrap(); + let e = post( + &mut l, + date(2026, 1, 20), + &[ + (a.equipment, Side::Debit, "900.00"), + (a.cash, Side::Credit, "900.00"), + ], + ); + l.set_asset_acquisition(id, e).unwrap(); + let fix = |on| AssetCorrection { + cost: usd("900.00"), + salvage_value: usd("0.00"), + useful_life_months: 12, + in_service_date: on, + }; + let before = l.clone(); + assert!(matches!( + l.correct_asset(id, fix(date(2026, 1, 19))), + Err(LedgerError::InServiceBeforeAcquisition { .. }) + )); + assert_eq!(l, before); + l.correct_asset(id, fix(date(2026, 1, 20))).unwrap(); + assert_eq!(l.get_asset(id).unwrap().in_service_date, date(2026, 1, 20)); +} + +/// The disposal floor is the acquired date: a disposal between acquisition +/// and service has nothing accrued; one before acquisition is refused. +#[test] +fn a_disposal_between_acquisition_and_service_is_allowed() { + let (mut l, a) = fixture(); + let id = l + .register_asset(new_asset(&a, "Kiln", "3000.00", 36, date(2026, 4, 1))) + .unwrap(); + let e = post( + &mut l, + date(2026, 3, 10), + &[ + (a.equipment, Side::Debit, "3000.00"), + (a.cash, Side::Credit, "3000.00"), + ], + ); + l.set_asset_acquisition(id, e).unwrap(); + assert!(matches!( + l.dispose_asset(id, date(2026, 3, 9), usd("0.00")), + Err(LedgerError::DisposalBeforeAcquisition { .. }) + )); + let amounts = + ledger_core::disposal_amounts(l.get_asset(id).unwrap(), date(2026, 3, 20), usd("2500.00")) + .unwrap(); + assert_eq!(amounts.accumulated, Decimal::ZERO); + assert_eq!(amounts.gain_loss, d("-500.00")); + l.dispose_asset(id, date(2026, 3, 20), usd("2500.00")) + .unwrap(); +} + +// ------------------------------------------------- posted and scheduled + +/// Books with one asset (1,200.00 over 12 months from 2026-01-01: 100.00 a +/// month) and January's run. +fn one_run() -> (Ledger, Accts, AssetId, ledger_core::EntryId) { + let (mut l, a) = fixture(); + let id = l + .register_asset(new_asset(&a, "Printer", "1200.00", 12, date(2026, 1, 1))) + .unwrap(); + let e = run(&mut l, &a, id, date(2026, 1, 31), "100.00"); + (l, a, id, e) +} + +/// accumulated_posted INVARIANT 2: a run counts from its entry's date; a +/// reversal takes it back from the reversal's date (so a reversal dated +/// after as_of still leaves it counted). +#[test] +fn posted_follows_the_entries_dates() { + let (mut l, _, id, e) = one_run(); + assert_eq!(l.accumulated_posted(id, date(2026, 1, 30)).unwrap(), d("0")); + assert_eq!( + l.accumulated_posted(id, date(2026, 1, 31)).unwrap(), + d("100.00") + ); + l.reverse_entry(e, date(2026, 3, 1), "undo").unwrap(); + assert_eq!( + l.accumulated_posted(id, date(2026, 2, 28)).unwrap(), + d("100.00") + ); + assert_eq!(l.accumulated_posted(id, date(2026, 3, 1)).unwrap(), d("0")); + assert_eq!(l.live_depreciation_run(id, "2026-01"), None); +} + +/// A reversal of the reversal reinstates the run (B-9), and the run counts +/// again: the month is not due twice (review R2). +#[test] +fn a_reinstated_run_counts_and_is_live() { + let (mut l, _, id, e) = one_run(); + let undo = l.reverse_entry(e, date(2026, 2, 1), "undo").unwrap(); + let redo = l.reverse_entry(undo, date(2026, 2, 2), "redo").unwrap(); + assert_eq!(l.accumulated_posted(id, date(2026, 2, 1)).unwrap(), d("0")); + assert_eq!( + l.accumulated_posted(id, date(2026, 2, 2)).unwrap(), + d("100.00") + ); + let live = l.live_depreciation_run(id, "2026-01").unwrap(); + assert_eq!(live.entry, e); + let _ = redo; +} + +/// A reversal dated before its run: between the two dates the books carry +/// −100.00, as the trial balance does (review R2). +#[test] +fn a_reversal_dated_before_its_run_matches_the_trial_balance() { + let (mut l, a, id, e) = one_run(); + l.reverse_entry(e, date(2026, 1, 15), "early").unwrap(); + let on = date(2026, 1, 20); + assert_eq!(l.accumulated_posted(id, on).unwrap(), d("-100.00")); + let tb = ledger_core::trial_balance_as_of(&l, on); + let accum = l.chart().get(a.accum).unwrap().code.clone(); + let line = tb.lines.iter().find(|x| x.code == accum).unwrap(); + assert_eq!(line.debit - line.credit, d("100.00")); + assert_eq!(l.accumulated_posted(id, date(2026, 1, 31)).unwrap(), d("0")); +} + +/// accumulated_posted INVARIANT 1: zero once disposed on or before as_of; +/// counted before. +#[test] +fn posted_is_zero_once_disposed() { + let (mut l, _, id, _) = one_run(); + l.dispose_asset(id, date(2026, 2, 15), usd("900.00")) + .unwrap(); + assert_eq!( + l.accumulated_posted(id, date(2026, 2, 14)).unwrap(), + d("100.00") + ); + assert_eq!(l.accumulated_posted(id, date(2026, 2, 15)).unwrap(), d("0")); + // Its runs stay: depreciation posted in January is still January's. + assert_eq!( + l.depreciation_posted_between(id, date(2026, 1, 1), date(2026, 12, 31)) + .unwrap(), + d("100.00") + ); +} + +/// The register's book value is cost less posted, the scheduled figure +/// beside it, and a hand entry in the account is unattributed. +#[test] +fn the_registers_book_value_is_what_the_books_carry() { + let (mut l, a, id, _) = one_run(); + let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap(); + let x = &r.rows[0]; + assert_eq!(x.accumulated_scheduled, d("300.00")); + assert_eq!(x.accumulated_posted, d("100.00")); + assert_eq!(x.book_value, d("1100.00")); + assert_eq!(r.total_book_value, d("1100.00")); + assert_eq!(r.unattributed_accumulated(), Decimal::ZERO); + post( + &mut l, + date(2026, 2, 28), + &[ + (a.depr_expense, Side::Debit, "100.00"), + (a.accum, Side::Credit, "100.00"), + ], + ); + let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap(); + assert_eq!(r.rows[0].book_value, d("1100.00")); + assert_eq!(r.unattributed_accumulated(), d("100.00")); + assert_eq!(r.unposted_depreciation(), d("100.00")); + let _ = id; +} + +/// check_depreciation_run: a month, a positive amount in cents, a posted +/// entry crediting the asset's accumulated depreciation by exactly that; +/// record_depreciation_run (replay) needs only the asset and entry. +#[test] +fn a_run_must_be_its_entry() { + let (mut l, a, id, e) = one_run(); + for (period, amount, why) in [ + ("2026-1", "100.00", "period"), + ("10000-01", "100.00", "period"), + ("2026-13", "100.00", "period"), + ("2026-01", "0", "amount"), + ("2026-01", "100.001", "amount"), + ("2026-01", "99.99", "credit"), + ] { + assert!( + matches!( + l.check_depreciation_run(id, period, e, d(amount)), + Err(LedgerError::InvalidDepreciationRun { .. }) + ), + "{why}" + ); + } + assert!(matches!( + l.check_depreciation_run(id + 9, "2026-01", e, d("100.00")), + Err(LedgerError::UnknownAsset(_)) + )); + let draft = l.try_create_entry(date(2026, 2, 28), "draft").unwrap(); + l.add_line(draft, a.depr_expense, Side::Debit, usd("100.00")) + .unwrap(); + l.add_line(draft, a.accum, Side::Credit, usd("100.00")) + .unwrap(); + assert!( + l.check_depreciation_run(id, "2026-02", draft, d("100.00")) + .is_err() + ); + // Replay: any journaled period and amount, the asset and entry known. + l.record_depreciation_run(id, "10000-01", e, d("0.001")) + .unwrap(); + assert!(matches!( + l.record_depreciation_run(id, "2026-01", e + 99, d("1")), + Err(LedgerError::UnknownEntry(_)) + )); + assert!(matches!( + l.record_depreciation_run(id + 9, "2026-01", e, d("1")), + Err(LedgerError::UnknownAsset(_)) + )); +} + +// ---------------------------------------------------------- the register + +/// Register rows carry each asset's facts and figures (INVARIANT 5) and +/// the totals cover held assets only (INVARIANT 3): a purchase, a +/// carryover, a personal-use conversion, one with no basis, one disposed. +/// By hand: depreciable 3,000.00 + 650.25 + 1,200.00 = 4,850.25; §723 +/// 650.25 + 2,000.00 = 2,650.25; the disposed one left out. +#[test] +fn register_rows_carry_the_assets_figures_and_totals_the_held() { + let (mut l, a) = fixture(); + let bought = register(&mut l, &a, "Server", "3000.00"); + let carried = register(&mut l, &a, "Printer", "500.00"); + let converted = register(&mut l, &a, "Laptop", "1200.00"); + let unrecorded = register(&mut l, &a, "Desk", "700.00"); + let sold = register(&mut l, &a, "Old monitor", "360.00"); + l.set_asset_basis(bought, AssetBasis::purchase()).unwrap(); + l.set_asset_basis( + carried, + contribution(BasisRule::Carryover723, "650.25", "500.00"), + ) + .unwrap(); + l.set_asset_basis( + converted, + contribution(BasisRule::PersonalUseConversion, "2000.00", "1200.00"), + ) + .unwrap(); + l.set_asset_basis(sold, AssetBasis::purchase()).unwrap(); + l.dispose_asset(sold, date(2026, 3, 31), usd("100.00")) + .unwrap(); + let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); + for row in &r.rows { + let x = l.get_asset(row.asset_id).unwrap(); + let f = x.basis_figures().unwrap(); + assert_eq!(row.basis_rule, x.basis.as_ref().map(|b| b.rule)); + assert_eq!(row.tax_basis_723, f.and_then(|f| f.tax_basis_723)); + assert_eq!(row.depreciable_basis, f.map(|f| f.depreciable_basis)); + assert_eq!(row.built_in_gain_704c, f.and_then(|f| f.built_in_gain_704c)); + assert_eq!(row.fmv_at_contribution, x.fmv_at_contribution()); + } + let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap(); + assert_eq!(row(carried).built_in_gain_704c, Some(d("-150.25"))); + assert!(row(carried).sec704c_1c); + assert_eq!(row(converted).depreciable_basis, Some(d("1200.00"))); + assert_eq!(row(converted).tax_basis_723, Some(d("2000.00"))); + assert_eq!(row(unrecorded).depreciable_basis, None); + assert!(row(sold).disposed.is_some()); + assert_eq!(r.total_depreciable_basis, d("4850.25")); + assert_eq!(r.total_tax_basis_723, d("2650.25")); + assert_eq!(r.missing_basis, 1); + // Before the disposal the monitor is held. + let r = fixed_asset_register(&l, date(2026, 2, 28)).unwrap(); + assert_eq!(r.total_depreciable_basis, d("5210.25")); +} + +/// Recording a basis leaves the book totals alone. +#[test] +fn a_basis_does_not_move_book_totals() { + let (mut l, a) = fixture(); + let x = register(&mut l, &a, "Laptop", "1200.00"); + let before = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); + l.set_asset_basis( + x, + contribution(BasisRule::PersonalUseConversion, "2000.00", "1200.00"), + ) + .unwrap(); + let after = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); + assert_eq!(after.total_cost, before.total_cost); + assert_eq!( + after.total_accumulated_scheduled, + before.total_accumulated_scheduled + ); + assert_eq!( + after.total_accumulated_posted, + before.total_accumulated_posted + ); + assert_eq!(after.total_book_value, before.total_book_value); + assert_eq!(after.ledger_cost, before.ledger_cost); +} + +// ---------------------------------------------------------------- proptest + +fn cents() -> impl Strategy { + (0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2)) +} + +fn positive_cents() -> impl Strategy { + (1i64..=100_000_000).prop_map(|c| Decimal::new(c, 2)) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// The figures follow the rule over random costs, values and bases, + /// and the register's totals agree (prop_basis_figures_laws on the + /// ledger). + #[test] + fn prop_figures_follow_their_rule( + assets in proptest::collection::vec((positive_cents(), cents(), cents(), 0usize..4), 1..8) + ) { + let (mut l, a) = fixture(); + let (mut dep_total, mut t723_total, mut missing) = (Decimal::ZERO, Decimal::ZERO, 0usize); + for (i, (cost, adj, fmv, rule)) in assets.iter().enumerate() { + let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string()); + match rule { + 0 => { + l.set_asset_basis(id, AssetBasis::purchase()).unwrap(); + let x = l.get_asset(id).unwrap(); + prop_assert_eq!(x.depreciable_basis(), Some(*cost)); + prop_assert_eq!(x.built_in_gain_704c(), None); + dep_total += *cost; + } + 1 | 2 => { + let r = RULES[*rule]; + l.set_asset_basis(id, AssetBasis::contribution(r, *adj, *fmv)).unwrap(); + let x = l.get_asset(id).unwrap(); + prop_assert_eq!(x.tax_basis_723(), Some(*adj)); + prop_assert_eq!(x.built_in_gain_704c(), Some(*fmv - *adj)); + prop_assert_eq!(x.sec704c_1c(), adj > fmv); + let dep = x.depreciable_basis().unwrap(); + if r == BasisRule::PersonalUseConversion { + prop_assert!(dep <= *adj && dep <= *fmv); + prop_assert!(dep == *adj || dep == *fmv); + } else { + prop_assert_eq!(dep, *adj); + } + dep_total += dep; + t723_total += *adj; + } + _ => missing += 1, + } + } + let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); + prop_assert_eq!(r.total_depreciable_basis, dep_total); + prop_assert_eq!(r.total_tax_basis_723, t723_total); + prop_assert_eq!(r.missing_basis, missing); + } + + /// check agrees with set, and a refused record changes nothing; the + /// spec, independently: a purchase takes no facts, a contribution both, + /// each non-negative and in cents. + #[test] + fn prop_check_agrees_with_set( + adj in proptest::option::of((-1_000_000i64..1_000_000, 0u32..5)), + fmv in proptest::option::of((-1_000_000i64..1_000_000, 0u32..5)), + rule in 0usize..3, + ) { + let (mut l, id) = one("1000.00"); + let b = AssetBasis { + rule: RULES[rule], + contributor_adjusted_basis: adj.map(|(c, s)| Decimal::new(c, s)), + fmv_at_contribution: fmv.map(|(c, s)| Decimal::new(c, s)), + sec704c_method: None, + source: None, + }; + let before = l.clone(); + let checked = l.check_asset_basis(id, &b); + let done = l.set_asset_basis(id, b.clone()); + prop_assert_eq!(checked.is_ok(), done.is_ok()); + let fine = |v: Option| v.is_some_and(|v| v >= Decimal::ZERO && v.normalize().scale() <= 2); + let valid = match RULES[rule] { + BasisRule::Purchase => adj.is_none() && fmv.is_none(), + _ => fine(b.contributor_adjusted_basis) && fine(b.fmv_at_contribution), + }; + prop_assert_eq!(done.is_ok(), valid, "{:?}", b); + if done.is_err() { + prop_assert_eq!(&l, &before); + } else { + prop_assert_eq!(l.get_asset(id).unwrap().basis.clone(), Some(b)); + } + } +} + +/// Recording a basis or a run changes no entry and no figure of +/// the trial balance (review R16: these commands are records about the +/// books, as Lean's `Step.record` is; the purity law of +/// `settings_replay_is_pure`). +#[test] +fn basis_links_and_runs_change_no_entry_or_balance() { + let (mut l, a, id, e) = one_run(); + let tb = |l: &Ledger| { + serde_json::to_value(ledger_core::trial_balance_as_of(l, date(2026, 12, 31))).unwrap() + }; + let entries = + |l: &Ledger| -> Vec { l.posted_entries().cloned().collect() }; + let (before_tb, before_entries) = (tb(&l), entries(&l)); + l.set_asset_basis( + id, + contribution(BasisRule::Carryover723, "1000.00", "1200.00"), + ) + .unwrap(); + l.replay_set_asset_tax_basis(id, BasisRule::PersonalUseConversion, Some(d("1500.00"))) + .unwrap(); + l.record_depreciation_run(id, "2026-01", e, d("100.00")) + .unwrap(); + let _ = a; + assert_eq!(tb(&l), before_tb); + assert_eq!(entries(&l), before_entries); +} diff --git a/crates/ledger-core/tests/spec_asset_tags.rs b/crates/ledger-core/tests/spec_asset_tags.rs index 343b6c7..2e4e381 100644 --- a/crates/ledger-core/tests/spec_asset_tags.rs +++ b/crates/ledger-core/tests/spec_asset_tags.rs @@ -9,7 +9,7 @@ //! `AssetRegister`, and `docs/spec/rules.md` B-15 and B-24. Depreciation //! amounts are worked by hand (straight line, whole months) or, for //! mid-month dates, taken from the already-tested building block -//! `accumulated_depreciation`. Nothing is read back from the code under +//! `accumulated_scheduled_through`. Nothing is read back from the code under //! test and then asserted. use std::collections::BTreeMap; @@ -21,7 +21,7 @@ use ledger_core::assets::{ }; use ledger_core::{ AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side, - accumulated_depreciation, fixed_asset_register, + accumulated_scheduled_through, fixed_asset_register, }; use proptest::prelude::*; use rust_decimal::Decimal; @@ -102,7 +102,7 @@ fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) { fn equipment(a: &Accts, description: &str, cost: &str, life: u32, on: NaiveDate) -> NewAsset { NewAsset { description: description.to_string(), - purchase_date: on, + in_service_date: on, cost: usd(cost), salvage_value: usd("0.00"), useful_life_months: life, @@ -715,6 +715,22 @@ fn depreciate(l: &mut Ledger, a: &Accts, accum: AccountId, on: NaiveDate, amt: & ); } +/// One asset's depreciation run for the month of `on`, as a store posts +/// and records it: Dr depreciation expense / Cr its accumulated +/// depreciation, then the run (Ledger::record_depreciation_run). +fn run(l: &mut Ledger, a: &Accts, asset: AssetId, on: NaiveDate, amt: &str) { + let accum = l.get_asset(asset).unwrap().accum_depr_account; + let e = l.try_create_entry(on, "depreciation").unwrap(); + l.add_line(e, a.depr_expense, Side::Debit, usd(amt)) + .unwrap(); + l.add_line(e, accum, Side::Credit, usd(amt)).unwrap(); + l.post_entry(e).unwrap(); + let period = on.format("%Y-%m").to_string(); + l.check_depreciation_run(asset, &period, e, d(amt)).unwrap(); + l.record_depreciation_run(asset, &period, e, d(amt)) + .unwrap(); +} + /// Buy equipment for cash: Dr cost account / Cr 1000. fn buy(l: &mut Ledger, a: &Accts, account: AccountId, on: NaiveDate, amt: &str) { post( @@ -747,7 +763,8 @@ fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) { .register_asset(equipment(&a, "Server", "2400.00", 24, date(2026, 1, 1))) .unwrap(); for m in 1..=3 { - depreciate(&mut l, &a, a.accum, month_end(m), "1100.00"); + run(&mut l, &a, laptop, month_end(m), "1000.00"); + run(&mut l, &a, server, month_end(m), "100.00"); } (l, a, laptop, server) } @@ -755,22 +772,27 @@ fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) { /// INVARIANT 3: totals are the sums over held (not disposed) rows. fn assert_totals_are_sums(r: &ledger_core::AssetRegister) { let held = r.rows.iter().filter(|x| x.disposed.is_none()); - let (mut c, mut acc, mut bv) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); + let (mut c, mut acc, mut posted, mut bv) = + (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); for x in held { c += x.cost; - acc += x.accumulated; + acc += x.accumulated_scheduled; + posted += x.accumulated_posted; bv += x.book_value; } assert_eq!(r.total_cost, c); - assert_eq!(r.total_accumulated, acc); + assert_eq!(r.total_accumulated_scheduled, acc); + assert_eq!(r.total_accumulated_posted, posted); assert_eq!(r.total_book_value, bv); for x in &r.rows { // INVARIANT 2 (held) / INVARIANT 1 (disposed). if x.disposed.is_some() { - assert_eq!(x.accumulated, Decimal::ZERO); + assert_eq!(x.accumulated_scheduled, Decimal::ZERO); + assert_eq!(x.accumulated_posted, Decimal::ZERO); assert_eq!(x.book_value, Decimal::ZERO); } else { - assert_eq!(x.book_value, x.cost - x.accumulated); + // Book value is what the books carry: cost less posted. + assert_eq!(x.book_value, x.cost - x.accumulated_posted); } } } @@ -790,42 +812,52 @@ fn register_agrees_with_the_books_when_depreciation_is_posted() { assert_eq!(x.tag.as_deref(), Some("FL-0001")); assert_eq!(x.serial.as_deref(), Some("C02XG0FDJGH5")); assert_eq!(x.description, "Laptop"); - assert_eq!(x.in_service, date(2026, 1, 1)); + assert_eq!(x.in_service_date, date(2026, 1, 1)); assert_eq!(x.cost, d("12000.00")); assert_eq!(x.salvage, d("0.00")); assert_eq!(x.useful_life_months, 12); - assert_eq!(x.accumulated, d("3000.00")); + assert_eq!(x.accumulated_scheduled, d("3000.00")); + assert_eq!(x.accumulated_posted, d("3000.00")); assert_eq!(x.book_value, d("9000.00")); assert_eq!(x.disposed, None); let y = &r.rows[1]; assert_eq!(y.tag, None); assert_eq!(y.serial, None); - assert_eq!(y.accumulated, d("300.00")); + assert_eq!(y.accumulated_scheduled, d("300.00")); + assert_eq!(y.accumulated_posted, d("300.00")); assert_eq!(y.book_value, d("2100.00")); assert_eq!(r.total_cost, d("14400.00")); - assert_eq!(r.total_accumulated, d("3300.00")); + assert_eq!(r.total_accumulated_scheduled, d("3300.00")); + assert_eq!(r.total_accumulated_posted, d("3300.00")); assert_eq!(r.total_book_value, d("11100.00")); // INVARIANT 4: the GL, each shared account counted once. assert_eq!(r.ledger_cost, d("14400.00")); assert_eq!(r.ledger_accumulated, d("3300.00")); assert_eq!(r.unposted_depreciation(), Decimal::ZERO); + assert_eq!(r.unattributed_accumulated(), Decimal::ZERO); assert_eq!(r.unregistered_cost(), Decimal::ZERO); assert_totals_are_sums(&r); } -/// unposted_depreciation: due by schedule but not in the books. +/// unposted_depreciation: due by schedule but not in the books. Book +/// value stays what the books carry (cost less March's posted figure), +/// never the schedule's April figure. #[test] fn register_shows_unposted_depreciation() { let (l, _, _, _) = register_fixture(); let r = fixed_asset_register(&l, date(2026, 4, 30)).unwrap(); - assert_eq!(r.total_accumulated, d("4400.00")); + assert_eq!(r.total_accumulated_scheduled, d("4400.00")); + assert_eq!(r.total_accumulated_posted, d("3300.00")); + assert_eq!(r.total_book_value, d("11100.00")); assert_eq!(r.ledger_accumulated, d("3300.00")); assert_eq!(r.unposted_depreciation(), d("1100.00")); assert_eq!(r.unregistered_cost(), Decimal::ZERO); assert_totals_are_sums(&r); } -/// Negative unposted_depreciation when the books carry more. +/// Negative unposted_depreciation when the books carry more; a hand +/// entry no asset's run accounts for is unattributed, and no asset's book +/// value takes it. #[test] fn register_shows_overposted_depreciation_as_negative() { let (mut l, a, _, _) = register_fixture(); @@ -833,6 +865,8 @@ fn register_shows_overposted_depreciation_as_negative() { let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap(); assert_eq!(r.ledger_accumulated, d("3350.00")); assert_eq!(r.unposted_depreciation(), d("-50.00")); + assert_eq!(r.unattributed_accumulated(), d("50.00")); + assert_eq!(r.total_book_value, d("11100.00")); } /// The GL balances are on as_of: later postings don't count. @@ -840,25 +874,27 @@ fn register_shows_overposted_depreciation_as_negative() { fn register_ledger_balances_are_as_of_the_date() { let (l, _, _, _) = register_fixture(); let r = fixed_asset_register(&l, date(2026, 2, 28)).unwrap(); - assert_eq!(r.total_accumulated, d("2200.00")); + assert_eq!(r.total_accumulated_scheduled, d("2200.00")); + assert_eq!(r.total_accumulated_posted, d("2200.00")); assert_eq!(r.ledger_accumulated, d("2200.00")); assert_eq!(r.ledger_cost, d("14400.00")); assert_eq!(r.unposted_depreciation(), Decimal::ZERO); } -/// INVARIANT 2 mid-month: accumulated is accumulated_depreciation through -/// as_of. +/// INVARIANT 2 mid-month: accumulated_scheduled is accumulated_scheduled_through +/// through as_of; book value stays cost less what is posted. #[test] fn register_accumulated_mid_month_follows_the_schedule() { let (l, _, laptop, server) = register_fixture(); let on = date(2026, 5, 17); let r = fixed_asset_register(&l, on).unwrap(); - let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on).unwrap(); - let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on).unwrap(); - assert_eq!(r.rows[0].accumulated, la); - assert_eq!(r.rows[1].accumulated, sa); - assert_eq!(r.rows[0].book_value, d("12000.00") - la); - assert_eq!(r.total_accumulated, la + sa); + let la = accumulated_scheduled_through(l.get_asset(laptop).unwrap(), on).unwrap(); + let sa = accumulated_scheduled_through(l.get_asset(server).unwrap(), on).unwrap(); + assert_eq!(r.rows[0].accumulated_scheduled, la); + assert_eq!(r.rows[1].accumulated_scheduled, sa); + assert_eq!(r.rows[0].accumulated_posted, d("3000.00")); + assert_eq!(r.rows[0].book_value, d("9000.00")); + assert_eq!(r.total_accumulated_scheduled, la + sa); assert_totals_are_sums(&r); } @@ -881,7 +917,8 @@ fn register_shows_unregistered_cost() { assert_eq!(r2.unposted_depreciation(), d("100.00")); } -/// INVARIANT 1: only assets registered (placed in service) by as_of. +/// INVARIANT 1: only assets acquired by as_of (with no acquisition entry +/// linked, the in-service date). #[test] fn register_leaves_out_assets_not_yet_in_service() { let (mut l, a, laptop, server) = register_fixture(); @@ -897,14 +934,14 @@ fn register_leaves_out_assets_not_yet_in_service() { let ids: Vec = r.rows.iter().map(|x| x.asset_id).collect(); assert_eq!(ids, vec![laptop, server, chair]); assert_eq!( - r.rows[2].accumulated, - accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap() + r.rows[2].accumulated_scheduled, + accumulated_scheduled_through(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap() ); // Before anything: empty and zero. let r = fixed_asset_register(&l, date(2025, 12, 31)).unwrap(); assert!(r.rows.is_empty()); assert_eq!(r.total_cost, Decimal::ZERO); - assert_eq!(r.total_accumulated, Decimal::ZERO); + assert_eq!(r.total_accumulated_scheduled, Decimal::ZERO); assert_eq!(r.total_book_value, Decimal::ZERO); } @@ -912,8 +949,14 @@ fn register_leaves_out_assets_not_yet_in_service() { /// depreciation: book value 1,800, gain 200. Entry: Dr 1000 2,000, /// Dr 1510 600, Cr 1500 2,400, Cr 4900 200. fn dispose_server(l: &mut Ledger, a: &Accts, server: AssetId) { + let laptop = l + .registered_assets() + .find(|x| x.description == "Laptop") + .unwrap() + .id; for m in 4..=6 { - depreciate(l, a, a.accum, month_end(m), "1100.00"); + run(l, a, laptop, month_end(m), "1000.00"); + run(l, a, server, month_end(m), "100.00"); } l.dispose_asset(server, date(2026, 6, 30), usd("2000.00")) .unwrap(); @@ -938,18 +981,22 @@ fn register_shows_a_disposed_asset_outside_the_totals() { assert_eq!(r.rows.len(), 2, "a disposed asset still has a row"); let s = r.rows.iter().find(|x| x.asset_id == server).unwrap(); assert_eq!(s.disposed, Some((date(2026, 6, 30), d("2000.00")))); - assert_eq!(s.accumulated, Decimal::ZERO); + assert_eq!(s.accumulated_scheduled, Decimal::ZERO); + assert_eq!(s.accumulated_posted, Decimal::ZERO); assert_eq!(s.book_value, Decimal::ZERO); assert_eq!(s.tag.as_deref(), Some("FL-0002")); assert_eq!(s.cost, d("2400.00")); let lp = r.rows.iter().find(|x| x.asset_id == laptop).unwrap(); - assert_eq!(lp.accumulated, d("6000.00")); + assert_eq!(lp.accumulated_scheduled, d("6000.00")); + assert_eq!(lp.accumulated_posted, d("6000.00")); assert_eq!(r.total_cost, d("12000.00")); - assert_eq!(r.total_accumulated, d("6000.00")); + assert_eq!(r.total_accumulated_scheduled, d("6000.00")); + assert_eq!(r.total_accumulated_posted, d("6000.00")); assert_eq!(r.total_book_value, d("6000.00")); assert_eq!(r.ledger_cost, d("12000.00")); assert_eq!(r.ledger_accumulated, d("6000.00")); assert_eq!(r.unposted_depreciation(), Decimal::ZERO); + assert_eq!(r.unattributed_accumulated(), Decimal::ZERO); assert_eq!(r.unregistered_cost(), Decimal::ZERO); assert_totals_are_sums(&r); } @@ -962,10 +1009,11 @@ fn register_before_the_disposal_date_shows_the_asset_held() { let r = fixed_asset_register(&l, date(2026, 5, 31)).unwrap(); let s = r.rows.iter().find(|x| x.asset_id == server).unwrap(); assert_eq!(s.disposed, None); - assert_eq!(s.accumulated, d("500.00")); + assert_eq!(s.accumulated_scheduled, d("500.00")); + assert_eq!(s.accumulated_posted, d("500.00")); assert_eq!(s.book_value, d("1900.00")); assert_eq!(r.total_cost, d("14400.00")); - assert_eq!(r.total_accumulated, d("5500.00")); + assert_eq!(r.total_accumulated_scheduled, d("5500.00")); assert_eq!(r.ledger_cost, d("14400.00")); assert_eq!(r.ledger_accumulated, d("5500.00")); assert_eq!(r.unposted_depreciation(), Decimal::ZERO); @@ -977,27 +1025,29 @@ fn register_before_the_disposal_date_shows_the_asset_held() { fn register_sums_the_gl_over_distinct_asset_accounts() { let (mut l, a, _, _) = register_fixture(); buy(&mut l, &a, a.vehicles, date(2026, 1, 1), "36000.00"); - l.register_asset(NewAsset { - description: "Van".to_string(), - purchase_date: date(2026, 1, 1), - cost: usd("36000.00"), - salvage_value: usd("6000.00"), - useful_life_months: 60, - cost_account: a.vehicles, - accum_depr_account: a.vehicles_accum, - expense_account: a.depr_expense, - }) - .unwrap(); + let van = l + .register_asset(NewAsset { + description: "Van".to_string(), + in_service_date: date(2026, 1, 1), + cost: usd("36000.00"), + salvage_value: usd("6000.00"), + useful_life_months: 60, + cost_account: a.vehicles, + accum_depr_account: a.vehicles_accum, + expense_account: a.depr_expense, + }) + .unwrap(); // (36,000 − 6,000) / 60 = 500/month; Jan–Mar posted. for m in 1..=3 { - depreciate(&mut l, &a, a.vehicles_accum, month_end(m), "500.00"); + run(&mut l, &a, van, month_end(m), "500.00"); } let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap(); assert_eq!(r.rows[2].salvage, d("6000.00")); - assert_eq!(r.rows[2].accumulated, d("1500.00")); + assert_eq!(r.rows[2].accumulated_scheduled, d("1500.00")); + assert_eq!(r.rows[2].accumulated_posted, d("1500.00")); assert_eq!(r.rows[2].book_value, d("34500.00")); assert_eq!(r.total_cost, d("50400.00")); - assert_eq!(r.total_accumulated, d("4800.00")); + assert_eq!(r.total_accumulated_scheduled, d("4800.00")); assert_eq!(r.total_book_value, d("45600.00")); assert_eq!(r.ledger_cost, d("50400.00")); assert_eq!(r.ledger_accumulated, d("4800.00")); @@ -1031,13 +1081,15 @@ fn register_of_no_assets_is_empty() { } /// Defect 22 in the register (INVARIANT 2): an asset disposed of later in -/// the same month as `as_of` is held on `as_of`, its accumulated -/// depreciation prorated by days through `as_of`, never plugged to the -/// whole. Hand check: the 12,000.00 laptop (12 months from 2026-01-01), -/// disposed of 2026-06-20; on 2026-06-10, five months of 1,000.00 and -/// June's 10/30: 5,333.33, book value 6,666.67. Before P3 the disposed +/// the same month as `as_of` is held on `as_of`, its scheduled +/// accumulated depreciation prorated by days through `as_of`, never +/// plugged to the whole. Hand check: the 12,000.00 laptop (12 months from +/// 2026-01-01), disposed of 2026-06-20; on 2026-06-10, five months of +/// 1,000.00 and June's 10/30: 5,333.33 scheduled. Before P3 the disposed /// schedule ended in a plug, so the register showed 12,000.00 and 0.00. -/// The same in the life's final month: on 2026-12-15, 11,483.87. +/// The same in the life's final month: on 2026-12-15, 11,483.87. Book +/// value is cost less what the books carry (defect 13, rules.md B-15 rule +/// 8): the fixture's runs, January to March, 3,000.00, so 9,000.00. #[test] fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() { let (mut l, _, laptop, _) = register_fixture(); @@ -1045,19 +1097,21 @@ fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() { .unwrap(); let r = fixed_asset_register(&l, date(2026, 6, 10)).unwrap(); assert_eq!(r.rows[0].disposed, None); - assert_eq!(r.rows[0].accumulated, d("5333.33")); - assert_eq!(r.rows[0].book_value, d("6666.67")); + assert_eq!(r.rows[0].accumulated_scheduled, d("5333.33")); + // Book value is what the books carry: January to March's runs. + assert_eq!(r.rows[0].accumulated_posted, d("3000.00")); + assert_eq!(r.rows[0].book_value, d("9000.00")); assert_totals_are_sums(&r); // On the disposal day it is gone: no figures, in no total. let r = fixed_asset_register(&l, date(2026, 6, 20)).unwrap(); - assert_eq!(r.rows[0].accumulated, Decimal::ZERO); + assert_eq!(r.rows[0].accumulated_scheduled, Decimal::ZERO); assert!(r.rows[0].disposed.is_some()); let (l, _, laptop, _) = register_fixture(); let r = fixed_asset_register(&l, date(2026, 12, 15)).unwrap(); let row = r.rows.iter().find(|x| x.asset_id == laptop).unwrap(); assert_eq!( - (row.accumulated, row.book_value), - (d("11483.87"), d("516.13")) + (row.accumulated_scheduled, row.book_value), + (d("11483.87"), d("9000.00")) ); } diff --git a/crates/ledger-core/tests/spec_assets_payroll.rs b/crates/ledger-core/tests/spec_assets_payroll.rs index 8370413..fae4614 100644 --- a/crates/ledger-core/tests/spec_assets_payroll.rs +++ b/crates/ledger-core/tests/spec_assets_payroll.rs @@ -14,8 +14,8 @@ use ledger_core::payroll::{ }; use ledger_core::{ AccountId, AccountType, Asset, AssetId, AssetStatus, Currency, DepreciationMethod, EntryId, - Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_depreciation, book_value, - depreciation_schedule, disposal_amounts, + Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_scheduled_through, + depreciation_schedule, disposal_amounts, scheduled_book_value, }; use proptest::prelude::*; use rust_decimal::Decimal; @@ -49,7 +49,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset { Asset { id: 1, description: "Test asset".to_string(), - purchase_date: purchase, + in_service_date: purchase, cost: usd(cost), salvage_value: usd(salvage), useful_life_months: life, @@ -61,7 +61,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset { tag: None, serial: None, acquired_by: None, - tax: None, + basis: None, } } @@ -130,7 +130,7 @@ fn fixture() -> (Ledger, Accts) { fn new_asset(a: &Accts) -> NewAsset { NewAsset { description: "Laptop".to_string(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: usd("12000.00"), salvage_value: usd("0.00"), useful_life_months: 12, @@ -377,9 +377,12 @@ fn salvage_value_is_never_depreciated_b15() { let amounts: Vec = s.iter().map(|l| l.amount).collect(); assert_eq!(amounts, vec![d("1000.00"); 4]); assert_eq!(s.last().unwrap().book_value, d("1000.00")); - assert_eq!(book_value(&a, date(2040, 1, 31)).unwrap(), d("1000.00")); assert_eq!( - accumulated_depreciation(&a, date(2040, 1, 31)).unwrap(), + scheduled_book_value(&a, date(2040, 1, 31)).unwrap(), + d("1000.00") + ); + assert_eq!( + accumulated_scheduled_through(&a, date(2040, 1, 31)).unwrap(), d("4000.00") ); } @@ -418,10 +421,13 @@ fn nothing_depreciates_before_purchase() { .is_empty() ); assert_eq!( - accumulated_depreciation(&a, date(2025, 12, 31)).unwrap(), + accumulated_scheduled_through(&a, date(2025, 12, 31)).unwrap(), d("0") ); - assert_eq!(book_value(&a, date(2025, 12, 31)).unwrap(), d("12000.00")); + assert_eq!( + scheduled_book_value(&a, date(2025, 12, 31)).unwrap(), + d("12000.00") + ); } /// Oracle: interface docs "accumulated through as_of" and "book value = @@ -430,10 +436,13 @@ fn nothing_depreciates_before_purchase() { fn accumulated_and_book_value_mid_life() { let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); assert_eq!( - accumulated_depreciation(&a, date(2026, 6, 30)).unwrap(), + accumulated_scheduled_through(&a, date(2026, 6, 30)).unwrap(), + d("6000.00") + ); + assert_eq!( + scheduled_book_value(&a, date(2026, 6, 30)).unwrap(), d("6000.00") ); - assert_eq!(book_value(&a, date(2026, 6, 30)).unwrap(), d("6000.00")); } // ------------------------------- defect 22: the final month, disposals @@ -454,7 +463,10 @@ fn defect22_depreciation_through_mid_final_month_is_prorated_not_plugged() { assert_eq!(dec.amount, d("483.87")); assert_eq!(dec.accumulated, d("11483.87")); assert_eq!(dec.book_value, d("516.13")); - assert_eq!(book_value(&a, date(2026, 12, 15)).unwrap(), d("516.13")); + assert_eq!( + scheduled_book_value(&a, date(2026, 12, 15)).unwrap(), + d("516.13") + ); // Through its last day, the whole of it. let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap(); assert_eq!( @@ -531,12 +543,12 @@ fn defect22_a_disposed_assets_schedule_stops_at_its_disposal_day() { ); assert_eq!(jun.accumulated, before.accumulated); assert_eq!( - accumulated_depreciation(a, NaiveDate::MAX).unwrap(), + accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(), d("5500.00") ); // A through-date before the disposal is unchanged by it. assert_eq!( - accumulated_depreciation(a, date(2026, 5, 31)).unwrap(), + accumulated_scheduled_through(a, date(2026, 5, 31)).unwrap(), d("5000.00") ); @@ -680,12 +692,13 @@ fn disposal_refuses_negative_proceeds() { /// Oracle: LedgerError::DisposalBeforePurchase doc "disposal date must not /// precede the purchase date". #[test] -fn disposal_refuses_date_before_purchase() { +fn disposal_before_service_accrues_nothing() { + // The floor is the acquired date, the ledger's to check + // (Ledger::dispose_asset); the math alone has nothing accrued yet. let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); - assert!(matches!( - disposal_amounts(&a, date(2025, 12, 31), usd("100.00")), - Err(LedgerError::DisposalBeforePurchase(_)) - )); + let got = disposal_amounts(&a, date(2025, 12, 31), usd("100.00")).unwrap(); + assert_eq!(got.accumulated, d("0")); + assert_eq!(got.gain_loss, d("-11900.00")); } // ------------------------------------------------- register / dispose @@ -702,7 +715,7 @@ fn register_asset_records_inputs_under_next_id() { assert_eq!(id, expected_id); let got = l.get_asset(id).unwrap(); assert_eq!(got.description, "Laptop"); - assert_eq!(got.purchase_date, date(2026, 1, 1)); + assert_eq!(got.in_service_date, date(2026, 1, 1)); assert_eq!(got.cost, usd("12000.00")); assert_eq!(got.salvage_value, usd("0.00")); assert_eq!(got.useful_life_months, 12); @@ -900,7 +913,7 @@ fn dispose_asset_refuses_bad_disposals_unchanged() { ); assert!(matches!( l.dispose_asset(id, date(2025, 12, 1), usd("1.00")), - Err(LedgerError::DisposalBeforePurchase(_)) + Err(LedgerError::DisposalBeforeAcquisition { .. }) )); assert_eq!(snapshot(&l), before); } @@ -1317,7 +1330,7 @@ proptest! { prop_assert_eq!(total, cost - salvage); let life = a.useful_life_months as usize; prop_assert!(s.len() == life || s.len() == life + 1, "len {} life {}", s.len(), life); - prop_assert_eq!(&s[0].period, &format!("{:04}-{:02}", a.purchase_date.year(), a.purchase_date.month())); + prop_assert_eq!(&s[0].period, &format!("{:04}-{:02}", a.in_service_date.year(), a.in_service_date.month())); let mut acc = Decimal::ZERO; let mut prev_bv = cost; for (i, l) in s.iter().enumerate() { @@ -1336,7 +1349,7 @@ proptest! { prop_assert_eq!(s.last().unwrap().book_value, salvage); } - /// Oracle: accumulated_depreciation / book_value docs agree with the + /// Oracle: accumulated_scheduled_through / book_value docs agree with the /// schedule at each month end, a shorter schedule is a prefix of the full /// one, and nothing is depreciated before purchase. #[test] @@ -1344,14 +1357,14 @@ proptest! { let full = depreciation_schedule(&a, date(2050, 12, 31)).unwrap(); for (i, l) in full.iter().enumerate() { let end = period_end(&l.period); - prop_assert_eq!(accumulated_depreciation(&a, end).unwrap(), l.accumulated); - prop_assert_eq!(book_value(&a, end).unwrap(), l.book_value); + prop_assert_eq!(accumulated_scheduled_through(&a, end).unwrap(), l.accumulated); + prop_assert_eq!(scheduled_book_value(&a, end).unwrap(), l.book_value); let prefix = depreciation_schedule(&a, end).unwrap(); prop_assert_eq!(&prefix[..], &full[..=i]); } - let before = a.purchase_date.pred_opt().unwrap(); - prop_assert_eq!(accumulated_depreciation(&a, before).unwrap(), Decimal::ZERO); - prop_assert_eq!(book_value(&a, before).unwrap(), a.cost.amount); + let before = a.in_service_date.pred_opt().unwrap(); + prop_assert_eq!(accumulated_scheduled_through(&a, before).unwrap(), Decimal::ZERO); + prop_assert_eq!(scheduled_book_value(&a, before).unwrap(), a.cost.amount); } /// Oracle: B-15 partial months by days + interface "the last month absorbs @@ -1386,12 +1399,12 @@ proptest! { offset in 0u32..=200, proceeds in 0i64..=20_000_000, ) { - let when = month_end(a.purchase_date.checked_add_months(Months::new(offset)).unwrap()); + let when = month_end(a.in_service_date.checked_add_months(Months::new(offset)).unwrap()); let pr = Decimal::new(proceeds, 2); let got = disposal_amounts(&a, when, Money::new(pr, Currency::USD)).unwrap(); - prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when).unwrap()); + prop_assert_eq!(got.accumulated, accumulated_scheduled_through(&a, when).unwrap()); prop_assert_eq!(got.book_value, a.cost.amount - got.accumulated); - prop_assert_eq!(got.book_value, book_value(&a, when).unwrap()); + prop_assert_eq!(got.book_value, scheduled_book_value(&a, when).unwrap()); prop_assert_eq!(got.gain_loss, pr - got.book_value); prop_assert!(got.book_value >= a.salvage_value.amount); } diff --git a/crates/ledger-core/tests/spec_bounds.rs b/crates/ledger-core/tests/spec_bounds.rs index f02b74d..d626f22 100644 --- a/crates/ledger-core/tests/spec_bounds.rs +++ b/crates/ledger-core/tests/spec_bounds.rs @@ -230,9 +230,9 @@ fn replayed_dates_outside_the_calendar_are_accepted() { let equipment = add("1500", AccountType::Asset); let accum = add("1501", AccountType::Asset); let depreciation = add("6100", AccountType::Expense); - let asset = |purchase_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset { + let asset = |in_service_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset { description: "Late".into(), - purchase_date, + in_service_date, cost: usd(Decimal::new(120000, 2)), salvage_value: usd(Decimal::ZERO), useful_life_months, @@ -267,11 +267,11 @@ fn replayed_dates_outside_the_calendar_are_accepted() { .ledger .replay_register_asset(asset(d(2026, 1, 1), 12)) .unwrap(); - let fix = |purchase_date: NaiveDate| ledger_core::AssetCorrection { + let fix = |in_service_date: NaiveDate| ledger_core::AssetCorrection { cost: usd(Decimal::new(120000, 2)), salvage_value: usd(Decimal::ZERO), useful_life_months: 60, - purchase_date, + in_service_date, }; assert_eq!( b.ledger.correct_asset(id, fix(d(9999, 1, 1))).unwrap_err(), @@ -280,7 +280,10 @@ fn replayed_dates_outside_the_calendar_are_accepted() { b.ledger .replay_correct_asset(id, fix(d(9999, 1, 1))) .unwrap(); - assert_eq!(b.ledger.get_asset(id).unwrap().purchase_date, d(9999, 1, 1)); + assert_eq!( + b.ledger.get_asset(id).unwrap().in_service_date, + d(9999, 1, 1) + ); // A disposal dated past the calendar. let proceeds = usd(Decimal::ZERO); assert_eq!( @@ -467,18 +470,36 @@ fn amounts_larger_than_a_line_are_refused_everywhere() { ledger_core::payout_plan(&payout(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT), b.ledger.chart()) .is_ok() ); - // A contributor's basis. - let basis = |b: Decimal| ledger_core::TaxBasis { - rule: ledger_core::TaxBasisRule::Carryover, - contributor_basis: Some(b), + // A contributor's basis and a value at contribution. + let basis = |b: Decimal, v: Decimal| { + ledger_core::AssetBasis::contribution(ledger_core::BasisRule::Carryover723, b, v) }; - assert!(basis(big).check().is_err()); - assert!(basis(MAX_LINE_AMOUNT).check().is_ok()); - // On replay: every journaled basis passed check_journaled. + assert!(basis(big, Decimal::ONE).check().is_err()); + assert!(basis(Decimal::ONE, big).check().is_err()); + assert!(basis(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT).check().is_ok()); + // On replay: every journaled basis passed check_journaled (a pre-P3 + // one check_legacy_journaled). let cents_max = Decimal::MAX.round_dp(2) / Decimal::ONE_HUNDRED; - assert!(basis(big.round_dp(2)).check_journaled().is_ok()); - assert!(basis(cents_max.round_dp(2)).check_journaled().is_ok()); - assert!(basis(-Decimal::ONE).check_journaled().is_err()); + assert!( + basis(big.round_dp(2), big.round_dp(2)) + .check_journaled() + .is_ok() + ); + assert!( + basis(cents_max.round_dp(2), Decimal::ONE) + .check_journaled() + .is_ok() + ); + assert!( + basis(-Decimal::ONE, Decimal::ONE) + .check_journaled() + .is_err() + ); + let mut legacy = basis(big.round_dp(2), Decimal::ONE); + legacy.fmv_at_contribution = None; + assert!(legacy.check_legacy_journaled().is_ok()); + legacy.contributor_adjusted_basis = Some(-Decimal::ONE); + assert!(legacy.check_legacy_journaled().is_err()); // A party's share of an entry. let share = |a: Decimal| { vec![ledger_core::PartyShare { @@ -508,16 +529,19 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() { // Decimal's largest is a whole number of cents, as every version // required of a basis and a share. let cents_max = Decimal::MAX; - let basis = ledger_core::TaxBasis { - rule: ledger_core::TaxBasisRule::Carryover, - contributor_basis: Some(cents_max), + let basis = ledger_core::AssetBasis { + rule: ledger_core::BasisRule::Carryover723, + contributor_adjusted_basis: Some(cents_max), + fmv_at_contribution: None, + sec704c_method: None, + source: None, }; for _ in 0..2 { let id = b .ledger .register_asset(ledger_core::NewAsset { description: "Laptop".into(), - purchase_date: d(2026, 1, 1), + in_service_date: d(2026, 1, 1), cost: usd(Decimal::new(240000, 2)), salvage_value: usd(Decimal::ZERO), useful_life_months: 24, @@ -526,12 +550,16 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() { expense_account: depreciation, }) .unwrap(); + let mut new = basis.clone(); + new.fmv_at_contribution = Some(Decimal::new(240000, 2)); assert!(matches!( - b.ledger.set_asset_tax_basis(id, basis), + b.ledger.set_asset_basis(id, new), Err(LedgerError::InvalidTaxBasis(_)) )); - b.ledger.replay_set_asset_tax_basis(id, basis).unwrap(); - assert_eq!(b.ledger.get_asset(id).unwrap().tax, Some(basis)); + b.ledger + .replay_set_asset_tax_basis(id, basis.rule, basis.contributor_adjusted_basis) + .unwrap(); + assert_eq!(b.ledger.get_asset(id).unwrap().basis, Some(basis.clone())); } assert!(matches!( ledger_core::fixed_asset_register(&b.ledger, d(2026, 12, 31)), @@ -630,7 +658,7 @@ fn disposal_proceeds_and_dates_are_bounded() { let depreciation = add("6100", AccountType::Expense); let laptop = || ledger_core::NewAsset { description: "Laptop".into(), - purchase_date: d(2026, 1, 1), + in_service_date: d(2026, 1, 1), cost: usd(Decimal::new(240000, 2)), salvage_value: usd(Decimal::ZERO), useful_life_months: 24, @@ -672,8 +700,11 @@ fn disposal_proceeds_and_dates_are_bounded() { b.ledger.get_asset(old).unwrap().status, ledger_core::AssetStatus::Disposed { date, .. } if date == NaiveDate::MAX )); - // Replay keeps what every version checked: no disposal twice, none - // before the purchase, no negative proceeds. + // Replay keeps what every version checked: no disposal twice, no + // negative proceeds. The date floor (the acquired date, P3) is a + // command's: replay accepts a disposal before it, which every earlier + // version's commands refused anyway (rules.md B-28: replay accepts + // more, never less). assert_eq!( b.ledger .replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::ONE)) @@ -681,16 +712,19 @@ fn disposal_proceeds_and_dates_are_bounded() { LedgerError::AssetAlreadyDisposed(old) ); let early = b.ledger.register_asset(laptop()).unwrap(); - assert!(matches!( - b.ledger - .replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)), - Err(LedgerError::DisposalBeforePurchase(_)) - )); assert!(matches!( b.ledger .replay_dispose_asset(early, d(2026, 2, 1), usd(-Decimal::ONE)), Err(LedgerError::NegativeProceeds(_)) )); + assert!(matches!( + b.ledger + .dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)), + Err(LedgerError::DisposalBeforeAcquisition { .. }) + )); + b.ledger + .replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)) + .unwrap(); } /// The bounds are the line limit exactly: a new draft line (base or @@ -808,12 +842,12 @@ fn new_asset( accounts: (AccountId, AccountId, AccountId), cost: &str, salvage: &str, - purchase_date: NaiveDate, + in_service_date: NaiveDate, life: u32, ) -> ledger_core::NewAsset { ledger_core::NewAsset { description: "Machine".into(), - purchase_date, + in_service_date, cost: usd(cost.parse().unwrap()), salvage_value: usd(salvage.parse().unwrap()), useful_life_months: life, diff --git a/crates/ledger-core/tests/spec_cash_flow.rs b/crates/ledger-core/tests/spec_cash_flow.rs index ec91637..c582d4e 100644 --- a/crates/ledger-core/tests/spec_cash_flow.rs +++ b/crates/ledger-core/tests/spec_cash_flow.rs @@ -72,7 +72,7 @@ impl Books { self.l .register_asset(NewAsset { description: format!("Asset on {cost}"), - purchase_date: d(2026, 1, 1), + in_service_date: d(2026, 1, 1), cost: Money::new(x(amount), Currency::USD), salvage_value: Money::new(Decimal::ZERO, Currency::USD), useful_life_months: 36, diff --git a/crates/ledger-core/tests/spec_replayed_extremes.rs b/crates/ledger-core/tests/spec_replayed_extremes.rs index a166ed7..ae8342a 100644 --- a/crates/ledger-core/tests/spec_replayed_extremes.rs +++ b/crates/ledger-core/tests/spec_replayed_extremes.rs @@ -46,7 +46,7 @@ fn depreciation_reaches_chronos_last_month() { let id = l .replay_register_asset(NewAsset { description: "edge".into(), - purchase_date: d(y, 1, 15), + in_service_date: d(y, 1, 15), cost: usd(Decimal::new(120000, 2)), salvage_value: usd(Decimal::ZERO), useful_life_months: 11, @@ -83,11 +83,11 @@ fn depreciation_reaches_chronos_last_month() { assert_eq!(want, Decimal::new(118592, 2)); assert_eq!(dec.last().unwrap().accumulated, want); assert_eq!( - ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(), + ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(), Decimal::new(120000, 2) ); assert_eq!( - ledger_core::book_value(a, NaiveDate::MAX).unwrap(), + ledger_core::scheduled_book_value(a, NaiveDate::MAX).unwrap(), Decimal::ZERO ); } @@ -111,7 +111,7 @@ fn a_life_past_9999_trues_up_to_the_cent() { let id = l .replay_register_asset(NewAsset { description: "far".into(), - purchase_date: start, + in_service_date: start, cost: usd(Decimal::new(100_000, 2)), salvage_value: usd(Decimal::new(1, 2)), useful_life_months: months, @@ -172,7 +172,7 @@ fn february_is_prorated_by_chronos_calendar_in_every_year() { let id = l .replay_register_asset(NewAsset { description: format!("{year}"), - purchase_date: d(year, 2, 1), + in_service_date: d(year, 2, 1), cost: usd(Decimal::from(2900)), salvage_value: usd(Decimal::ZERO), useful_life_months: 2, diff --git a/crates/ledger-core/tests/spec_reports.rs b/crates/ledger-core/tests/spec_reports.rs index a9e4701..881701a 100644 --- a/crates/ledger-core/tests/spec_reports.rs +++ b/crates/ledger-core/tests/spec_reports.rs @@ -1523,7 +1523,7 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() { ); co.l.register_asset(NewAsset { description: "Laptop".into(), - purchase_date: d(2026, 2, 1), + in_service_date: d(2026, 2, 1), cost: usd(120000), salvage_value: usd(0), useful_life_months: 12, diff --git a/crates/ledger-core/tests/spec_tax_basis.rs b/crates/ledger-core/tests/spec_tax_basis.rs deleted file mode 100644 index 49fc9fb..0000000 --- a/crates/ledger-core/tests/spec_tax_basis.rs +++ /dev/null @@ -1,693 +0,0 @@ -//! Spec-derived tests for an asset's federal tax basis (rules.md B-25): -//! `TaxBasisRule`, `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`, -//! `Asset::tax_basis`, `Asset::built_in_gain`, and the tax fields of -//! `reports::fixed_asset_register`. -//! -//! Every expected value comes from the written specification: the doc -//! comments and INVARIANTS/SCENARIOS blocks on those items, and the -//! vendored sources (`docs/spec/sources/irs-partnership-basis.md`): cost -//! basis (IRC §1012(a)), carryover of the contributor's adjusted basis -//! (IRC §723), the lesser of fair market value and adjusted basis on a -//! conversion from personal use (Treas. Reg. §1.168(i)-4(b)(1)), and -//! built-in gain or loss as fair market value less the contributor's basis -//! (IRC §704(c)). Amounts are worked by hand. - -use std::str::FromStr; - -use chrono::NaiveDate; -use ledger_core::{ - AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side, - TaxBasis, TaxBasisRule, fixed_asset_register, -}; -use proptest::prelude::*; -use rust_decimal::Decimal; - -// ---------------------------------------------------------------- helpers - -fn d(s: &str) -> Decimal { - Decimal::from_str(s).expect("decimal literal") -} - -fn usd(s: &str) -> Money { - Money::new(d(s), Currency::USD) -} - -fn date(y: i32, m: u32, day: u32) -> NaiveDate { - NaiveDate::from_ymd_opt(y, m, day).expect("valid date") -} - -struct Accts { - equipment: AccountId, - accum: AccountId, - depr_expense: AccountId, -} - -/// A USD ledger with a fixed-asset chart, funded with $50,000 cash. -fn fixture() -> (Ledger, Accts) { - let mut l = Ledger::new(Currency::USD); - let mut acct = |code: &str, name: &str, ty: AccountType| { - l.add_account(code, name, ty, None, false).unwrap() - }; - let cash = acct("1000", "Cash", AccountType::Asset); - let equipment = acct("1500", "Equipment", AccountType::Asset); - let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset); - let capital = acct("3000", "Owner Capital", AccountType::Equity); - let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense); - let e = l.try_create_entry(date(2026, 1, 1), "funding").unwrap(); - l.add_line(e, cash, Side::Debit, usd("50000.00")).unwrap(); - l.add_line(e, capital, Side::Credit, usd("50000.00")) - .unwrap(); - l.post_entry(e).unwrap(); - ( - l, - Accts { - equipment, - accum, - depr_expense, - }, - ) -} - -fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId { - l.register_asset(NewAsset { - description: description.to_string(), - purchase_date: date(2026, 1, 1), - cost: usd(cost), - salvage_value: usd("0.00"), - useful_life_months: 36, - cost_account: a.equipment, - accum_depr_account: a.accum, - expense_account: a.depr_expense, - }) - .unwrap() -} - -fn tb(rule: TaxBasisRule, basis: Option<&str>) -> TaxBasis { - TaxBasis { - rule, - contributor_basis: basis.map(d), - } -} - -/// A ledger with one asset of book cost (fair market value) `cost`. -fn one(cost: &str) -> (Ledger, AssetId) { - let (mut l, a) = fixture(); - let id = register(&mut l, &a, "Laptop", cost); - (l, id) -} - -fn set(l: &mut Ledger, id: AssetId, rule: TaxBasisRule, basis: Option<&str>) { - l.set_asset_tax_basis(id, tb(rule, basis)).unwrap(); -} - -const RULES: [TaxBasisRule; 3] = [ - TaxBasisRule::Cost, - TaxBasisRule::Carryover, - TaxBasisRule::PersonalUseConversion, -]; - -// ---------------------------------------------------------------- TaxBasisRule - -/// TaxBasisRule::key: the wire names `cost`, `carryover`, -/// `personal_use_conversion`. -#[test] -fn rule_keys_are_the_documented_wire_names() { - assert_eq!(TaxBasisRule::Cost.key(), "cost"); - assert_eq!(TaxBasisRule::Carryover.key(), "carryover"); - assert_eq!( - TaxBasisRule::PersonalUseConversion.key(), - "personal_use_conversion" - ); -} - -/// TaxBasisRule::parse: the rule named `key`; round-trips with key(). -#[test] -fn rule_parse_round_trips_with_key() { - for r in RULES { - assert_eq!(TaxBasisRule::parse(r.key()), Some(r)); - } -} - -/// Anything other than a documented wire name is no rule. -#[test] -fn rule_parse_refuses_other_names() { - for bad in [ - "", - "Cost", - "COST", - " cost", - "cost ", - "carry_over", - "personal-use-conversion", - "PersonalUseConversion", - "fmv", - ] { - assert_eq!(TaxBasisRule::parse(bad), None, "{bad:?}"); - } -} - -/// The serde names are snake_case, the same as the wire names. -#[test] -fn rule_serde_names_are_the_wire_names() { - for r in RULES { - let j = serde_json::to_value(r).unwrap(); - assert_eq!(j, serde_json::Value::String(r.key().to_string())); - let back: TaxBasisRule = serde_json::from_value(j).unwrap(); - assert_eq!(back, r); - } -} - -// ---------------------------------------------------------------- set / check - -/// SCENARIO 1: an unknown asset is refused with UnknownAsset. -#[test] -fn an_unknown_asset_is_refused() { - let (mut l, id) = one("1000.00"); - let before = l.clone(); - let unknown = id + 99; - for t in [ - tb(TaxBasisRule::Cost, None), - tb(TaxBasisRule::Carryover, Some("500.00")), - ] { - assert!(matches!( - l.check_asset_tax_basis(unknown, t), - Err(LedgerError::UnknownAsset(x)) if x == unknown - )); - assert!(matches!( - l.set_asset_tax_basis(unknown, t), - Err(LedgerError::UnknownAsset(x)) if x == unknown - )); - } - assert_eq!(l, before, "INVARIANT 3"); -} - -/// INVARIANT 1: Cost takes no contributor basis. -#[test] -fn cost_with_a_contributor_basis_is_refused() { - let (mut l, id) = one("1000.00"); - let before = l.clone(); - for b in ["0.00", "500.00", "1000.00"] { - let t = tb(TaxBasisRule::Cost, Some(b)); - assert!(matches!( - l.check_asset_tax_basis(id, t), - Err(LedgerError::InvalidTaxBasis(_)) - )); - assert!(matches!( - l.set_asset_tax_basis(id, t), - Err(LedgerError::InvalidTaxBasis(_)) - )); - } - assert_eq!(l, before, "INVARIANT 3"); - assert_eq!(l.get_asset(id).unwrap().tax, None); -} - -/// INVARIANT 1: Carryover and PersonalUseConversion require one. -#[test] -fn a_contribution_without_a_contributor_basis_is_refused() { - let (mut l, id) = one("1000.00"); - let before = l.clone(); - for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] { - let t = tb(r, None); - assert!(matches!( - l.check_asset_tax_basis(id, t), - Err(LedgerError::InvalidTaxBasis(_)) - )); - assert!(matches!( - l.set_asset_tax_basis(id, t), - Err(LedgerError::InvalidTaxBasis(_)) - )); - } - assert_eq!(l, before, "INVARIANT 3"); -} - -/// INVARIANT 2: a contributor basis is non-negative. -#[test] -fn a_negative_contributor_basis_is_refused() { - let (mut l, id) = one("1000.00"); - let before = l.clone(); - for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] { - for b in ["-0.01", "-1", "-500.00"] { - let t = tb(r, Some(b)); - assert!( - matches!( - l.set_asset_tax_basis(id, t), - Err(LedgerError::InvalidTaxBasis(_)) - ), - "{r:?} {b}" - ); - assert!(l.check_asset_tax_basis(id, t).is_err()); - } - } - assert_eq!(l, before, "INVARIANT 3"); -} - -/// INVARIANT 2: in cents at most. -#[test] -fn more_than_two_decimal_places_is_refused() { - let (mut l, id) = one("1000.00"); - let before = l.clone(); - for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] { - for b in ["500.001", "0.005", "1.2345"] { - let t = tb(r, Some(b)); - assert!( - matches!( - l.set_asset_tax_basis(id, t), - Err(LedgerError::InvalidTaxBasis(_)) - ), - "{r:?} {b}" - ); - assert!(l.check_asset_tax_basis(id, t).is_err()); - } - } - assert_eq!(l, before, "INVARIANT 3"); -} - -/// Valid records: zero is non-negative; whole dollars and one decimal -/// place are "in cents at most". check agrees with set, and a valid set -/// changes nothing but the asset's tax record (book figures don't change). -#[test] -fn valid_records_are_accepted_and_book_figures_stay() { - for (r, b) in [ - (TaxBasisRule::Cost, None), - (TaxBasisRule::Carryover, Some("0")), - (TaxBasisRule::Carryover, Some("0.00")), - (TaxBasisRule::Carryover, Some("750")), - (TaxBasisRule::PersonalUseConversion, Some("750.5")), - (TaxBasisRule::PersonalUseConversion, Some("750.50")), - (TaxBasisRule::PersonalUseConversion, Some("2500.00")), - ] { - let (mut l, id) = one("1000.00"); - let t = tb(r, b); - l.check_asset_tax_basis(id, t).unwrap(); - let before = l.get_asset(id).unwrap().clone(); - l.set_asset_tax_basis(id, t).unwrap(); - let after = l.get_asset(id).unwrap(); - assert_eq!(after.tax, Some(t), "{r:?} {b:?}"); - // Book figures don't change. - let mut same = after.clone(); - same.tax = before.tax; - assert_eq!(same, before, "{r:?} {b:?}"); - } -} - -/// SCENARIO 2: set again, the new record replaces the old. -#[test] -fn setting_again_replaces() { - let (mut l, id) = one("1000.00"); - set(&mut l, id, TaxBasisRule::Cost, None); - assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("1000.00"))); - set(&mut l, id, TaxBasisRule::Carryover, Some("400.00")); - let a = l.get_asset(id).unwrap(); - assert_eq!(a.tax, Some(tb(TaxBasisRule::Carryover, Some("400.00")))); - assert_eq!(a.tax_basis(), Some(d("400.00"))); - set( - &mut l, - id, - TaxBasisRule::PersonalUseConversion, - Some("1200.00"), - ); - let a = l.get_asset(id).unwrap(); - assert_eq!( - a.tax, - Some(tb(TaxBasisRule::PersonalUseConversion, Some("1200.00"))) - ); - assert_eq!(a.tax_basis(), Some(d("1000.00"))); - set(&mut l, id, TaxBasisRule::Cost, None); - let a = l.get_asset(id).unwrap(); - assert_eq!(a.tax, Some(tb(TaxBasisRule::Cost, None))); - assert_eq!(a.built_in_gain(), None); -} - -/// INVARIANT 3: a refusal after a valid record leaves that record. -#[test] -fn a_refusal_keeps_the_earlier_record() { - let (mut l, id) = one("1000.00"); - set(&mut l, id, TaxBasisRule::Carryover, Some("300.00")); - let before = l.clone(); - assert!( - l.set_asset_tax_basis(id, tb(TaxBasisRule::Cost, Some("1.00"))) - .is_err() - ); - assert!( - l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, None)) - .is_err() - ); - assert!( - l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("-1.00"))) - .is_err() - ); - assert!( - l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("1.001"))) - .is_err() - ); - assert_eq!(l, before); - assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("300.00"))); -} - -/// Setting one asset's record leaves the others alone. -#[test] -fn a_record_belongs_to_one_asset() { - let (mut l, a) = fixture(); - let x = register(&mut l, &a, "Laptop", "1000.00"); - let y = register(&mut l, &a, "Desk", "400.00"); - set(&mut l, x, TaxBasisRule::Carryover, Some("800.00")); - assert_eq!(l.get_asset(y).unwrap().tax, None); - assert_eq!(l.get_asset(y).unwrap().tax_basis(), None); -} - -// ---------------------------------------------------------------- tax_basis - -/// Not recorded: None (and no built-in gain). -#[test] -fn no_record_means_no_tax_basis() { - let (l, id) = one("1000.00"); - let a = l.get_asset(id).unwrap(); - assert_eq!(a.tax, None); - assert_eq!(a.tax_basis(), None); - assert_eq!(a.built_in_gain(), None); -} - -/// INVARIANT 1: Cost: the book cost (IRC §1012(a)). -#[test] -fn cost_basis_is_the_book_cost() { - let (mut l, id) = one("1234.56"); - set(&mut l, id, TaxBasisRule::Cost, None); - let a = l.get_asset(id).unwrap(); - assert_eq!(a.tax_basis(), Some(d("1234.56"))); - // A purchase has no built-in gain. - assert_eq!(a.built_in_gain(), None); -} - -/// INVARIANT 2: Carryover: the contributor's adjusted basis (IRC §723), -/// whether above or below fair market value. -#[test] -fn carryover_basis_is_the_contributors_basis() { - for (fmv, basis, gain) in [ - ("1000.00", "600.00", "400.00"), // built-in gain - ("1000.00", "1500.00", "-500.00"), // built-in loss - ("1000.00", "1000.00", "0.00"), // boundary - ("1000.00", "0.00", "1000.00"), // fully depreciated - ] { - let (mut l, id) = one(fmv); - set(&mut l, id, TaxBasisRule::Carryover, Some(basis)); - let a = l.get_asset(id).unwrap(); - assert_eq!(a.tax_basis(), Some(d(basis)), "{fmv} {basis}"); - assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}"); - } -} - -/// INVARIANT 3: PersonalUseConversion: min(book cost, contributor's basis) -/// (Treas. Reg. §1.168(i)-4(b)(1): "the lesser of its fair market value or -/// its adjusted depreciable basis"). -#[test] -fn personal_use_conversion_is_the_lesser_of_value_and_basis() { - for (fmv, basis, tax, gain) in [ - // Paid 2,000, worth 1,200 now: basis 1,200; built-in loss −800. - ("1200.00", "2000.00", "1200.00", "-800.00"), - // Paid 300, worth 450: basis 300; built-in gain 150. - ("450.00", "300.00", "300.00", "150.00"), - // Boundary: value equals basis. - ("999.99", "999.99", "999.99", "0.00"), - // A cent either side of the boundary. - ("1000.00", "1000.01", "1000.00", "-0.01"), - ("1000.00", "999.99", "999.99", "0.01"), - ] { - let (mut l, id) = one(fmv); - set(&mut l, id, TaxBasisRule::PersonalUseConversion, Some(basis)); - let a = l.get_asset(id).unwrap(); - assert_eq!(a.tax_basis(), Some(d(tax)), "{fmv} {basis}"); - assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}"); - } -} - -/// built_in_gain: FMV (book cost) less the contributor's basis; positive -/// for a gain, negative for a loss, zero at FMV == basis, regardless of -/// which contribution rule (the definition does not depend on the rule). -#[test] -fn built_in_gain_sign_and_boundary() { - for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] { - let (mut l, id) = one("800.00"); - set(&mut l, id, r, Some("500.00")); - let g = l.get_asset(id).unwrap().built_in_gain().unwrap(); - assert_eq!(g, d("300.00")); - assert!(g > Decimal::ZERO, "{r:?}: FMV > basis is a gain"); - - set(&mut l, id, r, Some("1100.00")); - let g = l.get_asset(id).unwrap().built_in_gain().unwrap(); - assert_eq!(g, d("-300.00")); - assert!(g < Decimal::ZERO, "{r:?}: basis > FMV is a loss"); - - set(&mut l, id, r, Some("800.00")); - let g = l.get_asset(id).unwrap().built_in_gain().unwrap(); - assert!(g.is_zero(), "{r:?}: FMV == basis is neither"); - } -} - -// ---------------------------------------------------------------- register - -struct RegisterBooks { - l: Ledger, - bought: AssetId, - carried: AssetId, - converted: AssetId, - unrecorded: AssetId, - sold: AssetId, -} - -/// Five assets: a purchase at cost, a carryover contribution, a -/// personal-use conversion, one with no tax basis, and one disposed (with -/// a tax basis, which the totals must leave out). -fn register_books() -> RegisterBooks { - let (mut l, a) = fixture(); - let bought = register(&mut l, &a, "Server", "3000.00"); - let carried = register(&mut l, &a, "Printer", "500.00"); - let converted = register(&mut l, &a, "Laptop", "1200.00"); - let unrecorded = register(&mut l, &a, "Desk", "700.00"); - let sold = register(&mut l, &a, "Old monitor", "360.00"); - set(&mut l, bought, TaxBasisRule::Cost, None); - set(&mut l, carried, TaxBasisRule::Carryover, Some("650.25")); - set( - &mut l, - converted, - TaxBasisRule::PersonalUseConversion, - Some("2000.00"), - ); - set(&mut l, sold, TaxBasisRule::Cost, None); - l.dispose_asset(sold, date(2026, 3, 31), usd("100.00")) - .unwrap(); - RegisterBooks { - l, - bought, - carried, - converted, - unrecorded, - sold, - } -} - -/// INVARIANT 5: each row's tax fields are the asset's. -#[test] -fn register_rows_carry_the_assets_tax_fields() { - let b = register_books(); - let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap(); - assert_eq!(r.rows.len(), 5); - for row in &r.rows { - let a = b.l.get_asset(row.asset_id).unwrap(); - assert_eq!(row.tax_rule, a.tax.map(|t| t.rule)); - assert_eq!( - row.contributor_basis, - a.tax.and_then(|t| t.contributor_basis) - ); - assert_eq!(row.tax_basis, a.tax_basis()); - assert_eq!(row.built_in_gain, a.built_in_gain()); - } - let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap(); - // By hand. - let x = row(b.bought); - assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost)); - assert_eq!(x.contributor_basis, None); - assert_eq!(x.tax_basis, Some(d("3000.00"))); - assert_eq!(x.built_in_gain, None); - let x = row(b.carried); - assert_eq!(x.tax_rule, Some(TaxBasisRule::Carryover)); - assert_eq!(x.contributor_basis, Some(d("650.25"))); - assert_eq!(x.tax_basis, Some(d("650.25"))); - assert_eq!(x.built_in_gain, Some(d("-150.25"))); - let x = row(b.converted); - assert_eq!(x.tax_rule, Some(TaxBasisRule::PersonalUseConversion)); - assert_eq!(x.contributor_basis, Some(d("2000.00"))); - assert_eq!(x.tax_basis, Some(d("1200.00"))); - assert_eq!(x.built_in_gain, Some(d("-800.00"))); - let x = row(b.unrecorded); - assert_eq!(x.tax_rule, None); - assert_eq!(x.contributor_basis, None); - assert_eq!(x.tax_basis, None); - assert_eq!(x.built_in_gain, None); - // A disposed asset's row still shows its record. - let x = row(b.sold); - assert!(x.disposed.is_some()); - assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost)); - assert_eq!(x.tax_basis, Some(d("360.00"))); -} - -/// INVARIANT 3: total_tax_basis sums held assets with a basis, exactly; -/// missing_tax_basis counts held ones without; disposed excluded. -#[test] -fn register_tax_totals_cover_held_assets_only() { - let b = register_books(); - let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap(); - // 3000.00 + 650.25 + 1200.00 (the disposed 360.00 left out). - assert_eq!(r.total_tax_basis, d("4850.25")); - assert_eq!(r.missing_tax_basis, 1); - // Before the disposal date the old monitor is still held. - let r = fixed_asset_register(&b.l, date(2026, 2, 28)).unwrap(); - assert_eq!(r.total_tax_basis, d("5210.25")); - assert_eq!(r.missing_tax_basis, 1); -} - -/// A disposed asset without a tax basis isn't counted as missing. -#[test] -fn a_disposed_asset_without_a_basis_is_not_missing() { - let (mut l, a) = fixture(); - let x = register(&mut l, &a, "Laptop", "1000.00"); - let y = register(&mut l, &a, "Desk", "400.00"); - l.dispose_asset(y, date(2026, 3, 31), usd("0.00")).unwrap(); - let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); - assert_eq!(r.missing_tax_basis, 1); - assert_eq!(r.total_tax_basis, Decimal::ZERO); - set(&mut l, x, TaxBasisRule::Carryover, Some("900.00")); - let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); - assert_eq!(r.missing_tax_basis, 0); - assert_eq!(r.total_tax_basis, d("900.00")); -} - -/// No assets: zero total, none missing. -#[test] -fn an_empty_register_has_no_tax_basis() { - let (l, _) = fixture(); - let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); - assert!(r.rows.is_empty()); - assert_eq!(r.total_tax_basis, Decimal::ZERO); - assert_eq!(r.missing_tax_basis, 0); -} - -/// Recording tax basis leaves the book totals alone. -#[test] -fn tax_basis_does_not_move_book_totals() { - let (mut l, a) = fixture(); - let x = register(&mut l, &a, "Laptop", "1200.00"); - let before = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); - set( - &mut l, - x, - TaxBasisRule::PersonalUseConversion, - Some("2000.00"), - ); - let after = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); - assert_eq!(after.total_cost, before.total_cost); - assert_eq!(after.total_accumulated, before.total_accumulated); - assert_eq!(after.total_book_value, before.total_book_value); - assert_eq!(after.ledger_cost, before.ledger_cost); - assert_eq!(after.ledger_accumulated, before.ledger_accumulated); -} - -// ---------------------------------------------------------------- proptest - -fn cents() -> impl Strategy { - (0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2)) -} - -fn positive_cents() -> impl Strategy { - (1i64..=100_000_000).prop_map(|c| Decimal::new(c, 2)) -} - -proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - - /// Asset::tax_basis INVARIANTS 1–3 and built_in_gain over random - /// costs, bases, and rules; register totals agree. - #[test] - fn prop_tax_basis_follows_its_rule( - assets in proptest::collection::vec((positive_cents(), cents(), 0usize..4), 1..8) - ) { - let (mut l, a) = fixture(); - let mut expected_total = Decimal::ZERO; - let mut expected_missing = 0usize; - for (i, (cost, basis, rule)) in assets.iter().enumerate() { - let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string()); - let want = match rule { - 0 => { - set(&mut l, id, TaxBasisRule::Cost, None); - Some((*cost, None)) - } - 1 => { - l.set_asset_tax_basis(id, TaxBasis { - rule: TaxBasisRule::Carryover, - contributor_basis: Some(*basis), - }).unwrap(); - Some((*basis, Some(*cost - *basis))) - } - 2 => { - l.set_asset_tax_basis(id, TaxBasis { - rule: TaxBasisRule::PersonalUseConversion, - contributor_basis: Some(*basis), - }).unwrap(); - Some((if cost < basis { *cost } else { *basis }, Some(*cost - *basis))) - } - _ => None, - }; - let asset = l.get_asset(id).unwrap(); - match want { - Some((tax, gain)) => { - prop_assert_eq!(asset.tax_basis(), Some(tax)); - prop_assert_eq!(asset.built_in_gain(), gain); - expected_total += tax; - } - None => { - prop_assert_eq!(asset.tax_basis(), None); - expected_missing += 1; - } - } - if let Some(t) = asset.tax_basis() { - // Personal-use conversion never exceeds fair market value. - if *rule == 2 { - prop_assert!(t <= *cost); - prop_assert!(t <= *basis); - } - } - } - let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap(); - prop_assert_eq!(r.total_tax_basis, expected_total); - prop_assert_eq!(r.missing_tax_basis, expected_missing); - } - - /// check agrees with set, and a refused record changes nothing. - #[test] - fn prop_check_agrees_with_set( - cents in -1_000_000i64..1_000_000, - scale in 0u32..5, - rule in 0usize..3, - some in any::(), - ) { - let (mut l, id) = one("1000.00"); - let basis = some.then(|| Decimal::new(cents, scale)); - let t = TaxBasis { rule: RULES[rule], contributor_basis: basis }; - let before = l.clone(); - let checked = l.check_asset_tax_basis(id, t); - let done = l.set_asset_tax_basis(id, t); - prop_assert_eq!(checked.is_ok(), done.is_ok()); - // The spec, independently: INVARIANTS 1 and 2. - let valid = match (RULES[rule], basis) { - (TaxBasisRule::Cost, None) => true, - (TaxBasisRule::Cost, Some(_)) => false, - (_, None) => false, - (_, Some(b)) => b >= Decimal::ZERO && b.normalize().scale() <= 2, - }; - prop_assert_eq!(done.is_ok(), valid, "{:?} {:?}", RULES[rule], basis); - if done.is_err() { - prop_assert_eq!(&l, &before); - } else { - prop_assert_eq!(l.get_asset(id).unwrap().tax, Some(t)); - } - } -} diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index dd10b45..53358ec 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -1210,7 +1210,7 @@ fn a_loss_on_disposal_moves_out_of_operating_into_investing() { .unwrap(); l.register_asset(ledger_core::NewAsset { description: "Laptop".to_string(), - purchase_date: d(1, 10), + in_service_date: d(1, 10), cost: usd(1_000), salvage_value: usd(0), useful_life_months: 10, diff --git a/crates/ledger-core/tests/tax_capital.rs b/crates/ledger-core/tests/tax_capital.rs new file mode 100644 index 0000000..b313a2c --- /dev/null +++ b/crates/ledger-core/tests/tax_capital.rs @@ -0,0 +1,494 @@ +//! Spec tests for the K-1 worksheet's tax-basis capital +//! (`ledger_core::tax_capital`, rules.md B-36): Item L on the tax basis +//! beside the book figures, every figure the books can't know left open +//! and named, and the reconciliation from book to tax capital. +//! +//! Expected values are worked by hand from the Form 1065 instructions' +//! Item L (`docs/spec/sources/irs-partnerships.md`): capital contributed is +//! "the amount of cash plus the adjusted tax basis of all property +//! contributed"; withdrawals and distributions "the amount of cash plus +//! the adjusted tax basis of all property distributed". Names and figures +//! are invented. + +use std::str::FromStr; + +use chrono::NaiveDate; +use ledger_core::reports::MemberCapital; +use ledger_core::tax_capital::{Open, Reconciliation, tax_basis_capital}; +use ledger_core::{ + AccountId, AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Ledger, Money, + NewAsset, Sec704cMethod, Side, +}; +use rust_decimal::Decimal; + +fn d(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() +} + +fn date(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(d(s), Currency::USD) +} + +struct Books { + l: Ledger, + cash: AccountId, + prepaid: AccountId, + equipment: AccountId, + accum: AccountId, + tobin: AccountId, + morgan: AccountId, + revenue: AccountId, + depreciation: AccountId, +} + +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); + let cash = add("1000", "Bank", AccountType::Asset); + let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset); + let equipment = add("1500", "Equipment", AccountType::Asset); + let accum = add("1510", "Accumulated Depreciation", AccountType::Asset); + let tobin = add("3000", "Capital, Tobin Harlow", AccountType::Equity); + let morgan = add("3001", "Capital, Morgan Ellis", AccountType::Equity); + let revenue = add("4000", "Revenue", AccountType::Revenue); + let depreciation = add("6200", "Depreciation", AccountType::Expense); + Books { + l, + cash, + prepaid, + equipment, + accum, + tobin, + morgan, + revenue, + depreciation, + } +} + +fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId { + let e = l.try_create_entry(on, "entry").unwrap(); + for (a, side, amt) in lines { + l.add_line(e, *a, *side, usd(amt)).unwrap(); + } + l.post_entry(e).unwrap(); + e +} + +fn members(b: &Books) -> [MemberCapital; 2] { + [ + MemberCapital { + name: "Tobin Harlow".into(), + capital_account: b.tobin, + distribution_account: None, + share: d("0.5"), + }, + MemberCapital { + name: "Morgan Ellis".into(), + capital_account: b.morgan, + distribution_account: None, + share: d("0.5"), + }, + ] +} + +/// A device two members contribute, in service 2026-01-01 over 24 months. +fn device(b: &mut Books, name: &str, fmv: &str, adjusted: &str, entry: EntryId) -> AssetId { + let id = + b.l.register_asset(NewAsset { + description: name.into(), + in_service_date: date(2026, 1, 1), + cost: usd(fmv), + salvage_value: usd("0.00"), + useful_life_months: 24, + cost_account: b.equipment, + accum_depr_account: b.accum, + expense_account: b.depreciation, + }) + .unwrap(); + b.l.set_asset_acquisition(id, entry).unwrap(); + let mut basis = AssetBasis::contribution(BasisRule::PersonalUseConversion, d(adjusted), d(fmv)); + basis.sec704c_method = Some(Sec704cMethod::Traditional); + b.l.set_asset_basis(id, basis).unwrap(); + id +} + +/// Every month's run Jan..=Dec 2026, as the schedule gives it. +fn a_year_of_runs(b: &mut Books, id: AssetId) { + let a = b.l.get_asset(id).unwrap().clone(); + for l in ledger_core::depreciation_schedule(&a, date(2026, 12, 31)).unwrap() { + let (y, m) = ( + l.period[..4].parse().unwrap(), + l.period[5..].parse().unwrap(), + ); + let end = date(y, m, 1) + .checked_add_months(chrono::Months::new(1)) + .unwrap() + .pred_opt() + .unwrap(); + let amt = l.amount.to_string(); + let e = post( + &mut b.l, + end, + &[ + (b.depreciation, Side::Debit, &amt), + (b.accum, Side::Credit, &amt), + ], + ); + b.l.check_depreciation_run(id, &l.period, e, l.amount) + .unwrap(); + b.l.record_depreciation_run(id, &l.period, e, l.amount) + .unwrap(); + } +} + +/// The year, by hand. Tobin Harlow and Morgan Ellis, 50/50: +/// - 2026-01-01 cash in: Dr 1000 2,000.00 / Cr 3000 1,000.00, Cr 3001 +/// 1,000.00; +/// - 2026-01-01 two devices in, jointly, from personal use: a laptop worth +/// 1,440.00 (adjusted basis 1,688.40: built-in loss 248.40) and a camera +/// worth 1,300.00 (basis 1,080.00: built-in gain 220.00); Dr 1500 +/// 2,740.00 / Cr 3000 1,370.00, Cr 3001 1,370.00; +/// - revenue 600.00; a year of depreciation, 24-month lives: 1,440/2 + +/// 1,300/2 = 720.00 + 650.00 = 1,370.00; net income 600.00 − 1,370.00 = +/// −770.00, −385.00 each; +/// - 2026-12-15 cash out: Dr 3000 300.00, Dr 3001 300.00 / Cr 1000 600.00. +/// +/// Book: 1,000.00 + 1,370.00 − 385.00 − 300.00 = 1,685.00 each. +/// Tax: property at §723 basis, (1,688.40 + 1,080.00) / 2 = 1,384.20 each +/// (book 1,370.00 less the built-in gain share (−248.40 + 220.00) / 2 = +/// −14.20); the year's income is open (tax depreciation is the CPA's), so +/// the ending the books know is 1,000.00 + 1,384.20 − 385.00 − 300.00 = +/// 1,699.20; book less tax = −14.20, the built-in gain share. +fn the_year() -> (Books, EntryId, AssetId, AssetId) { + let mut b = books(); + let (cash, tobin, morgan, equipment) = (b.cash, b.tobin, b.morgan, b.equipment); + post( + &mut b.l, + date(2026, 1, 1), + &[ + (cash, Side::Debit, "2000.00"), + (tobin, Side::Credit, "1000.00"), + (morgan, Side::Credit, "1000.00"), + ], + ); + let devices = post( + &mut b.l, + date(2026, 1, 1), + &[ + (equipment, Side::Debit, "1440.00"), + (equipment, Side::Debit, "1300.00"), + (tobin, Side::Credit, "1370.00"), + (morgan, Side::Credit, "1370.00"), + ], + ); + let laptop = device(&mut b, "Laptop", "1440.00", "1688.40", devices); + let camera = device(&mut b, "Camera", "1300.00", "1080.00", devices); + let revenue = b.revenue; + post( + &mut b.l, + date(2026, 2, 1), + &[ + (cash, Side::Debit, "600.00"), + (revenue, Side::Credit, "600.00"), + ], + ); + a_year_of_runs(&mut b, laptop); + a_year_of_runs(&mut b, camera); + post( + &mut b.l, + date(2026, 12, 15), + &[ + (tobin, Side::Debit, "300.00"), + (morgan, Side::Debit, "300.00"), + (cash, Side::Credit, "600.00"), + ], + ); + (b, devices, laptop, camera) +} + +/// Item L on the tax basis, per member, for the year (B-36): a cash +/// contribution, a contribution of property, a distribution, a year of +/// depreciation. +#[test] +fn tax_basis_capital_per_member() { + let (b, _, _, _) = the_year(); + let tc = tax_basis_capital( + &b.l, + date(2026, 1, 1), + date(2026, 12, 31), + &members(&b), + &[b.cash], + ) + .unwrap(); + let depreciation = Open::TaxDepreciation { + book: d("1370.00"), + contributed_book: d("1370.00"), + }; + assert_eq!(tc.open, vec![depreciation.clone()]); + for row in &tc.rows { + // The book column is the K-1 worksheet's row. + assert_eq!(row.book.beginning, d("0")); + assert_eq!(row.book.contributions_cash, d("1000.00")); + assert_eq!(row.book.contributions_property, d("1370.00")); + assert_eq!(row.book.net_income, d("-385.00")); + assert_eq!(row.book.distributions, d("300.00")); + assert_eq!(row.book.ending, d("1685.00")); + // The tax column. + assert_eq!(row.beginning.tax(), Some(d("0"))); + assert_eq!(row.contributions_cash, d("1000.00")); + assert_eq!(row.contributions_property.tax(), Some(d("1384.20"))); + assert_eq!(row.net_income.tax(), None, "tax depreciation is the CPA's"); + assert_eq!(row.net_income.known, d("-385.00")); + assert_eq!(row.net_income.open, vec![depreciation.clone()]); + assert_eq!(row.other.tax(), Some(d("0"))); + assert_eq!(row.distributions.tax(), Some(d("300.00"))); + assert_eq!(row.ending.tax(), None); + assert_eq!(row.ending.known, d("1699.20")); + assert_eq!(row.ending.open, vec![depreciation.clone()]); + // Book less tax, by cause. + assert_eq!( + row.reconciliation, + Reconciliation { + beginning: d("0"), + built_in_gain_704c: d("-14.20"), + property_without_basis: d("0"), + property_distributed: d("0"), + } + ); + assert_eq!( + row.reconciliation.total(), + Some(row.book.ending - row.ending.known) + ); + } +} + +/// Beginning capital the next year is this year's ending as the books +/// know it, left open while an earlier year left a figure open. +#[test] +fn next_years_beginning_carries_what_is_known_and_says_what_isnt() { + let (b, _, _, _) = the_year(); + let tc = tax_basis_capital( + &b.l, + date(2027, 1, 1), + date(2027, 12, 31), + &members(&b), + &[b.cash], + ) + .unwrap(); + for row in &tc.rows { + assert_eq!(row.book.beginning, d("1685.00")); + assert_eq!(row.beginning.known, d("1699.20")); + assert_eq!(row.beginning.open, vec![Open::PriorYears]); + assert_eq!(row.reconciliation.beginning, d("-14.20")); + assert_eq!( + row.reconciliation.total(), + Some(row.book.ending - row.ending.known) + ); + } +} + +/// Cash only, no fixed assets: every line is the tax figure, equal to the +/// book figure. +#[test] +fn cash_only_books_are_complete() { + let mut b = books(); + let (cash, tobin, morgan, revenue) = (b.cash, b.tobin, b.morgan, b.revenue); + post( + &mut b.l, + date(2026, 3, 1), + &[ + (cash, Side::Debit, "500.00"), + (tobin, Side::Credit, "250.00"), + (morgan, Side::Credit, "250.00"), + ], + ); + post( + &mut b.l, + date(2026, 4, 1), + &[ + (cash, Side::Debit, "100.01"), + (revenue, Side::Credit, "100.01"), + ], + ); + let tc = tax_basis_capital( + &b.l, + date(2026, 1, 1), + date(2026, 12, 31), + &members(&b), + &[cash], + ) + .unwrap(); + assert!(tc.open.is_empty()); + for row in &tc.rows { + assert_eq!(row.ending.tax(), Some(row.book.ending)); + assert!(row.ending.open.is_empty()); + assert_eq!(row.reconciliation, Reconciliation::default()); + } +} + +/// Property contributed with no basis in the books (a prepaid contract +/// handed over, no registered asset) is open with its book amount; a +/// linked asset carrying a purchase's basis on a contribution is open +/// too. Neither is guessed. +#[test] +fn property_without_a_basis_is_open_never_guessed() { + let mut b = books(); + let (prepaid, equipment, tobin, morgan, cash) = + (b.prepaid, b.equipment, b.tobin, b.morgan, b.cash); + let contract = post( + &mut b.l, + date(2026, 1, 5), + &[ + (prepaid, Side::Debit, "25.00"), + (tobin, Side::Credit, "12.50"), + (morgan, Side::Credit, "12.50"), + ], + ); + let tool = post( + &mut b.l, + date(2026, 1, 6), + &[ + (equipment, Side::Debit, "400.00"), + (tobin, Side::Credit, "400.00"), + ], + ); + let id = + b.l.register_asset(NewAsset { + description: "Drill".into(), + in_service_date: date(2026, 1, 6), + cost: usd("400.00"), + salvage_value: usd("0.00"), + useful_life_months: 24, + cost_account: b.equipment, + accum_depr_account: b.accum, + expense_account: b.depreciation, + }) + .unwrap(); + b.l.set_asset_acquisition(id, tool).unwrap(); + b.l.set_asset_basis(id, AssetBasis::purchase()).unwrap(); + let tc = tax_basis_capital( + &b.l, + date(2026, 1, 1), + date(2026, 12, 31), + &members(&b), + &[cash], + ) + .unwrap(); + let (t, m) = (&tc.rows[0], &tc.rows[1]); + assert_eq!(t.book.contributions_property, d("412.50")); + assert_eq!(t.contributions_property.known, d("0")); + assert_eq!( + t.contributions_property.open, + vec![ + Open::PropertyBasis { + entry: contract, + book: d("12.50") + }, + Open::PropertyBasis { + entry: tool, + book: d("400.00") + }, + ] + ); + assert_eq!(t.reconciliation.property_without_basis, d("412.50")); + assert_eq!(m.contributions_property.known, d("0")); + assert_eq!(m.reconciliation.property_without_basis, d("12.50")); + for row in &tc.rows { + assert_eq!( + row.reconciliation.total(), + Some(row.book.ending - row.ending.known) + ); + } +} + +/// Property distributed: its adjusted tax basis is the CPA's (it needs +/// tax depreciation), so the line is open with its book amount. +#[test] +fn property_distributed_is_open_at_its_book_amount() { + let mut b = books(); + let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash); + post( + &mut b.l, + date(2026, 1, 1), + &[ + (cash, Side::Debit, "1000.00"), + (tobin, Side::Credit, "500.00"), + (morgan, Side::Credit, "500.00"), + ], + ); + post( + &mut b.l, + date(2026, 1, 2), + &[ + (equipment, Side::Debit, "100.00"), + (cash, Side::Credit, "100.00"), + ], + ); + let out = post( + &mut b.l, + date(2026, 6, 1), + &[ + (tobin, Side::Debit, "100.00"), + (equipment, Side::Credit, "100.00"), + ], + ); + let tc = tax_basis_capital( + &b.l, + date(2026, 1, 1), + date(2026, 12, 31), + &members(&b), + &[cash], + ) + .unwrap(); + let t = &tc.rows[0]; + assert_eq!(t.book.distributions, d("100.00")); + assert_eq!(t.distributions.known, d("0")); + assert_eq!( + t.distributions.open, + vec![Open::PropertyDistribution { + entry: out, + book: d("100.00") + }] + ); + assert_eq!(t.reconciliation.property_distributed, d("100.00")); + assert_eq!( + t.reconciliation.total(), + Some(t.book.ending - t.ending.known) + ); + assert_eq!(tc.rows[1].distributions.tax(), Some(d("0"))); +} + +/// tax_capital INVARIANTS 4 and 5 hold for every row of the year and of +/// every span within it. +#[test] +fn every_span_rolls_forward_and_reconciles() { + let (b, _, _, _) = the_year(); + for (start, end) in [ + (date(2026, 1, 1), date(2026, 6, 30)), + (date(2026, 7, 1), date(2026, 12, 31)), + (date(2026, 12, 15), date(2026, 12, 15)), + ] { + let tc = tax_basis_capital(&b.l, start, end, &members(&b), &[b.cash]).unwrap(); + for row in &tc.rows { + assert_eq!( + row.ending.known, + row.beginning.known + + row.contributions_cash + + row.contributions_property.known + + row.net_income.known + + row.other.known + - row.distributions.known, + "{start}..{end}" + ); + assert_eq!( + row.reconciliation.total(), + Some(row.book.ending - row.ending.known), + "{start}..{end}" + ); + } + } +} diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index 9f866e3..afc44a9 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -651,6 +651,26 @@ pub fn find(b: &Books) -> Vec { ), }); } + // Accumulated depreciation no asset's runs account for: a hand entry, + // or a run reversed after its asset left the books. + let unattributed = register + .as_ref() + .map_or(Decimal::ZERO, |r| r.unattributed_accumulated()); + if !unattributed.is_zero() { + out.push(Attention { + severity: Severity::Problem, + title: format!( + "{} of accumulated depreciation not from any asset's runs", + fmt::dollars(unattributed.abs()) + ), + detail: "The accumulated depreciation accounts hold depreciation (or take it back) that no registered asset's depreciation run accounts for: an entry made by hand, or a run reversed after its asset was disposed.".into(), + target: Some(Route::Assets), + prompt: format!( + "In wanderbooks, the accumulated depreciation accounts differ by {} from what the assets' depreciation runs put there (books.export fixed_asset_register, unattributed_accumulated). Find the entries behind it in those accounts' registers and tell me how to fix them.", + fmt::dollars(unattributed) + ), + }); + } let unregistered = register.map_or(Decimal::ZERO, |r| r.unregistered_cost()); if !unregistered.is_zero() { let (title, why) = if unregistered > Decimal::ZERO { @@ -681,16 +701,25 @@ pub fn find(b: &Books) -> Vec { ), }); } - for a in b.ledger.registered_assets().filter(|a| { - a.tag.is_none() && a.status == ledger_core::AssetStatus::Active && a.purchase_date <= today - }) { + let held = |a: &&ledger_core::Asset| { + a.status == ledger_core::AssetStatus::Active + && b.ledger + .asset_acquired_date(a.id) + .is_some_and(|d| d <= today) + }; + for a in b + .ledger + .registered_assets() + .filter(held) + .filter(|a| a.tag.is_none()) + { out.push(Attention { severity: Severity::Todo, title: format!("No tag: {}", a.description), detail: format!( "Asset {}, in service {}. A tag is the label on the thing itself.", a.id, - fmt::date(a.purchase_date) + fmt::date(a.in_service_date) ), target: Some(Route::Asset(a.id)), prompt: format!( @@ -700,27 +729,62 @@ pub fn find(b: &Books) -> Vec { }); } - for a in b.ledger.registered_assets().filter(|a| { - a.tax_basis().is_none() - && a.status == ledger_core::AssetStatus::Active - && a.purchase_date <= today - }) { + for a in b.ledger.registered_assets().filter(held) { let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id)); - out.push(Attention { - severity: Severity::Todo, - title: format!("No tax basis: {}", b.asset_label(a.id)), - detail: format!( - "Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.", - fmt::dollars(a.cost.amount), - fmt::date(a.purchase_date) - ), - target: Some(Route::Asset(a.id)), - prompt: format!( - "In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (cost), contributed by a member from business use (carryover, IRC §723: the member's adjusted basis), or converted by a member from personal use (personal-use conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis; the tax basis is the lesser of that and its fair market value). Then show me the asset.", - a.description, - fmt::dollars(a.cost.amount) - ), - }); + let Some(basis) = &a.basis else { + out.push(Attention { + severity: Severity::Todo, + title: format!("No tax basis: {}", b.asset_label(a.id)), + detail: format!( + "Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.", + fmt::dollars(a.cost.amount), + fmt::date(a.in_service_date) + ), + target: Some(Route::Asset(a.id)), + prompt: format!( + "In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (purchase), contributed by a member from business use (carryover_723, IRC §723: the member's adjusted basis), or contributed by a member from personal use (personal_use_conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis, depreciated from the lesser of that and its fair market value); for a contribution, its fair market value when contributed, and who says so. Then show me the asset.", + a.description, + fmt::dollars(a.cost.amount) + ), + }); + continue; + }; + if !basis.rule.is_contribution() { + continue; + } + if !a.fmv_is_recorded() { + out.push(Attention { + severity: Severity::Todo, + title: format!("Confirm its value at contribution: {}", b.asset_label(a.id)), + detail: format!( + "Its basis was recorded before the value at contribution was asked, so the books read it as the book cost, {}. The §704(c) built-in gain or loss rests on it.", + fmt::dollars(a.cost.amount) + ), + target: Some(Route::Asset(a.id)), + prompt: format!( + "In wanderbooks, {name} (\"{}\") was contributed with its value at contribution not recorded; the books read it as its book cost, {}. Ask me what it was worth when contributed, then record it with books.set_asset_tax_basis (rule {}, contributor_adjusted_basis {}, fmv_at_contribution, and basis_source: who says so, citing the contribution entry). Then show me the asset.", + a.description, + fmt::dollars(a.cost.amount), + basis.rule.key(), + basis + .contributor_adjusted_basis + .map(|v| v.to_string()) + .unwrap_or_default() + ), + }); + } + if a.built_in_gain_704c().is_some_and(|g| !g.is_zero()) && basis.sec704c_method.is_none() { + out.push(Attention { + severity: Severity::Todo, + title: format!("Choose a §704(c) method with the CPA: {}", b.asset_label(a.id)), + detail: "It was contributed with a built-in gain or loss, and no §704(c) method (traditional, traditional with curative allocations, or remedial) is recorded for it.".into(), + target: Some(Route::Asset(a.id)), + prompt: format!( + "In wanderbooks, {name} (\"{}\") has a §704(c) built-in gain or loss and no §704(c) method. Ask me which method the CPA and I chose (traditional, traditional_with_curative, or remedial: Treas. Reg. §1.704-3(b)-(d)), then record it with books.set_asset_tax_basis, keeping its other facts as they are. Then show me the asset.", + a.description + ), + }); + } } use ledger_store::DiskCheck; @@ -861,7 +925,7 @@ mod tests { b.post(d(2026, 2, 1), cash, capital, 500_000); b.s.register_asset(NewAsset { description: "Laptop".into(), - purchase_date: d(2026, 2, 2), + in_service_date: d(2026, 2, 2), cost: Money::new(rust_decimal::Decimal::new(100_000, 2), Currency::USD), salvage_value: Money::new(rust_decimal::Decimal::ZERO, Currency::USD), useful_life_months: 36, diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index c19bb1c..7f9a4bf 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -1238,11 +1238,11 @@ impl Books { }; let amount = self.asset_register(through)?.unposted_depreciation(); let mut periods = std::collections::BTreeSet::new(); - for a in self - .ledger - .registered_assets() - .filter(|a| a.purchase_date <= through) - { + for a in self.ledger.registered_assets().filter(|a| { + self.ledger + .asset_acquired_date(a.id) + .is_some_and(|d| d <= through) + }) { if matches!(a.status, AssetStatus::Disposed { date, .. } if date <= through) { continue; } @@ -2091,26 +2091,22 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap { - if let (Some(period), Some(entry), Some(amount)) = ( - p.get("period").and_then(|v| v.as_str()), - u(p, "entry_id"), - p.get("amount") - .and_then(|v| v.as_str()) - .and_then(|s| s.parse::().ok()), - ) { - t.runs.push((entry, amount)); - t.periods.push(period.to_string()); - } + if line.line_type == "asset.disposed" { + t.disposal_entry = u(p, "entry_id"); + if let Some(a) = asset { + gain_loss_accounts.insert(a, u(p, "gain_loss_account")); } - "asset.disposed" => { - t.disposal_entry = u(p, "entry_id"); - if let Some(a) = asset { - gain_loss_accounts.insert(a, u(p, "gain_loss_account")); - } + } + } + // Every run journaled, reversed or not, in the order recorded: the + // ledger's runs (each asset.depreciation_posted as replay recorded it), + // one reading of the journal, never a second copy read here. + for a in ledger.registered_assets() { + if let Some(t) = out.get_mut(&a.id) { + for run in ledger.depreciation_runs(a.id) { + t.runs.push((run.entry, run.amount)); + t.periods.push(run.period.clone()); } - _ => {} } } // The runs in effect, by month; a run reversed (and not re-instated) @@ -2168,7 +2164,7 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap 31 { continue; } @@ -2214,7 +2210,8 @@ fn disposal_true_up( }) .sum(); } - ledger_core::accumulated_depreciation(asset, NaiveDate::MAX).map_or(Decimal::ZERO, |t| t - runs) + ledger_core::accumulated_scheduled_through(asset, NaiveDate::MAX) + .map_or(Decimal::ZERO, |t| t - runs) } /// Entry ids a journal line refers to. diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index fece384..106b484 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -489,7 +489,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { )?; let asset = s.register_asset(NewAsset { description: "MacBook Pro 16\"".into(), - purchase_date: laptop_date, + in_service_date: laptop_date, cost: usd(laptop), salvage_value: usd(dec(400)), useful_life_months: 36, @@ -501,13 +501,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { s.set_asset_tag(asset, "JS-0001")?; s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?; // Bought by the company, so its federal tax basis is its cost. - s.set_asset_tax_basis( - asset, - ledger_core::TaxBasis { - rule: ledger_core::TaxBasisRule::Cost, - contributor_basis: None, - }, - )?; + s.set_asset_basis(asset, ledger_core::AssetBasis::purchase())?; // Prepaid insurance, spread over a year. let ins_date = day(start + Months::new(2), 15); diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs index 7593a4b..ca9a6d6 100644 --- a/crates/ledger-gui/src/pages/assets.rs +++ b/crates/ledger-gui/src/pages/assets.rs @@ -2,11 +2,14 @@ //! asset, reconciled to the books) and one asset completely (its label, //! its schedule month by month, the entries and documents behind it). //! Every number comes from `ledger-core` (`fixed_asset_register`, -//! `depreciation_schedule`, `Asset::tax_basis`, `Asset::built_in_gain`); -//! what is posted comes from the journal. Book value and federal tax -//! basis sit side by side and are never confused: book cost is what was -//! paid or, for a member's contribution, its fair market value; tax basis -//! follows its rule (docs/spec/rules.md B-25). +//! `depreciation_schedule`, `Asset::basis_figures`), what is posted too +//! (`Ledger::accumulated_posted`, its runs). Book value is what the books +//! carry (cost less the depreciation posted), never the schedule's figure, +//! which sits beside it as "scheduled" (B-24). Book value and the federal +//! tax figures sit side by side and are never confused: book cost is what +//! was paid or, for a member's contribution, its fair market value; the +//! §723 basis, the depreciable basis and the §704(c) built-in gain follow +//! the basis rule (docs/spec/rules.md B-25). use super::page; use crate::app::Shell; @@ -18,7 +21,9 @@ use crate::widgets; use chrono::{Datelike, NaiveDate}; use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2}; use egui_phosphor::regular as icon; -use ledger_core::{Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, TaxBasisRule}; +use ledger_core::{ + Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, BasisRule, Sec704cMethod, +}; use rust_decimal::Decimal; /// The register's date (None: today), kept across navigation. @@ -52,8 +57,9 @@ pub struct Notice { /// 2. unposted_depreciation() < 0, unregistered_cost() != 0: a problem /// each, with the exact amount. /// 3. Both zero: one note that the register agrees with the books. -/// 4. missing_tax_basis > 0: a problem, last, from -/// [`missing_tax_notice`]. +/// 4. unattributed_accumulated() != 0: a problem (accumulated +/// depreciation no asset's runs account for), with the exact amount. +/// 5. missing_basis > 0: a problem, last, from [`missing_tax_notice`]. /// ``` pub fn notices(b: &Books, r: &AssetRegister) -> Vec { let mut out = Vec::new(); @@ -125,7 +131,17 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec { ), }); } - if unposted.is_zero() && unregistered.is_zero() { + let unattributed = r.unattributed_accumulated(); + if !unattributed.is_zero() { + out.push(Notice { + problem: true, + text: format!( + "{} of accumulated depreciation in the books isn't from any asset's depreciation runs — ask the bookkeeper to find the entries behind it.", + fmt::dollars(unattributed) + ), + }); + } + if unposted.is_zero() && unregistered.is_zero() && unattributed.is_zero() { out.push(Notice { problem: false, text: format!( @@ -134,7 +150,7 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec { ), }); } - if let Some(text) = missing_tax_notice(r.missing_tax_basis) { + if let Some(text) = missing_tax_notice(r.missing_basis) { out.push(Notice { problem: true, text, @@ -148,118 +164,141 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec { pub fn missing_tax_notice(missing: usize) -> Option { (missing > 0).then(|| { format!( - "Tax basis not recorded for {} — ask the bookkeeper to record {} (books.set_asset_tax_basis): bought by the company, contributed by a member from business use, or converted from personal use.", + "Tax basis not recorded for {} — ask the bookkeeper to record {} (books.set_asset_tax_basis): bought by the company, contributed by a member from business use, or contributed from personal use.", fmt::count(missing, "asset", "assets"), if missing == 1 { "it" } else { "each" } ) }) } -/// A tax basis rule for a sentence: "cost", "carryover", "personal-use +/// A basis rule for a sentence: "purchase", "carryover", "personal-use /// conversion". -pub fn rule_words(r: TaxBasisRule) -> &'static str { +pub fn rule_words(r: BasisRule) -> &'static str { match r { - TaxBasisRule::Cost => "cost", - TaxBasisRule::Carryover => "carryover", - TaxBasisRule::PersonalUseConversion => "personal-use conversion", + BasisRule::Purchase => "purchase", + BasisRule::Carryover723 => "carryover", + BasisRule::PersonalUseConversion => "personal-use conversion", } } /// What a rule means, in plain words, with the law it comes from. -pub fn rule_explained(r: TaxBasisRule) -> &'static str { +pub fn rule_explained(r: BasisRule) -> &'static str { match r { - TaxBasisRule::Cost => { + BasisRule::Purchase => { "Bought by the company: its tax basis is what it paid (IRC §1012(a))." } - TaxBasisRule::Carryover => { - "Contributed by a member from business use: it keeps the member's adjusted basis — what they paid less any depreciation they took (IRC §723)." + BasisRule::Carryover723 => { + "Contributed by a member from business use: the company's basis is the member's adjusted basis — cost plus improvements, less depreciation allowed or allowable (IRC §723, §1016(a)) — and tax depreciation starts from it." } - TaxBasisRule::PersonalUseConversion => { - "Contributed by a member from personal use: it depreciates from the lesser of its fair market value and the member's adjusted basis (Treas. Reg. §1.168(i)-4(b))." + BasisRule::PersonalUseConversion => { + "Contributed by a member from personal use: the company's basis is the member's adjusted basis (IRC §723); tax depreciation starts from the lesser of its fair market value and that basis (Treas. Reg. §1.168(i)-4(b))." } } } -/// Book value against tax basis, in one sentence, for an asset under -/// `rule` (or none recorded). -pub fn book_vs_tax(rule: Option) -> &'static str { +/// Book value against the tax figures, in one sentence, for an asset +/// under `rule` (or none recorded). +pub fn book_vs_tax(rule: Option) -> &'static str { match rule { - Some(TaxBasisRule::Cost) => { + Some(BasisRule::Purchase) => { "Book cost and tax basis are both what the company paid; tax depreciation starts from the tax basis — the CPA's." } _ => { - "Book value is the fair market value at contribution; tax depreciation starts from the tax basis — the CPA's." + "Book value is the fair market value at contribution; the tax figures are at contribution, and tax depreciation starts from the depreciable basis — the CPA's." } } } -/// An asset's federal tax basis as the viewer shows it: every figure is -/// ledger-core's (`Asset::tax_basis`, `Asset::built_in_gain`), never -/// re-derived. +/// An asset's basis facts and tax figures as the viewer shows them: every +/// figure is ledger-core's (`Asset::basis_figures`), never re-derived. #[derive(Clone, Debug, PartialEq, Eq)] pub struct TaxFacts { /// The rule, if recorded. - pub rule: Option, + pub rule: Option, /// The contributing member's adjusted basis (a contribution only). - pub contributor_basis: Option, + pub contributor_adjusted_basis: Option, + /// The value at contribution (a contribution only). + pub fmv_at_contribution: Option, + /// Whether that value was recorded (false: read as the book cost). + pub fmv_recorded: bool, + /// The §723 basis (a contribution only). + pub tax_basis_723: Option, /// The depreciable tax basis, if recorded. - pub tax_basis: Option, + pub depreciable_basis: Option, /// Built-in gain (+) or loss (−) at contribution, if any. - pub built_in_gain: Option, + pub built_in_gain_704c: Option, + /// The §704(c) method, if chosen. + pub sec704c_method: Option, } impl TaxFacts { /// An asset's. pub fn of(a: &Asset) -> Self { + let f = a.basis_figures().ok().flatten(); TaxFacts { - rule: a.tax.map(|t| t.rule), - contributor_basis: a.tax.and_then(|t| t.contributor_basis), - tax_basis: a.tax_basis(), - built_in_gain: a.built_in_gain(), + rule: a.basis.as_ref().map(|b| b.rule), + contributor_adjusted_basis: a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis), + fmv_at_contribution: a.fmv_at_contribution(), + fmv_recorded: a.fmv_is_recorded(), + tax_basis_723: f.and_then(|f| f.tax_basis_723), + depreciable_basis: f.map(|f| f.depreciable_basis), + built_in_gain_704c: f.and_then(|f| f.built_in_gain_704c), + sec704c_method: a.basis.as_ref().and_then(|b| b.sec704c_method), } } /// A register row's. pub fn of_row(x: &AssetRegisterRow) -> Self { TaxFacts { - rule: x.tax_rule, - contributor_basis: x.contributor_basis, - tax_basis: x.tax_basis, - built_in_gain: x.built_in_gain, + rule: x.basis_rule, + contributor_adjusted_basis: x.contributor_adjusted_basis, + fmv_at_contribution: x.fmv_at_contribution, + fmv_recorded: x.fmv_recorded, + tax_basis_723: x.tax_basis_723, + depreciable_basis: x.depreciable_basis, + built_in_gain_704c: x.built_in_gain_704c, + sec704c_method: x.sec704c_method, } } - /// "Carryover · contributor's basis $900.00", or "Tax basis not - /// recorded": the line under an asset in the register. + /// "Carryover · §723 basis $900.00", or "Tax basis not recorded": the + /// line under an asset in the register. pub fn summary(&self) -> String { match self.rule { None => "Tax basis not recorded".into(), Some(r) => { let mut s = ledger_import::asset_register::rule_label(r).to_string(); - if let Some(b) = self.contributor_basis { - s.push_str(&format!(" · contributor's basis {}", fmt::dollars(b))); + if let Some(b) = self.tax_basis_723 { + s.push_str(&format!(" · §723 basis {}", fmt::dollars(b))); } s } } } - /// Everything, with the rule's citation, for a tooltip: "Tax basis - /// $900.00: Carryover (IRC §723); contributor's basis $1,000.00; - /// built-in gain $100.00." or "Tax basis not recorded." + /// Everything, with the rule's citation, for a tooltip: "Depreciable + /// basis $900.00: Carryover (IRC §723); §723 basis $1,000.00; value at + /// contribution $1,100.00; built-in gain $100.00." or "Tax basis not + /// recorded." pub fn detail(&self) -> String { - let (Some(r), Some(t)) = (self.rule, self.tax_basis) else { + let (Some(r), Some(t)) = (self.rule, self.depreciable_basis) else { return "Tax basis not recorded.".into(); }; let mut s = format!( - "Tax basis {}: {}", + "Depreciable basis {}: {}", fmt::dollars(t), ledger_import::asset_register::rule_cited(r) ); - if let Some(b) = self.contributor_basis { - s.push_str(&format!("; contributor's basis {}", fmt::dollars(b))); + if let Some(b) = self.tax_basis_723 { + s.push_str(&format!("; §723 basis {}", fmt::dollars(b))); } - if let Some(g) = self.built_in_gain { + if let Some(v) = self.fmv_at_contribution { + s.push_str(&format!("; value at contribution {}", fmt::dollars(v))); + if !self.fmv_recorded { + s.push_str(" (the book cost: not recorded)"); + } + } + if let Some(g) = self.built_in_gain_704c { let (label, amount) = gain_or_loss(g); s.push_str(&format!( "; {} {}", @@ -267,6 +306,12 @@ impl TaxFacts { fmt::dollars(amount) )); } + if let Some(m) = self.sec704c_method { + s.push_str(&format!( + "; §704(c) method {}", + ledger_import::asset_register::method_cited(m) + )); + } s.push('.'); s } @@ -481,26 +526,31 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { None, ), 1 => ( - "Accumulated depreciation", - r.total_accumulated, - "Their book depreciation by schedule through the date.", - None, + "Depreciation posted", + r.total_accumulated_posted, + "The book depreciation the books carry for them on the date: their depreciation runs, less any reversed.", + (r.total_accumulated_scheduled != r.total_accumulated_posted).then(|| { + ( + format!("{} scheduled", fmt::dollars(r.total_accumulated_scheduled)), + p.muted, + ) + }), ), 2 => ( "Book value", r.total_book_value, - "Cost less accumulated depreciation.", + "Cost less the depreciation posted: what the books carry.", None, ), _ => ( - "Tax basis", - r.total_tax_basis, - "The federal tax basis of the assets held that have one recorded: what tax depreciation (the CPA's) starts from.", - (r.missing_tax_basis > 0).then(|| { + "Depreciable tax basis", + r.total_depreciable_basis, + "The federal depreciable basis of the assets held that have one recorded, at contribution or purchase: what tax depreciation (the CPA's) starts from.", + (r.missing_basis > 0).then(|| { ( format!( "{} not recorded", - fmt::count(r.missing_tax_basis, "asset", "assets") + fmt::count(r.missing_basis, "asset", "assets") ), p.warning, ) @@ -528,13 +578,15 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { widgets::empty( ui, icon::PACKAGE, - "Nothing in service yet", - &format!("No asset was in service by {}.", fmt::date(as_of)), + "Nothing acquired yet", + &format!("No asset was acquired by {}.", fmt::date(as_of)), ); return; } let amt = widgets::AMOUNT_MIN + 24.0; - let fixed = 110.0 + 104.0 + amt * 5.0 + 190.0; + // The depreciable basis's heading is the longest. + let wide = amt + 36.0; + let fixed = 110.0 + 104.0 + amt * 4.0 + wide + 190.0; let w = ui.available_width().max(fixed + 200.0); widgets::min_width(ui, "asset_register", w, |ui| { let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); @@ -546,10 +598,10 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { head("Description", 0.0, false), head("In service", 104.0, false), head("Cost", amt, true), - head("Accumulated", amt, true), + head("Posted", amt, true), head("Book value", amt, true), - head("Tax basis", amt, true), - head("Built-in gain", amt, true), + head("Scheduled", amt, true), + head("Depreciable basis", wide, true), head("Status", 190.0, false), ], None, @@ -572,7 +624,16 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { Some(s) => format!("Serial {s} · {}", tax.summary()), None => tax.summary(), }; - let (tax_text, tax_color) = match tax.tax_basis { + let dates = if x.acquired_date != x.in_service_date { + format!( + "Acquired {}, in service {}.", + fmt::date(x.acquired_date), + fmt::date(x.in_service_date) + ) + } else { + format!("Acquired and in service {}.", fmt::date(x.in_service_date)) + }; + let (tax_text, tax_color) = match tax.depreciable_basis { _ if disposed => (String::new(), text), Some(t) => (fmt::money(t), text), None => ("not recorded".to_string(), p.warning), @@ -588,17 +649,12 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { if x.tag.is_some() { text } else { p.warning }, ), (x.description.clone(), 0.0, false, text), - (fmt::date(x.in_service), 104.0, false, text), + (fmt::date(x.in_service_date), 104.0, false, text), (money(x.cost), amt, true, text), - (money(x.accumulated), amt, true, text), + (money(x.accumulated_posted), amt, true, text), (money(x.book_value), amt, true, text), - (tax_text, amt, true, tax_color), - ( - x.built_in_gain.map(money).unwrap_or_default(), - amt, - true, - text, - ), + (money(x.accumulated_scheduled), amt, true, p.muted), + (tax_text, wide, true, tax_color), (status_text(x.disposed), 190.0, false, p.muted), ], Some((sub.as_str(), p.faint)), @@ -607,12 +663,12 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ); let resp = resp.on_hover_text(if disposed { format!( - "Open this asset (it cost {}; it left the books when disposed). {}", + "Open this asset (it cost {}; it left the books when disposed). {dates} {}", fmt::dollars(x.cost), tax.detail() ) } else { - format!("Open this asset. {}", tax.detail()) + format!("Open this asset. {dates} {}", tax.detail()) }); resp.context_menu(|ui| { if let Some(t) = &x.tag { @@ -622,8 +678,8 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { widgets::copy_item(ui, "Copy serial number", s); } widgets::copy_item(ui, "Copy book value", &x.book_value.to_string()); - if let Some(t) = x.tax_basis { - widgets::copy_item(ui, "Copy tax basis", &t.to_string()); + if let Some(t) = x.depreciable_basis { + widgets::copy_item(ui, "Copy depreciable basis", &t.to_string()); } }); if resp.clicked() { @@ -639,10 +695,15 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { (String::new(), 0.0, false, p.text), (String::new(), 104.0, false, p.text), (fmt::money(r.total_cost), amt, true, p.text), - (fmt::money(r.total_accumulated), amt, true, p.text), + (fmt::money(r.total_accumulated_posted), amt, true, p.text), (fmt::money(r.total_book_value), amt, true, p.text), - (fmt::money(r.total_tax_basis), amt, true, p.text), - (String::new(), amt, true, p.text), + ( + fmt::money(r.total_accumulated_scheduled), + amt, + true, + p.muted, + ), + (fmt::money(r.total_depreciable_basis), wide, true, p.text), (String::new(), 190.0, false, p.text), ], None, @@ -655,7 +716,7 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ui.add( egui::Label::new( RichText::new( - "Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule (under each asset) and can differ. Tax depreciation starts from the tax basis — the CPA's.", + "Book value is cost less the depreciation posted (what the books carry); scheduled is what the schedule says is due by the date. Cost is what the company paid or, for a member's contribution, its fair market value then; the tax figures follow its rule (under each asset), at contribution or purchase, and can differ. Tax depreciation starts from the depreciable basis — the CPA's.", ) .font(theme::regular(11.5)) .color(p.faint), @@ -722,7 +783,7 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<' line( ui, "Accumulated depreciation by schedule", - r.total_accumulated, + r.total_accumulated_scheduled, false, p.text, ); @@ -740,6 +801,22 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<' .is_ok_and(|due| due.amount <= Decimal::ZERO); let color = if accruing { p.muted } else { diff_color(d) }; line(ui, "Difference (not yet posted)", d, true, color); + ui.add_space(8.0); + line( + ui, + "Accumulated depreciation from the assets' runs", + r.total_accumulated_posted, + false, + p.text, + ); + let u = r.unattributed_accumulated(); + line( + ui, + "Difference (in the books, not from runs)", + u, + true, + diff_color(u), + ); }); ui.add_space(8.0); ui.horizontal_wrapped(|ui| { @@ -1011,7 +1088,7 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { |_| {}, |ui| { let end = a - .purchase_date + .in_service_date .checked_add_months(chrono::Months::new(a.useful_life_months)) .and_then(|d| d.pred_opt()); facts(ui, |ui, k| { @@ -1024,8 +1101,15 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { }; widgets::copyable(ui, rt, &d.to_string()); }; + if let Some(acquired) = b.ledger.asset_acquired_date(a.id) + && acquired != a.in_service_date + { + k(ui, "Acquired"); + ui.label(fmt::date(acquired)); + ui.end_row(); + } k(ui, "In service"); - ui.label(fmt::date(a.purchase_date)); + ui.label(fmt::date(a.in_service_date)); ui.end_row(); k(ui, "Cost"); money(ui, a.cost.amount, false); @@ -1047,8 +1131,11 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { ui.end_row(); match &today { Ok(Some(r)) => { - k(ui, &format!("Accumulated, {}", fmt::date_short(b.today))); - money(ui, r.accumulated, false); + k(ui, &format!("Posted, {}", fmt::date_short(b.today))); + money(ui, r.accumulated_posted, false); + ui.end_row(); + k(ui, &format!("Scheduled, {}", fmt::date_short(b.today))); + money(ui, r.accumulated_scheduled, false); ui.end_row(); k(ui, "Book value today"); money(ui, r.book_value, true); @@ -1061,9 +1148,6 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { ui.end_row(); } } - k(ui, "Posted in the books"); - money(ui, trail.posted(), false); - ui.end_row(); if let AssetStatus::Disposed { date, proceeds } = &a.status { k(ui, "Disposed"); ui.label(fmt::date(*date)); @@ -1146,7 +1230,9 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { head("Month", 100.0, false), head("Amount", amt, true), head("Accumulated", amt, true), - head("Book value", amt, true), + // The schedule's cost less accumulated, never + // what the books carry (that is above). + head("Remaining", amt, true), head("", 24.0, false), head("In the books", 0.0, false), ], @@ -1372,29 +1458,67 @@ fn tax_card(ui: &mut Ui, a: &Asset) { k(ui, "Book cost"); money(ui, a.cost.amount, false); ui.end_row(); - if let Some(c) = t.contributor_basis { - k(ui, "Contributor's basis"); + if let Some(v) = t.fmv_at_contribution { + k(ui, "Value at contribution"); + ui.horizontal(|ui| { + money(ui, v, false); + if !t.fmv_recorded { + ui.label( + RichText::new( + "from book cost, recorded before the value was asked", + ) + .font(theme::regular(12.0)) + .color(p.warning), + ); + } + }); + ui.end_row(); + } + if let Some(c) = t.contributor_adjusted_basis { + k(ui, "Contributor's adjusted basis"); money(ui, c, false); ui.end_row(); } - if let Some(tb) = t.tax_basis { - k(ui, "Tax basis"); + if let Some(b) = t.tax_basis_723 { + k(ui, "§723 basis"); + money(ui, b, false); + ui.end_row(); + } + if let Some(tb) = t.depreciable_basis { + k(ui, "Depreciable basis"); money(ui, tb, true); ui.end_row(); } - if let Some(g) = t.built_in_gain { + if let Some(g) = t.built_in_gain_704c { let (label, amount) = gain_or_loss(g); - k(ui, label); + k(ui, &format!("{label} §704(c)")); money(ui, amount, false); ui.end_row(); } + if rule.is_contribution() { + k(ui, "§704(c) method"); + match t.sec704c_method { + Some(m) => { + ui.label(ledger_import::asset_register::method_cited(m)); + } + None => { + ui.label(RichText::new("not chosen").color(p.warning)); + } + } + ui.end_row(); + } + if let Some(src) = a.basis.as_ref().and_then(|b| b.source.as_ref()) { + k(ui, "Facts from"); + ui.label(ledger_import::asset_register::source_text(src)); + ui.end_row(); + } }); ui.add_space(6.0); let mut why = rule_explained(rule).to_string(); - if t.built_in_gain.is_some_and(|g| g < Decimal::ZERO) { - why.push_str(" A built-in loss is taken into account only for the contributing member (IRC §704(c)(1)(C))."); - } else if t.built_in_gain.is_some_and(|g| g > Decimal::ZERO) { - why.push_str(" IRC §704(c) has the partnership take the built-in gain into account for the contributing member."); + if t.built_in_gain_704c.is_some_and(|g| g < Decimal::ZERO) { + why.push_str(" A built-in loss counts only for the contributing member; for the others the basis is treated as the value at contribution (IRC §704(c)(1)(C))."); + } else if t.built_in_gain_704c.is_some_and(|g| g > Decimal::ZERO) { + why.push_str(" IRC §704(c) has the company take the built-in gain into account for the contributing member."); } for line in [why.as_str(), book_vs_tax(Some(rule))] { ui.add( @@ -1451,7 +1575,7 @@ mod tests { .map(|(cost, months)| { b.s.register_asset(NewAsset { description: format!("Thing {months}"), - purchase_date: d(2026, 1, 1), + in_service_date: d(2026, 1, 1), cost: Money::new(cents(cost), Currency::USD), salvage_value: Money::new(Decimal::ZERO, Currency::USD), useful_life_months: months, @@ -1501,17 +1625,26 @@ mod tests { assert_eq!(none.color, positive); } - /// One asset's page shows its own book value today, not another's. + /// One asset's page shows its own book value today, not another's: + /// what the books carry (cost less posted: nothing is posted, so its + /// cost), with the schedule's figure beside it, never as book value. #[test] fn an_assets_page_shows_its_own_book_value() { for i in 0..2 { let (_dir, books, ids) = two_assets(&format!("asset-page-{i}")); let a = books.ledger.get_asset(ids[i]).unwrap().clone(); - let want = ledger_core::book_value(&a, today()).unwrap(); + let scheduled = ledger_core::accumulated_scheduled_through(&a, today()).unwrap(); + assert!(scheduled > Decimal::ZERO, "the schedule has run ahead"); let (texts, _) = render(books, Route::Asset(ids[i]), |_| {}); assert_eq!( beside(&texts, "Book value today").text, - fmt::dollars(want), + fmt::dollars(a.cost.amount), + "asset {i}" + ); + let label = format!("Scheduled, {}", fmt::date_short(today())); + assert_eq!( + beside(&texts, &label).text, + fmt::dollars(scheduled), "asset {i}" ); } diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 3684bbb..150b197 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -11,9 +11,8 @@ use crate::theme::{self, pal}; use crate::widgets; use egui::{CornerRadius, RichText, Sense, Ui, vec2}; use egui_phosphor::regular as icon; -use ledger_core::AssetStatus; -use ledger_core::PeriodStatus; -use ledger_core::assets::{accumulated_depreciation, book_value}; +use ledger_core::assets::accumulated_scheduled_through; +use ledger_core::{AssetStatus, PeriodStatus}; use rust_decimal::Decimal; fn kv(ui: &mut Ui, k: &str, v: &str) { @@ -356,18 +355,17 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }, |ui| { for a in &assets { - // What the books carry (runs in effect), so this ties to the - // balance sheet; the schedule may run ahead of the books. - let acc: Decimal = b.asset_trail(a.id).posted(); - let bv = a.cost.amount - acc; - let scheduled = accumulated_depreciation(a, b.today); - let _ = book_value; - let dep = a.cost.amount - a.salvage_value.amount; - let frac: f32 = if dep.is_zero() { - 1.0 - } else { - (acc / dep).try_into().unwrap_or(0.0) + // What the books carry (its runs, less reversals: + // Ledger::accumulated_posted; zero once disposed, when the + // asset left the books), so this ties to the balance + // sheet; the schedule may run ahead of the books. + let disposed = match &a.status { + AssetStatus::Disposed { date, .. } if *date <= b.today => Some(*date), + _ => None, }; + let acc = b.ledger.accumulated_posted(a.id, b.today); + let scheduled = accumulated_scheduled_through(a, b.today); + let dep = a.cost.amount - a.salvage_value.amount; ui.horizontal_wrapped(|ui| { if widgets::link( ui, @@ -379,8 +377,8 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { } ui.label( RichText::new(format!( - "bought {} · {} months · {}", - fmt::date(a.purchase_date), + "in service {} · {} months · {}", + fmt::date(a.in_service_date), a.useful_life_months, match &a.status { AssetStatus::Active => "active".to_string(), @@ -392,29 +390,51 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { .color(p.muted), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label( - RichText::new(format!("book value {}", fmt::dollars(bv))) - .font(theme::semibold(13.0)), - ); + let text = match (&acc, disposed) { + (_, Some(_)) => format!("book value {}", fmt::dollars(Decimal::ZERO)), + (Ok(acc), None) => { + format!("book value {}", fmt::dollars(a.cost.amount - *acc)) + } + (Err(e), None) => format!("book value can't be computed: {e}"), + }; + ui.label(RichText::new(text).font(theme::semibold(13.0))); }); }); - let note = match scheduled { - Ok(s) if s > acc => { - format!(" · {} more scheduled through today", fmt::dollars(s - acc)) - } - Ok(_) => String::new(), - // An earlier version's asset: said, never guessed. - Err(_) => " · its schedule can't be computed".to_string(), - }; - progress( - ui, - frac, - &format!( - "{} of {} depreciated{note}", - fmt::dollars(acc), - fmt::dollars(dep) + match (&acc, disposed) { + (_, Some(date)) => progress( + ui, + 1.0, + &format!("disposed {}: it left the books", fmt::date(date)), ), - ); + (Ok(acc), None) => { + let frac: f32 = if dep.is_zero() { + 1.0 + } else { + (*acc / dep).try_into().unwrap_or(0.0) + }; + let note = match scheduled { + Ok(s) if s > *acc => { + format!( + " · {} more scheduled through today", + fmt::dollars(s - *acc) + ) + } + Ok(_) => String::new(), + // An earlier version's asset: said, never guessed. + Err(_) => " · its schedule can't be computed".to_string(), + }; + progress( + ui, + frac, + &format!( + "{} of {} depreciated{note}", + fmt::dollars(*acc), + fmt::dollars(dep) + ), + ); + } + (Err(_), None) => {} + } ui.add_space(8.0); } }, diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index f18b519..8ba0336 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -60,41 +60,80 @@ fn attachment_name(b: &Books, id: u64) -> String { .unwrap_or_else(|| format!("attachment {id}")) } -/// "Tax basis of FL-0001: personal-use conversion, contributor's basis -/// $2,000.00 → tax basis $1,700.00". The tax basis is ledger-core's -/// (`Asset::tax_basis`) for the event's rule and basis on the asset's -/// book cost, which never changes once registered. -fn tax_basis_text(b: &Books, asset_id: Option, p: &Value) -> String { +/// "Tax basis of FL-0001: personal-use conversion, value $1,440.00, +/// contributor's adjusted basis $1,688.40: §723 basis $1,688.40, +/// depreciable basis $1,440.00, built-in loss $248.40". The figures are +/// ledger-core's (`Asset::basis_figures`) for the event's facts on the +/// asset; a pre-P3 `asset.tax_basis_set` (`legacy`: its rule by its old +/// name, no value) reads the value as the book cost, as it was recorded. +fn tax_basis_text(b: &Books, asset_id: Option, p: &Value, legacy: bool) -> String { let asset = asset_id.and_then(|id| b.ledger.get_asset(id)); let name = match (asset, asset_id) { (Some(a), _) => a.tag.clone().unwrap_or_else(|| a.description.clone()), (None, Some(id)) => format!("asset {id}"), (None, None) => "an asset".to_string(), }; - let rule = ledger_core::TaxBasisRule::parse(s(p, "rule")); - let contributor: Option = p - .get("contributor_basis") - .and_then(|v| v.as_str()) - .and_then(|v| v.parse().ok()); + let dec = |k: &str| -> Option { + p.get(k) + .and_then(|v| v.as_str()) + .and_then(|v| v.parse().ok()) + }; + let rule = if legacy { + ledger_core::BasisRule::parse_legacy(s(p, "rule")) + } else { + ledger_core::BasisRule::parse(s(p, "rule")) + }; let Some(rule) = rule else { return format!("Tax basis of {name} recorded"); }; - let tax = asset.and_then(|a| { + let adjusted = dec(if legacy { + "contributor_basis" + } else { + "contributor_adjusted_basis" + }); + let fmv = (!legacy).then(|| dec("fmv_at_contribution")).flatten(); + let method = p + .get("sec704c_method") + .and_then(|v| v.as_str()) + .and_then(ledger_core::Sec704cMethod::parse); + let figures = asset.and_then(|a| { let mut a = a.clone(); - a.tax = Some(ledger_core::TaxBasis { + a.basis = Some(ledger_core::AssetBasis { rule, - contributor_basis: contributor, + contributor_adjusted_basis: adjusted, + fmv_at_contribution: fmv, + sec704c_method: method, + source: None, }); - a.tax_basis() + a.basis_figures().ok().flatten() }); let mut text = format!("Tax basis of {name}: {}", super::assets::rule_words(rule)); - if let Some(c) = contributor { - text.push_str(&format!(", contributor's basis {}", fmt::dollars(c))); + if let Some(v) = fmv { + text.push_str(&format!(", value {}", fmt::dollars(v))); } - match (tax, contributor.is_some()) { - (Some(t), true) => text.push_str(&format!(" → tax basis {}", fmt::dollars(t))), - (Some(t), false) => text.push_str(&format!(", tax basis {}", fmt::dollars(t))), - (None, _) => {} + if let Some(c) = adjusted { + text.push_str(&format!( + ", contributor's adjusted basis {}", + fmt::dollars(c) + )); + } + if let Some(f) = figures { + let mut parts = Vec::new(); + if let Some(t) = f.tax_basis_723 { + parts.push(format!("§723 basis {}", fmt::dollars(t))); + } + parts.push(format!( + "depreciable basis {}", + fmt::dollars(f.depreciable_basis) + )); + if let Some(g) = f.built_in_gain_704c { + let (label, amount) = super::assets::gain_or_loss(g); + parts.push(format!("{} {}", label.to_lowercase(), fmt::dollars(amount))); + } + text.push_str(&format!(": {}", parts.join(", "))); + } + if let Some(m) = method { + text.push_str(&format!("; §704(c) method {}", m.key().replace('_', " "))); } text } @@ -785,7 +824,12 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { } "asset.tax_basis_set" => ev( icon::SCALES, - tax_basis_text(b, asset_id, p), + tax_basis_text(b, asset_id, p, true), + asset_id.map(Route::Asset), + ), + "asset.basis_set" => ev( + icon::SCALES, + tax_basis_text(b, asset_id, p, false), asset_id.map(Route::Asset), ), "asset.corrected" => ev( diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 4eb733a..c2b3a54 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -1156,20 +1156,21 @@ fn aging( fn asset_register(b: &Books, as_of: NaiveDate) -> Table { let mut t = Table::new(vec![ "Cost".into(), - "Accumulated".into(), + "Scheduled".into(), + "Posted".into(), "Book value".into(), - "Contributor's basis".into(), - "Tax basis".into(), - "Built-in gain (loss)".into(), + // Short enough for the columns: the note says what each is. + "§723 basis".into(), + "Depr. basis".into(), + "Built-in gain".into(), ]); let r = match b.asset_register(as_of) { Ok(r) => r, Err(e) => return cant_compute(t, &e), }; - let none3 = || vec![Cell::none(), Cell::none(), Cell::none()]; if r.rows.is_empty() { t.note(format!( - "No fixed asset was in service by {}.", + "No fixed asset was acquired by {}.", fmt::date(as_of) )); return t; @@ -1184,7 +1185,10 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { if let Some(s) = &x.serial { detail.push(format!("serial {s}")); } - detail.push(format!("in service {}", fmt::date(x.in_service))); + if x.acquired_date != x.in_service_date { + detail.push(format!("acquired {}", fmt::date(x.acquired_date))); + } + detail.push(format!("in service {}", fmt::date(x.in_service_date))); detail.push(fmt::count(x.useful_life_months as usize, "month", "months")); if !x.salvage.is_zero() { detail.push(format!("salvage {}", fmt::dollars(x.salvage))); @@ -1197,6 +1201,22 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { ), None => "tax basis not recorded".into(), }); + if let Some(v) = tax.fmv_at_contribution { + detail.push(if tax.fmv_recorded { + format!("value at contribution {}", fmt::dollars(v)) + } else { + format!( + "value at contribution {} (the book cost: not recorded)", + fmt::dollars(v) + ) + }); + } + if let Some(m) = tax.sec704c_method { + detail.push(format!( + "§704(c) method: {}", + ledger_import::asset_register::method_cited(m) + )); + } let opt = |v: Option| Cell { value: v, drill: None, @@ -1208,15 +1228,16 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { super::assets::status_text(x.disposed), fmt::dollars(x.cost) )); - [none3(), none3()].concat() + vec![Cell::none(); 7] } None => vec![ Cell::of(x.cost), - Cell::of(x.accumulated), + Cell::of(x.accumulated_scheduled), + Cell::of(x.accumulated_posted), Cell::of(x.book_value), - opt(tax.contributor_basis), - opt(tax.tax_basis), - opt(tax.built_in_gain), + opt(tax.tax_basis_723), + opt(tax.depreciable_basis), + opt(tax.built_in_gain_704c), ], }; t.rows.push(Row { @@ -1231,10 +1252,11 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { "Total held", vec![ Cell::of(r.total_cost), - Cell::of(r.total_accumulated), + Cell::of(r.total_accumulated_scheduled), + Cell::of(r.total_accumulated_posted), Cell::of(r.total_book_value), - Cell::none(), - Cell::of(r.total_tax_basis), + Cell::of(r.total_tax_basis_723), + Cell::of(r.total_depreciable_basis), Cell::none(), ], ); @@ -1270,6 +1292,10 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { value: Some(r.ledger_accumulated), drill: accum_drill, }, + Cell { + value: Some(r.ledger_accumulated), + drill: accum_drill, + }, Cell::of(r.ledger_cost - r.ledger_accumulated), Cell::none(), Cell::none(), @@ -1282,7 +1308,8 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { vec![ Cell::of(-r.unregistered_cost()), Cell::of(r.unposted_depreciation()), - Cell::of(-r.unregistered_cost() - r.unposted_depreciation()), + Cell::of(-r.unattributed_accumulated()), + Cell::of(r.total_book_value - (r.ledger_cost - r.ledger_accumulated)), Cell::none(), Cell::none(), Cell::none(), @@ -1292,7 +1319,7 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table { t.note(n.text); } t.note( - "Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule and can differ. Built-in gain (loss) is that value less the contributor's basis (IRC §704(c)). Tax depreciation starts from the tax basis — the CPA's.", + "Book value is cost less the depreciation posted (what the books carry); scheduled is what the schedule says is due by the date. Cost is what the company paid or, for a member's contribution, its fair market value then; the tax figures follow its rule and can differ: the §723 basis is the member's adjusted basis (IRC §723), the depreciable basis (Depr. basis) what tax depreciation starts from, and the built-in gain, a loss in parentheses, the value less the §723 basis (IRC §704(c)). The tax figures are at contribution or purchase; tax depreciation is the CPA's.", ); t } @@ -2389,10 +2416,11 @@ mod tests { } } - /// The fixed asset register report's tax columns are the register's: - /// each held asset's contributor's basis, tax basis, and built-in gain - /// (ledger-core's Asset::tax_basis and built_in_gain), the total its - /// total_tax_basis, and its rule (cited) in the detail. + /// The fixed asset register report's columns are the register's: each + /// held asset's cost, depreciation scheduled and posted, book value, + /// §723 basis, depreciable basis and built-in gain (ledger-core's + /// Asset::basis_figures), the totals its totals, and its rule (cited) + /// in the detail. #[test] fn asset_register_report_carries_tax_basis() { let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); @@ -2407,21 +2435,23 @@ mod tests { .unwrap(); let r = ledger_core::fixed_asset_register(&b.ledger, today).unwrap(); assert!(!r.rows.is_empty(), "the demo has an asset"); - assert_eq!(r.missing_tax_basis, 0, "the demo's asset has a tax basis"); + assert_eq!(r.missing_basis, 0, "the demo's asset has a tax basis"); let t = asset_register(&b, today); assert_eq!( t.columns, [ "Cost", - "Accumulated", + "Scheduled", + "Posted", "Book value", - "Contributor's basis", - "Tax basis", - "Built-in gain (loss)" + "§723 basis", + "Depr. basis", + "Built-in gain" ] ); for x in &r.rows { let a = b.ledger.get_asset(x.asset_id).unwrap(); + let f = a.basis_figures().unwrap().unwrap(); let row = t .rows .iter() @@ -2432,22 +2462,25 @@ mod tests { v, [ Some(x.cost), - Some(x.accumulated), + Some(x.accumulated_scheduled), + Some(x.accumulated_posted), Some(x.book_value), - a.tax.and_then(|t| t.contributor_basis), - a.tax_basis(), - a.built_in_gain(), + f.tax_basis_723, + Some(f.depreciable_basis), + f.built_in_gain_704c, ] ); - assert!(row.detail.contains("tax basis: Cost"), "{}", row.detail); + assert_eq!(x.book_value, x.cost - x.accumulated_posted); + assert!(row.detail.contains("tax basis: Purchase"), "{}", row.detail); } let total = t.rows.iter().find(|row| row.label == "Total held").unwrap(); - assert_eq!(total.cells[4].value, Some(r.total_tax_basis)); - assert_eq!(total.cells[4].value, r.rows[0].tax_basis); + assert_eq!(total.cells[5].value, Some(r.total_depreciable_basis)); + assert_eq!(total.cells[5].value, r.rows[0].depreciable_basis); + assert_eq!(total.cells[2].value, Some(r.total_accumulated_posted)); assert!( t.rows .iter() - .all(|row| row.cells.is_empty() || row.cells.len() == 6) + .all(|row| row.cells.is_empty() || row.cells.len() == 7) ); assert!( t.rows @@ -2527,7 +2560,7 @@ mod tests { b.post_as("Bank loan", d(2026, 3, 27), cash, loan, 250_000); b.s.register_asset(ledger_core::NewAsset { description: "Tablet".into(), - purchase_date: d(2026, 3, 27), + in_service_date: d(2026, 3, 27), cost: Money::new(Decimal::new(63_000, 2), ledger_core::Currency::USD), salvage_value: Money::new(Decimal::ZERO, ledger_core::Currency::USD), useful_life_months: 36, diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index 2d30557..3e5e521 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -339,7 +339,7 @@ pub fn search(b: &Books, query: &str) -> Vec { if !serial.is_empty() { detail.push(format!("serial {serial}")); } - detail.push(format!("in service {}", fmt::date(a.purchase_date))); + detail.push(format!("in service {}", fmt::date(a.in_service_date))); out.push(Item { icon: icon::PACKAGE, title: b.asset_label(a.id), diff --git a/crates/ledger-gui/tests/assets.rs b/crates/ledger-gui/tests/assets.rs index 8b7eb60..d2403f2 100644 --- a/crates/ledger-gui/tests/assets.rs +++ b/crates/ledger-gui/tests/assets.rs @@ -5,7 +5,7 @@ use chrono::{Datelike, Days, Months, NaiveDate}; use egui_kittest::Harness; use ledger_core::{ - AccountType, AssetId, Currency, EntryId, Money, NewAsset, Side, TaxBasis, TaxBasisRule, + AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Money, NewAsset, Side, depreciation_schedule, fixed_asset_register, }; use ledger_gui::books::Books; @@ -124,7 +124,7 @@ fn fixture(tag: &str) -> Fixture { let register = |s: &mut Store, what: &str, date, cents, salvage, months| { s.register_asset(NewAsset { description: what.into(), - purchase_date: date, + in_service_date: date, cost: usd(cents), salvage_value: usd(salvage), useful_life_months: months, @@ -146,15 +146,13 @@ fn fixture(tag: &str) -> Fixture { s.set_asset_tag(old, "FL-0009").unwrap(); s.set_asset_serial(old, Some("X1")).unwrap(); s.set_asset_serial(old, None).unwrap(); - let tax = |rule, basis: Option| TaxBasis { - rule, - contributor_basis: basis.map(|c| Decimal::new(c, 2)), + let given = |rule, adjusted: i64, fmv: i64| { + AssetBasis::contribution(rule, Decimal::new(adjusted, 2), Decimal::new(fmv, 2)) }; - s.set_asset_tax_basis(laptop, tax(TaxBasisRule::Carryover, Some(200_000))) + s.set_asset_basis(laptop, given(BasisRule::Carryover723, 200_000, 300_000)) .unwrap(); - s.set_asset_tax_basis(laptop, tax(TaxBasisRule::Cost, None)) - .unwrap(); - s.set_asset_tax_basis(old, tax(TaxBasisRule::PersonalUseConversion, Some(70_000))) + s.set_asset_basis(laptop, AssetBasis::purchase()).unwrap(); + s.set_asset_basis(old, given(BasisRule::PersonalUseConversion, 70_000, 60_000)) .unwrap(); for m in 1..=7 { s.run_depreciation(laptop, &format!("2026-{m:02}")).unwrap(); @@ -457,39 +455,50 @@ fn attention_asks_for_depreciation_and_tags() { } /// The viewer's tax figures are ledger-core's: each asset's -/// (Asset::tax_basis, Asset::built_in_gain), each register row's, and the -/// register's total and missing count, on every date. +/// (Asset::basis_figures), each register row's, and the register's totals +/// and missing count, on every date. #[test] -fn tax_basis_is_ledger_cores() { +fn basis_figures_are_ledger_cores() { let f = fixture("tax"); let b = &f.books; for id in [f.laptop, f.monitor, f.old] { let core = f.oracle.get_asset(id).unwrap(); let mine = TaxFacts::of(b.ledger.get_asset(id).unwrap()); - assert_eq!(mine.rule, core.tax.map(|t| t.rule), "asset {id}"); + let figures = core.basis_figures().unwrap(); + assert_eq!(mine.rule, core.basis.as_ref().map(|x| x.rule), "asset {id}"); assert_eq!( - mine.contributor_basis, - core.tax.and_then(|t| t.contributor_basis) + mine.contributor_adjusted_basis, + core.basis + .as_ref() + .and_then(|x| x.contributor_adjusted_basis) ); - assert_eq!(mine.tax_basis, core.tax_basis(), "asset {id}"); - assert_eq!(mine.built_in_gain, core.built_in_gain(), "asset {id}"); + assert_eq!(mine.tax_basis_723, figures.and_then(|x| x.tax_basis_723)); + assert_eq!(mine.depreciable_basis, figures.map(|x| x.depreciable_basis)); + assert_eq!( + mine.built_in_gain_704c, + figures.and_then(|x| x.built_in_gain_704c) + ); + assert_eq!(mine.fmv_at_contribution, core.fmv_at_contribution()); } - // Independently: the laptop's basis is its cost; the old laptop's is - // its value ($600.00, under the member's $700.00), a $100.00 loss. + // Independently: the laptop's basis is its cost; the old laptop's + // §723 basis is the member's $700.00, its depreciable basis its value + // ($600.00, the lesser), a $100.00 built-in loss. let laptop = TaxFacts::of(b.ledger.get_asset(f.laptop).unwrap()); - assert_eq!(laptop.rule, Some(TaxBasisRule::Cost)); - assert_eq!(laptop.tax_basis, Some(Decimal::new(300_000, 2))); - assert_eq!(laptop.built_in_gain, None); + assert_eq!(laptop.rule, Some(BasisRule::Purchase)); + assert_eq!(laptop.depreciable_basis, Some(Decimal::new(300_000, 2))); + assert_eq!(laptop.tax_basis_723, None); + assert_eq!(laptop.built_in_gain_704c, None); let old = TaxFacts::of(b.ledger.get_asset(f.old).unwrap()); - assert_eq!(old.tax_basis, Some(Decimal::new(60_000, 2))); - assert_eq!(old.built_in_gain, Some(Decimal::new(-10_000, 2))); + assert_eq!(old.tax_basis_723, Some(Decimal::new(70_000, 2))); + assert_eq!(old.depreciable_basis, Some(Decimal::new(60_000, 2))); + assert_eq!(old.built_in_gain_704c, Some(Decimal::new(-10_000, 2))); assert_eq!( old.summary(), - "Personal-use conversion · contributor's basis $700.00" + "Personal-use conversion · §723 basis $700.00" ); assert_eq!( old.detail(), - "Tax basis $600.00: Personal-use conversion (Treas. Reg. §1.168(i)-4(b)); contributor's basis $700.00; built-in loss $100.00." + "Depreciable basis $600.00: Personal-use conversion (Treas. Reg. §1.168(i)-4(b)); §723 basis $700.00; value at contribution $600.00; built-in loss $100.00." ); assert_eq!( TaxFacts::of(b.ledger.get_asset(f.monitor).unwrap()).detail(), @@ -499,29 +508,37 @@ fn tax_basis_is_ledger_cores() { TaxFacts::of(b.ledger.get_asset(f.monitor).unwrap()), TaxFacts { rule: None, - contributor_basis: None, - tax_basis: None, - built_in_gain: None + contributor_adjusted_basis: None, + fmv_at_contribution: None, + fmv_recorded: false, + tax_basis_723: None, + depreciable_basis: None, + built_in_gain_704c: None, + sec704c_method: None, } ); for as_of in [d(2026, 1, 31), d(2026, 6, 30), today()] { let r = b.asset_register(as_of).unwrap(); let oracle = fixed_asset_register(&f.oracle, as_of).unwrap(); - assert_eq!(r.total_tax_basis, oracle.total_tax_basis, "{as_of}"); - assert_eq!(r.missing_tax_basis, oracle.missing_tax_basis, "{as_of}"); + assert_eq!( + r.total_depreciable_basis, oracle.total_depreciable_basis, + "{as_of}" + ); + assert_eq!(r.total_tax_basis_723, oracle.total_tax_basis_723, "{as_of}"); + assert_eq!(r.missing_basis, oracle.missing_basis, "{as_of}"); for x in &r.rows { let a = f.oracle.get_asset(x.asset_id).unwrap(); let facts = TaxFacts::of_row(x); - assert_eq!(facts.tax_basis, a.tax_basis()); - assert_eq!(facts.built_in_gain, a.built_in_gain()); + assert_eq!(facts.depreciable_basis, a.depreciable_basis()); + assert_eq!(facts.built_in_gain_704c, a.built_in_gain_704c()); assert_eq!(facts, TaxFacts::of(a)); } } // Today: the laptop's $3,000.00 (the monitor has none; the old laptop // is gone), and one missing, said in the notices. let r = b.asset_register(today()).unwrap(); - assert_eq!(r.total_tax_basis, Decimal::new(300_000, 2)); - assert_eq!(r.missing_tax_basis, 1); + assert_eq!(r.total_depreciable_basis, Decimal::new(300_000, 2)); + assert_eq!(r.missing_basis, 1); let want = missing_tax_notice(1).unwrap(); assert!(want.starts_with("Tax basis not recorded for 1 asset")); assert!(want.contains("books.set_asset_tax_basis")); @@ -534,14 +551,17 @@ fn tax_basis_is_ledger_cores() { ); } +/// The journal names each basis figure for what it is: the §723 basis, +/// the depreciable basis, the built-in gain or loss; a pre-P3 +/// `asset.tax_basis_set` too, its value read as the book cost. #[test] -fn journal_text_for_tax_basis() { +fn journal_text_names_each_basis_figure() { let f = fixture("tax-journal"); let b = &f.books; let texts: Vec<(String, Option)> = b .journal .iter() - .filter(|l| l.line_type == "asset.tax_basis_set") + .filter(|l| l.line_type == "asset.basis_set") .filter_map(|l| describe(b, l)) .map(|e| (e.text, e.route)) .collect(); @@ -549,21 +569,44 @@ fn journal_text_for_tax_basis() { texts, vec![ ( - "Tax basis of FL-0001: carryover, contributor's basis $2,000.00 → tax basis $2,000.00" + "Tax basis of FL-0001: carryover, value $3,000.00, contributor's adjusted basis $2,000.00: §723 basis $2,000.00, depreciable basis $2,000.00, built-in gain $1,000.00" .to_string(), Some(Route::Asset(f.laptop)) ), ( - "Tax basis of FL-0001: cost, tax basis $3,000.00".to_string(), + "Tax basis of FL-0001: purchase: depreciable basis $3,000.00".to_string(), Some(Route::Asset(f.laptop)) ), ( - "Tax basis of FL-0009: personal-use conversion, contributor's basis $700.00 → tax basis $600.00" + "Tax basis of FL-0009: personal-use conversion, value $600.00, contributor's adjusted basis $700.00: §723 basis $700.00, depreciable basis $600.00, built-in loss $100.00" .to_string(), Some(Route::Asset(f.old)) ), ] ); + // A line an earlier version wrote: rule by its old name, no value. + let data = f._dir.0.join("books-data"); + let mut w = ledger_store::JournalWriter::open(&data.join(ledger_store::JOURNAL_FILE)).unwrap(); + w.append( + "asset.tax_basis_set", + serde_json::json!({"asset_id": f.monitor, "rule": "personal_use_conversion", "contributor_basis": "1500.00"}), + ) + .unwrap(); + drop(w); + let b = load(&f._dir.0, today()); + let legacy: Vec = b + .journal + .iter() + .filter(|l| l.line_type == "asset.tax_basis_set") + .filter_map(|l| describe(&b, l)) + .map(|e| e.text) + .collect(); + assert_eq!( + legacy, + [ + "Tax basis of Monitor: personal-use conversion, contributor's adjusted basis $1,500.00: §723 basis $1,500.00, depreciable basis $1,200.00, built-in loss $300.00" + ] + ); } /// An asset held with no tax basis asks for one, with a prompt naming @@ -602,12 +645,13 @@ fn attention_asks_for_tax_basis() { ); let mut s = Store::open(&data).unwrap(); - s.set_asset_tax_basis( + s.set_asset_basis( f.monitor, - TaxBasis { - rule: TaxBasisRule::Carryover, - contributor_basis: Some(Decimal::new(90_000, 2)), - }, + AssetBasis::contribution( + BasisRule::Carryover723, + Decimal::new(90_000, 2), + Decimal::new(120_000, 2), + ), ) .unwrap(); drop(s); @@ -618,14 +662,27 @@ fn attention_asks_for_tax_basis() { .any(|a| a.title.starts_with("No tax basis")) ); let r = b.asset_register(today()).unwrap(); - assert_eq!(r.missing_tax_basis, 0); + assert_eq!(r.missing_basis, 0); assert!( !notices(&b, &r) .iter() .any(|n| n.text.contains("Tax basis not recorded")) ); // $3,000.00 laptop + the monitor's $900.00 carryover. - assert_eq!(r.total_tax_basis, Decimal::new(390_000, 2)); + assert_eq!(r.total_depreciable_basis, Decimal::new(390_000, 2)); + // Its value is recorded, so nothing asks for it; its $300.00 built-in + // gain has no §704(c) method, so a method is asked for. + let items = attention::find(&b); + assert!( + !items + .iter() + .any(|a| a.title.starts_with("Confirm its value at contribution")) + ); + let method = items + .iter() + .find(|a| a.title.starts_with("Choose a §704(c) method")) + .expect("asked for a method"); + assert!(method.prompt.contains("books.set_asset_tax_basis")); } #[test] @@ -763,7 +820,7 @@ proptest! { s.post_entry(e).unwrap(); let id = s.register_asset(NewAsset { description: format!("Asset {i}"), - purchase_date: date, + in_service_date: date, cost: usd(*cents), salvage_value: usd(cents * salvage_pct / 100), useful_life_months: *months, @@ -856,7 +913,7 @@ fn asset_x(tag: &str) -> (TempDir, PathBuf, Store, AssetId, (u64, u64)) { let x = s .register_asset(NewAsset { description: "Asset X".into(), - purchase_date: d(2026, 1, 1), + in_service_date: d(2026, 1, 1), cost: usd(1_200_000), salvage_value: usd(0), useful_life_months: 12, @@ -1119,13 +1176,13 @@ proptest! { if disposed { prop_assert_eq!( trail.posted(), - ledger_core::accumulated_depreciation(&a, NaiveDate::MAX).unwrap() + ledger_core::accumulated_scheduled_through(&a, NaiveDate::MAX).unwrap() ); prop_assert_eq!(balance_on(&s, accum, NaiveDate::MAX), Decimal::ZERO); } else { prop_assert_eq!(trail.posted(), -balance_on(&s, accum, NaiveDate::MAX)); let june = d(2026, 6, 30); - let owed = ledger_core::accumulated_depreciation(&a, june).unwrap() + let owed = ledger_core::accumulated_scheduled_through(&a, june).unwrap() - balance_on(&s, expense, june); prop_assert_eq!( ledger_gui::pages::assets::unposted(&b, x).unwrap(), diff --git a/crates/ledger-gui/tests/attachments.rs b/crates/ledger-gui/tests/attachments.rs index a483f24..cb9ac3b 100644 --- a/crates/ledger-gui/tests/attachments.rs +++ b/crates/ledger-gui/tests/attachments.rs @@ -165,7 +165,7 @@ fn fixture(tag: &str) -> Fixture { let asset = s .register_asset(NewAsset { description: "Laptop".into(), - purchase_date: d(2026, 7, 6), + in_service_date: d(2026, 7, 6), cost: usd(200_000), salvage_value: usd(0), useful_life_months: 36, diff --git a/crates/ledger-import/src/asset_register.rs b/crates/ledger-import/src/asset_register.rs index 604f4ac..da6f743 100644 --- a/crates/ledger-import/src/asset_register.rs +++ b/crates/ledger-import/src/asset_register.rs @@ -9,38 +9,46 @@ //! of [`crate::xlsx`] for XLSX). //! //! ```text -//! GOAL: one list of what the company owns, its cost, depreciation, and -//! book value, that a CPA can file, and that shows at a glance -//! whether the register and the books agree. -//! SOURCE: ledger_core::fixed_asset_register (INVARIANTS 1-4); CONVENTION +//! GOAL: one list of what the company owns, its cost, depreciation due and +//! posted, and book value, with its tax figures, that a CPA can +//! file, and that shows at a glance whether the register and the +//! books agree. +//! SOURCE: ledger_core::fixed_asset_register (INVARIANTS 1-6); CONVENTION //! (layout, the statements' header and footer). //! INVARIANTS: //! 1. One line per register row, in the register's order, disposed ones //! included and marked "Disposed DATE, proceeds AMOUNT"; held ones -//! "Active". -//! 2. The totals are the register's own totals (held assets only), -//! never re-summed here. +//! "Active". Each shows its acquired and in-service dates. +//! 2. Accumulated depreciation twice, never confused: scheduled (due by +//! the date) and posted (what the books carry); book value is the +//! posted figure's. The totals are the register's own (held assets +//! only), never re-summed here. //! 3. Reconciliation: cost per register, per books, and their difference //! (unregistered_cost); accumulated depreciation per schedule, per -//! books, and their difference (unposted_depreciation), exactly. +//! books, and their difference (unposted_depreciation); from the +//! assets' runs, and the books' difference from it +//! (unattributed_accumulated), exactly. //! 4. An empty register says "No fixed assets" and still shows totals //! and the reconciliation (the books may carry unregistered cost). //! 5. Header and footer as on every statement: company, "Fixed Asset //! Register", "As of DATE"; basis, prepared on, journal seq. //! 6. PDF: deterministic bytes; every text is data, never markup. -//! 7. Tax basis beside book value: every line carries its tax basis -//! rule (in words: [`rule_label`], [`rule_cited`]), the contributor's -//! basis, the tax basis, and the built-in gain (loss), each the -//! register row's own (ledger_core::Asset::tax_basis, -//! Asset::built_in_gain), never re-derived here; a line with none -//! recorded says "Not recorded" and leaves the amounts blank. -//! 8. The tax basis total is the register's total_tax_basis; when -//! missing_tax_basis > 0 a line says "Tax basis not recorded for N -//! asset(s)" (see [`missing_line`]). -//! 9. The notes ([`notes`]) say that book value is fair market value at -//! contribution and tax basis follows its rule, and cite each rule -//! (IRC §1012(a), §723, Treas. Reg. §1.168(i)-4(b), IRC §704(c)). -//! The PDF marks each rule in the tax table with its note's number. +//! 7. Tax figures beside book value: every line carries its basis rule +//! (in words: [`rule_label`], [`rule_cited`]), the value at +//! contribution (marked [`FMV_NOT_RECORDED_MARK`] when read as the +//! book cost), the contributor's adjusted basis, the §723 basis, the +//! depreciable basis, the §704(c) built-in gain (loss), the §704(c) +//! method, and who gave the facts, each the register row's own +//! (ledger_core::Asset::basis_figures), never re-derived here; a line +//! with none recorded says "Not recorded" and leaves them blank. +//! 8. The tax totals are the register's total_tax_basis_723 and +//! total_depreciable_basis; when missing_basis > 0 a line says "Tax +//! basis not recorded for N asset(s)" (see [`missing_line`]). +//! 9. The notes ([`notes`]) say what book value and the scheduled figure +//! are, that the tax figures are at contribution, and cite each rule +//! (IRC §1012(a), §723, §1016(a), Treas. Reg. §1.168(i)-4(b), +//! §1.704-3(a)(3)(ii), IRC §704(c)(1)(C)). The PDF marks each rule in +//! the tax table with its note's number. //! ``` //! //! SOURCE (7-9): docs/spec/rules.md B-25; @@ -49,8 +57,8 @@ use crate::pdf::PdfError; use crate::report::{ReportMeta, amount_parts, format_amount, long_date}; use crate::xlsx::{Formats, XlsxError, footer, header, number}; -use ledger_core::TaxBasisRule; use ledger_core::reports::{AssetRegister, AssetRegisterRow}; +use ledger_core::{BasisRule, Sec704cMethod}; use rust_decimal::Decimal; use rust_xlsxwriter::{Format, FormatAlign, Workbook}; use std::sync::OnceLock; @@ -62,46 +70,57 @@ use typst_layout::PagedDocument; pub const TITLE: &str = "Fixed Asset Register"; /// Column headings, left to right. -pub const COLUMNS: [&str; 10] = [ +pub const COLUMNS: [&str; 12] = [ "Tag", "Description", "Serial", + "Acquired", "In service", "Cost", "Salvage", "Life (months)", - "Accumulated depreciation", + "Depreciation scheduled", + "Depreciation posted", "Book value", "Status", ]; -/// The PDF's tax basis table's headings, left to right. -pub const TAX_COLUMNS: [&str; 7] = [ +/// The PDF's tax table's headings, left to right. +pub const TAX_COLUMNS: [&str; 9] = [ "Tag", "Description", - "Tax basis rule", + "Basis rule", "Book cost", - "Contributor's basis", - "Tax basis", + "Value at contribution", + "Contributor's adjusted basis", + "§723 basis", + "Depreciable basis", "Built-in gain (loss)", ]; /// The XLSX headings, left to right: the book columns, the tax columns, /// then the status. -pub const XLSX_COLUMNS: [&str; 14] = [ +pub const XLSX_COLUMNS: [&str; 21] = [ "Tag", "Description", "Serial", + "Acquired", "In service", "Cost", "Salvage", "Life (months)", - "Accumulated depreciation", + "Depreciation scheduled", + "Depreciation posted", "Book value", - "Tax basis rule", - "Contributor's basis", - "Tax basis", + "Basis rule", + "Value at contribution", + "Contributor's adjusted basis", + "§723 basis", + "Depreciable basis", "Built-in gain (loss)", + "Allocation method", + "Basis facts from", + "Value recorded", "Status", ]; @@ -109,42 +128,55 @@ pub const XLSX_COLUMNS: [&str; 14] = [ pub const EMPTY: &str = "No fixed assets"; /// The tax table's title. -pub const TAX_TITLE: &str = "Federal tax basis"; +pub const TAX_TITLE: &str = "Federal tax figures, at contribution or purchase"; /// The rule cell of an asset with no tax basis recorded. pub const NOT_RECORDED: &str = "Not recorded"; -/// A rule in short words, for the table: "Cost", "Carryover", +/// The mark on a value at contribution read as the book cost (recorded +/// before the value was a fact): its note is [`notes`]' last. +pub const FMV_NOT_RECORDED_MARK: &str = "*"; + +/// A rule in short words, for the table: "Purchase", "Carryover", /// "Personal-use conversion". -pub fn rule_label(r: TaxBasisRule) -> &'static str { +pub fn rule_label(r: BasisRule) -> &'static str { match r { - TaxBasisRule::Cost => "Cost", - TaxBasisRule::Carryover => "Carryover", - TaxBasisRule::PersonalUseConversion => "Personal-use conversion", + BasisRule::Purchase => "Purchase", + BasisRule::Carryover723 => "Carryover", + BasisRule::PersonalUseConversion => "Personal-use conversion", } } -/// A rule in words with its citation: "Cost", "Carryover (IRC §723)", -/// "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))". -pub fn rule_cited(r: TaxBasisRule) -> &'static str { +/// A rule in words with its citation: "Purchase (IRC §1012)", "Carryover +/// (IRC §723)", "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))". +pub fn rule_cited(r: BasisRule) -> &'static str { match r { - TaxBasisRule::Cost => "Cost", - TaxBasisRule::Carryover => "Carryover (IRC §723)", - TaxBasisRule::PersonalUseConversion => { - "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))" - } + BasisRule::Purchase => "Purchase (IRC §1012)", + BasisRule::Carryover723 => "Carryover (IRC §723)", + BasisRule::PersonalUseConversion => "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))", } } /// The number of a rule's note in [`notes`]. -pub fn rule_mark(r: TaxBasisRule) -> &'static str { +pub fn rule_mark(r: BasisRule) -> &'static str { match r { - TaxBasisRule::Cost => "1", - TaxBasisRule::Carryover => "2", - TaxBasisRule::PersonalUseConversion => "3", + BasisRule::Purchase => "1", + BasisRule::Carryover723 => "2", + BasisRule::PersonalUseConversion => "3", } } +/// A §704(c) method in words with its paragraph: "Traditional (Treas. +/// Reg. §1.704-3(b))", ... +pub fn method_cited(m: Sec704cMethod) -> String { + let words = match m { + Sec704cMethod::Traditional => "Traditional", + Sec704cMethod::TraditionalWithCurative => "Traditional with curative allocations", + Sec704cMethod::Remedial => "Remedial", + }; + format!("{words} ({})", m.citation()) +} + /// One note under the register. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Note { @@ -154,8 +186,9 @@ pub struct Note { pub text: String, } -/// The notes (INVARIANT 9): book value against tax basis, then each -/// rule's citation and the built-in gain (loss). +/// The notes (INVARIANT 9): book value and the scheduled figure, the tax +/// figures, each rule's citation, the built-in gain (loss), and a value +/// read as the book cost. pub fn notes() -> Vec { let n = |mark: &str, text: &str| Note { mark: mark.to_string(), @@ -164,31 +197,41 @@ pub fn notes() -> Vec { vec![ n( "", - "Book value is the fair market value at contribution for property a member contributed, \ - and the price paid for property the company bought; tax basis follows its rule and can \ - differ. Depreciation here is book depreciation; tax depreciation starts from the tax \ - basis and is the CPA's.", + "Book value is cost less the depreciation the books carry (posted); scheduled is what \ + the depreciation schedule says is due by the date, and the difference is depreciation \ + not yet run. Property a member contributed is on the books at its fair market value \ + at contribution, property the company bought at its price. Depreciation here is book \ + depreciation. The tax figures are at contribution (or purchase): tax depreciation, \ + and what remains of a built-in gain or loss later, are the CPA's.", ), n( - rule_mark(TaxBasisRule::Cost), - "Cost: property the company bought has a basis of its cost (IRC §1012(a)).", + rule_mark(BasisRule::Purchase), + "Purchase (IRC §1012): property the company bought has a basis of its cost, which tax \ + depreciation starts from.", ), n( - rule_mark(TaxBasisRule::Carryover), - "Carryover (IRC §723): property a member contributes keeps the member's adjusted \ - basis at contribution (their cost less any depreciation they took).", + rule_mark(BasisRule::Carryover723), + "Carryover (IRC §723): the company's basis in property a member contributes is the \ + member's adjusted basis at contribution (cost plus improvements, less depreciation \ + allowed or allowable, IRC §1016(a)); tax depreciation starts from it.", ), n( - rule_mark(TaxBasisRule::PersonalUseConversion), - "Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted from \ - personal use depreciates from the lesser of its fair market value and the member's \ - adjusted basis at conversion.", + rule_mark(BasisRule::PersonalUseConversion), + "Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): the §723 basis as for \ + carryover; tax depreciation starts from the lesser of its fair market value and that \ + basis at conversion.", ), n( "4", - "Built-in gain (loss): fair market value at contribution less the contributor's basis \ - (IRC §704(c)(1)(A)); a built-in loss is taken into account only for the contributing \ - member (IRC §704(c)(1)(C)).", + "Built-in gain (loss), IRC §704(c): fair market value less the §723 basis at \ + contribution (Treas. Reg. §1.704-3(a)(3)(ii)); a built-in loss counts only for the \ + contributing member (IRC §704(c)(1)(C)). The method chosen allocates it among the \ + members (Treas. Reg. §1.704-3(b)-(d)).", + ), + n( + FMV_NOT_RECORDED_MARK, + "Value at contribution not recorded as a fact: read as the book cost, as it was \ + recorded before the value was asked for.", ), ] } @@ -213,6 +256,8 @@ pub struct AssetLine { pub description: String, /// Serial number, or empty. pub serial: String, + /// Acquired date, ISO 8601. + pub acquired: String, /// In-service date, ISO 8601. pub in_service: String, /// Capitalized cost. @@ -222,21 +267,33 @@ pub struct AssetLine { /// Useful life in months. pub life_months: u32, /// Accumulated depreciation by schedule (zero once disposed). - pub accumulated: Decimal, - /// Book value (zero once disposed). + pub accumulated_scheduled: Decimal, + /// Accumulated depreciation the books carry (zero once disposed). + pub accumulated_posted: Decimal, + /// Book value: cost less posted (zero once disposed). pub book_value: Decimal, /// "Active", or "Disposed 2026-09-30, proceeds 450.00". pub status: String, /// Disposed by the date (shown, not in the totals). pub disposed: bool, - /// The tax basis rule, if recorded. - pub tax_rule: Option, + /// The basis rule, if recorded. + pub basis_rule: Option, + /// Fair market value at contribution (a contribution only). + pub fmv_at_contribution: Option, + /// Whether that value was recorded (false: read as the book cost). + pub fmv_recorded: bool, /// The contributing member's adjusted basis (a contribution only). - pub contributor_basis: Option, - /// The depreciable federal tax basis, if recorded. - pub tax_basis: Option, + pub contributor_adjusted_basis: Option, + /// The §723 basis (a contribution only). + pub tax_basis_723: Option, + /// The depreciable tax basis, if recorded. + pub depreciable_basis: Option, /// Built-in gain (+) or loss (−) at contribution, if any. - pub built_in_gain: Option, + pub built_in_gain_704c: Option, + /// The §704(c) method, in words with its paragraph, if chosen. + pub sec704c_method: Option, + /// Who gave the basis facts, when, and on what, if recorded. + pub basis_source: Option, } /// Which register column a reconciliation amount sits under. @@ -244,7 +301,7 @@ pub struct AssetLine { pub enum ReconColumn { /// Under "Cost". Cost, - /// Under "Accumulated depreciation". + /// Under the accumulated depreciation columns. Accumulated, } @@ -270,17 +327,17 @@ pub struct RegisterLayout { pub period: String, /// One per asset, in register order. pub lines: Vec, - /// Total cost, accumulated depreciation, and book value of the assets - /// held. - pub totals: [Decimal; 3], - /// Total tax basis of the assets held that have one. - pub tax_total: Decimal, - /// [`missing_line`] of the register's missing_tax_basis. + /// Total cost, accumulated depreciation scheduled, posted, and book + /// value of the assets held. + pub totals: [Decimal; 4], + /// Total §723 basis and depreciable basis of the assets held. + pub tax_totals: [Decimal; 2], + /// [`missing_line`] of the register's missing_basis. pub missing: Option, /// [`notes`]. pub notes: Vec, - /// Cost then accumulated depreciation: per register, per books, - /// difference. + /// Cost, then accumulated depreciation: per register, per books, + /// differences. pub reconciliation: Vec, } @@ -293,6 +350,15 @@ fn status(a: &AssetRegisterRow) -> String { } } +/// Who gave an asset's basis facts: "Morgan Ellis, 2026-10-20: entry 14". +pub fn source_text(s: &ledger_core::settings::Source) -> String { + let mut t = format!("{}, {}", s.decided_by, s.decided_on); + if let Some(c) = &s.citation { + t.push_str(&format!(": {c}")); + } + t +} + /// Lay the register out (see the module spec). pub fn layout(r: &AssetRegister) -> RegisterLayout { let recon = |label: &str, amount, column, difference| ReconLine { @@ -311,23 +377,35 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout { tag: a.tag.clone().unwrap_or_default(), description: a.description.clone(), serial: a.serial.clone().unwrap_or_default(), - in_service: a.in_service.to_string(), + acquired: a.acquired_date.to_string(), + in_service: a.in_service_date.to_string(), cost: a.cost, salvage: a.salvage, life_months: a.useful_life_months, - accumulated: a.accumulated, + accumulated_scheduled: a.accumulated_scheduled, + accumulated_posted: a.accumulated_posted, book_value: a.book_value, status: status(a), disposed: a.disposed.is_some(), - tax_rule: a.tax_rule, - contributor_basis: a.contributor_basis, - tax_basis: a.tax_basis, - built_in_gain: a.built_in_gain, + basis_rule: a.basis_rule, + fmv_at_contribution: a.fmv_at_contribution, + fmv_recorded: a.fmv_recorded, + contributor_adjusted_basis: a.contributor_adjusted_basis, + tax_basis_723: a.tax_basis_723, + depreciable_basis: a.depreciable_basis, + built_in_gain_704c: a.built_in_gain_704c, + sec704c_method: a.sec704c_method.map(method_cited), + basis_source: a.basis_source.as_ref().map(source_text), }) .collect(), - totals: [r.total_cost, r.total_accumulated, r.total_book_value], - tax_total: r.total_tax_basis, - missing: missing_line(r.missing_tax_basis), + totals: [ + r.total_cost, + r.total_accumulated_scheduled, + r.total_accumulated_posted, + r.total_book_value, + ], + tax_totals: [r.total_tax_basis_723, r.total_depreciable_basis], + missing: missing_line(r.missing_basis), notes: notes(), reconciliation: vec![ recon("Cost per register", r.total_cost, ReconColumn::Cost, false), @@ -340,7 +418,7 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout { ), recon( "Accumulated depreciation per schedule", - r.total_accumulated, + r.total_accumulated_scheduled, ReconColumn::Accumulated, false, ), @@ -356,6 +434,24 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout { ReconColumn::Accumulated, true, ), + recon( + "Accumulated depreciation from the assets' runs", + r.total_accumulated_posted, + ReconColumn::Accumulated, + false, + ), + recon( + "Accumulated depreciation per books", + r.ledger_accumulated, + ReconColumn::Accumulated, + false, + ), + recon( + "Difference (not from runs)", + r.unattributed_accumulated(), + ReconColumn::Accumulated, + true, + ), ], } } @@ -365,14 +461,15 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout { /// Layout as every statement (crate::xlsx INVARIANT 4): company, title, /// and date on rows 1-3, headings ([`XLSX_COLUMNS`]) on row 5, one row /// per asset from row 6, amounts as numbers in the accounting format, the -/// tax rule in words with its citation ([`rule_cited`]); then the totals -/// row (double rule) with the tax basis total under Tax basis, the -/// missing tax basis line when there is one, an empty row, "Reconciliation -/// to the books" and its lines with amounts under Cost and Accumulated -/// depreciation, an empty row, and "Notes" with [`notes`]. +/// basis rule and §704(c) method in words with their citations +/// ([`rule_cited`], [`method_cited`]); then the totals row (double rule) +/// with the §723 and depreciable basis totals under theirs, the missing +/// basis line when there is one, an empty row, "Reconciliation to the +/// books" and its lines with amounts under Cost and Depreciation +/// scheduled, an empty row, and "Notes" with [`notes`]. pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result, XlsxError> { - const AMOUNTS: [u16; 7] = [4, 5, 7, 8, 10, 11, 12]; - const TOTALS: [u16; 4] = [4, 7, 8, 11]; + const AMOUNTS: [u16; 10] = [5, 6, 8, 9, 10, 12, 13, 14, 15, 16]; + const TOTALS: [u16; 6] = [5, 8, 9, 10, 14, 15]; let l = layout(r); let mut workbook = Workbook::new(); let sheet = workbook.add_worksheet(); @@ -381,7 +478,7 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result Result sheet.write_string_with_format(row, 9, rule_cited(rule), text)?, + number(sheet, row, 8, a.accumulated_scheduled, amount)?; + number(sheet, row, 9, a.accumulated_posted, amount)?; + number(sheet, row, 10, a.book_value, amount)?; + match a.basis_rule { + Some(rule) => sheet.write_string_with_format(row, 11, rule_cited(rule), text)?, None => sheet.write_string_with_format( row, - 9, + 11, NOT_RECORDED, if a.disposed { text } else { &missing }, )?, }; for (c, v) in [ - (10, a.contributor_basis), - (11, a.tax_basis), - (12, a.built_in_gain), + (12, a.fmv_at_contribution), + (13, a.contributor_adjusted_basis), + (14, a.tax_basis_723), + (15, a.depreciable_basis), + (16, a.built_in_gain_704c), ] { if let Some(v) = v { number(sheet, row, c, v, amount)?; } } - sheet.write_string_with_format(row, 13, &a.status, text)?; + if let Some(m) = &a.sec704c_method { + sheet.write_string_with_format(row, 17, m, text)?; + } + if let Some(src) = &a.basis_source { + sheet.write_string_with_format(row, 18, src, text)?; + } + if a.fmv_at_contribution.is_some() { + sheet.write_string_with_format( + row, + 19, + if a.fmv_recorded { + "Yes" + } else { + "No: read as the book cost" + }, + text, + )?; + } + sheet.write_string_with_format(row, 20, &a.status, text)?; row += 1; } sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?; @@ -443,7 +562,7 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result Result 4, - ReconColumn::Accumulated => 7, + ReconColumn::Cost => 5, + ReconColumn::Accumulated => 8, }; let (label_fmt, amount_fmt) = if line.difference { (&f.bold, &f.dollars_sum) @@ -488,16 +607,23 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result serde_json::Value "tax_title": TAX_TITLE, "tax_columns": TAX_COLUMNS, "empty": EMPTY, + "fmv_mark": FMV_NOT_RECORDED_MARK, "lines": l.lines.iter().map(|a| serde_json::json!({ "tag": a.tag, "description": a.description, "serial": a.serial, + "acquired": a.acquired, "in_service": a.in_service, "cost": amount(a.cost), "salvage": amount(a.salvage), "life": a.life_months.to_string(), - "accumulated": amount(a.accumulated), + "accumulated_scheduled": amount(a.accumulated_scheduled), + "accumulated_posted": amount(a.accumulated_posted), "book_value": amount(a.book_value), "status": a.status, "disposed": a.disposed, - "rule": a.tax_rule.map_or(NOT_RECORDED, rule_label), - "rule_mark": a.tax_rule.map_or("", rule_mark), - "recorded": a.tax_rule.is_some(), - "contributor_basis": maybe(a.contributor_basis), - "tax_basis": maybe(a.tax_basis), - "built_in_gain": maybe(a.built_in_gain), + "rule": a.basis_rule.map_or(NOT_RECORDED, rule_label), + "rule_mark": a.basis_rule.map_or("", rule_mark), + "recorded": a.basis_rule.is_some(), + "fmv_at_contribution": maybe(a.fmv_at_contribution), + "fmv_recorded": a.fmv_recorded, + "contributor_adjusted_basis": maybe(a.contributor_adjusted_basis), + "tax_basis_723": maybe(a.tax_basis_723), + "depreciable_basis": maybe(a.depreciable_basis), + "built_in_gain": maybe(a.built_in_gain_704c), + "method": a.sec704c_method, + "source": a.basis_source, })).collect::>(), "totals": l.totals.iter().map(|d| amount(*d)).collect::>(), - "tax_total": amount(l.tax_total), + "tax_totals": l.tax_totals.iter().map(|d| amount(*d)).collect::>(), "missing": l.missing, "notes": l.notes.iter().map(|n| serde_json::json!({ "mark": n.mark, diff --git a/crates/ledger-import/templates/asset_register.typ b/crates/ledger-import/templates/asset_register.typ index 499028a..9febdc0 100644 --- a/crates/ledger-import/templates/asset_register.typ +++ b/crates/ledger-import/templates/asset_register.typ @@ -1,20 +1,24 @@ -// The fixed asset register: every asset, its cost, depreciation, and book -// value, then the totals and the reconciliation to the general ledger, -// then each asset's federal tax basis beside its book cost, and the notes -// that cite the tax basis rules. +// The fixed asset register: every asset, its cost, depreciation scheduled +// and posted, and book value, then the totals and the reconciliation to +// the general ledger, then each asset's federal tax figures beside its +// book cost, and the notes that cite the rules. // // Everything arrives as data in sys.inputs.data (JSON), laid out by // ledger-import::asset_register, with amounts already split into exact // parts. The template only typesets: no arithmetic, and no text is ever // parsed as markup. // -// Shape: { company, title, period, footer, columns: [10 headings], -// tax_title, tax_columns: [7 headings], empty, -// lines: [{ tag, description, serial, in_service, cost, salvage, -// life, accumulated, book_value, status, disposed, -// rule, rule_mark, recorded, contributor_basis|null, -// tax_basis|null, built_in_gain|null }], -// totals: [cost, accumulated, book_value], tax_total, +// Shape: { company, title, period, footer, columns: [12 headings], +// tax_title, tax_columns: [9 headings], empty, fmv_mark, +// lines: [{ tag, description, serial, acquired, in_service, cost, +// salvage, life, accumulated_scheduled, +// accumulated_posted, book_value, status, disposed, +// rule, rule_mark, recorded, fmv_at_contribution|null, +// fmv_recorded, contributor_adjusted_basis|null, +// tax_basis_723|null, depreciable_basis|null, +// built_in_gain|null, method|null, source|null }], +// totals: [cost, scheduled, posted, book_value], +// tax_totals: [tax_basis_723, depreciable_basis], // missing: text|null, notes: [{ mark, text }], // reconciliation: [{ label, amount, difference }] } // where every amount is { digits: "1,234.56", negative }. @@ -66,14 +70,14 @@ [#open#sign#a.digits#close] } -// Columns: 4-8 are numbers (right-aligned). -#let numeric = (4, 5, 6, 7, 8) +// Columns: 5-10 are numbers (right-aligned). +#let numeric = (5, 6, 7, 8, 9, 10) #let ncols = d.columns.len() #let full(body, ..args) = table.cell(colspan: ncols, ..args, body) #table( // Number columns are fixed widths (headings wrap); text takes the rest. - columns: (auto, 1.4fr, auto, auto, 6.4em, 5.6em, 6em, 7.4em, 6.4em, 1fr), + columns: (auto, 1.4fr, auto, auto, auto, 6.2em, 5.6em, 5.6em, 7em, 7em, 6.2em, 1.2fr), stroke: none, inset: (x: 3.5pt, y: 3.4pt), align: (col, row) => (if col in numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }), @@ -95,11 +99,13 @@ t(l.tag), t(l.description), t(l.serial), + t(l.acquired), t(l.in_service), a(l.cost), a(l.salvage), t(l.life), - a(l.accumulated), + a(l.accumulated_scheduled), + a(l.accumulated_posted), a(l.book_value), t(l.status), ) @@ -112,12 +118,13 @@ let tot(x) = table.cell(stroke: s, text(weight: "bold", amount(x, currency: true))) let blank = table.cell(stroke: s, []) ( - table.cell(stroke: s, colspan: 4, text(weight: "bold", "Total, assets held")), + table.cell(stroke: s, colspan: 5, text(weight: "bold", "Total, assets held")), tot(d.totals.at(0)), blank, blank, tot(d.totals.at(1)), tot(d.totals.at(2)), + tot(d.totals.at(3)), blank, ) }, @@ -154,16 +161,19 @@ ) }) -// Federal tax basis, asset by asset, beside the book cost it may differ -// from; each rule marked with the number of the note that cites it. -#let tax_numeric = (3, 4, 5, 6) +// Federal tax figures, asset by asset, beside the book cost they may +// differ from; each rule marked with the number of the note that cites +// it, a value read as the book cost marked too; under the description, +// the §704(c) method and who gave the facts. +#let tax_numeric = (3, 4, 5, 6, 7, 8) #let tcols = d.tax_columns.len() #let maybe(x) = if x == none { [] } else { amount(x) } #v(12pt) #text(weight: "semibold", d.tax_title) #v(2pt) #table( - columns: (auto, 1fr, auto, 7.4em, 7.4em, 7.4em, 7.4em), + // Each amount column wide enough for its heading's longest word. + columns: (auto, 1fr, auto, 6.2em, 8.6em, 7.6em, 6.4em, 7.2em, 7.6em), stroke: none, inset: (x: 3.5pt, y: 3.4pt), align: (col, row) => (if col in tax_numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }), @@ -190,13 +200,29 @@ } else { text(fill: rgb("#9a5b00"), emph(l.rule)) } + let fmv = if l.fmv_at_contribution == none { + [] + } else if l.fmv_recorded { + amount(l.fmv_at_contribution) + } else { + [#super(d.fmv_mark)#amount(l.fmv_at_contribution)] + } + let about = (l.method, l.source).filter(x => x != none) ( t(l.tag), - t(l.description), + t({ + l.description + for x in about { + linebreak() + text(6.5pt, fill: quiet, x) + } + }), t(rule), t(amount(l.cost)), - t(maybe(l.contributor_basis)), - t(maybe(l.tax_basis)), + t(fmv), + t(maybe(l.contributor_adjusted_basis)), + t(maybe(l.tax_basis_723)), + t(maybe(l.depreciable_basis)), t(maybe(l.built_in_gain)), ) }) @@ -206,8 +232,9 @@ let s = (top: firm) let blank = table.cell(stroke: s, []) ( - table.cell(stroke: s, colspan: 5, text(weight: "bold", "Total tax basis, assets held")), - table.cell(stroke: s, text(weight: "bold", amount(d.tax_total, currency: true))), + table.cell(stroke: s, colspan: 6, text(weight: "bold", "Total, assets held")), + table.cell(stroke: s, text(weight: "bold", amount(d.tax_totals.at(0), currency: true))), + table.cell(stroke: s, text(weight: "bold", amount(d.tax_totals.at(1), currency: true))), blank, ) }, diff --git a/crates/ledger-import/tests/asset_register.rs b/crates/ledger-import/tests/asset_register.rs index 736fe5b..c41a630 100644 --- a/crates/ledger-import/tests/asset_register.rs +++ b/crates/ledger-import/tests/asset_register.rs @@ -4,10 +4,10 @@ //! INVARIANT it checks. use chrono::NaiveDate; -use ledger_core::{AssetRegister, AssetRegisterRow, TaxBasisRule}; +use ledger_core::{AssetRegister, AssetRegisterRow, BasisRule, Sec704cMethod}; use ledger_import::asset_register::{ - layout, missing_line, notes, rule_cited, rule_label, rule_mark, template_data, - typeset_asset_register, + FMV_NOT_RECORDED_MARK, layout, method_cited, missing_line, notes, rule_cited, rule_label, + rule_mark, template_data, typeset_asset_register, }; use ledger_import::report::ReportMeta; use ledger_import::{PdfError, asset_register_pdf}; @@ -31,68 +31,95 @@ fn meta() -> ReportMeta { } } -fn row(id: u64, tag: &str, desc: &str, cost: i64, acc: i64) -> AssetRegisterRow { +/// A row: `scheduled` the schedule's accumulated depreciation, `posted` +/// what the books carry; book value is cost less posted. +fn row(id: u64, tag: &str, desc: &str, cost: i64, scheduled: i64, posted: i64) -> AssetRegisterRow { AssetRegisterRow { asset_id: id, tag: Some(tag.into()), description: desc.into(), serial: None, - in_service: d(2026, 1, 15), + acquired_date: d(2026, 1, 15), + in_service_date: d(2026, 1, 15), cost: cents(cost), salvage: Decimal::ZERO, useful_life_months: 36, - accumulated: cents(acc), - book_value: cents(cost - acc), + accumulated_scheduled: cents(scheduled), + accumulated_posted: cents(posted), + book_value: cents(cost - posted), disposed: None, - tax_rule: None, - contributor_basis: None, - tax_basis: None, - built_in_gain: None, + basis_rule: None, + contributor_adjusted_basis: None, + fmv_at_contribution: None, + fmv_recorded: false, + tax_basis_723: None, + depreciable_basis: None, + built_in_gain_704c: None, + sec704c_1c: false, + sec704c_method: None, + basis_source: None, } } -/// Bought: tax basis is cost. +/// Bought: depreciable basis is cost. fn bought(mut r: AssetRegisterRow) -> AssetRegisterRow { - r.tax_rule = Some(TaxBasisRule::Cost); - r.tax_basis = Some(r.cost); + r.basis_rule = Some(BasisRule::Purchase); + r.depreciable_basis = Some(r.cost); r } -/// Contributed under `rule` with the member's basis `basis` (cents); tax -/// basis and built-in gain as ledger_core::Asset computes them. -fn contributed(mut r: AssetRegisterRow, rule: TaxBasisRule, basis: i64) -> AssetRegisterRow { +/// Contributed under `rule` with the member's adjusted basis `basis` +/// (cents) and its value `fmv` (cents; None: recorded before P3, read as +/// the book cost); figures as ledger_core::kernel::basis_figures gives +/// them. +fn contributed( + mut r: AssetRegisterRow, + rule: BasisRule, + basis: i64, + fmv: Option, +) -> AssetRegisterRow { let b = cents(basis); - r.tax_rule = Some(rule); - r.contributor_basis = Some(b); - r.tax_basis = Some(match rule { - TaxBasisRule::PersonalUseConversion => b.min(r.cost), - _ => b, - }); - r.built_in_gain = Some(r.cost - b); + let v = fmv.map_or(r.cost, cents); + let f = ledger_core::kernel::basis_figures(rule, r.cost, b, v).unwrap(); + r.basis_rule = Some(rule); + r.contributor_adjusted_basis = Some(b); + r.fmv_at_contribution = Some(v); + r.fmv_recorded = fmv.is_some(); + r.tax_basis_723 = f.tax_basis_723; + r.depreciable_basis = Some(f.depreciable_basis); + r.built_in_gain_704c = f.built_in_gain_704c; + r.sec704c_1c = f.built_in_gain_704c.is_some_and(|g| g < Decimal::ZERO); r } fn register(rows: Vec) -> AssetRegister { let held = || rows.iter().filter(|r| r.disposed.is_none()); - let total_cost = held().map(|r| r.cost).sum(); - let total_accumulated = held().map(|r| r.accumulated).sum(); - let total_tax_basis = held().filter_map(|r| r.tax_basis).sum(); - let missing_tax_basis = held().filter(|r| r.tax_basis.is_none()).count(); + let total_cost: Decimal = held().map(|r| r.cost).sum(); + let total_accumulated_scheduled: Decimal = held().map(|r| r.accumulated_scheduled).sum(); + let total_accumulated_posted: Decimal = held().map(|r| r.accumulated_posted).sum(); AssetRegister { - total_tax_basis, - missing_tax_basis, as_of: d(2026, 10, 15), - total_book_value: total_cost - total_accumulated, + total_depreciable_basis: held().filter_map(|r| r.depreciable_basis).sum(), + total_tax_basis_723: held().filter_map(|r| r.tax_basis_723).sum(), + missing_basis: held().filter(|r| r.depreciable_basis.is_none()).count(), + total_book_value: total_cost - total_accumulated_posted, ledger_cost: total_cost, - ledger_accumulated: total_accumulated - cents(1234), + ledger_accumulated: total_accumulated_scheduled - cents(1234), total_cost, - total_accumulated, + total_accumulated_scheduled, + total_accumulated_posted, rows, } } +/// Hand-worked: held cost 2,500.00 + 1,299.00 + 1,750.00 + 800.00 = +/// 6,349.00; scheduled 527.78 + 324.75 = 852.53; posted 500.00 + 324.75 = +/// 824.75; book value 6,349.00 − 824.75 = 5,524.25; §723 basis 900.00 + +/// 2,000.00 = 2,900.00; depreciable basis 2,500.00 + 900.00 + 1,750.00 = +/// 5,150.00; the books carry 852.53 − 12.34 = 840.19 accumulated, so +/// 12.34 isn't yet posted and 840.19 − 824.75 = 15.44 isn't from runs. fn sample() -> AssetRegister { - let mut disposed = row(3, "FL-0003", "Old monitor", 40000, 0); + let mut disposed = row(3, "FL-0003", "Old monitor", 40000, 0, 0); disposed.book_value = Decimal::ZERO; disposed.disposed = Some((d(2026, 9, 30), cents(4500))); let mut laptop = row( @@ -101,27 +128,41 @@ fn sample() -> AssetRegister { "Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)", 250000, 52778, + 50000, ); laptop.serial = Some("FRANBMCP0A1234".into()); laptop.salvage = cents(10000); + laptop.acquired_date = d(2026, 1, 10); + // A member's business laptop: carryover basis, built-in gain, its + // value recorded, the traditional method chosen, the facts' source. + let mut air = contributed( + row(2, "FL-0002", "MacBook Air", 129900, 32475, 32475), + BasisRule::Carryover723, + 90000, + Some(129900), + ); + air.sec704c_method = Some(Sec704cMethod::Traditional); + air.basis_source = Some(ledger_core::settings::Source { + decided_by: "Morgan Ellis".into(), + decided_on: d(2026, 10, 20), + citation: Some("entry 7".into()), + note: None, + }); register(vec![ bought(laptop), - // A member's business laptop: carryover basis, built-in gain. - contributed( - row(2, "FL-0002", "MacBook Air", 129900, 32475), - TaxBasisRule::Carryover, - 90000, - ), + air, bought(disposed), - // A member's personal camera: worth less than its basis, so tax - // basis is the fair market value and there's a built-in loss. + // A member's personal camera, recorded before P3 (value read as + // the book cost): worth less than its basis, so the depreciable + // basis is the value and there's a built-in loss. contributed( - row(4, "FL-0004", "Sony A7 IV camera", 175000, 0), - TaxBasisRule::PersonalUseConversion, + row(4, "FL-0004", "Sony A7 IV camera", 175000, 0, 0), + BasisRule::PersonalUseConversion, 200000, + None, ), // No tax basis recorded yet. - row(5, "FL-0005", "Standing desk", 80000, 0), + row(5, "FL-0005", "Standing desk", 80000, 0, 0), ]) } @@ -157,8 +198,10 @@ fn pdf_is_deterministic() { assert_eq!(a, asset_register_pdf(&r, &meta()).unwrap()); } -/// INVARIANTS 1, 2, 3, 5: header, every asset, the status of each, the -/// register's totals, and the reconciliation with both differences. +/// INVARIANTS 1, 2, 3, 5: header, every asset with its acquired and +/// in-service dates, the status of each, depreciation scheduled and +/// posted, the register's totals, and the reconciliation with its three +/// differences. #[test] fn pdf_shows_assets_totals_and_reconciliation() { let t = all_text(&sample(), &meta()); @@ -166,22 +209,26 @@ fn pdf_shows_assets_totals_and_reconciliation() { "Fernhill Labs LLC", "Fixed Asset Register", "As of October 15, 2026", + "ACQUIRED", + "IN SERVICE", + "DEPRECIATION SCHEDULED", + "DEPRECIATION POSTED", "FL-0001", "FRANBMCP0A1234", + "2026-01-10 2026-01-15", "MacBook Air", "Active", "Disposed 2026-09-30, proceeds 45.00", "Total, assets held", - "6,349.00", // cost held: 2,500.00 + 1,299.00 + 1,750.00 + 800.00 - "852.53", // accumulated held - "5,496.47", // book value held: 6,349.00 - 852.53 + "$6,349.00 $852.53 $824.75 $5,524.25", "Cost per register", "Cost per books", "Difference (unregistered cost)", "Accumulated depreciation per schedule", "Accumulated depreciation per books", - "Difference (not yet posted)", - "12.34", + "Difference (not yet posted) $12.34", + "Accumulated depreciation from the assets' runs", + "Difference (not from runs) $15.44", "Journal seq 314", ] { assert!(t.contains(s), "{s:?} missing from {t:?}"); @@ -189,45 +236,58 @@ fn pdf_shows_assets_totals_and_reconciliation() { // The disposed asset's cost (400.00) is shown but in no total. assert!(t.contains("400.00")); let l = layout(&sample()); - assert_eq!(l.totals, [cents(634900), cents(85253), cents(549647)]); + assert_eq!( + l.totals, + [cents(634900), cents(85253), cents(82475), cents(552425)] + ); } /// INVARIANTS 7-9: the tax table shows each rule in short words, the -/// contributor's basis, the tax basis, the built-in gain and loss, the -/// register's tax basis total, the missing line, and the notes with -/// their citations. +/// value at contribution (marked when read as the book cost), the +/// contributor's adjusted basis, the §723 basis, the depreciable basis, +/// the built-in gain and loss, the method and the facts' source under the +/// description, the register's tax totals, the missing line, and the +/// notes with their citations. #[test] -fn pdf_shows_tax_basis() { +fn pdf_shows_723_and_depreciable_basis() { let r = sample(); let t = all_text(&r, &meta()); for s in [ - "Federal tax basis", - "TAX BASIS RULE", - "CONTRIBUTOR'S BASIS", + "Federal tax figures, at contribution or purchase", + "BASIS RULE", + "VALUE AT CONTRIBUTION", + "CONTRIBUTOR'S ADJUSTED BASIS", + "§723 BASIS", + "DEPRECIABLE BASIS", "BUILT-IN GAIN (LOSS)", - "Carryover", - "Personal-use conversion", - "900.00", // MacBook: contributor's basis = tax basis - "399.00", // its built-in gain - "2,000.00", // camera: contributor's basis - "1,750.00", // its tax basis: the fair market value - "(250.00)", // its built-in loss: 1,750.00 - 2,000.00 - "Not recorded", - "Total tax basis, assets held", - "5,150.00", // 2,500.00 + 900.00 + 1,750.00; the desk has none + // MacBook: cost, value, adjusted basis, §723, depreciable, gain. + "Carryover 2 1,299.00 1,299.00 900.00 900.00 900.00 399.00", + "Traditional (Treas. Reg. §1.704-3(b))", + "Morgan Ellis, 2026-10-20: entry 7", + // Camera: value read as cost (marked), basis 2,000.00, the lesser. + "Personal-use conversion 3 1,750.00 * 1,750.00 2,000.00 2,000.00 1,750.00 (250.00)", + "Standing desk Not recorded 800.00", + "Total, assets held $2,900.00 $5,150.00", "Tax basis not recorded for 1 asset", "Notes", - "Book value is the fair market value at contribution", - "IRC §1012(a)", + "Book value is cost less the depreciation the books carry", + "IRC §1012", "IRC §723", + "IRC §1016(a)", "Treas. Reg. §1.168(i)-4(b)", + // The PDF wraps "§1.704-3(a)(3)(ii)" between its parentheses. + "Treas. Reg. §1.704-3(a) (3)(ii)", "IRC §704(c)(1)(C)", + "Value at contribution not recorded as a fact", ] { assert!(t.contains(s), "{s:?} missing from {t:?}"); } let l = layout(&r); - assert_eq!(l.tax_total, r.total_tax_basis); - assert_eq!(l.tax_total, cents(515000)); + assert_eq!( + l.tax_totals, + [r.total_tax_basis_723, r.total_depreciable_basis] + ); + assert_eq!(l.tax_totals, [cents(290000), cents(515000)]); assert_eq!( l.missing.as_deref(), Some("Tax basis not recorded for 1 asset") @@ -235,10 +295,19 @@ fn pdf_shows_tax_basis() { assert_eq!(l.notes, notes()); // Each line carries its row's tax fields unchanged. for (line, row) in l.lines.iter().zip(&r.rows) { - assert_eq!(line.tax_rule, row.tax_rule); - assert_eq!(line.contributor_basis, row.contributor_basis); - assert_eq!(line.tax_basis, row.tax_basis); - assert_eq!(line.built_in_gain, row.built_in_gain); + assert_eq!(line.basis_rule, row.basis_rule); + assert_eq!(line.fmv_at_contribution, row.fmv_at_contribution); + assert_eq!(line.fmv_recorded, row.fmv_recorded); + assert_eq!( + line.contributor_adjusted_basis, + row.contributor_adjusted_basis + ); + assert_eq!(line.tax_basis_723, row.tax_basis_723); + assert_eq!(line.depreciable_basis, row.depreciable_basis); + assert_eq!(line.built_in_gain_704c, row.built_in_gain_704c); + assert_eq!(line.sec704c_method, row.sec704c_method.map(method_cited)); + assert_eq!(line.accumulated_posted, row.accumulated_posted); + assert_eq!(line.accumulated_scheduled, row.accumulated_scheduled); } } @@ -246,11 +315,11 @@ fn pdf_shows_tax_basis() { /// (a disposed one without doesn't count), and the plural reads right. #[test] fn missing_line_only_when_missing() { - let mut gone = row(1, "FL-0001", "Old monitor", 40000, 0); + let mut gone = row(1, "FL-0001", "Old monitor", 40000, 0, 0); gone.book_value = Decimal::ZERO; gone.disposed = Some((d(2026, 9, 30), cents(4500))); - let r = register(vec![bought(row(2, "FL-0002", "Desk", 50000, 0)), gone]); - assert_eq!(r.missing_tax_basis, 0); + let r = register(vec![bought(row(2, "FL-0002", "Desk", 50000, 0, 0)), gone]); + assert_eq!(r.missing_basis, 0); assert_eq!(layout(&r).missing, None); assert!(!all_text(&r, &meta()).contains("Tax basis not recorded")); assert_eq!(missing_line(0), None); @@ -260,36 +329,46 @@ fn missing_line_only_when_missing() { ); } -/// INVARIANT 7: the rules in words, short and with their citations. +/// INVARIANT 7: the rules in words, short and with their citations; the +/// methods with their paragraphs. #[test] fn rule_words() { - use TaxBasisRule::*; + use BasisRule::*; assert_eq!( - [Cost, Carryover, PersonalUseConversion].map(rule_label), - ["Cost", "Carryover", "Personal-use conversion"] + [Purchase, Carryover723, PersonalUseConversion].map(rule_label), + ["Purchase", "Carryover", "Personal-use conversion"] ); assert_eq!( - [Cost, Carryover, PersonalUseConversion].map(rule_cited), + [Purchase, Carryover723, PersonalUseConversion].map(rule_cited), [ - "Cost", + "Purchase (IRC §1012)", "Carryover (IRC §723)", "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))" ] ); + assert_eq!( + Sec704cMethod::ALL.map(method_cited), + [ + "Traditional (Treas. Reg. §1.704-3(b))".to_string(), + "Traditional with curative allocations (Treas. Reg. §1.704-3(c))".to_string(), + "Remedial (Treas. Reg. §1.704-3(d))".to_string(), + ] + ); } /// INVARIANT 9: the notes number the rules 1, 2, 3 (the general note -/// first and unnumbered, the built-in gain note 4), each rule's note -/// cites that rule, and the PDF marks each recorded line's rule with its -/// note's number; a line with no rule recorded has no mark. +/// first and unnumbered, the built-in gain note 4, the value read as cost +/// "*"), each rule's note cites that rule, and the PDF marks each +/// recorded line's rule with its note's number; a line with no rule +/// recorded has no mark. #[test] fn rule_marks_match_their_notes() { - use TaxBasisRule::*; - let rules = [Cost, Carryover, PersonalUseConversion]; + use BasisRule::*; + let rules = [Purchase, Carryover723, PersonalUseConversion]; assert_eq!(rules.map(rule_mark), ["1", "2", "3"]); let n = notes(); let marks: Vec<&str> = n.iter().map(|n| n.mark.as_str()).collect(); - assert_eq!(marks, ["", "1", "2", "3", "4"]); + assert_eq!(marks, ["", "1", "2", "3", "4", FMV_NOT_RECORDED_MARK]); for rule in rules { let note = n.iter().find(|n| n.mark == rule_mark(rule)).unwrap(); let cites = format!("{}: ", rule_cited(rule)); @@ -308,15 +387,15 @@ fn rule_marks_match_their_notes() { // The PDF: the mark right after the rule, the note under its number. let t = all_text(&r, &meta()); for s in [ - "(Ryzen 7 7840U, 64 GB, 4 TB) Cost 1 2,500.00", - "MacBook Air Carryover 2 1,299.00", - "Old monitor Cost 1 400.00", + "(Ryzen 7 7840U, 64 GB, 4 TB) Purchase 1 2,500.00", + "Carryover 2 1,299.00", + "Old monitor Purchase 1 400.00", "Sony A7 IV camera Personal-use conversion 3 1,750.00", "Standing desk Not recorded 800.00", - "1 Cost: property the company bought", - "2 Carryover (IRC §723): property a member contributes", - "3 Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted", - "4 Built-in gain (loss):", + "1 Purchase (IRC §1012): property the company bought", + "2 Carryover (IRC §723): the company's basis in property a member contributes", + "3 Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): the §723 basis", + "4 Built-in gain (loss), IRC §704(c):", ] { assert!(t.contains(s), "{s:?} missing from {t:?}"); } @@ -334,6 +413,7 @@ fn empty_register_renders() { "No fixed assets", "Total, assets held", "Difference (not yet posted)", + "Difference (not from runs)", ] { assert!(t.contains(s), "{s:?} missing from {t:?}"); } @@ -342,7 +422,7 @@ fn empty_register_renders() { /// INVARIANT 6: markup in every text field prints literally and never runs. #[test] fn text_is_data() { - let mut a = row(1, "#FL-*1*", "#panic() *bold* $x$ @r [x] \\", 100, 0); + let mut a = row(1, "#FL-*1*", "#panic() *bold* $x$ @r [x] \\", 100, 0, 0); a.serial = Some("_u_ // /*".into()); let m = ReportMeta { company: "#co *Inc*".into(), @@ -357,7 +437,7 @@ fn text_is_data() { for s in [ "#panic()", "*", "_", "$", "[", "]", "\\", "#", "", "@a", "//", "/*", "\"", "`", ] { - let mut a = row(1, s, s, 100, 0); + let mut a = row(1, s, s, 100, 0, 0); a.serial = Some(s.into()); let m = ReportMeta { company: s.into(), @@ -379,6 +459,7 @@ fn long_register_paginates_landscape() { &format!("Asset {i}"), 100_000, 1_000, + 1_000, ) }) .collect(); diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs index d6f447c..dfa6709 100644 --- a/crates/ledger-import/tests/spec_import.rs +++ b/crates/ledger-import/tests/spec_import.rs @@ -4005,7 +4005,7 @@ fn cash_flow_adjustments_appear_only_when_there_are_some() { assert_eq!(section_lines(&plain, "Noncash")[0].label, "None"); l.register_asset(ledger_core::NewAsset { description: "Laptop".into(), - purchase_date: d(2026, 1, 3), + in_service_date: d(2026, 1, 3), cost: usd(900), salvage_value: usd(0), useful_life_months: 36, @@ -4488,56 +4488,87 @@ fn tax_summary_optional_sections() { /// A register as ledger_core::fixed_asset_register would return it: two /// assets held, one disposed (shown, in no total), and books that differ -/// from the register on both counts. Tax basis: the laptop a member -/// converted from personal use (worth less than their basis: tax basis is -/// its value, a built-in loss), the desk with none recorded, the monitor -/// a carryover with a built-in gain (disposed, in no total). +/// from the register on all three counts. Tax figures: the laptop a +/// member contributed from personal use (worth less than their basis: +/// depreciable basis is its value, a built-in loss), the desk with none +/// recorded, the monitor a carryover recorded before its value was asked +/// (read as its cost) with a built-in gain (disposed, in no total). +/// Hand-worked totals of the held: cost 2,500.00 + 1,200.00 = 3,700.00; +/// scheduled 527.78 + 250.00 = 777.78; posted 500.00 + 250.00 = 750.00; +/// book value 3,700.00 − 750.00 = 2,950.00; §723 basis 2,800.00; +/// depreciable basis 2,500.00. The books: cost 4,000.00 (300.00 +/// unregistered), accumulated 700.00 (77.78 not yet posted, −50.00 not +/// from runs). fn asset_register_fixture() -> ledger_core::AssetRegister { use ledger_core::AssetRegisterRow; - let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| { - AssetRegisterRow { - asset_id: id, - tag: tag.map(Into::into), - description: desc.into(), - serial: serial.map(Into::into), - in_service: d(2026, 1, 15), - cost: cents(cost), - salvage: cents(10000), - useful_life_months: 36, - accumulated: cents(acc), - book_value: cents(cost - acc), - disposed, - tax_rule: None, - contributor_basis: None, - tax_basis: None, - built_in_gain: None, - } - }; - let taxed = |mut r: AssetRegisterRow, rule, basis: Decimal, tax: Decimal| { - r.tax_rule = Some(rule); - r.contributor_basis = Some(basis); - r.tax_basis = Some(tax); - r.built_in_gain = Some(r.cost - basis); + let row = + |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, sched, posted, disposed| { + AssetRegisterRow { + asset_id: id, + tag: tag.map(Into::into), + description: desc.into(), + serial: serial.map(Into::into), + acquired_date: d(2026, 1, 10), + in_service_date: d(2026, 1, 15), + cost: cents(cost), + salvage: cents(10000), + useful_life_months: 36, + accumulated_scheduled: cents(sched), + accumulated_posted: cents(posted), + book_value: cents(cost - posted), + disposed, + basis_rule: None, + contributor_adjusted_basis: None, + fmv_at_contribution: None, + fmv_recorded: false, + tax_basis_723: None, + depreciable_basis: None, + built_in_gain_704c: None, + sec704c_1c: false, + sec704c_method: None, + basis_source: None, + } + }; + let taxed = |mut r: AssetRegisterRow, rule, basis: Decimal, fmv: Option| { + let v = fmv.unwrap_or(r.cost); + let f = ledger_core::kernel::basis_figures(rule, r.cost, basis, v).unwrap(); + r.basis_rule = Some(rule); + r.contributor_adjusted_basis = Some(basis); + r.fmv_at_contribution = Some(v); + r.fmv_recorded = fmv.is_some(); + r.tax_basis_723 = f.tax_basis_723; + r.depreciable_basis = Some(f.depreciable_basis); + r.built_in_gain_704c = f.built_in_gain_704c; + r.sec704c_1c = f.built_in_gain_704c.is_some_and(|g| g < Decimal::ZERO); r }; + let mut laptop = taxed( + row( + 1, + Some("FL-0001"), + "Framework Laptop 13", + Some("FRAN123"), + 250000, + 52778, + 50000, + None, + ), + ledger_core::BasisRule::PersonalUseConversion, + cents(280000), + Some(cents(250000)), + ); + laptop.sec704c_method = Some(ledger_core::Sec704cMethod::Traditional); + laptop.basis_source = Some(ledger_core::settings::Source { + decided_by: "Tobin Harlow".into(), + decided_on: d(2026, 10, 20), + citation: Some("entry 4".into()), + note: None, + }); ledger_core::AssetRegister { as_of: d(2026, 10, 15), rows: vec![ - taxed( - row( - 1, - Some("FL-0001"), - "Framework Laptop 13", - Some("FRAN123"), - 250000, - 52778, - None, - ), - ledger_core::TaxBasisRule::PersonalUseConversion, - cents(280000), - cents(250000), - ), - row(2, None, "Desk", None, 120000, 25000, None), + laptop, + row(2, None, "Desk", None, 120000, 25000, 25000, None), { let mut r = row( 3, @@ -4546,22 +4577,20 @@ fn asset_register_fixture() -> ledger_core::AssetRegister { None, 40000, 0, + 0, Some((d(2026, 9, 30), cents(4500))), ); r.book_value = Decimal::ZERO; - taxed( - r, - ledger_core::TaxBasisRule::Carryover, - cents(30000), - cents(30000), - ) + taxed(r, ledger_core::BasisRule::Carryover723, cents(30000), None) }, ], total_cost: cents(370000), - total_accumulated: cents(77778), - total_book_value: cents(292222), - total_tax_basis: cents(250000), - missing_tax_basis: 1, + total_accumulated_scheduled: cents(77778), + total_accumulated_posted: cents(75000), + total_book_value: cents(295000), + total_depreciable_basis: cents(250000), + total_tax_basis_723: cents(280000), + missing_basis: 1, ledger_cost: cents(400000), ledger_accumulated: cents(70000), } @@ -4581,10 +4610,11 @@ fn text_cell(cells: &BTreeMap<(u32, u32), Cell>, at: (u32, u32)) -> String { } } -/// asset_register INVARIANTS 1-3 and 5, read back from the workbook's XML: -/// header, headings, one row per asset in order (disposed marked), the -/// register's own totals, and the reconciliation under Cost and -/// Accumulated depreciation. +/// asset_register INVARIANTS 1-3, 5 and 7-9, read back from the +/// workbook's XML: header, headings, one row per asset in order (disposed +/// marked) with its acquired and in-service dates, depreciation scheduled +/// and posted, its tax figures, method and source; the register's own +/// totals, and the reconciliation's three differences. #[test] fn asset_register_xlsx_cells() { let r = asset_register_fixture(); @@ -4593,75 +4623,99 @@ fn asset_register_xlsx_cells() { assert_eq!(text_cell(&c, (1, 0)), "Spec Test LLC"); assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register"); assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026"); - let heads: Vec = (0..14).map(|i| text_cell(&c, (5, i))).collect(); + let heads: Vec = (0..21).map(|i| text_cell(&c, (5, i))).collect(); assert_eq!( heads, [ "TAG", "DESCRIPTION", "SERIAL", + "ACQUIRED", "IN SERVICE", "COST", "SALVAGE", "LIFE (MONTHS)", - "ACCUMULATED DEPRECIATION", + "DEPRECIATION SCHEDULED", + "DEPRECIATION POSTED", "BOOK VALUE", - "TAX BASIS RULE", - "CONTRIBUTOR'S BASIS", - "TAX BASIS", + "BASIS RULE", + "VALUE AT CONTRIBUTION", + "CONTRIBUTOR'S ADJUSTED BASIS", + "§723 BASIS", + "DEPRECIABLE BASIS", "BUILT-IN GAIN (LOSS)", + "ALLOCATION METHOD", + "BASIS FACTS FROM", + "VALUE RECORDED", "STATUS" ] ); assert_eq!(text_cell(&c, (6, 0)), "FL-0001"); assert_eq!(text_cell(&c, (6, 1)), "Framework Laptop 13"); assert_eq!(text_cell(&c, (6, 2)), "FRAN123"); - assert_eq!(text_cell(&c, (6, 3)), "2026-01-15"); - assert_eq!(number_cell(&c, (6, 4)), dec("2500.00")); - assert_eq!(number_cell(&c, (6, 5)), dec("100.00")); - assert_eq!(number_cell(&c, (6, 6)), dec("36")); - assert_eq!(number_cell(&c, (6, 7)), dec("527.78")); - assert_eq!(number_cell(&c, (6, 8)), dec("1972.22")); - // Tax basis (INVARIANT 7): rule in words with its citation, the - // contributor's basis, tax basis, built-in loss as a negative. + assert_eq!(text_cell(&c, (6, 3)), "2026-01-10"); + assert_eq!(text_cell(&c, (6, 4)), "2026-01-15"); + assert_eq!(number_cell(&c, (6, 5)), dec("2500.00")); + assert_eq!(number_cell(&c, (6, 6)), dec("100.00")); + assert_eq!(number_cell(&c, (6, 7)), dec("36")); + assert_eq!(number_cell(&c, (6, 8)), dec("527.78")); + assert_eq!(number_cell(&c, (6, 9)), dec("500.00")); + assert_eq!(number_cell(&c, (6, 10)), dec("2000.00")); + // Tax figures (INVARIANT 7): rule in words with its citation, value, + // adjusted basis, §723 basis, depreciable basis (the lesser), the + // built-in loss as a negative, the method, the source. assert_eq!( - text_cell(&c, (6, 9)), + text_cell(&c, (6, 11)), "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))" ); - assert_eq!(number_cell(&c, (6, 10)), dec("2800.00")); - assert_eq!(number_cell(&c, (6, 11)), dec("2500.00")); - assert_eq!(number_cell(&c, (6, 12)), dec("-300.00")); - assert_eq!(text_cell(&c, (6, 13)), "Active"); + assert_eq!(number_cell(&c, (6, 12)), dec("2500.00")); + assert_eq!(number_cell(&c, (6, 13)), dec("2800.00")); + assert_eq!(number_cell(&c, (6, 14)), dec("2800.00")); + assert_eq!(number_cell(&c, (6, 15)), dec("2500.00")); + assert_eq!(number_cell(&c, (6, 16)), dec("-300.00")); + assert_eq!( + text_cell(&c, (6, 17)), + "Traditional (Treas. Reg. §1.704-3(b))" + ); + assert_eq!(text_cell(&c, (6, 18)), "Tobin Harlow, 2026-10-20: entry 4"); + assert_eq!(text_cell(&c, (6, 19)), "Yes"); + assert_eq!(text_cell(&c, (6, 20)), "Active"); // The desk: no tag, no serial, no tax basis recorded. assert_eq!(text_cell(&c, (7, 1)), "Desk"); - assert_eq!(number_cell(&c, (7, 4)), dec("1200.00")); - assert_eq!(number_cell(&c, (7, 7)), dec("250.00")); - assert_eq!(number_cell(&c, (7, 8)), dec("950.00")); - assert_eq!(text_cell(&c, (7, 9)), "Not recorded"); - for col in 10..=12 { + assert_eq!(number_cell(&c, (7, 5)), dec("1200.00")); + assert_eq!(number_cell(&c, (7, 8)), dec("250.00")); + assert_eq!(number_cell(&c, (7, 9)), dec("250.00")); + assert_eq!(number_cell(&c, (7, 10)), dec("950.00")); + assert_eq!(text_cell(&c, (7, 11)), "Not recorded"); + for col in 12..=19 { assert!( !c.contains_key(&(7, col)), - "desk has no tax amount in {col}" + "desk has nothing in tax column {col}" ); } - assert_eq!(text_cell(&c, (7, 13)), "Active"); + assert_eq!(text_cell(&c, (7, 20)), "Active"); assert_eq!(text_cell(&c, (8, 0)), "FL-0003"); - assert_eq!(text_cell(&c, (8, 9)), "Carryover (IRC §723)"); - assert_eq!(number_cell(&c, (8, 10)), dec("300.00")); - assert_eq!(number_cell(&c, (8, 11)), dec("300.00")); - assert_eq!(number_cell(&c, (8, 12)), dec("100.00")); + assert_eq!(text_cell(&c, (8, 11)), "Carryover (IRC §723)"); + assert_eq!(number_cell(&c, (8, 12)), dec("400.00")); + assert_eq!(number_cell(&c, (8, 13)), dec("300.00")); + assert_eq!(number_cell(&c, (8, 14)), dec("300.00")); + assert_eq!(number_cell(&c, (8, 15)), dec("300.00")); + assert_eq!(number_cell(&c, (8, 16)), dec("100.00")); + assert_eq!(text_cell(&c, (8, 19)), "No: read as the book cost"); assert_eq!( - text_cell(&c, (8, 13)), + text_cell(&c, (8, 20)), "Disposed 2026-09-30, proceeds 45.00" ); - assert_eq!(number_cell(&c, (8, 8)), Decimal::ZERO); + assert_eq!(number_cell(&c, (8, 10)), Decimal::ZERO); // Totals: the register's (held assets only), not a re-sum of the rows. assert_eq!(text_cell(&c, (9, 0)), "Total, assets held"); - assert_eq!(number_cell(&c, (9, 4)), dec("3700.00")); - assert_eq!(number_cell(&c, (9, 7)), dec("777.78")); - assert_eq!(number_cell(&c, (9, 8)), dec("2922.22")); - assert_eq!(number_cell(&c, (9, 11)), dec("2500.00")); - for col in [5, 10, 12] { + assert_eq!(number_cell(&c, (9, 5)), dec("3700.00")); + assert_eq!(number_cell(&c, (9, 8)), dec("777.78")); + assert_eq!(number_cell(&c, (9, 9)), dec("750.00")); + assert_eq!(number_cell(&c, (9, 10)), dec("2950.00")); + assert_eq!(number_cell(&c, (9, 14)), dec("2800.00")); + assert_eq!(number_cell(&c, (9, 15)), dec("2500.00")); + for col in [6, 7, 12, 13, 16] { assert!(!c.contains_key(&(9, col)), "no total in {col}"); } // INVARIANT 8: the missing tax basis line. @@ -4669,19 +4723,27 @@ fn asset_register_xlsx_cells() { // Reconciliation. assert_eq!(text_cell(&c, (12, 0)), "Reconciliation to the books"); let recon = [ - (13, "Cost per register", 4, "3700.00"), - (14, "Cost per books", 4, "4000.00"), - (15, "Difference (unregistered cost)", 4, "300.00"), - (17, "Accumulated depreciation per schedule", 7, "777.78"), - (18, "Accumulated depreciation per books", 7, "700.00"), - (19, "Difference (not yet posted)", 7, "77.78"), + (13, "Cost per register", 5, "3700.00"), + (14, "Cost per books", 5, "4000.00"), + (15, "Difference (unregistered cost)", 5, "300.00"), + (17, "Accumulated depreciation per schedule", 8, "777.78"), + (18, "Accumulated depreciation per books", 8, "700.00"), + (19, "Difference (not yet posted)", 8, "77.78"), + ( + 21, + "Accumulated depreciation from the assets' runs", + 8, + "750.00", + ), + (22, "Accumulated depreciation per books", 8, "700.00"), + (23, "Difference (not from runs)", 8, "-50.00"), ]; for (row, label, col, amt) in recon { assert_eq!(text_cell(&c, (row, 0)), label); assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}"); } // INVARIANT 9: the notes, numbered as the PDF marks them. - assert_eq!(text_cell(&c, (21, 0)), "Notes"); + assert_eq!(text_cell(&c, (25, 0)), "Notes"); let notes = ledger_import::asset_register::notes(); for (i, n) in notes.iter().enumerate() { let want = if n.mark.is_empty() { @@ -4689,19 +4751,21 @@ fn asset_register_xlsx_cells() { } else { format!("{}. {}", n.mark, n.text) }; - assert_eq!(text_cell(&c, (22 + i as u32, 0)), want); + assert_eq!(text_cell(&c, (26 + i as u32, 0)), want); } assert!( - text_cell(&c, (22, 0)).starts_with("Book value is the fair market value at contribution") + text_cell(&c, (26, 0)) + .starts_with("Book value is cost less the depreciation the books carry") ); for (row, start) in [ - (23, "1. Cost: "), - (24, "2. Carryover (IRC §723): "), + (27, "1. Purchase (IRC §1012): "), + (28, "2. Carryover (IRC §723): "), ( - 25, + 29, "3. Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): ", ), - (26, "4. Built-in gain (loss): "), + (30, "4. Built-in gain (loss), IRC §704(c): "), + (31, "*. Value at contribution not recorded"), ] { assert!(text_cell(&c, (row, 0)).starts_with(start), "note {start:?}"); } @@ -4721,16 +4785,16 @@ fn asset_register_xlsx_headings_and_totals_rule() { let x = ledger_import::asset_register_xlsx(&asset_register_fixture(), &meta()).unwrap(); let c = sheet_cells(&x); let styles = cell_styles(&x); - let right_aligned = [4, 5, 6, 7, 8, 10, 11, 12]; - for col in 0..14 { + let right_aligned = [5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16]; + for col in 0..21 { let right = styles[&(5, col)].halign.as_deref() == Some("right"); assert_eq!(right, right_aligned.contains(&col), "heading column {col}"); } assert_eq!(text_cell(&c, (9, 0)), "Total, assets held"); assert!(!styles[&(9, 0)].top_border, "the label isn't ruled"); - let total = &styles[&(9, 4)]; + let total = &styles[&(9, 5)]; assert!(total.top_border, "{total:?}"); - for col in 1..14 { + for col in 1..21 { assert_eq!( styles.get(&(9, col)), Some(total), @@ -4738,7 +4802,7 @@ fn asset_register_xlsx_headings_and_totals_rule() { ); } for row in 6..=8 { - for col in 0..14 { + for col in 0..21 { let ruled = styles.get(&(row, col)).is_some_and(|s| s.top_border); assert!(!ruled, "asset row {row} column {col}"); } @@ -4753,15 +4817,18 @@ fn asset_register_xlsx_empty() { as_of: d(2026, 10, 15), rows: vec![], total_cost: Decimal::ZERO, - total_accumulated: Decimal::ZERO, + total_accumulated_scheduled: Decimal::ZERO, + total_accumulated_posted: Decimal::ZERO, total_book_value: Decimal::ZERO, - total_tax_basis: Decimal::ZERO, - missing_tax_basis: 0, + total_depreciable_basis: Decimal::ZERO, + total_tax_basis_723: Decimal::ZERO, + missing_basis: 0, ledger_cost: cents(99900), ledger_accumulated: Decimal::ZERO, }; let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap()); - assert_eq!(number_cell(&c, (7, 11)), Decimal::ZERO); + assert_eq!(number_cell(&c, (7, 14)), Decimal::ZERO); + assert_eq!(number_cell(&c, (7, 15)), Decimal::ZERO); assert!( !c.values() .any(|v| matches!(v, Cell::Text(t) if t.starts_with("Tax basis not recorded"))), @@ -4769,7 +4836,7 @@ fn asset_register_xlsx_empty() { ); assert_eq!(text_cell(&c, (6, 0)), "No fixed assets"); assert_eq!(text_cell(&c, (7, 0)), "Total, assets held"); - assert_eq!(number_cell(&c, (7, 4)), Decimal::ZERO); + assert_eq!(number_cell(&c, (7, 5)), Decimal::ZERO); assert_eq!(text_cell(&c, (12, 0)), "Difference (unregistered cost)"); - assert_eq!(number_cell(&c, (12, 4)), dec("999.00")); + assert_eq!(number_cell(&c, (12, 5)), dec("999.00")); } diff --git a/crates/ledger-mcp/handbook/cpa-handoff.md b/crates/ledger-mcp/handbook/cpa-handoff.md index 7767713..2e26774 100644 --- a/crates/ledger-mcp/handbook/cpa-handoff.md +++ b/crates/ledger-mcp/handbook/cpa-handoff.md @@ -4,3 +4,4 @@ - Never file the package, or a file from it, into the books. - tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again. - Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, the memos grouped); tell the human. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one. +- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, and the reconciliation from book to tax capital). A line the books can't know (tax depreciation, the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in. diff --git a/crates/ledger-mcp/handbook/fixed-assets.md b/crates/ledger-mcp/handbook/fixed-assets.md index 0936504..b2254ff 100644 --- a/crates/ledger-mcp/handbook/fixed-assets.md +++ b/crates/ledger-mcp/handbook/fixed-assets.md @@ -3,6 +3,8 @@ - Register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). - Depreciation is straight-line, book basis only; tax depreciation stays with the CPA. - It accrues by the day: a partial first month, the final month and a disposal month are prorated by days (the disposal day is held), and the lifetime total is exactly cost minus salvage. A month skipped is caught up by the next run (books.run_depreciation says how much under caught_up; a material miss is a restatement for the CPA); a disposal dated inside a month already run credits the extra days back; one dated before a later month's run is refused until that run is reversed. +- Two dates: the in-service date (in_service_date: the day it is ready and available for its use) starts depreciation; the acquired date is the date of the entry that put it on the books (its purchase or a member's contribution: give it as acquisition_entry, dated on or before the in-service date), and puts it on the register. Something bought before it is used is on the register from the purchase and depreciates from the day it serves. +- Two accumulated figures, never confused: what the books carry (accumulated_posted: its depreciation runs, less any reversed) and what the schedule says is due by a date (accumulated_scheduled_through). Book value is cost less what the books carry; the difference is depreciation not yet run. - Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FA-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (give the human the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. -- Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. -- books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer. +- Record every asset's tax basis beside its book value (books.set_asset_tax_basis, or basis_rule on books.register_asset): purchase for something bought (basis is its cost); for a member's contribution, carryover_723 (their business property) or personal_use_conversion (their personal property), with the member's adjusted basis (cost plus improvements, less depreciation allowed or allowable) and its fair market value when contributed: ask the human for both and who says so (basis_source), never assume the value is the cost. Three figures follow, each named for what it is: the §723 basis (the member's adjusted basis), the depreciable basis (that basis; for personal use, the lesser of it and the value), and the §704(c) built-in gain or loss (value less §723 basis; a loss counts only for the contributing member). They are at contribution; tax depreciation, and what remains of the gain or loss later, are the CPA's. Record the §704(c) method (traditional, traditional_with_curative, remedial) once chosen with the CPA. Book value stays fair market value. +- books.export fixed_asset_register is the register for the CPA (posted and scheduled, every tax figure, reconciled to the books); the CPA package's k1-tax-basis-capital.csv carries each member's capital on the tax basis beside the book figures, with what only the CPA can supply left open. books.show asset opens one in the viewer. diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 43f98d2..cc782bc 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -42,8 +42,7 @@ use chrono::NaiveDate; use ledger_config::Company; use ledger_core::{ AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry, - Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value, - depreciation_schedule, reports, + Ledger, Money, NewAsset, PeriodStatus, Side, depreciation_schedule, reports, }; use ledger_import::ReportMeta; use ledger_package::{csv_escape, form_1099_label, money, parse_date, tin_kind, vault_tin}; @@ -1438,26 +1437,143 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { out } -/// A tax basis record from a tool's rule name and contributor basis. -fn parse_tax_basis(rule: &str, basis: Option<&str>) -> Result { - let rule = ledger_core::TaxBasisRule::parse(rule).ok_or_else(|| { - format!("tax basis rule {rule:?}: expected cost, carryover, or personal_use_conversion") - })?; - Ok(ledger_core::TaxBasis { - rule, - contributor_basis: basis.map(parse_amount).transpose()?, - }) +/// A parameter and its deprecated alias (one release): the value given, +/// noting when it came by the alias; both given and different is refused. +fn aliased<'a>( + name: &str, + value: Option<&'a str>, + alias: &str, + old: Option<&'a str>, + notes: &mut Vec, +) -> Result, String> { + match (value, old) { + (Some(v), Some(o)) if v != o => Err(format!( + "{name} {v:?} and its old name {alias} {o:?} disagree: give {name} only" + )), + (Some(v), _) => Ok(Some(v)), + (None, Some(o)) => { + notes.push(format!("{alias} is now {name}")); + Ok(Some(o)) + } + (None, None) => Ok(None), + } } -/// An asset's tax basis for a tool's output. -fn tax_out(a: &ledger_core::Asset) -> Option { - let t = a.tax?; - Some(TaxBasisOut { - rule: t.rule.key().to_string(), - contributor_basis: t.contributor_basis.map(money), - tax_basis: a.tax_basis().map(money), - built_in_gain: a.built_in_gain().map(money), - }) +/// A basis rule from a tool: its wire name, or its pre-P3 name (an alias +/// for one release, noted). +fn parse_basis_rule(rule: &str, notes: &mut Vec) -> Result { + if let Some(r) = ledger_core::BasisRule::parse(rule) { + return Ok(r); + } + match ledger_core::BasisRule::parse_legacy(rule) { + Some(r) => { + notes.push(format!("the rule {rule:?} is now {:?}", r.key())); + Ok(r) + } + None => Err(format!( + "basis rule {rule:?}: expected purchase, carryover_723, or personal_use_conversion" + )), + } +} + +/// A basis record from a tool's facts, checked (AssetBasis::check). A +/// contribution's value at contribution is required: it is a fact the +/// human gives, never assumed from the cost. +fn parse_asset_basis( + rule: &str, + adjusted: Option<&str>, + fmv: Option<&str>, + method: Option<&str>, + source: Option<&BasisSourceIn>, + notes: &mut Vec, +) -> Result { + let rule = parse_basis_rule(rule, notes)?; + if rule.is_contribution() && fmv.is_none() { + return Err(format!( + "a contribution ({}) needs fmv_at_contribution: its fair market value when contributed (ask the human; the books' amount in the contribution entry, if that is what it was worth)", + rule.key() + )); + } + let basis = ledger_core::AssetBasis { + rule, + contributor_adjusted_basis: adjusted.map(parse_amount).transpose()?, + fmv_at_contribution: fmv.map(parse_amount).transpose()?, + sec704c_method: method + .map(|m| { + ledger_core::Sec704cMethod::parse(m).ok_or_else(|| { + format!( + "sec704c_method {m:?}: expected traditional, traditional_with_curative, or remedial" + ) + }) + }) + .transpose()?, + source: source + .map(|s| -> Result<_, String> { + Ok(ledger_core::settings::Source { + decided_by: s.decided_by.clone(), + decided_on: match &s.decided_on { + Some(d) => parse_date(d)?, + None => today(), + }, + citation: s.citation.clone(), + note: s.note.clone(), + }) + }) + .transpose()?, + }; + basis + .check() + .map_err(|why| format!("bad tax basis: {why}"))?; + Ok(basis) +} + +/// An asset's basis facts and figures for a tool's output. +fn basis_out(a: &ledger_core::Asset) -> Result, String> { + let Some(b) = &a.basis else { + return Ok(None); + }; + let Some(f) = a.basis_figures().map_err(|e| e.to_string())? else { + return Ok(None); + }; + use ledger_core::BasisRule; + let loss = f + .built_in_gain_704c + .filter(|g| g.is_sign_negative() && !g.is_zero()); + Ok(Some(AssetBasisOut { + rule: b.rule.key().to_string(), + contributor_adjusted_basis: b.contributor_adjusted_basis.map(money), + fmv_at_contribution: a.fmv_at_contribution().map(money), + fmv_recorded: a.fmv_is_recorded(), + tax_basis_723: f.tax_basis_723.map(money), + depreciable_basis: money(f.depreciable_basis), + built_in_gain_704c: f.built_in_gain_704c.map(money), + sec704c_1c_note: loss.map(|l| { + format!( + "a built-in loss of {}: it counts only for the contributing member(s); for the others the basis is treated as the value at contribution (IRC §704(c)(1)(C))", + money(-l) + ) + }), + sec704c_method: b.sec704c_method.map(|m| m.key().to_string()), + source: b.source.as_ref().map(|s| BasisSourceOut { + decided_by: s.decided_by.clone(), + decided_on: s.decided_on.to_string(), + citation: s.citation.clone(), + note: s.note.clone(), + }), + sources: BasisSourcesOut { + tax_basis_723: b.rule.is_contribution().then(|| "IRC §723".to_string()), + depreciable_basis: match b.rule { + BasisRule::Purchase => "IRC §1012", + BasisRule::Carryover723 => "IRC §723", + BasisRule::PersonalUseConversion => "Treas. Reg. §1.168(i)-4(b)", + } + .to_string(), + built_in_gain_704c: b + .rule + .is_contribution() + .then(|| "Treas. Reg. §1.704-3(a)(3)(ii)".to_string()), + }, + })) } /// An asset named by id or by tag (exactly one). @@ -5493,7 +5609,7 @@ impl BooksServer { #[tool( name = "books.correct_asset", title = "Correct a fixed asset", - description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date when it was registered wrong, with the reason; omitted fields keep their values. Refused once any depreciation for it is posted (correct with entries then). Its tag, serial, links, and tax basis record stay; correct the entry that put it on the books too, so the books and the register agree (books.export fixed_asset_register shows any difference). Journaled with the old figures.", + description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date (never before its acquisition entry's date) when it was registered wrong, with the reason; omitted fields keep their values. Refused while the books carry depreciation for it (a run posted and not reversed: correct with entries then). Its tag, serial, links, and basis record stay (a contribution's value and built-in gain are as contributed); correct the entry that put it on the books too, so the books and the register agree (books.export fixed_asset_register shows any difference). Journaled with the old figures.", annotations( read_only_hint = false, destructive_hint = true, @@ -5505,6 +5621,14 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let p = params.0; + let mut notes = Vec::new(); + let in_service = aliased( + "in_service_date", + p.in_service_date.as_deref(), + "in_service", + p.in_service.as_deref(), + &mut notes, + )?; let mut store = self.lock()?; let id = resolve_asset_ref(&store, &p.asset)?; let a = store.ledger().get_asset(id).expect("resolved").clone(); @@ -5519,30 +5643,36 @@ impl BooksServer { None => a.salvage_value, }, useful_life_months: p.useful_life_months.unwrap_or(a.useful_life_months), - purchase_date: match &p.in_service { + in_service_date: match in_service { Some(d) => parse_date(d)?, - None => a.purchase_date, + None => a.in_service_date, }, }; store .correct_asset(id, fix, &p.reason) .map_err(|e| e.to_string())?; - let a = store.ledger().get_asset(id).expect("resolved"); + let ledger = store.ledger(); + let a = ledger.get_asset(id).expect("resolved"); Ok(Json(AssetSummaryOne { asset_id: id, tag: a.tag.clone(), cost: money(a.cost.amount), salvage_value: money(a.salvage_value.amount), useful_life_months: a.useful_life_months, - in_service: a.purchase_date.to_string(), - tax: tax_out(a), + in_service_date: a.in_service_date.to_string(), + acquired_date: ledger + .asset_acquired_date(id) + .expect("known asset") + .to_string(), + basis: basis_out(a)?, + notes, })) } #[tool( name = "books.set_asset_tax_basis", title = "Set an asset's tax basis", - description = "Record a fixed asset's federal tax basis beside its book value: rule cost (the company bought it: basis is its cost, IRC §1012), carryover (a member contributed business property: the member's adjusted basis, IRC §723), or personal_use_conversion (a member contributed personal-use property: the lesser of fair market value, which is the book cost, and the member's adjusted basis, Treas. Reg. §1.168(i)-4(b)). Contributions take the member's adjusted basis (what they paid, less any depreciation they took). Reports the tax basis and the built-in gain or loss (IRC §704(c)). Journaled; book figures don't change. Every asset should have one: the register and the CPA package show it.", + description = "Record how the company got a fixed asset and its tax figures beside book value. rule: purchase (basis is cost, IRC §1012); carryover_723 (a member's business property: their adjusted basis carries over, IRC §723); personal_use_conversion (a member's personal property: §723 basis likewise, depreciable basis the lesser of value and that basis, Treas. Reg. §1.168(i)-4(b)). A contribution needs contributor_adjusted_basis and fmv_at_contribution (ask the human; never assume the cost); built_in_gain_704c = value − §723 basis (Treas. Reg. §1.704-3(a)(3)(ii)), a loss counting only for the contributor. sec704c_method once chosen; basis_source: who says so. Journaled; book figures don't change.", annotations( read_only_hint = false, destructive_hint = false, @@ -5552,16 +5682,35 @@ impl BooksServer { async fn set_asset_tax_basis( &self, params: Parameters, - ) -> Result, String> { + ) -> Result, String> { let p = params.0; + let mut notes = Vec::new(); + let adjusted = aliased( + "contributor_adjusted_basis", + p.contributor_adjusted_basis.as_deref(), + "contributor_basis", + p.contributor_basis.as_deref(), + &mut notes, + )?; + let basis = parse_asset_basis( + &p.rule, + adjusted, + p.fmv_at_contribution.as_deref(), + p.sec704c_method.as_deref(), + p.basis_source.as_ref(), + &mut notes, + )?; let mut store = self.lock()?; let id = resolve_asset_ref(&store, &p.asset)?; - let tax = parse_tax_basis(&p.rule, p.contributor_basis.as_deref())?; store - .set_asset_tax_basis(id, tax) + .set_asset_basis(id, basis) .map_err(|e| e.to_string())?; let a = store.ledger().get_asset(id).expect("resolved"); - Ok(Json(tax_out(a).expect("just set"))) + Ok(Json(SetAssetTaxBasisOut { + asset_id: id, + basis: basis_out(a)?.ok_or("the basis just set has no figures")?, + notes, + })) } #[tool( @@ -6087,7 +6236,7 @@ impl BooksServer { #[tool( name = "books.register_asset", title = "Register a fixed asset", - description = "Register a fixed asset (equipment, vehicles, buildings). Links three accounts: cost (asset), accumulated depreciation (asset, contra), depreciation expense. Depreciation is straight-line, book basis only — tax depreciation (MACRS, Section 179) stays with the CPA.", + description = "Register a fixed asset (equipment, vehicles, buildings). Links three accounts: cost (asset), accumulated depreciation (asset, contra), depreciation expense. in_service_date (ready and available for its use) starts depreciation; the acquisition_entry (its purchase or contribution, dated on or before that) gives the acquired date, which puts it on the register. Its tax basis (basis_rule and the contribution facts) as books.set_asset_tax_basis takes it. Depreciation is straight-line, book basis only — tax depreciation (MACRS, Section 179) stays with the CPA.", annotations( read_only_hint = false, destructive_hint = false, @@ -6099,6 +6248,17 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let p = params.0; + let mut notes = Vec::new(); + let in_service_date = parse_date( + aliased( + "in_service_date", + p.in_service_date.as_deref(), + "purchase_date", + p.purchase_date.as_deref(), + &mut notes, + )? + .ok_or("in_service_date is required: the day it was placed in service")?, + )?; let mut store = self.lock()?; let base = store.ledger().base_currency(); let cost_account = resolve_account(&store, &p.cost_account)?; @@ -6133,35 +6293,95 @@ impl BooksServer { "entry {entry} can't be the acquisition: it must be posted and debit the cost account" )); } + let reversed = (1..store.ledger().next_entry_id()) + .filter_map(|id| store.ledger().get_entry(id)) + .any(|x| x.reversal_of() == Some(entry)); + if e.reversal_of().is_some() || reversed { + return Err(format!( + "entry {entry} can't be the acquisition: it is a reversal or has been reversed (link the entry that stands)" + )); + } + if e.date() > in_service_date { + return Err(format!( + "entry {entry} can't be the acquisition: it is dated {}, after the in-service date {in_service_date} (an asset is acquired before it serves: correct the in-service date, or the entry)", + e.date() + )); + } } // Checked before registering, so a bad tax basis registers nothing // (found by the spec-blind MCP suite, 2026-10-15). - let tax = match &p.tax_basis_rule { - Some(rule) => { - let tax = parse_tax_basis(rule, p.contributor_basis.as_deref())?; - tax.check().map_err(|why| format!("bad tax basis: {why}"))?; - Some(tax) - } - None if p.contributor_basis.is_some() => { - return Err("contributor_basis needs tax_basis_rule".into()); + let rule = aliased( + "basis_rule", + p.basis_rule.as_deref(), + "tax_basis_rule", + p.tax_basis_rule.as_deref(), + &mut notes, + )?; + let adjusted = aliased( + "contributor_adjusted_basis", + p.contributor_adjusted_basis.as_deref(), + "contributor_basis", + p.contributor_basis.as_deref(), + &mut notes, + )?; + let basis = match rule { + Some(rule) => Some(parse_asset_basis( + rule, + adjusted, + p.fmv_at_contribution.as_deref(), + p.sec704c_method.as_deref(), + p.basis_source.as_ref(), + &mut notes, + )?), + None if adjusted.is_some() + || p.fmv_at_contribution.is_some() + || p.sec704c_method.is_some() + || p.basis_source.is_some() => + { + return Err("the basis facts need basis_rule".into()); } None => None, }; - let asset_id = store - .register_asset(NewAsset { - description: p.description, - purchase_date: parse_date(&p.purchase_date)?, - cost: Money::new(parse_amount(&p.cost)?, base), - salvage_value: Money::new( - parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?, - base, - ), - useful_life_months: p.useful_life_months, - cost_account, - accum_depr_account, - expense_account, - }) - .map_err(|e| e.to_string())?; + let new = NewAsset { + description: p.description, + in_service_date, + cost: Money::new(parse_amount(&p.cost)?, base), + salvage_value: Money::new( + parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?, + base, + ), + useful_life_months: p.useful_life_months, + cost_account, + accum_depr_account, + expense_account, + }; + // One registration is several commands: rehearse them all on a + // copy first, and ask for the journal feature the basis needs + // (B-27), so a refusal anywhere (the tag, the serial, the link, the + // basis, books waiting for their format upgrade) journals nothing. + { + let mut copy = store.ledger().clone(); + let id = copy + .register_asset(new.clone()) + .map_err(|e| e.to_string())?; + copy.set_asset_tag(id, &tag).map_err(|e| e.to_string())?; + if let Some(serial) = &p.serial { + copy.set_asset_serial(id, Some(serial)) + .map_err(|e| e.to_string())?; + } + if let Some(entry) = p.acquisition_entry { + copy.set_asset_acquisition(id, entry) + .map_err(|e| e.to_string())?; + } + if let Some(basis) = &basis { + copy.set_asset_basis(id, basis.clone()) + .map_err(|e| e.to_string())?; + store + .ensure_feature(ledger_store::format::ASSET_BASIS) + .map_err(|e| e.to_string())?; + } + } + let asset_id = store.register_asset(new).map_err(|e| e.to_string())?; store .set_asset_tag(asset_id, &tag) .map_err(|e| e.to_string())?; @@ -6175,12 +6395,24 @@ impl BooksServer { .set_asset_acquisition(asset_id, entry) .map_err(|e| e.to_string())?; } - if let Some(tax) = tax { + if let Some(basis) = basis { store - .set_asset_tax_basis(asset_id, tax) + .set_asset_basis(asset_id, basis) .map_err(|e| e.to_string())?; } - Ok(Json(RegisterAssetOut { asset_id, tag })) + let ledger = store.ledger(); + let a = ledger.get_asset(asset_id).expect("registered"); + Ok(Json(RegisterAssetOut { + asset_id, + tag, + in_service_date: a.in_service_date.to_string(), + acquired_date: ledger + .asset_acquired_date(asset_id) + .expect("registered") + .to_string(), + basis: basis_out(a)?, + notes, + })) } #[tool( @@ -6234,45 +6466,72 @@ impl BooksServer { #[tool( name = "books.list_assets", title = "List fixed assets", - description = "List all registered fixed assets with accumulated depreciation by schedule and book value as of today (a disposed asset's through its disposal day). An asset whose schedule can't be computed lists with null figures and schedule_error.", + description = "List every registered fixed asset as of a date (as_of, default today): in-service and acquired dates, cost, and per asset accumulated_posted (what the books carry: runs posted, less reversals), accumulated_scheduled_through (what the schedule says is due by as_of), unposted_depreciation (the difference: run books.run_depreciation for it) and book_value (cost less posted: what the books carry); held says whether the books hold it on as_of. With its basis facts and tax figures. An asset whose figures can't be computed (one an earlier version took) lists with null figures and schedule_error.", annotations(read_only_hint = true, open_world_hint = false) )] - async fn list_assets(&self) -> Result, String> { + async fn list_assets( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let as_of = match &p.as_of { + Some(d) => parse_date(d)?, + None => today(), + }; let store = self.lock()?; - let today = today(); - let assets = store - .ledger() + let ledger = store.ledger(); + // The register's rows (one derivation, reports::register_row): an + // asset acquired by as_of has one; a later one has no figures yet. + let assets = ledger .registered_assets() .map(|a| { - // One asset whose schedule can't be computed (an earlier - // version's) lists with why, never failing the whole list. - let figures = accumulated_depreciation(a, today) - .and_then(|acc| Ok((acc, book_value(a, today)?))); - let (accumulated, book_value, schedule_error) = match figures { - Ok((acc, bv)) => (Some(money(acc)), Some(money(bv)), None), - Err(e) => (None, None, Some(e.to_string())), + let acquired = ledger.asset_acquired_date(a.id).expect("registered"); + let disposed = + matches!(a.status, AssetStatus::Disposed { date, .. } if date <= as_of); + // One asset whose figures can't be computed (one an earlier + // version took) lists with why, never failing the whole + // list. + let row = ledger + .accumulated_posted(a.id, as_of) + .map(|posted| std::collections::BTreeMap::from([(a.id, posted)])) + .and_then(|posted| reports::register_row(ledger, a, as_of, &posted)); + let (row, schedule_error) = match row { + Ok(row) => (row, None), + Err(e) => (None, Some(e.to_string())), }; - AssetSummary { + let row = row.as_ref(); + let figure = + |f: fn(&ledger_core::AssetRegisterRow) -> Decimal| row.map(|r| money(f(r))); + Ok(AssetSummary { asset_id: a.id, tag: a.tag.clone(), serial: a.serial.clone(), acquisition_entry: a.acquired_by, - tax: tax_out(a), + basis: basis_out(a)?, description: a.description.clone(), - purchase_date: a.purchase_date.to_string(), + in_service_date: a.in_service_date.to_string(), + acquired_date: acquired.to_string(), cost: money(a.cost.amount), useful_life_months: a.useful_life_months, status: match a.status { AssetStatus::Active => "active".to_string(), AssetStatus::Disposed { .. } => "disposed".to_string(), }, - accumulated, - book_value, + held: acquired <= as_of && !disposed, + accumulated_posted: figure(|r| r.accumulated_posted), + accumulated_scheduled_through: figure(|r| r.accumulated_scheduled), + unposted_depreciation: figure(|r| { + r.accumulated_scheduled - r.accumulated_posted + }), + book_value: figure(|r| r.book_value), schedule_error, - } + }) }) - .collect(); - Ok(Json(ListAssetsOut { assets })) + .collect::>()?; + Ok(Json(ListAssetsOut { + as_of: as_of.to_string(), + assets, + })) } #[tool( @@ -6322,11 +6581,11 @@ impl BooksServer { // failing the whole batch. if p.asset_id.is_none() && let Some(asset) = store.ledger().get_asset(id) - && asset.purchase_date > period_end + && asset.in_service_date > period_end { skipped.push(skip(format!( "placed in service {}, after {}", - asset.purchase_date, p.period + asset.in_service_date, p.period ))); continue; } @@ -8715,8 +8974,9 @@ impl BooksServer { /// 2. With members, k1-members.csv has one row per [[company.members]] /// member: name, party, share, capital account, TIN and type, and /// the K-1 worksheet's capital figures (ledger_core::k1) for the - /// fiscal year to date. The 1099 worksheet adds each payee's party, - /// last four, TIN, and type. + /// fiscal year to date; k1-tax-basis-capital.csv the same capital on + /// the tax basis beside them (ledger_core::tax_capital). The 1099 + /// worksheet adds each payee's party, last four, TIN, and type. /// 3. A locked vault or a missing TIN never fails the package: the /// row says why (vault_tin), the README lists it, and so does /// tin_gaps (members, and 1099 payees that are reportable). @@ -8725,7 +8985,7 @@ impl BooksServer { #[tool( name = "books.export_cpa_package", title = "CPA handoff package", - description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was bought by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", annotations( read_only_hint = false, destructive_hint = false, @@ -8921,68 +9181,109 @@ impl BooksServer { Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)), None => "Active".to_string(), }; + let source = |row: &ledger_core::AssetRegisterRow| { + row.basis_source.as_ref().map(|s| { + let mut t = format!("{}, {}", s.decided_by, s.decided_on); + if let Some(c) = &s.citation { + t.push_str(&format!(": {c}")); + } + t + }) + }; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "as_of": as_of.to_string(), + "tax_figures_are": "at contribution (or purchase): the CPA adjusts them for tax depreciation, and the §704(c) gain or loss as it is reduced after (Treas. Reg. §1.704-3(a)(3)(ii))", "assets": r.rows.iter().map(|a| serde_json::json!({ "asset_id": a.asset_id, "tag": a.tag, "description": a.description, "serial": a.serial, - "in_service": a.in_service.to_string(), + "acquired_date": a.acquired_date.to_string(), + "in_service_date": a.in_service_date.to_string(), "cost": money(a.cost), "salvage": money(a.salvage), "useful_life_months": a.useful_life_months, - "accumulated": money(a.accumulated), + "accumulated_scheduled": money(a.accumulated_scheduled), + "accumulated_posted": money(a.accumulated_posted), "book_value": money(a.book_value), "status": status(a), "proceeds": a.disposed.map(|(_, p)| money(p)), - "tax_basis_rule": a.tax_rule.map(|r| r.key()), - "contributor_basis": a.contributor_basis.map(money), - "tax_basis": a.tax_basis.map(money), - "built_in_gain": a.built_in_gain.map(money), + "basis_rule": a.basis_rule.map(|r| r.key()), + "contributor_adjusted_basis": a.contributor_adjusted_basis.map(money), + "fmv_at_contribution": a.fmv_at_contribution.map(money), + "fmv_recorded": a.fmv_recorded, + "tax_basis_723": a.tax_basis_723.map(money), + "depreciable_basis": a.depreciable_basis.map(money), + "built_in_gain_704c": a.built_in_gain_704c.map(money), + "sec704c_1c": a.sec704c_1c, + "sec704c_method": a.sec704c_method.map(|m| m.key()), + "basis_source": source(a), })).collect::>(), - "total_tax_basis": money(r.total_tax_basis), - "missing_tax_basis": r.missing_tax_basis, "total_cost": money(r.total_cost), - "total_accumulated": money(r.total_accumulated), + "total_accumulated_scheduled": money(r.total_accumulated_scheduled), + "total_accumulated_posted": money(r.total_accumulated_posted), "total_book_value": money(r.total_book_value), + "total_depreciable_basis": money(r.total_depreciable_basis), + "total_tax_basis_723": money(r.total_tax_basis_723), + "missing_basis": r.missing_basis, "ledger_cost": money(r.ledger_cost), "ledger_accumulated": money(r.ledger_accumulated), "unposted_depreciation": money(r.unposted_depreciation()), + "unattributed_accumulated": money(r.unattributed_accumulated()), "unregistered_cost": money(r.unregistered_cost()), })) .map_err(|e| e.to_string()); } let mut csv = String::from( - "tag,description,serial,in_service,cost,salvage,useful_life_months,accumulated,book_value,status,proceeds,tax_basis_rule,contributor_basis,tax_basis,built_in_gain\n", + "tag,description,serial,acquired,in_service,cost,salvage,useful_life_months,accumulated_scheduled,accumulated_posted,book_value,status,proceeds,basis_rule,contributor_adjusted_basis,fmv_at_contribution,fmv_recorded,tax_basis_723,depreciable_basis,built_in_gain_704c,sec704c_method,basis_source\n", ); for a in &r.rows { - csv.push_str(&format!( - "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n", + let cells = [ csv_escape(a.tag.as_deref().unwrap_or("")), csv_escape(&a.description), csv_escape(a.serial.as_deref().unwrap_or("")), - a.in_service, + a.acquired_date.to_string(), + a.in_service_date.to_string(), money(a.cost), money(a.salvage), - a.useful_life_months, - money(a.accumulated), + a.useful_life_months.to_string(), + money(a.accumulated_scheduled), + money(a.accumulated_posted), money(a.book_value), status(a), a.disposed.map(|(_, p)| money(p)).unwrap_or_default(), - a.tax_rule.map(|r| r.key()).unwrap_or_default(), - a.contributor_basis.map(money).unwrap_or_default(), - a.tax_basis.map(money).unwrap_or_default(), - a.built_in_gain.map(money).unwrap_or_default(), - )); + a.basis_rule + .map(|r| r.key()) + .unwrap_or_default() + .to_string(), + a.contributor_adjusted_basis.map(money).unwrap_or_default(), + a.fmv_at_contribution.map(money).unwrap_or_default(), + if a.basis_rule.is_some_and(|r| r.is_contribution()) { + a.fmv_recorded.to_string() + } else { + String::new() + }, + a.tax_basis_723.map(money).unwrap_or_default(), + a.depreciable_basis.map(money).unwrap_or_default(), + a.built_in_gain_704c.map(money).unwrap_or_default(), + a.sec704c_method + .map(|m| m.key()) + .unwrap_or_default() + .to_string(), + csv_escape(&source(a).unwrap_or_default()), + ]; + csv.push_str(&cells.join(",")); + csv.push('\n'); } csv.push_str(&format!( - "TOTAL,,,,{},,,{},{},,,,,{},", + "TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,", money(r.total_cost), - money(r.total_accumulated), + money(r.total_accumulated_scheduled), + money(r.total_accumulated_posted), money(r.total_book_value), - money(r.total_tax_basis) + money(r.total_tax_basis_723), + money(r.total_depreciable_basis) )); Ok(csv) } diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index cfb41f6..cd0d641 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -2213,8 +2213,16 @@ pub struct ExportOut { pub struct RegisterAssetParams { /// What it is, e.g. "2023 Sprinter van". pub description: String, - /// Purchase date, YYYY-MM-DD. Depreciation starts here. - pub purchase_date: String, + /// The day it was placed in service (ready and available for its use), + /// YYYY-MM-DD: depreciation starts here. Required (or its alias + /// purchase_date). + #[serde(default)] + pub in_service_date: Option, + /// Deprecated alias of in_service_date (it always meant the in-service + /// date), accepted for one release. Giving both with different dates is + /// refused. + #[serde(default)] + pub purchase_date: Option, /// Capitalized cost, decimal string, e.g. "60000.00". pub cost: String, /// Expected residual value, decimal string. Defaults to "0". @@ -2234,14 +2242,54 @@ pub struct RegisterAssetParams { /// The manufacturer's serial number, if known. pub serial: Option, /// The posted entry that put it on the books (its purchase, or a - /// member's contribution of it); it must debit the cost account. + /// member's contribution of it): it must debit the cost account and be + /// dated on or before the in-service date. Its date is the asset's + /// acquired date, which puts it on the register. pub acquisition_entry: Option, - /// The federal tax basis rule (see books.set_asset_tax_basis): cost, - /// carryover, or personal_use_conversion. Omit to record it later. + /// How the company got it (see books.set_asset_tax_basis): purchase, + /// carryover_723, or personal_use_conversion. Omit to record it later. + #[serde(default)] + pub basis_rule: Option, + /// Deprecated alias of basis_rule, with the old names (cost, + /// carryover, personal_use_conversion), for one release. + #[serde(default)] pub tax_basis_rule: Option, - /// The contributing member's adjusted basis, for carryover and - /// personal_use_conversion. + /// The contributing member's adjusted basis at contribution, for + /// carryover_723 and personal_use_conversion. + #[serde(default)] + pub contributor_adjusted_basis: Option, + /// Deprecated alias of contributor_adjusted_basis, for one release. + #[serde(default)] pub contributor_basis: Option, + /// Fair market value at contribution, for carryover_723 and + /// personal_use_conversion (required: ask the human). + #[serde(default)] + pub fmv_at_contribution: Option, + /// The §704(c) method chosen for a contribution: traditional, + /// traditional_with_curative, or remedial. + #[serde(default)] + pub sec704c_method: Option, + /// Who gave the basis facts, when, and on what (e.g. the entry that + /// records the value). + #[serde(default)] + pub basis_source: Option, +} + +/// Who gave an asset's basis facts, and when: the shape every tool that +/// records a decision takes (books.set_setting's source). +#[derive(Debug, Deserialize, JsonSchema)] +pub struct BasisSourceIn { + /// Who gave them: a person's name, as the human says. + pub decided_by: String, + /// When, YYYY-MM-DD. Default: today. + #[serde(default)] + pub decided_on: Option, + /// The document or entry they rest on, if any ("entry 14"). + #[serde(default)] + pub citation: Option, + /// Anything else worth keeping with them. + #[serde(default)] + pub note: Option, } /// Parameters for `books.correct_asset`. @@ -2255,7 +2303,12 @@ pub struct CorrectAssetParams { pub salvage_value: Option, /// Corrected useful life in months. Omit to keep it. pub useful_life_months: Option, - /// Corrected in-service date, YYYY-MM-DD. Omit to keep it. + /// Corrected in-service date, YYYY-MM-DD (never before its acquisition + /// entry's date). Omit to keep it. + #[serde(default)] + pub in_service_date: Option, + /// Deprecated alias of in_service_date, for one release. + #[serde(default)] pub in_service: Option, /// Why (required): what was wrong. pub reason: String, @@ -2275,10 +2328,16 @@ pub struct AssetSummaryOne { /// Useful life in months. pub useful_life_months: u32, /// In-service date, YYYY-MM-DD. - pub in_service: String, - /// Its federal tax basis (follows the corrected cost under - /// personal_use_conversion and cost). - pub tax: Option, + pub in_service_date: String, + /// Acquired date (its acquisition entry's), YYYY-MM-DD. + pub acquired_date: String, + /// Its basis facts and tax figures (a purchase's depreciable basis + /// follows the corrected cost; a contribution's figures stay as + /// contributed). + pub basis: Option, + /// Anything the call did beyond the correction (an alias used). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, } /// Parameters for `books.set_asset_tax_basis`. @@ -2286,31 +2345,119 @@ pub struct AssetSummaryOne { pub struct SetAssetTaxBasisParams { /// The asset: its id or tag. pub asset: String, - /// "cost" (the company bought it: basis is its cost), "carryover" (a - /// member contributed business property: the member's adjusted basis, - /// IRC §723), or "personal_use_conversion" (a member contributed - /// personal-use property: the lesser of fair market value and the - /// member's adjusted basis, Treas. Reg. §1.168(i)-4(b)). + /// How the company got it: "purchase" (bought: basis is cost, IRC + /// §1012), "carryover_723" (a member contributed business property: + /// the member's adjusted basis carries over, IRC §723), or + /// "personal_use_conversion" (a member contributed personal-use + /// property: §723 basis as for carryover, depreciable basis the lesser + /// of fair market value and that basis, Treas. Reg. §1.168(i)-4(b)). + /// The old names cost and carryover are aliases for one release. pub rule: String, /// The contributing member's adjusted basis at contribution, decimal - /// string (what they paid, less any depreciation they took). Required - /// for carryover and personal_use_conversion; omit for cost. + /// string: cost plus improvements, less depreciation allowed or + /// allowable (IRC §1016(a)). Required for a contribution; omit for a + /// purchase. + #[serde(default)] + pub contributor_adjusted_basis: Option, + /// Deprecated alias of contributor_adjusted_basis, for one release. + #[serde(default)] pub contributor_basis: Option, + /// Fair market value at contribution, decimal string. Required for a + /// contribution: ask the human (the books' amount in the contribution + /// entry, when that is what it was worth). + #[serde(default)] + pub fmv_at_contribution: Option, + /// The §704(c) method chosen for a contribution: traditional, + /// traditional_with_curative, or remedial (Treas. Reg. §1.704-3(b)-(d)). + #[serde(default)] + pub sec704c_method: Option, + /// Who gave the facts, when, and on what (e.g. the contribution + /// entry). + #[serde(default)] + pub basis_source: Option, } -/// An asset's federal tax basis, as tools report it. +/// Where each basis figure's rule comes from. #[derive(Debug, Serialize, JsonSchema)] -pub struct TaxBasisOut { - /// The rule: cost, carryover, or personal_use_conversion. +pub struct BasisSourcesOut { + /// The §723 basis's: "IRC §723" (contributions). + #[serde(skip_serializing_if = "Option::is_none")] + pub tax_basis_723: Option, + /// The depreciable basis's: "IRC §1012" (purchase), "IRC §723" + /// (carryover), or "Treas. Reg. §1.168(i)-4(b)" (personal use). + pub depreciable_basis: String, + /// The built-in gain's: "Treas. Reg. §1.704-3(a)(3)(ii)" + /// (contributions). + #[serde(skip_serializing_if = "Option::is_none")] + pub built_in_gain_704c: Option, +} + +/// Who gave an asset's basis facts, as tools show it. +#[derive(Debug, Serialize, JsonSchema)] +pub struct BasisSourceOut { + /// Who gave them. + pub decided_by: String, + /// When, YYYY-MM-DD. + pub decided_on: String, + /// What they rest on, if anything. + #[serde(skip_serializing_if = "Option::is_none")] + pub citation: Option, + /// Anything kept with them. + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +/// An asset's basis facts and tax figures, as tools report them. The +/// figures are at contribution (or purchase): the CPA adjusts them for tax +/// depreciation and, for the §704(c) gain or loss, as it is reduced after. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AssetBasisOut { + /// How the company got it: purchase, carryover_723, or + /// personal_use_conversion. pub rule: String, - /// The contributing member's adjusted basis, decimal string, if a - /// contribution. - pub contributor_basis: Option, - /// The depreciable federal tax basis, decimal string. - pub tax_basis: Option, - /// Built-in gain (+) or loss (−) at contribution (fair market value less - /// the contributor's basis), IRC §704(c), decimal string. - pub built_in_gain: Option, + /// The contributing member's adjusted basis at contribution, decimal + /// string (a contribution). + pub contributor_adjusted_basis: Option, + /// Fair market value at contribution, decimal string (a contribution): + /// recorded, or the book cost for a record made before the value was + /// a fact (fmv_recorded false). + pub fmv_at_contribution: Option, + /// Whether fmv_at_contribution was recorded as a fact. + pub fmv_recorded: bool, + /// The company's basis in contributed property at contribution (IRC + /// §723: the member's adjusted basis), decimal string. + pub tax_basis_723: Option, + /// The basis tax depreciation starts from, decimal string. + pub depreciable_basis: String, + /// Fair market value less the §723 basis at contribution, IRC §704(c) + /// (positive a built-in gain, negative a built-in loss), decimal string. + /// What remains later is the CPA's (Treas. Reg. §1.704-3(a)(3)(ii)). + pub built_in_gain_704c: Option, + /// For a built-in loss: it counts only for the contributing member(s); + /// for the others the basis is treated as the value (IRC + /// §704(c)(1)(C)). + #[serde(skip_serializing_if = "Option::is_none")] + pub sec704c_1c_note: Option, + /// The §704(c) method chosen: traditional, traditional_with_curative, + /// or remedial; null until chosen. + pub sec704c_method: Option, + /// Who gave the facts, when, and on what. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// The authority for each figure. + pub sources: BasisSourcesOut, +} + +/// Result of `books.set_asset_tax_basis`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct SetAssetTaxBasisOut { + /// The asset. + pub asset_id: u64, + /// Its basis facts and figures now. + pub basis: AssetBasisOut, + /// Anything to know (an alias used). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, } /// Result of `books.register_asset`. @@ -2320,6 +2467,17 @@ pub struct RegisterAssetOut { pub asset_id: u64, /// Its tag (given or generated). pub tag: String, + /// In-service date, YYYY-MM-DD. + pub in_service_date: String, + /// Acquired date, YYYY-MM-DD: the acquisition entry's, or the + /// in-service date with none linked. + pub acquired_date: String, + /// Its basis facts and figures, if given. + #[serde(skip_serializing_if = "Option::is_none")] + pub basis: Option, + /// Anything to know (an alias used). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, } /// Parameters for `books.label_asset`. @@ -2389,7 +2547,9 @@ pub struct ScheduleLineOut { pub amount: String, /// Accumulated depreciation through this month, decimal string. pub accumulated: String, - /// Book value after this month, decimal string. + /// Cost less the schedule's accumulated through this month, decimal + /// string: the schedule's figure; what the books carry is + /// books.list_assets' book_value. pub book_value: String, } @@ -2424,7 +2584,7 @@ pub struct AssetScheduleOut { pub schedule: Vec, } -/// One asset in `books.list_assets`. +/// One asset in `books.list_assets`, as of the date asked. #[derive(Debug, Serialize, JsonSchema)] pub struct AssetSummary { /// Asset id. @@ -2435,38 +2595,60 @@ pub struct AssetSummary { pub serial: Option, /// The entry that acquired it, if linked. pub acquisition_entry: Option, - /// Its federal tax basis, if recorded. - pub tax: Option, + /// Its basis facts and tax figures, if recorded. + pub basis: Option, /// Asset description. pub description: String, - /// Purchase date, YYYY-MM-DD. - pub purchase_date: String, + /// In-service date, YYYY-MM-DD: depreciation starts here. + pub in_service_date: String, + /// Acquired date, YYYY-MM-DD: on the books from here. + pub acquired_date: String, /// Cost, decimal string. pub cost: String, /// Useful life in months. pub useful_life_months: u32, /// "active" or "disposed". pub status: String, - /// Accumulated depreciation by its schedule through today (a disposed - /// asset's through its disposal day), decimal string. Null when the - /// schedule can't be computed (see `schedule_error`). - pub accumulated: Option, - /// Book value today (cost less `accumulated`), decimal string. Null - /// when the schedule can't be computed. + /// Whether the books hold it on as_of: acquired by then and not yet + /// disposed. + pub held: bool, + /// Accumulated depreciation the books carry on as_of (runs posted, less + /// reversals), decimal string; "0.00" once disposed; null before it + /// was acquired. + pub accumulated_posted: Option, + /// Accumulated depreciation the schedule says is due through as_of, + /// decimal string; "0.00" once disposed; null before it was acquired. + pub accumulated_scheduled_through: Option, + /// Scheduled less posted: run books.run_depreciation for it. Decimal + /// string; null before it was acquired. + pub unposted_depreciation: Option, + /// What the books carry: cost less accumulated_posted ("0.00" once + /// disposed), decimal string; null before it was acquired. pub book_value: Option, - /// Why `accumulated` and `book_value` are null, when they are: an - /// asset an earlier version took whose schedule the books can't - /// compute. It lists, and the rest of the register with it. + /// Why the figures are null for an asset acquired by as_of: one an + /// earlier version took whose schedule the books can't compute. It + /// lists, and the rest of the register with it. pub schedule_error: Option, } /// Result of `books.list_assets`. #[derive(Debug, Serialize, JsonSchema)] pub struct ListAssetsOut { + /// The date the figures are as of, YYYY-MM-DD. + pub as_of: String, /// All registered assets. pub assets: Vec, } +/// Parameters for `books.list_assets`. +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct ListAssetsParams { + /// The date the figures are as of, YYYY-MM-DD. Default: today (the + /// company's time zone). + #[serde(default)] + pub as_of: Option, +} + /// Parameters for `books.run_depreciation`. #[derive(Debug, Deserialize, JsonSchema)] pub struct RunDepreciationParams { diff --git a/crates/ledger-mcp/tests/hostile_args.rs b/crates/ledger-mcp/tests/hostile_args.rs index 098e069..e92e68e 100644 --- a/crates/ledger-mcp/tests/hostile_args.rs +++ b/crates/ledger-mcp/tests/hostile_args.rs @@ -677,6 +677,7 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy { "ss_", "medicare", "deductions", + "fmv", ]; if n.contains("period") && !n.contains("period_") { likely.extend(pick(period_like)); @@ -992,11 +993,13 @@ fn inputs_that_once_panicked_are_refused_or_answered() { json!({"description": "y", "purchase_date": "2026-01-01", "cost": "100.00", "useful_life_months": 12, "cost_account": "1500", "accum_depr_account": "1501", "expense_account": "6100", - "tax_basis_rule": "carryover", "contributor_basis": MAX}), + "tax_basis_rule": "carryover", "contributor_basis": MAX, + "fmv_at_contribution": MAX}), ), ( "books.set_asset_tax_basis", - json!({"asset": "1", "rule": "carryover", "contributor_basis": MAX}), + json!({"asset": "1", "rule": "carryover", "contributor_basis": MAX, + "fmv_at_contribution": MAX}), ), ( "books.export", diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index 01367f0..7143b0c 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -562,7 +562,7 @@ fn rich_books(s: &mut Store) -> Rich { let asset = s .register_asset(NewAsset { description: "Laptop".into(), - purchase_date: d("2025-03-20"), + in_service_date: d("2025-03-20"), cost: usd("3000.00"), salvage_value: usd("0.00"), useful_life_months: 36, @@ -1905,7 +1905,7 @@ fn dispose_asset_previews_equal_the_store_and_gate_receipts_on_debits() { let reg = |s: &mut Store, desc: &str| { s.register_asset(NewAsset { description: desc.into(), - purchase_date: d("2025-01-01"), + in_service_date: d("2025-01-01"), cost: usd("1200.00"), salvage_value: usd("0.00"), useful_life_months: 12, @@ -2030,7 +2030,7 @@ fn run_depreciation_catches_up_and_skips_what_has_nothing_due() { let reg = |s: &mut Store, desc: &str, date: &str, months: u32| { s.register_asset(NewAsset { description: desc.into(), - purchase_date: d(date), + in_service_date: d(date), cost: usd("600.00"), salvage_value: usd("0.00"), useful_life_months: months, diff --git a/crates/ledger-mcp/tests/spec_assets.rs b/crates/ledger-mcp/tests/spec_assets.rs index 55a8c8d..c0f1d77 100644 --- a/crates/ledger-mcp/tests/spec_assets.rs +++ b/crates/ledger-mcp/tests/spec_assets.rs @@ -323,7 +323,7 @@ impl Mcp { /// Register a $1,200, 12-month laptop from 2026-01-01 with `extra` args /// (tag, serial, description). fn register(&mut self, extra: Value) -> Out { - let mut args = json!({"description": "Laptop", "purchase_date": "2026-01-01", + let mut args = json!({"description": "Laptop", "in_service_date": "2026-01-01", "cost": "1200.00", "salvage_value": "0.00", "useful_life_months": 12, "cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"}); for (k, v) in extra.as_object().unwrap() { @@ -798,7 +798,7 @@ fn asset_labels_refuses_an_asset_without_a_tag() { let usd = |a: &str| Money::new(a.parse().unwrap(), Currency::USD); s.register_asset(NewAsset { description: "Old laptop".into(), - purchase_date: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), + in_service_date: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), cost: usd("1200.00"), salvage_value: usd("0.00"), useful_life_months: 12, @@ -909,7 +909,7 @@ fn export_fixed_asset_register_reconciles_to_the_books() { fn export_fixed_asset_register_as_of_leaves_out_later_assets() { let mut m = books("export-later"); m.register_ok(json!({"tag": "EARLY-1"})); - m.register_ok(json!({"tag": "LATE-1", "purchase_date": "2026-06-01"})); + m.register_ok(json!({"tag": "LATE-1", "in_service_date": "2026-06-01"})); let r = m.read_only( "books.export", json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}), @@ -921,14 +921,14 @@ fn export_fixed_asset_register_as_of_leaves_out_later_assets() { // ------------------------------------------------- tax basis (rules.md B-25) // -// From the `books.set_asset_tax_basis` description and -// `SetAssetTaxBasisParams` / `TaxBasisOut` docs, `RegisterAssetParams` -// (`tax_basis_rule`, `contributor_basis`), `AssetSummary::tax`, and the -// fixed_asset_register export. Cost: the book cost (IRC §1012); -// carryover: the member's adjusted basis (IRC §723); -// personal_use_conversion: the lesser of fair market value (the book -// cost) and the member's basis (Treas. Reg. §1.168(i)-4(b)); built-in -// gain = fair market value less the member's basis (IRC §704(c)). +// From the `books.set_asset_tax_basis` and `books.register_asset` +// descriptions, `SetAssetTaxBasisParams` / `AssetBasisOut` / +// `RegisterAssetParams` docs, and the fixed_asset_register export. +// Purchase: the book cost (IRC §1012); carryover_723: the member's +// adjusted basis (IRC §723); personal_use_conversion: §723 basis as for +// carryover, depreciable basis the lesser of fair market value and that +// basis (Treas. Reg. §1.168(i)-4(b)); built_in_gain_704c = value less the +// §723 basis at contribution (Treas. Reg. §1.704-3(a)(3)(ii)). /// A decimal-string field, compared by value; `None` for null. fn dec(v: &Value, key: &str) -> Option { @@ -939,146 +939,252 @@ fn dec(v: &Value, key: &str) -> Option { } } -fn tax_basis_lines(m: &Mcp) -> usize { +fn basis_lines(m: &Mcp) -> usize { m.journal_types() .iter() - .filter(|t| *t == "asset.tax_basis_set") + .filter(|t| *t == "asset.basis_set") .count() } -/// books.set_asset_tax_basis by tag and by id; the output reports rule, -/// contributor basis, tax basis, and built-in gain; each call journals. +/// books.set_asset_tax_basis by tag and by id; the output names each +/// figure for what it is, with its authority; each call journals an +/// asset.basis_set; the old rule names are aliases, noted. #[test] -fn set_asset_tax_basis_by_tag_or_id_reports_the_basis() { +fn set_asset_tax_basis_by_tag_or_id_reports_each_figure() { let mut m = books("tax-set"); let (a, t) = m.register_ok(json!({})); - assert_eq!(m.asset(a)["tax"], Value::Null, "not recorded yet"); + assert_eq!(m.asset(a)["basis"], Value::Null, "not recorded yet"); - // By tag (any case): carryover of the member's $900 basis. + // By tag (any case): the member's $900 basis in a $1,200 laptop from + // business use. let r = m.ok( "books.set_asset_tax_basis", - json!({"asset": t.to_ascii_lowercase(), "rule": "carryover", "contributor_basis": "900.00"}), + json!({"asset": t.to_ascii_lowercase(), "rule": "carryover_723", + "contributor_adjusted_basis": "900.00", "fmv_at_contribution": "1200.00", + "sec704c_method": "traditional", + "basis_source": {"decided_by": "Morgan Ellis", "decided_on": "2026-10-20", + "citation": "entry 1"}}), ); - assert_eq!(r["rule"], "carryover"); - assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("900")); - assert_eq!(dec(&r, "tax_basis").as_deref(), Some("900")); + assert_eq!(r["asset_id"], json!(a)); + assert!(r.get("notes").is_none(), "{r}"); + let b = &r["basis"]; + assert_eq!(b["rule"], "carryover_723"); + assert_eq!(dec(b, "contributor_adjusted_basis").as_deref(), Some("900")); + assert_eq!(dec(b, "fmv_at_contribution").as_deref(), Some("1200")); + assert_eq!(b["fmv_recorded"], true); + assert_eq!(dec(b, "tax_basis_723").as_deref(), Some("900")); + assert_eq!(dec(b, "depreciable_basis").as_deref(), Some("900")); + assert_eq!(dec(b, "built_in_gain_704c").as_deref(), Some("300")); + assert!(b.get("sec704c_1c_note").is_none(), "a gain has no note"); + assert_eq!(b["sec704c_method"], "traditional"); + assert_eq!(b["source"]["decided_by"], "Morgan Ellis"); + assert_eq!(b["sources"]["tax_basis_723"], "IRC §723"); + assert_eq!(b["sources"]["depreciable_basis"], "IRC §723"); assert_eq!( - dec(&r, "built_in_gain").as_deref(), - Some("300"), - "1200 − 900" + b["sources"]["built_in_gain_704c"], + "Treas. Reg. §1.704-3(a)(3)(ii)" ); - assert_eq!(tax_basis_lines(&m), 1); - - // By id: personal-use conversion, member paid $2,000 for a $1,200 laptop. - let r = m.ok( - "books.set_asset_tax_basis", - json!({"asset": a.to_string(), "rule": "personal_use_conversion", "contributor_basis": "2000.00"}), - ); - assert_eq!(r["rule"], "personal_use_conversion"); - assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("2000")); - assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"), "the lesser"); + assert_eq!(basis_lines(&m), 1); assert_eq!( - dec(&r, "built_in_gain").as_deref(), - Some("-800"), - "a built-in loss" + m.journal_types() + .iter() + .filter(|t| *t == "journal.format_set") + .count(), + 1, + "the feature declared once" ); - assert_eq!(tax_basis_lines(&m), 2); - // Boundary: value equals basis. + // By id: personal use, the member's $2,000 laptop worth $1,200. let r = m.ok( "books.set_asset_tax_basis", - json!({"asset": t, "rule": "personal_use_conversion", "contributor_basis": "1200.00"}), + json!({"asset": a.to_string(), "rule": "personal_use_conversion", + "contributor_adjusted_basis": "2000.00", "fmv_at_contribution": "1200.00"}), ); - assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200")); - assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("0")); + let b = &r["basis"]; + assert_eq!(dec(b, "tax_basis_723").as_deref(), Some("2000")); + assert_eq!( + dec(b, "depreciable_basis").as_deref(), + Some("1200"), + "the lesser" + ); + assert_eq!(dec(b, "built_in_gain_704c").as_deref(), Some("-800")); + assert!( + b["sec704c_1c_note"] + .as_str() + .unwrap() + .contains("IRC §704(c)(1)(C)"), + "{b}" + ); + assert_eq!( + b["sources"]["depreciable_basis"], + "Treas. Reg. §1.168(i)-4(b)" + ); + assert_eq!(b["sec704c_method"], Value::Null); - // Cost: the book cost, no contributor basis, no built-in gain. + // A purchase: the book cost, nothing contributed. + let r = m.ok( + "books.set_asset_tax_basis", + json!({"asset": t, "rule": "purchase"}), + ); + let b = &r["basis"]; + assert_eq!(b["rule"], "purchase"); + assert_eq!(b["contributor_adjusted_basis"], Value::Null); + assert_eq!(b["tax_basis_723"], Value::Null); + assert_eq!(dec(b, "depreciable_basis").as_deref(), Some("1200")); + assert_eq!(b["built_in_gain_704c"], Value::Null); + assert_eq!(b["sources"]["depreciable_basis"], "IRC §1012"); + assert_eq!(basis_lines(&m), 3); + + // list_assets shows the latest record. + let basis = m.asset(a)["basis"].clone(); + assert_eq!(basis["rule"], "purchase"); +} + +/// The old names (rule cost, carryover; contributor_basis) are aliases for +/// one release, noted in the result; a name and its alias that disagree +/// are refused. +#[test] +fn basis_rule_legacy_names_are_aliases() { + let mut m = books("tax-alias"); + let (_, t) = m.register_ok(json!({})); + let r = m.ok( + "books.set_asset_tax_basis", + json!({"asset": t, "rule": "carryover", "contributor_basis": "900.00", + "fmv_at_contribution": "1200.00"}), + ); + assert_eq!(r["basis"]["rule"], "carryover_723"); + let notes = r["notes"].to_string(); + assert!( + notes.contains("contributor_basis is now contributor_adjusted_basis"), + "{notes}" + ); + assert!( + notes.contains("\\\"carryover\\\" is now \\\"carryover_723\\\""), + "{notes}" + ); let r = m.ok( "books.set_asset_tax_basis", json!({"asset": t, "rule": "cost"}), ); - assert_eq!(r["rule"], "cost"); - assert_eq!(r["contributor_basis"], Value::Null); - assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200")); - assert_eq!(r["built_in_gain"], Value::Null); - assert_eq!(tax_basis_lines(&m), 4); - - // list_assets shows the latest record. - let tax = m.asset(a)["tax"].clone(); - assert_eq!(tax["rule"], "cost"); - assert_eq!(dec(&tax, "tax_basis").as_deref(), Some("1200")); + assert_eq!(r["basis"]["rule"], "purchase"); + let why = m.refuse( + "books.set_asset_tax_basis", + json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "1.00", + "contributor_basis": "2.00", "fmv_at_contribution": "3.00"}), + ); + assert!(why.contains("disagree"), "{why}"); + let (_, t2) = m.register_ok(json!({"tax_basis_rule": "carryover", + "contributor_basis": "400.00", "fmv_at_contribution": "1200.00"})); + assert_eq!(m.assets().len(), 2); + let _ = t2; } -/// Refusals: unknown asset, bad rule, cost with a basis, a contribution -/// without one, negative, finer than cents, not a decimal string. Each -/// leaves the journal byte-identical (the harness checks) and the record. +/// A contribution's fair market value is a fact the human gives, never +/// assumed from the cost (review R11): without it the call is refused. +#[test] +fn fmv_is_required_for_a_contribution() { + let mut m = books("tax-fmv"); + let (_, t) = m.register_ok(json!({})); + for rule in ["carryover_723", "personal_use_conversion"] { + let why = m.refuse( + "books.set_asset_tax_basis", + json!({"asset": t, "rule": rule, "contributor_adjusted_basis": "900.00"}), + ); + assert!(why.contains("fmv_at_contribution"), "{why}"); + let out = m.register(json!({"basis_rule": rule, "contributor_adjusted_basis": "1.00"})); + assert!(out.refused(), "{out:?}"); + } + assert_eq!(m.assets().len(), 1, "nothing registered"); + assert_eq!(basis_lines(&m), 0); +} + +/// Refusals: unknown asset, bad rule or method, a purchase with +/// contribution facts, a contribution without its basis, negative, finer +/// than cents, not a decimal string. Each leaves the journal byte-identical +/// (the harness checks) and the record. #[test] fn set_asset_tax_basis_refusals_change_nothing() { let mut m = books("tax-refuse"); let (a, t) = m.register_ok(json!({})); m.ok( "books.set_asset_tax_basis", - json!({"asset": t, "rule": "carryover", "contributor_basis": "500.00"}), + json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "500.00", + "fmv_at_contribution": "1200.00"}), ); - let before = m.asset(a)["tax"].clone(); + let before = m.asset(a)["basis"].clone(); + let c = |extra: Value| { + let mut v = json!({"asset": t, "rule": "carryover_723", + "contributor_adjusted_basis": "500.00", "fmv_at_contribution": "1200.00"}); + for (k, x) in extra.as_object().unwrap() { + v[k] = x.clone(); + } + v + }; for args in [ - json!({"asset": "NOPE-1", "rule": "cost"}), - json!({"asset": "999", "rule": "cost"}), + json!({"asset": "NOPE-1", "rule": "purchase"}), + json!({"asset": "999", "rule": "purchase"}), json!({"asset": t, "rule": "fmv"}), - json!({"asset": t, "rule": "Cost"}), + json!({"asset": t, "rule": "Purchase"}), json!({"asset": t, "rule": ""}), - json!({"asset": t, "rule": "cost", "contributor_basis": "100.00"}), - json!({"asset": t, "rule": "carryover"}), - json!({"asset": t, "rule": "personal_use_conversion"}), - json!({"asset": t, "rule": "carryover", "contributor_basis": "-1.00"}), - json!({"asset": t, "rule": "carryover", "contributor_basis": "1.001"}), - json!({"asset": t, "rule": "carryover", "contributor_basis": "lots"}), - json!({"asset": t, "rule": "carryover", "contributor_basis": "1,000.00"}), + json!({"asset": t, "rule": "purchase", "contributor_adjusted_basis": "100.00"}), + json!({"asset": t, "rule": "purchase", "fmv_at_contribution": "100.00"}), + json!({"asset": t, "rule": "purchase", "sec704c_method": "traditional"}), + json!({"asset": t, "rule": "carryover_723", "fmv_at_contribution": "1.00"}), + c(json!({"contributor_adjusted_basis": "-1.00"})), + c(json!({"contributor_adjusted_basis": "1.001"})), + c(json!({"fmv_at_contribution": "lots"})), + c(json!({"fmv_at_contribution": "1,000.00"})), + c(json!({"sec704c_method": "ceiling"})), + c(json!({"basis_source": {"decided_by": " "}})), ] { m.refuse("books.set_asset_tax_basis", args); } - assert_eq!(m.asset(a)["tax"], before); - assert_eq!(tax_basis_lines(&m), 1); + assert_eq!(m.asset(a)["basis"], before); + assert_eq!(basis_lines(&m), 1); } -/// register_asset with tax_basis_rule / contributor_basis records it at -/// registration; without them, list_assets shows tax null. +/// register_asset with basis_rule and its facts records it at +/// registration; without, list_assets shows basis null. #[test] -fn register_asset_records_a_tax_basis() { +fn register_asset_records_a_basis() { let mut m = books("tax-register"); - let (a, _) = m.register_ok(json!({"tax_basis_rule": "cost"})); - let (b, _) = m.register_ok(json!({"tax_basis_rule": "personal_use_conversion", - "contributor_basis": "1500.00"})); - let (c, _) = m.register_ok(json!({"tax_basis_rule": "carryover", - "contributor_basis": "400.00"})); + let (a, _) = m.register_ok(json!({"basis_rule": "purchase"})); + let (b, _) = m.register_ok(json!({"basis_rule": "personal_use_conversion", + "contributor_adjusted_basis": "1500.00", "fmv_at_contribution": "1200.00"})); + let (c, _) = m.register_ok(json!({"basis_rule": "carryover_723", + "contributor_adjusted_basis": "400.00", "fmv_at_contribution": "1200.00"})); let (n, _) = m.register_ok(json!({})); - let ta = m.asset(a)["tax"].clone(); - assert_eq!(ta["rule"], "cost"); - assert_eq!(ta["contributor_basis"], Value::Null); - assert_eq!(dec(&ta, "tax_basis").as_deref(), Some("1200")); - assert_eq!(ta["built_in_gain"], Value::Null); - let tb = m.asset(b)["tax"].clone(); - assert_eq!(tb["rule"], "personal_use_conversion"); - assert_eq!(dec(&tb, "contributor_basis").as_deref(), Some("1500")); - assert_eq!(dec(&tb, "tax_basis").as_deref(), Some("1200")); - assert_eq!(dec(&tb, "built_in_gain").as_deref(), Some("-300")); - let tc = m.asset(c)["tax"].clone(); - assert_eq!(tc["rule"], "carryover"); - assert_eq!(dec(&tc, "tax_basis").as_deref(), Some("400")); - assert_eq!(dec(&tc, "built_in_gain").as_deref(), Some("800")); - assert_eq!(m.asset(n)["tax"], Value::Null); + let ta = m.asset(a)["basis"].clone(); + assert_eq!(ta["rule"], "purchase"); + assert_eq!(dec(&ta, "depreciable_basis").as_deref(), Some("1200")); + let tb = m.asset(b)["basis"].clone(); + assert_eq!(dec(&tb, "tax_basis_723").as_deref(), Some("1500")); + assert_eq!(dec(&tb, "depreciable_basis").as_deref(), Some("1200")); + assert_eq!(dec(&tb, "built_in_gain_704c").as_deref(), Some("-300")); + let tc = m.asset(c)["basis"].clone(); + assert_eq!(dec(&tc, "depreciable_basis").as_deref(), Some("400")); + assert_eq!(dec(&tc, "built_in_gain_704c").as_deref(), Some("800")); + assert_eq!(m.asset(n)["basis"], Value::Null); } -/// contributor_basis without tax_basis_rule, or a bad rule, is refused -/// and registers nothing. +/// A basis fact without basis_rule, a bad rule, or a record the engine +/// refuses, given at registration, is a refused call: it registers +/// nothing (server invariant 2). #[test] -fn register_asset_refuses_a_bad_tax_basis() { +fn register_asset_refuses_a_bad_basis_and_registers_nothing() { let mut m = books("tax-register-bad"); m.register_ok(json!({})); for extra in [ - json!({"contributor_basis": "500.00"}), - json!({"tax_basis_rule": "market_value"}), - json!({"tax_basis_rule": "carryover", "contributor_basis": "five"}), + json!({"contributor_adjusted_basis": "500.00"}), + json!({"fmv_at_contribution": "500.00"}), + json!({"basis_rule": "market_value"}), + json!({"basis_rule": "carryover_723", "contributor_adjusted_basis": "five", + "fmv_at_contribution": "1.00"}), + json!({"basis_rule": "carryover_723", "fmv_at_contribution": "1.00"}), + json!({"basis_rule": "purchase", "contributor_adjusted_basis": "100.00"}), + json!({"basis_rule": "carryover_723", "contributor_adjusted_basis": "-1.00", + "fmv_at_contribution": "1.00"}), + json!({"basis_rule": "purchase", "tax_basis_rule": "carryover"}), ] { let out = m.register(extra.clone()); assert!(out.refused(), "{extra}: {out:?}"); @@ -1086,59 +1192,187 @@ fn register_asset_refuses_a_bad_tax_basis() { assert_eq!(m.assets().len(), 1, "nothing registered"); } -/// A record the engine refuses (Ledger::set_asset_tax_basis INVARIANTS -/// 1–2) given at registration is a refused call, so (server invariant 2) -/// it registers nothing either. +// ------------------------------------------- the dates (rules.md B-15) + +/// purchase_date is an alias for in_service_date for one release, noted. #[test] -fn register_asset_with_a_basis_the_engine_refuses_registers_nothing() { - let mut m = books("tax-register-engine"); - for extra in [ - json!({"tax_basis_rule": "carryover"}), - json!({"tax_basis_rule": "personal_use_conversion"}), - json!({"tax_basis_rule": "cost", "contributor_basis": "100.00"}), - json!({"tax_basis_rule": "carryover", "contributor_basis": "-1.00"}), - json!({"tax_basis_rule": "carryover", "contributor_basis": "1.001"}), - ] { - let out = m.register(extra.clone()); - assert!(out.refused(), "{extra}: {out:?}"); - } - assert!(m.assets().is_empty(), "nothing registered"); +fn purchase_date_is_an_alias_for_in_service_date() { + let mut m = books("alias-date"); + let r = match m.register(json!({"in_service_date": null, "purchase_date": "2026-02-01"})) { + Out::Ok(v) => v, + other => panic!("{other:?}"), + }; + assert_eq!(r["in_service_date"], "2026-02-01"); + assert_eq!(r["acquired_date"], "2026-02-01"); + assert!( + r["notes"] + .to_string() + .contains("purchase_date is now in_service_date"), + "{r}" + ); + let a = m.asset(r["asset_id"].as_u64().unwrap()); + assert_eq!(a["in_service_date"], "2026-02-01"); + assert!(a.get("purchase_date").is_none(), "{a}"); } -/// Every object anywhere in `v` that has `key`. -fn objects_with<'a>(v: &'a Value, key: &str, out: &mut Vec<&'a Value>) { - match v { - Value::Object(m) => { - if m.contains_key(key) { - out.push(v); - } - m.values().for_each(|c| objects_with(c, key, out)); - } - Value::Array(a) => a.iter().for_each(|c| objects_with(c, key, out)), - _ => {} - } +/// Both given with different dates: refused, nothing registered; neither: +/// refused. +#[test] +fn purchase_date_and_a_different_in_service_date_are_refused() { + let mut m = books("alias-differ"); + let out = m.register(json!({"in_service_date": "2026-01-01", "purchase_date": "2026-02-01"})); + assert!(out.refused(), "{out:?}"); + let out = m.register(json!({"in_service_date": null})); + assert!(out.refused(), "{out:?}"); + assert!(m.assets().is_empty()); + m.register_ok(json!({"in_service_date": "2026-01-01", "purchase_date": "2026-01-01"})); } -/// The fixed_asset_register export carries each asset's tax basis rule, -/// contributor basis, tax basis, and built-in gain; JSON also the total -/// tax basis of held assets that have one and how many held assets have -/// none. A disposed asset is out of the totals. +/// The acquired date is the acquisition entry's (an asset bought before it +/// is in service); an entry dated after the in-service date is refused, +/// registering nothing. #[test] -fn export_fixed_asset_register_carries_tax_basis() { - let mut m = books("tax-export"); +fn acquired_date_comes_from_the_acquisition_entry() { + let mut m = books("acquired"); + m.post( + "2026-01-10", + &[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")], + ); + let entry = 1; + let r = match m.register(json!({"in_service_date": "2026-02-01", "acquisition_entry": entry})) { + Out::Ok(v) => v, + other => panic!("{other:?}"), + }; + assert_eq!(r["acquired_date"], "2026-01-10"); + assert_eq!(r["in_service_date"], "2026-02-01"); + let a = m.asset(r["asset_id"].as_u64().unwrap()); + assert_eq!(a["acquired_date"], "2026-01-10"); + let why = match m.register(json!({"in_service_date": "2026-01-05", "acquisition_entry": entry})) + { + Out::Ok(v) => panic!("registered {v}"), + other => other.err_text(), + }; + assert!(why.contains("after the in-service date"), "{why}"); + assert_eq!(m.assets().len(), 1); + // correct_asset: never before the acquisition entry; in_service the + // old name of in_service_date. + let id = r["asset_id"].as_u64().unwrap(); + let why = m.refuse( + "books.correct_asset", + json!({"asset": id.to_string(), "in_service_date": "2026-01-09", "reason": "typo"}), + ); + assert!(why.contains("before it was acquired"), "{why}"); + let c = m.ok( + "books.correct_asset", + json!({"asset": id.to_string(), "in_service": "2026-01-15", "reason": "typo"}), + ); + assert_eq!(c["in_service_date"], "2026-01-15"); + assert_eq!(c["acquired_date"], "2026-01-10"); + assert!( + c["notes"] + .to_string() + .contains("in_service is now in_service_date") + ); +} + +// ------------------------------- posted and scheduled (rules.md B-24) + +/// Named regression 1 (the brief's reproduction 1, its figures made up): a +/// $1,440.00 laptop, 36 months, in service 2026-05-15, nothing run. As of +/// 2026-05-20 the schedule has 1,440/36 × 6/31 = 7.74 due, the books carry +/// nothing: posted 0.00, scheduled 7.74, unposted 7.74, book value +/// 1,440.00 (the tool called 1,432.26 book value before P3). May's run is +/// dated 05-31: as of 05-20 still 0.00 posted; as of 05-31 posted = +/// scheduled = 1,440/36 × 17/31 = 21.94. +#[test] +fn list_assets_book_value_is_what_the_books_carry() { + let mut m = books("book-value"); + let (id, _) = m.register_ok(json!({"in_service_date": "2026-05-15", "cost": "1440.00", + "useful_life_months": 36})); + let at = |m: &mut Mcp, as_of: &str| -> Value { + let r = m.read_only("books.list_assets", json!({"as_of": as_of})); + assert_eq!(r["as_of"], as_of); + r["assets"] + .as_array() + .unwrap() + .iter() + .find(|a| a["asset_id"] == json!(id)) + .unwrap() + .clone() + }; + let a = at(&mut m, "2026-05-20"); + assert_eq!(a["held"], true); + assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0")); + assert_eq!( + dec(&a, "accumulated_scheduled_through").as_deref(), + Some("7.74") + ); + assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("7.74")); + assert_eq!(dec(&a, "book_value").as_deref(), Some("1440")); + assert!(a.get("accumulated").is_none(), "{a}"); + m.ok( + "books.run_depreciation", + json!({"period": "2026-05", "asset_id": id}), + ); + let a = at(&mut m, "2026-05-20"); + assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0")); + let a = at(&mut m, "2026-05-31"); + assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("21.94")); + assert_eq!( + dec(&a, "accumulated_scheduled_through").as_deref(), + Some("21.94") + ); + assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("0")); + assert_eq!(dec(&a, "book_value").as_deref(), Some("1418.06")); + // Before it was acquired: not held, no figures. + let a = at(&mut m, "2026-05-14"); + assert_eq!(a["held"], false); + assert_eq!(a["book_value"], Value::Null); + assert_eq!(a["accumulated_posted"], Value::Null); +} + +/// A run reversed is no longer posted (named regression 2, its posted +/// figure): the books' accumulated depreciation and book value go back, +/// the scheduled figure stays, and the difference is due again. +#[test] +fn a_reversed_run_is_not_posted() { + let mut m = books("reversed-run"); + let (id, _) = m.register_ok(json!({})); + let run = m.ok( + "books.run_depreciation", + json!({"period": "2026-01", "asset_id": id}), + ); + let entry = run["runs"][0]["entry_id"].as_u64().unwrap(); + let at = |m: &mut Mcp| -> Value { + m.read_only("books.list_assets", json!({"as_of": "2026-01-31"}))["assets"][0].clone() + }; + assert_eq!( + dec(&at(&mut m), "accumulated_posted").as_deref(), + Some("100") + ); + m.ok( + "books.reverse_entry", + json!({"entry_id": entry, "date": "2026-01-31", "description": "undo", "confirmed": true}), + ); + let a = at(&mut m); + assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0")); + assert_eq!( + dec(&a, "accumulated_scheduled_through").as_deref(), + Some("100") + ); + assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("100")); + assert_eq!(dec(&a, "book_value").as_deref(), Some("1200")); +} + +/// A disposed asset is not held: every figure 0.00 once it left the books. +#[test] +fn a_disposed_asset_has_left_the_books() { + let mut m = books("disposed-list"); m.post( "2026-01-01", &[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")], ); - m.register_ok(json!({"tag": "BOUGHT-1", "tax_basis_rule": "cost"})); - m.register_ok(json!({"tag": "CARRY-1", "tax_basis_rule": "carryover", - "contributor_basis": "650.25"})); - m.register_ok( - json!({"tag": "CONV-1", "tax_basis_rule": "personal_use_conversion", - "contributor_basis": "2000.00"}), - ); - m.register_ok(json!({"tag": "NONE-1"})); - let (_, sold) = m.register_ok(json!({"tag": "SOLD-1", "tax_basis_rule": "cost"})); + let (id, sold) = m.register_ok(json!({})); let doc = m.file_bill_of_sale(); m.ok( "books.dispose_asset", @@ -1146,7 +1380,57 @@ fn export_fixed_asset_register_carries_tax_basis() { "proceeds_account": "1000", "gain_loss_account": "7100", "confirmed": true, "receipt_attachment_ids": [doc]}), ); + let a = m.asset(id); + assert_eq!(a["status"], "disposed"); + assert_eq!(a["held"], false); + for k in [ + "accumulated_posted", + "accumulated_scheduled_through", + "unposted_depreciation", + "book_value", + ] { + assert_eq!(dec(&a, k).as_deref(), Some("0"), "{k}"); + } +} +/// The fixed_asset_register export carries, per asset, its acquired and +/// in-service dates, accumulated depreciation scheduled and posted, book +/// value (cost less posted), and the basis facts and figures; JSON the +/// totals and the three differences. A disposed asset is out of the +/// totals. By hand, as of 2026-06-30: held with a basis, depreciable +/// BOUGHT-1 1,200.00, CARRY-1 650.25 and CONV-1 1,200.00, together +/// 3,050.25; §723 650.25 and 2,000.00, together 2,650.25; BOUGHT-1 ran +/// January: posted 100.00 of 600.00 scheduled. +#[test] +fn export_fixed_asset_register_carries_posted_scheduled_and_704c() { + let mut m = books("tax-export"); + m.post( + "2026-01-01", + &[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")], + ); + let (bought, _) = m.register_ok(json!({"tag": "BOUGHT-1", "basis_rule": "purchase"})); + m.register_ok(json!({"tag": "CARRY-1", "basis_rule": "carryover_723", + "contributor_adjusted_basis": "650.25", "fmv_at_contribution": "1200.00", + "sec704c_method": "traditional"})); + m.register_ok( + json!({"tag": "CONV-1", "basis_rule": "personal_use_conversion", + "contributor_adjusted_basis": "2000.00", "fmv_at_contribution": "1200.00", + "basis_source": {"decided_by": "Tobin Harlow", "decided_on": "2026-10-20", + "citation": "entry 9"}}), + ); + m.register_ok(json!({"tag": "NONE-1"})); + let (_, sold) = m.register_ok(json!({"tag": "SOLD-1", "basis_rule": "purchase"})); + m.ok( + "books.run_depreciation", + json!({"period": "2026-01", "asset_id": bought}), + ); + let doc = m.file_bill_of_sale(); + m.ok( + "books.dispose_asset", + json!({"tag": sold, "date": "2026-03-31", "proceeds": "100.00", + "proceeds_account": "1000", "gain_loss_account": "7100", + "confirmed": true, "receipt_attachment_ids": [doc]}), + ); let export = |m: &mut Mcp, format: &str| -> String { m.read_only( "books.export", @@ -1156,95 +1440,101 @@ fn export_fixed_asset_register_carries_tax_basis() { .unwrap() .to_string() }; - - // JSON. let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap(); - let mut rows = Vec::new(); - objects_with(&j, "tax_basis_rule", &mut rows); + let rows = j["assets"].as_array().unwrap(); assert_eq!(rows.len(), 5, "{j}"); - let row = |tag: &str| -> Value { - (*rows - .iter() - .find(|r| r["tag"] == tag) - .unwrap_or_else(|| panic!("{tag} row"))) - .clone() - }; + let row = |tag: &str| rows.iter().find(|r| r["tag"] == tag).unwrap().clone(); let r = row("BOUGHT-1"); - assert_eq!(r["tax_basis_rule"], "cost"); - assert_eq!(r["contributor_basis"], Value::Null); - assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200")); - assert_eq!(r["built_in_gain"], Value::Null); + assert_eq!(r["basis_rule"], "purchase"); + assert_eq!(dec(&r, "accumulated_scheduled").as_deref(), Some("600")); + assert_eq!(dec(&r, "accumulated_posted").as_deref(), Some("100")); + assert_eq!(dec(&r, "book_value").as_deref(), Some("1100")); + assert_eq!(dec(&r, "depreciable_basis").as_deref(), Some("1200")); + assert_eq!(r["acquired_date"], "2026-01-01"); + assert_eq!(r["in_service_date"], "2026-01-01"); let r = row("CARRY-1"); - assert_eq!(r["tax_basis_rule"], "carryover"); - assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("650.25")); - assert_eq!(dec(&r, "tax_basis").as_deref(), Some("650.25")); - assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("549.75")); + assert_eq!(dec(&r, "tax_basis_723").as_deref(), Some("650.25")); + assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("549.75")); + assert_eq!(r["sec704c_method"], "traditional"); + assert_eq!(r["fmv_recorded"], true); let r = row("CONV-1"); - assert_eq!(r["tax_basis_rule"], "personal_use_conversion"); - assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("2000")); - assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200")); - assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("-800")); - let r = row("NONE-1"); - assert_eq!(r["tax_basis_rule"], Value::Null); - assert_eq!(r["tax_basis"], Value::Null); - let r = row("SOLD-1"); - assert_eq!(r["tax_basis_rule"], "cost"); - // Held with a basis: 1200 + 650.25 + 1200; SOLD-1 out, NONE-1 missing. - assert_eq!(the_value(&j, "total_tax_basis"), "3050.25"); - let mut missing = Vec::new(); - find_key(&j, "missing_tax_basis", &mut missing); - assert_eq!(missing, vec![&json!(1)]); + assert_eq!(dec(&r, "depreciable_basis").as_deref(), Some("1200")); + assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("-800")); + assert_eq!(r["sec704c_1c"], true); + assert_eq!(r["basis_source"], "Tobin Harlow, 2026-10-20: entry 9"); + assert_eq!(row("NONE-1")["basis_rule"], Value::Null); + assert_eq!(the_value(&j, "total_depreciable_basis"), "3050.25"); + assert_eq!(the_value(&j, "total_tax_basis_723"), "2650.25"); + assert_eq!(the_value(&j, "total_accumulated_posted"), "100"); + assert_eq!(the_value(&j, "unattributed_accumulated"), "0"); + assert_eq!(j["missing_basis"], json!(1)); + assert!( + j["tax_figures_are"] + .as_str() + .unwrap() + .contains("at contribution") + ); - // CSV: the four tax columns, by header. + // CSV: by header. let csv = export(&mut m, "csv"); let mut lines = csv.lines(); let header: Vec = lines .next() .unwrap() .split(',') - .map(|h| h.trim().to_ascii_lowercase()) + .map(str::to_string) .collect(); - let col = |name: &str| { - header - .iter() - .position(|h| h == name) - .unwrap_or_else(|| panic!("{name} column in {header:?}")) - }; - let (c_tag, c_rule, c_basis, c_tax, c_gain) = ( - col("tag"), - col("tax_basis_rule"), - col("contributor_basis"), - col("tax_basis"), - col("built_in_gain"), + assert_eq!( + header, + [ + "tag", + "description", + "serial", + "acquired", + "in_service", + "cost", + "salvage", + "useful_life_months", + "accumulated_scheduled", + "accumulated_posted", + "book_value", + "status", + "proceeds", + "basis_rule", + "contributor_adjusted_basis", + "fmv_at_contribution", + "fmv_recorded", + "tax_basis_723", + "depreciable_basis", + "built_in_gain_704c", + "sec704c_method", + "basis_source" + ] ); + let col = |name: &str| header.iter().position(|h| h == name).unwrap(); let body: Vec> = lines .map(|l| l.split(',').map(str::to_string).collect()) .collect(); let csv_row = |tag: &str| { body.iter() - .find(|r| r.get(c_tag).map(String::as_str) == Some(tag)) + .find(|r| r.first().map(String::as_str) == Some(tag)) .unwrap_or_else(|| panic!("{tag} in {csv}")) .clone() }; let r = csv_row("CARRY-1"); - assert_eq!(r[c_rule], "carryover"); - assert_eq!(canon(&r[c_basis]), "650.25"); - assert_eq!(canon(&r[c_tax]), "650.25"); - assert_eq!(canon(&r[c_gain]), "549.75"); - let r = csv_row("CONV-1"); - assert_eq!(r[c_rule], "personal_use_conversion"); - assert_eq!(canon(&r[c_tax]), "1200"); - assert_eq!(canon(&r[c_gain]), "-800"); + assert_eq!(r[col("basis_rule")], "carryover_723"); + assert_eq!(canon(&r[col("tax_basis_723")]), "650.25"); + assert_eq!(canon(&r[col("built_in_gain_704c")]), "549.75"); + assert_eq!(r[col("fmv_recorded")], "true"); let r = csv_row("BOUGHT-1"); - assert_eq!(r[c_rule], "cost"); - assert_eq!(r[c_basis], ""); - assert_eq!(canon(&r[c_tax]), "1200"); - assert_eq!(r[c_gain], ""); - let r = csv_row("NONE-1"); - assert_eq!((r[c_rule].as_str(), r[c_tax].as_str()), ("", "")); - // The TOTAL row's tax basis column is the total tax basis. + assert_eq!(canon(&r[col("accumulated_posted")]), "100"); + assert_eq!(canon(&r[col("book_value")]), "1100"); + assert_eq!(r[col("contributor_adjusted_basis")], ""); let total = csv_row("TOTAL"); - assert_eq!(canon(&total[c_tax]), "3050.25"); + assert_eq!(total.len(), header.len()); + assert_eq!(canon(&total[col("depreciable_basis")]), "3050.25"); + assert_eq!(canon(&total[col("tax_basis_723")]), "2650.25"); + assert_eq!(canon(&total[col("accumulated_posted")]), "100"); } // ------------------------------------- P3: defect 22 and runs, over MCP @@ -1279,9 +1569,10 @@ fn dispose_args(x: u64, date: &str, proceeds: &str) -> Value { /// Defect 22, reproduction B over MCP: January through May run, then a /// disposal on 2026-06-15 for 7,000.00. Hand check: June held 15 of 30 /// days, 500.00 (the final depreciation); accumulated 5,500.00, book value -/// 6,500.00, gain 500.00. Afterwards books.asset_schedule ends there and -/// books.list_assets says the same. Before P3 the schedule showed June -/// 7,000.00, accumulated 12,000.00, book value 0.00. +/// 6,500.00, gain 500.00. Afterwards books.asset_schedule ends there, and +/// books.list_assets lists it as gone: not held, every figure 0.00 (it +/// left the books with its disposal; rules.md B-15 rule 8). Before P3 the +/// schedule showed June 7,000.00, accumulated 12,000.00, book value 0.00. #[test] fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() { let (mut m, x) = asset_x("disposed-figures"); @@ -1313,8 +1604,15 @@ fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() { assert_eq!(canon(jun["accumulated"].as_str().unwrap()), "5500"); assert_eq!(canon(jun["book_value"].as_str().unwrap()), "6500"); let listed = m.asset(x); - assert_eq!(canon(listed["accumulated"].as_str().unwrap()), "5500"); - assert_eq!(canon(listed["book_value"].as_str().unwrap()), "6500"); + assert_eq!(listed["held"], false); + for figure in [ + "accumulated_posted", + "accumulated_scheduled_through", + "unposted_depreciation", + "book_value", + ] { + assert_eq!(canon(listed[figure].as_str().unwrap()), "0", "{figure}"); + } assert_eq!(listed["schedule_error"], Value::Null); } diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 0013609..787ed3c 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -1013,7 +1013,7 @@ fn happy_path(name: &str) -> Mcp { ); m.ok( "books.set_asset_tax_basis", - json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00"}), + json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00", "fmv_at_contribution": "3000.00"}), ); m.ok( "books.correct_asset", diff --git a/crates/ledger-package/src/iif.rs b/crates/ledger-package/src/iif.rs index a69c5f8..7833a2f 100644 --- a/crates/ledger-package/src/iif.rs +++ b/crates/ledger-package/src/iif.rs @@ -39,7 +39,7 @@ pub fn export_iif( use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif}; let fixed: std::collections::HashSet = ledger .registered_assets() - .filter(|a| a.purchase_date <= as_of) + .filter(|a| ledger.asset_acquired_date(a.id).is_some_and(|d| d <= as_of)) .flat_map(|a| [a.cost_account, a.accum_depr_account]) .collect(); let receivables = ledger.role_account(&ledger_core::RECEIVABLES); diff --git a/crates/ledger-package/src/k1_tax.rs b/crates/ledger-package/src/k1_tax.rs new file mode 100644 index 0000000..fc637ad --- /dev/null +++ b/crates/ledger-package/src/k1_tax.rs @@ -0,0 +1,233 @@ +//! The K-1 worksheet on the tax basis: `k1-tax-basis-capital.csv`, each +//! member's Item L capital account on the tax basis beside the book +//! figures of `k1-members.csv`, with the reconciliation between them. +//! +//! ```text +//! GOAL: hand the CPA Item L on the tax basis as far as the books know it, +//! line by line beside the book figures, each figure the books can't +//! know left blank and named, never guessed. +//! SOURCE: ledger_core::tax_capital (INVARIANTS 1-5); Instructions for Form +//! 1065 (2025), Item L, "Tax-basis method" (rules.md B-36); +//! DECISION D-41 (owner, 2026-10-20). +//! INVARIANTS: +//! 1. Per member, in order: the seven Item L lines (beginning; capital +//! contributed, cash and property; current year net income (loss); +//! other increase (decrease); withdrawals and distributions; ending), +//! each with its book figure, its tax-basis figure (blank while open), +//! book less tax (blank while open), its status ("tax basis" or +//! "open"), and a note: for an open line, what the books know so far +//! and every figure the CPA supplies, with the book amount it +//! replaces. +//! 2. Then the reconciliation of book to tax capital, by cause, ending +//! with "book ending less tax ending" (tax_capital INVARIANT 5): when +//! the ending is open, it reconciles to what the books know so far. +//! 3. Every amount is exact (crate::text::money); nothing is rounded. +//! ``` + +use crate::text::{csv_escape, money}; +use ledger_core::Ledger; +use ledger_core::tax_capital::{Open, TaxCapital, TaxLine}; +use rust_decimal::Decimal; + +/// The file's name in the package. +pub const FILE_NAME: &str = "k1-tax-basis-capital.csv"; + +/// What the CPA supplies for an open item, with the book amount it +/// replaces. +pub fn open_text(ledger: &Ledger, o: &Open) -> String { + let asset = |id: u64| { + ledger + .get_asset(id) + .and_then(|a| a.tag.clone()) + .unwrap_or_else(|| format!("asset {id}")) + }; + match o { + Open::PriorYears => { + "last year's ending capital on the tax basis (Item L of last year's return)".into() + } + Open::PropertyBasis { entry, book } => format!( + "the adjusted basis of property contributed in entry {entry} with no tax basis in the books ({} at book; record it on its asset with books.set_asset_tax_basis)", + money(*book) + ), + Open::LinkedValueExceedsEntry { + entry, + linked, + property, + } => format!( + "the basis of the property contributed in entry {entry}: the assets linked to it are valued at {}, more than the {} of property it contributed (correct the link or the values)", + money(*linked), + money(*property) + ), + Open::PropertyDistribution { entry, book } => format!( + "the company's adjusted tax basis in property distributed in entry {entry} ({} at book)", + money(*book) + ), + Open::TaxDepreciation { + book, + contributed_book, + } => format!( + "tax depreciation of the fixed assets in service (MACRS, IRC §168), in place of the book depreciation posted ({} in the span, {} of it on contributed property, whose tax depreciation each property's §704(c) method allocates, Treas. Reg. §1.704-3)", + money(*book), + money(*contributed_book) + ), + Open::Disposal { + asset: id, + proceeds, + } => format!( + "the tax gain or loss on {}'s disposal (proceeds {}), from its adjusted tax basis", + asset(*id), + money(*proceeds) + ), + Open::OtherChange { book } => format!( + "the tax treatment of a {} change in book capital the books can't explain", + money(*book) + ), + } +} + +/// The worksheet (INVARIANTS 1-3). `names` are the members' names, in the +/// rows' order. +pub fn tax_basis_csv(ledger: &Ledger, names: &[String], tc: &TaxCapital) -> String { + let mut w = String::from("member,item,book,tax_basis,book_less_tax,status,note\n"); + let mut push = |name: &str, item: &str, cells: [String; 5]| { + w.push_str(&csv_escape(name)); + w.push(','); + w.push_str(&csv_escape(item)); + for c in cells { + w.push(','); + w.push_str(&csv_escape(&c)); + } + w.push('\n'); + }; + for (row, name) in tc.rows.iter().zip(names) { + let line = |book: Decimal, tax: &TaxLine, note: &str| -> [String; 5] { + match tax.tax() { + Some(t) => [ + money(book), + money(t), + money(book - t), + "tax basis".into(), + note.into(), + ], + None => { + let needs: Vec = + tax.open.iter().map(|o| open_text(ledger, o)).collect(); + [ + money(book), + String::new(), + String::new(), + "open: the CPA's".into(), + format!( + "known so far {}; needs {}", + money(tax.known), + needs.join("; ") + ), + ] + } + } + }; + let b = &row.book; + let cash = TaxLine { + known: row.contributions_cash, + open: Vec::new(), + }; + push( + name, + "Beginning capital", + line(b.beginning, &row.beginning, ""), + ); + push( + name, + "Capital contributed: cash", + line(b.contributions_cash, &cash, ""), + ); + push( + name, + "Capital contributed: property", + line( + b.contributions_property, + &row.contributions_property, + "at the property's adjusted basis (IRC §723); book: its fair market value", + ), + ); + push( + name, + "Current year net income (loss)", + line( + b.net_income, + &row.net_income, + "the book figure: the books know of no difference from it (any other, Schedule M-1, is the CPA's)", + ), + ); + push( + name, + "Other increase (decrease)", + line(b.other, &row.other, ""), + ); + push( + name, + "Withdrawals and distributions", + line( + b.distributions, + &row.distributions, + "cash, plus the adjusted tax basis of property distributed", + ), + ); + push(name, "Ending capital", line(b.ending, &row.ending, "")); + let r = &row.reconciliation; + let recon = |amount: Option, note: &str| -> [String; 5] { + [ + String::new(), + String::new(), + amount.map(money).unwrap_or_default(), + "reconciliation".into(), + match amount { + Some(_) => note.into(), + None => "too large to compute exactly".into(), + }, + ] + }; + push( + name, + "Reconciliation: beginning capital, book less tax", + recon(Some(r.beginning), ""), + ); + push( + name, + "Reconciliation: built-in gain (loss) of property contributed", + recon( + Some(r.built_in_gain_704c), + "fair market value less §723 basis at contribution, IRC §704(c) (Treas. Reg. §1.704-3(a)(3)(ii))", + ), + ); + push( + name, + "Reconciliation: property contributed with no tax basis in the books, at book", + recon( + Some(r.property_without_basis), + "its adjusted basis is the CPA's", + ), + ); + push( + name, + "Reconciliation: less property distributed, at book", + recon( + Some(-r.property_distributed), + "the company's adjusted tax basis in it is the CPA's", + ), + ); + push( + name, + "Reconciliation: book ending less tax ending", + recon( + r.total(), + if row.ending.open.is_empty() { + "" + } else { + "to the tax ending the books know so far, before the CPA's figures above" + }, + ), + ); + } + w +} diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index be930c5..0185d07 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -25,6 +25,7 @@ mod books; mod iif; +mod k1_tax; mod package; mod reports; mod text; @@ -33,6 +34,7 @@ mod vendor_1099; pub use books::PackageBooks; pub use iif::export_iif; +pub use k1_tax::{open_text, tax_basis_csv}; pub use package::{Package, PackageRequest, build_package, package_file_name}; pub use reports::{ Settings, as_of_date, bs_statement, cash_flow_for, cash_flow_statement, member_capitals, diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index f10c2e3..83608c8 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -23,9 +23,11 @@ //! share, capital account, TIN and type (or last four without a TIN //! source), and the K-1 worksheet's capital figures (ledger_core::k1) //! for the fiscal year to date; the members' total row with more than -//! one. The 1099 worksheet is books.vendor_1099's CSV -//! (crate::vendor_1099_csv), each payee's TIN and type added with a -//! TIN source. +//! one. Beside it, k1-tax-basis-capital.csv: each member's capital +//! on the tax basis (Item L), line by line beside the book figures, +//! with the reconciliation (crate::k1_tax). The 1099 worksheet is +//! books.vendor_1099's CSV (crate::vendor_1099_csv), each payee's +//! TIN and type added with a TIN source. //! 3. A TIN the source can't give never fails the package: the row says //! why, and so do the README and tin_gaps (members, and 1099 payees //! a 1099 is or may be due for). @@ -182,6 +184,7 @@ pub fn build_package( ), ]; let mut k1_csv: Option<(String, Vec)> = None; + let mut k1_tax: Option<(String, Vec)> = None; if !company.members.is_empty() { let capitals = member_capitals(ledger, &company.members)?; let me = @@ -325,6 +328,33 @@ pub fn build_package( ), }, ); + // The K-1 worksheet on the tax basis (Item L's tax-basis method, + // D-41), beside the book figures: a row per member or none. + let names: Vec = company.members.iter().map(|m| m.name.clone()).collect(); + k1_tax = Some( + match ledger_core::tax_capital::tax_basis_capital(ledger, fy, as_of, &capitals, &cash) + .map_err(|e| e.to_string()) + .and_then(|tc| { + if tc.rows.len() == names.len() { + Ok(tc) + } else { + Err(format!( + "the tax-basis worksheet has {} rows for {} members", + tc.rows.len(), + names.len() + )) + } + }) { + Ok(tc) => ( + crate::k1_tax::FILE_NAME.into(), + crate::k1_tax::tax_basis_csv(ledger, &names, &tc).into_bytes(), + ), + Err(e) => ( + "k1-tax-basis-capital-NOT-INCLUDED.txt".into(), + format!("{e}\n").into_bytes(), + ), + }, + ); } statements.push(( "tax-summary", @@ -342,9 +372,12 @@ pub fn build_package( files.push((format!("{name}.pdf"), pdf(st)?)); files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?)); } - // The fixed asset register, when an asset was bought by as_of (one - // bought later is not in it, so neither is an empty register). - if ledger.registered_assets().any(|a| a.purchase_date <= as_of) { + // The fixed asset register, when an asset was acquired by as_of (one + // acquired later is not in it, so neither is an empty register). + if ledger + .registered_assets() + .any(|a| ledger.asset_acquired_date(a.id).is_some_and(|d| d <= as_of)) + { let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?; files.push(( "fixed-asset-register.pdf".into(), @@ -513,6 +546,12 @@ pub fn build_package( if let Some(k1) = k1_csv { files.push(k1); } + let k1_tax_included = k1_tax + .as_ref() + .is_some_and(|(name, _)| name == crate::k1_tax::FILE_NAME); + if let Some(k1) = k1_tax { + files.push(k1); + } let seq = books.journal_seq(); // The journal through as_of for QuickBooks, closes left out, and what // it writes differently (INVARIANT 8). @@ -601,6 +640,11 @@ pub fn build_package( "" } ); + k.push_str(if k1_tax_included { + "k1-tax-basis-capital.csv: each member's capital account on the tax basis, as Item L reports it (Form 1065 instructions: the tax-basis method), line by line beside the book figures: cash contributed, property at its adjusted basis (IRC §723), the share of income as figured for tax, distributions at tax basis, and the reconciliation from book to tax capital (the §704(c) built-in gain or loss of contributed property). A line the books can't know (tax depreciation, a basis the books don't hold) is left blank, marked open, and says what the books know so far and what is needed.\n" + } else { + "k1-tax-basis-capital-NOT-INCLUDED.txt: why the tax-basis capital worksheet isn't here.\n" + }); let members = &company.members; for (i, m) in members.iter().enumerate() { if let Some(j) = &m.files_jointly_with diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index eae47b7..859bfc5 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -501,8 +501,16 @@ fn members_get_the_k1_worksheet_with_full_tins_only_from_a_source() { for p in [&with, &without] { let n = names(p); assert!(n.contains(&"members-equity.pdf") && n.contains(&"members-equity.xlsx")); - // The K-1 worksheet comes after the evidence, before the IIF. - assert_eq!(&n[n.len() - 2..], ["k1-members.csv", "general-journal.iif"]); + // The K-1 worksheet (book, then tax basis) comes after the + // evidence, before the IIF. + assert_eq!( + &n[n.len() - 3..], + [ + "k1-members.csv", + "k1-tax-basis-capital.csv", + "general-journal.iif" + ] + ); assert_zip_is_the_files(p); } // Capital: 5000 in each; income 4000 - 1200 - 2500 = 300, half each. @@ -1314,7 +1322,7 @@ fn books_around(dir: &Path, as_of: NaiveDate, later: bool, desk: bool) -> Books let id = s .register_asset(ledger_core::NewAsset { description: "Desk".into(), - purchase_date: d("2026-01-10"), + in_service_date: d("2026-01-10"), cost: usd("1200.00"), salvage_value: usd("0.00"), useful_life_months: 60, @@ -1352,7 +1360,7 @@ fn books_around(dir: &Path, as_of: NaiveDate, later: bool, desk: bool) -> Books let (cost, accum, expense) = (id_of(s, "1500"), id_of(s, "1510"), id_of(s, "6500")); s.register_asset(ledger_core::NewAsset { description: "Laptop".into(), - purchase_date: bought, + in_service_date: bought, cost: usd("1800.00"), salvage_value: usd("0.00"), useful_life_months: 36, @@ -2123,3 +2131,152 @@ fn the_cash_flow_carries_both_schedules() { assert!(!text.contains("Member distributions")); assert!(p.files.iter().any(|(n, _)| n == "cash-flow.pdf")); } + +/// The K-1 worksheet reports tax-basis capital (rules.md B-36; Form 1065 +/// instructions, Item L, the tax-basis method), beside the book figures, +/// with the reconciliation. By hand, per member of two at 50/50: cash in +/// 1,000.00; a laptop (worth 1,440.00, adjusted basis 1,688.40) and a +/// camera (1,300.00; 1,080.00) contributed jointly from personal use, +/// 1,370.00 each at book, 1,384.20 each at §723 basis ((1,688.40 + +/// 1,080.00) / 2), the built-in gain share (−248.40 + 220.00) / 2 = +/// −14.20; revenue 600.00 less a year of depreciation (24-month lives: +/// 720.00 + 650.00 = 1,370.00) = −770.00, −385.00 each, its tax figure +/// open (tax depreciation is the CPA's); 300.00 out each. Book ending +/// 1,685.00; tax ending as far as the books know 1,699.20; book less tax +/// −14.20. +#[test] +fn the_k1_worksheet_reports_tax_basis_capital() { + let dir = tempfile::tempdir().unwrap(); + let mut s = Store::create(&dir.path().join("books-data"), Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("1500", "Equipment", AccountType::Asset), + ("1510", "Accumulated Depreciation", AccountType::Asset), + ("3000", "Capital, Tobin", AccountType::Equity), + ("3001", "Capital, Mo", AccountType::Equity), + ("4000", "Sales", AccountType::Revenue), + ("6200", "Depreciation", AccountType::Expense), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + s.add_party("Tobin Example", &[Role::Member]).unwrap(); + s.add_party("Mo Example", &[Role::Member]).unwrap(); + post( + &mut s, + "2026-01-01", + "Cash in", + &[ + ("1000", Side::Debit, "2000.00"), + ("3000", Side::Credit, "1000.00"), + ("3001", Side::Credit, "1000.00"), + ], + ); + let devices = post( + &mut s, + "2026-01-01", + "Devices contributed", + &[ + ("1500", Side::Debit, "1440.00"), + ("1500", Side::Debit, "1300.00"), + ("3000", Side::Credit, "1370.00"), + ("3001", Side::Credit, "1370.00"), + ], + ); + let (equipment, accum, depreciation) = + (id_of(&s, "1500"), id_of(&s, "1510"), id_of(&s, "6200")); + for (name, fmv, adjusted) in [ + ("Laptop", "1440.00", "1688.40"), + ("Camera", "1300.00", "1080.00"), + ] { + let id = s + .register_asset(ledger_core::NewAsset { + description: name.into(), + in_service_date: d("2026-01-01"), + cost: usd(fmv), + salvage_value: usd("0.00"), + useful_life_months: 24, + cost_account: equipment, + accum_depr_account: accum, + expense_account: depreciation, + }) + .unwrap(); + s.set_asset_acquisition(id, devices).unwrap(); + let mut basis = ledger_core::AssetBasis::contribution( + ledger_core::BasisRule::PersonalUseConversion, + Decimal::from_str(adjusted).unwrap(), + Decimal::from_str(fmv).unwrap(), + ); + basis.sec704c_method = Some(ledger_core::Sec704cMethod::Traditional); + s.set_asset_basis(id, basis).unwrap(); + for m in 1..=12 { + s.run_depreciation(id, &format!("2026-{m:02}")).unwrap(); + } + } + post( + &mut s, + "2026-02-01", + "Sale", + &[ + ("1000", Side::Debit, "600.00"), + ("4000", Side::Credit, "600.00"), + ], + ); + post( + &mut s, + "2026-12-15", + "Draws", + &[ + ("3000", Side::Debit, "300.00"), + ("3001", Side::Debit, "300.00"), + ("1000", Side::Credit, "600.00"), + ], + ); + let company = company(true); + let cash = cash(); + let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap(); + let csv = text(&p, "k1-tax-basis-capital.csv"); + let lines: Vec<&str> = csv.lines().collect(); + assert_eq!( + lines[0], + "member,item,book,tax_basis,book_less_tax,status,note" + ); + assert_eq!(lines.len(), 1 + 2 * 12, "{csv}"); + let depreciation_note = "tax depreciation of the fixed assets in service (MACRS, IRC §168), in place of the book depreciation posted (1370.00 in the span, 1370.00 of it on contributed property, whose tax depreciation each property's §704(c) method allocates, Treas. Reg. §1.704-3)"; + for member in ["Tobin Example", "Mo Example"] { + for want in [ + format!("{member},Beginning capital,0.00,0.00,0.00,tax basis,"), + format!("{member},Capital contributed: cash,1000.00,1000.00,0.00,tax basis,"), + format!( + "{member},Capital contributed: property,1370.00,1384.20,-14.20,tax basis,at the property's adjusted basis (IRC §723); book: its fair market value" + ), + format!( + "{member},Current year net income (loss),-385.00,,,open: the CPA's,\"known so far -385.00; needs {depreciation_note}\"" + ), + format!("{member},Other increase (decrease),0.00,0.00,0.00,tax basis,"), + format!( + "{member},Withdrawals and distributions,300.00,300.00,0.00,tax basis,\"cash, plus the adjusted tax basis of property distributed\"" + ), + format!( + "{member},Ending capital,1685.00,,,open: the CPA's,\"known so far 1699.20; needs {depreciation_note}\"" + ), + format!( + "{member},\"Reconciliation: beginning capital, book less tax\",,,0.00,reconciliation," + ), + format!( + "{member},Reconciliation: built-in gain (loss) of property contributed,,,-14.20,reconciliation,\"fair market value less §723 basis at contribution, IRC §704(c) (Treas. Reg. §1.704-3(a)(3)(ii))\"" + ), + format!( + "{member},Reconciliation: book ending less tax ending,,,-14.20,reconciliation,\"to the tax ending the books know so far, before the CPA's figures above\"" + ), + ] { + assert!(lines.contains(&want.as_str()), "{want}\nnot in\n{csv}"); + } + } + let readme = text(&p, "README.txt"); + assert!( + readme.contains("k1-tax-basis-capital.csv: each member's capital account on the tax basis, as Item L reports it"), + "{readme}" + ); + // The book worksheet is the same as without assets' bases. + assert!(text(&p, "k1-members.csv").contains(",1685.00\n")); +} diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index e76757c..d17a38c 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -55,6 +55,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ VENDOR_1099_DECISIONS, MONTH_REPOSTING, SCHEDULE_CONVENTION, + ASSET_BASIS, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -106,6 +107,10 @@ pub const MONTH_REPOSTING: &str = "month_reposting"; /// months (P3 defect 9, plan/served.md D-11). pub const SCHEDULE_CONVENTION: &str = "schedule_convention"; +/// Feature: `asset.basis_set`, a new event type: an asset's basis facts +/// with the value at contribution and the §704(c) method (P3, defect 13). +pub const ASSET_BASIS: &str = "asset_basis"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -117,6 +122,7 @@ pub const EVENT_TYPES: &[&str] = &[ "account.renumbered", "account.tax_set", "asset.acquisition_linked", + "asset.basis_set", "asset.corrected", "asset.depreciation_posted", "asset.described", diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 761e4ad..730e857 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -326,15 +326,18 @@ CREATE TABLE IF NOT EXISTS entry_approvals( "#, 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. +-- 23; D-40), and assets' basis facts with their depreciation runs keyed +-- by entry (plan P3, defect 13; D-41). One migration: none of P3 had +-- shipped when they met. +-- +-- 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, @@ -356,5 +359,27 @@ CREATE TABLE IF NOT EXISTS entry_instruments( entry_id INTEGER PRIMARY KEY, instrument_id INTEGER NOT NULL ); +-- +-- An asset's basis facts beyond the rule and contributor basis +-- (asset.basis_set): the fair market value at contribution (TEXT, exact +-- decimal), the §704(c) method, and who gave the facts (JSON). tax_rule +-- keeps the rule as journaled, by its pre-P3 name or its wire name. +-- Depreciation runs keyed by their entry too: the journal can hold a +-- second run for a month whose first was reversed, and the projection +-- keeps every run, as replay does. +ALTER TABLE assets ADD COLUMN fmv_at_contribution TEXT; +ALTER TABLE assets ADD COLUMN sec704c_method TEXT; +ALTER TABLE assets ADD COLUMN basis_source_json TEXT; +CREATE TABLE asset_depreciation_v13( + asset_id INTEGER NOT NULL, + period TEXT NOT NULL, + entry_id INTEGER NOT NULL, + amount TEXT NOT NULL, + PRIMARY KEY (asset_id, period, entry_id) +); +INSERT INTO asset_depreciation_v13(asset_id, period, entry_id, amount) + SELECT asset_id, period, entry_id, amount FROM asset_depreciation; +DROP TABLE asset_depreciation; +ALTER TABLE asset_depreciation_v13 RENAME TO asset_depreciation; "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 75f7590..019cb00 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -277,8 +277,9 @@ pub struct DisposalPreview { pub period: String, } -/// A depreciation run the books carry for an asset: its row in the -/// projection, its entry in effect (not reversed). +/// A depreciation run the books carry for an asset: one the ledger +/// recorded (`Ledger::depreciation_runs`), its entry in effect (not +/// reversed). #[derive(Clone, Debug, PartialEq, Eq)] struct LiveRun { /// Its month, "YYYY-MM" as journaled. @@ -1489,7 +1490,7 @@ impl Store { } // Reversing `entry` re-instates the run, adding it back in the // reversal's month. - if let Some((now, _)) = self.depreciation_posted_entry(asset_id, period)? + if let Some((now, _)) = self.depreciation_posted_entry(asset_id, period) && now != top { return refuse(format!( @@ -1504,7 +1505,7 @@ impl Store { asset, date.year(), date.month(), - &self.depreciation_effects(asset_id)?, + &self.depreciation_effects(asset_id), )? .amount; if amount > room { @@ -1534,7 +1535,8 @@ impl Store { json!({ "id": id, "description": new.description, - "purchase_date": new.purchase_date.to_string(), + // The in-service date, as every asset.registered names it. + "purchase_date": new.in_service_date.to_string(), "cost": { "amount": new.cost.amount.to_string(), "currency": new.cost.currency.code() }, "salvage_value": { "amount": new.salvage_value.amount.to_string(), "currency": new.salvage_value.currency.code() }, "useful_life_months": new.useful_life_months, @@ -1738,8 +1740,9 @@ impl Store { } /// Correct a registered asset's cost, salvage, life, or in-service date - /// (see `Ledger::correct_asset`), with the reason. Refused once any - /// depreciation for it is posted. Journaled as `asset.corrected`, with + /// (see `Ledger::correct_asset`), with the reason. Refused while the + /// books carry any depreciation for it (a run posted and not reversed: + /// `Ledger::accumulated_posted`). Journaled as `asset.corrected`, with /// the figures it had. pub fn correct_asset( &mut self, @@ -1751,7 +1754,11 @@ impl Store { return Err(StoreError::ReasonRequired); } self.ledger.check_correct_asset(id, &fix)?; - if !self.asset_depreciation_total(id)?.is_zero() { + if !self + .ledger + .accumulated_posted(id, chrono::NaiveDate::MAX)? + .is_zero() + { return Err(StoreError::AssetDepreciationPosted(id)); } let a = self.ledger.get_asset(id).expect("checked").clone(); @@ -1764,13 +1771,14 @@ impl Store { "cost": money(&fix.cost), "salvage_value": money(&fix.salvage_value), "useful_life_months": fix.useful_life_months, - "purchase_date": fix.purchase_date.to_string(), + // The in-service date, as every asset.corrected names it. + "purchase_date": fix.in_service_date.to_string(), "reason": reason, "previous": { "cost": money(&a.cost), "salvage_value": money(&a.salvage_value), "useful_life_months": a.useful_life_months, - "purchase_date": a.purchase_date.to_string(), + "purchase_date": a.in_service_date.to_string(), }, }), )?; @@ -1779,23 +1787,46 @@ 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( + /// Record how the company got an asset and the facts its tax figures + /// follow from (see `Ledger::set_asset_basis`). Journaled as + /// `asset.basis_set {asset_id, rule, contributor_adjusted_basis, + /// fmv_at_contribution, sec704c_method, source, tax_basis_723, + /// depreciable_basis, built_in_gain_704c}`: the facts, and for the + /// line's readers the figures as derived when written (null for a + /// purchase's depreciable basis: the cost, whatever it becomes). + /// Replay reads the facts and never recomputes the figures (rules.md + /// B-28). It needs the journal feature `asset_basis`: refused, + /// journaling nothing, in books that haven't declared it (books an + /// older version wrote, until their format upgrade: B-27). + pub fn set_asset_basis( &mut self, id: AssetId, - tax: ledger_core::TaxBasis, + basis: ledger_core::AssetBasis, ) -> Result<(), StoreError> { - self.ledger.check_asset_tax_basis(id, tax)?; + self.ledger.check_asset_basis(id, &basis)?; + let mut asset = self.ledger.get_asset(id).expect("checked").clone(); + asset.basis = Some(basis.clone()); + let figures = asset.basis_figures()?.expect("a checked basis has figures"); + let dec = |d: Option| d.map(|d| d.to_string()); + self.ensure_feature(crate::format::ASSET_BASIS)?; let line = self.append_journal( - "asset.tax_basis_set", + "asset.basis_set", json!({ "asset_id": id, - "rule": tax.rule.key(), - "contributor_basis": tax.contributor_basis.map(|b| b.to_string()), + "rule": basis.rule.key(), + "contributor_adjusted_basis": dec(basis.contributor_adjusted_basis), + "fmv_at_contribution": dec(basis.fmv_at_contribution), + "sec704c_method": basis.sec704c_method.map(|m| m.key()), + "source": basis.source, + "tax_basis_723": dec(figures.tax_basis_723), + "depreciable_basis": dec(basis + .rule + .is_contribution() + .then_some(figures.depreciable_basis)), + "built_in_gain_704c": dec(figures.built_in_gain_704c), }), )?; - self.ledger.set_asset_tax_basis(id, tax)?; + self.ledger.set_asset_basis(id, basis)?; apply_line_to_sqlite(&self.conn, &line)?; Ok(()) } @@ -1854,114 +1885,48 @@ impl Store { } /// The depreciation runs the books carry for an asset: every run - /// journaled for it ([`Self::depreciation_runs_ever`]) whose entry is in - /// effect, in journal order. A reversed run is left out, so its month is - /// due again (P3 depreciation review R3); a reversed reversal counts - /// again, even after its month was run again and that run reversed (the - /// projection's row for the month names the latest run only, so it is - /// not read here: P3 review round 2). - fn live_runs(&self, asset_id: AssetId) -> Result, StoreError> { - Ok(self - .depreciation_runs_ever(asset_id)? - .into_iter() - .filter(|(entry_id, _, _)| self.ledger.in_effect(*entry_id)) - .map(|(entry_id, amount, period)| LiveRun { - period, - entry_id, - amount, + /// recorded for it (`asset.depreciation_posted`, its disposal's final + /// depreciation included: [`Ledger::depreciation_runs`]) whose entry is + /// in effect, in journal order. A reversed run is left out, so its + /// month is due again (P3 depreciation review R3); a reversed reversal + /// counts again, even after its month was run again and that run + /// reversed (P3 review round 2). + fn live_runs(&self, asset_id: AssetId) -> Vec { + self.ledger + .depreciation_runs(asset_id) + .iter() + .filter(|r| self.ledger.in_effect(r.entry)) + .map(|r| LiveRun { + period: r.period.clone(), + entry_id: r.entry, + amount: r.amount, }) - .collect()) + .collect() } /// The run the books carry for this asset and period, if any: (entry, - /// amount). - fn depreciation_posted_entry( - &self, - asset_id: AssetId, - period: &str, - ) -> Result, StoreError> { - Ok(self - .live_runs(asset_id)? - .into_iter() - .find(|r| r.period == period) - .map(|r| (r.entry_id, r.amount))) + /// amount) ([`Ledger::live_depreciation_run`]). + fn depreciation_posted_entry(&self, asset_id: AssetId, period: &str) -> Option<(u64, Decimal)> { + self.ledger + .live_depreciation_run(asset_id, period) + .map(|r| (r.entry, r.amount)) } /// Whether this asset's period was ever run, reversed or not: running /// it again writes a second `asset.depreciation_posted` for it, which an /// older version would ignore (feature `month_reposting`). - fn depreciation_period_was_run( - &self, - asset_id: AssetId, - period: &str, - ) -> Result { - Ok(self - .conn - .query_row( - "SELECT 1 FROM asset_depreciation WHERE asset_id = ? AND period = ?", - params![asset_id as i64, period], - |_| Ok(()), - ) - .optional()? - .is_some()) - } - - /// Every depreciation run journaled for an asset, reversed or not, in - /// journal order: each `asset.depreciation_posted` (its runs, and its - /// disposal's final depreciation) as (entry, amount, period). The - /// projection keeps a period's latest run only; the journal keeps each. - fn depreciation_runs_ever( - &self, - asset_id: AssetId, - ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - "SELECT payload FROM events WHERE type = 'asset.depreciation_posted' ORDER BY seq", - )?; - let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; - let mut out = Vec::new(); - for row in rows { - let p: Value = serde_json::from_str(&row?)?; - if get_u64(&p, "asset_id")? != asset_id { - continue; - } - let amount = get_str(&p, "amount")?; - let amount = amount - .parse() - .map_err(|_| StoreError::JournalCorrupt(format!("bad amount {amount}")))?; - out.push(( - get_u64(&p, "entry_id")?, - amount, - get_str(&p, "period")?.to_string(), - )); - } - Ok(out) + fn depreciation_period_was_run(&self, asset_id: AssetId, period: &str) -> bool { + self.ledger + .depreciation_runs(asset_id) + .iter() + .any(|r| r.period == period) } /// What the books carry for an asset's depreciation, entry by entry, /// each in its own month: every run and every reversal of one /// (ledger_core::depreciation_effects), as the catch-up rule reads it. - fn depreciation_effects( - &self, - asset_id: AssetId, - ) -> Result, StoreError> { - let runs: Vec<(EntryId, Decimal)> = self - .depreciation_runs_ever(asset_id)? - .into_iter() - .map(|(e, a, _)| (e, a)) - .collect(); - Ok(ledger_core::depreciation_effects(&self.ledger, &runs)) - } - - /// Sum of the depreciation the books carry for an asset (its runs in - /// effect). Summed in Rust with exact Decimal math: amounts are TEXT in - /// SQLite, so SQLite must not sum them (REAL would lose pennies). - fn asset_depreciation_total(&self, asset_id: AssetId) -> Result { - self.live_runs(asset_id)? - .iter() - .try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount)) - .ok_or_else(|| { - StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large")) - }) + fn depreciation_effects(&self, asset_id: AssetId) -> Vec { + self.ledger.depreciation_effects_of(asset_id) } /// Post one month's depreciation for one asset: what is due through the @@ -2018,7 +1983,7 @@ impl Store { .find(|l| l.period == period) .map_or(Decimal::ZERO, |l| l.amount)) }; - if let Some((entry_id, amount)) = self.depreciation_posted_entry(asset_id, period)? { + if let Some((entry_id, amount)) = self.depreciation_posted_entry(asset_id, period) { return Ok(DepreciationRun { asset_id, period: period.to_string(), @@ -2033,7 +1998,7 @@ impl Store { return Err(LedgerError::AssetAlreadyDisposed(asset_id).into()); } let due = - ledger_core::run_amount(&asset, year, month, &self.depreciation_effects(asset_id)?)?; + ledger_core::run_amount(&asset, year, month, &self.depreciation_effects(asset_id))?; if due.amount.is_zero() { let last = ledger_core::life_end(&asset)? .pred_opt() @@ -2066,16 +2031,18 @@ impl Store { let description = format!("Depreciation — {} — {period}", asset.description); let money = Money::new(amount, self.base_currency); // Every refusal before the first journaled line: the entry on a - // copy, then the journal feature a period run again needs. A - // refusal after the entry posted would leave it without the line - // that makes it the period's run. + // copy (and that it is the period's run, no more and no less: + // Ledger::check_depreciation_run), then the journal feature a + // period run again needs. A refusal after the entry posted would + // leave it without the line that makes it the period's run. self.rehearse(|l| { let e = l.try_create_entry(period_end, &description)?; l.add_line(e, asset.expense_account, Side::Debit, money)?; l.add_line(e, asset.accum_depr_account, Side::Credit, money)?; - l.post_entry(e) + l.post_entry(e)?; + l.check_depreciation_run(asset_id, period, e, amount) })?; - if self.depreciation_period_was_run(asset_id, period)? { + if self.depreciation_period_was_run(asset_id, period) { self.ensure_feature(crate::format::MONTH_REPOSTING)?; } let entry_id = self.create_entry(period_end, &description)?; @@ -2091,6 +2058,8 @@ impl Store { "amount": amount.to_string(), }), )?; + self.ledger + .record_depreciation_run(asset_id, period, entry_id, amount)?; apply_line_to_sqlite(&self.conn, &dline)?; Ok(DepreciationRun { asset_id, @@ -2202,8 +2171,9 @@ impl Store { let month = (date.year(), date.month()); let month_end = ledger_core::schedules::month_end(date); let (mut later, mut late) = (Vec::new(), Vec::new()); - for (run, amount, period) in self.depreciation_runs_ever(asset_id)? { - if period_month(&period) > month && self.ledger.in_effect(run) { + for r in self.ledger.depreciation_runs(asset_id) { + let (run, amount, period) = (r.entry, r.amount, &r.period); + if period_month(period) > month && self.ledger.in_effect(run) { later.push(format!("{period} (entry {run})")); continue; } @@ -2321,19 +2291,27 @@ impl Store { } .into()); } - if date < asset.purchase_date { - return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into()); + let acquired = self + .ledger + .asset_acquired_date(asset_id) + .expect("known asset"); + if date < acquired { + return Err(LedgerError::DisposalBeforeAcquisition { + date: date.to_string(), + acquired: acquired.to_string(), + } + .into()); } let period = date.format("%Y-%m").to_string(); self.check_nothing_after_disposal(asset_id, date)?; - let runs = self.live_runs(asset_id)?; + let runs = self.live_runs(asset_id); let too_large = || StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large")); let posted = runs .iter() .try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount)) .ok_or_else(too_large)?; - let target = ledger_core::accumulated_depreciation(asset, date)?; + let target = ledger_core::accumulated_scheduled_through(asset, date)?; let (up, down) = ledger_core::kernel::true_up(target, posted).ok_or_else(too_large)?; let in_month = runs.iter().any(|r| r.period == period); let (final_depreciation, catch_up_depreciation) = if in_month { @@ -2449,8 +2427,16 @@ impl Store { if asset.status != AssetStatus::Active { return Err(LedgerError::AssetAlreadyDisposed(asset_id).into()); } - if date < asset.purchase_date { - return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into()); + let acquired = self + .ledger + .asset_acquired_date(asset_id) + .expect("known asset"); + if date < acquired { + return Err(LedgerError::DisposalBeforeAcquisition { + date: date.to_string(), + acquired: acquired.to_string(), + } + .into()); } let base = self.base_currency; @@ -2488,14 +2474,24 @@ impl Store { ), ); // Several commands make one disposal: rehearse them all together, - // so a refusal anywhere leaves nothing journaled. + // so a refusal anywhere leaves nothing journaled (the final + // depreciation's entry checked as the period's run, no more and no + // less: Ledger::check_depreciation_run). self.rehearse(|l| { - for (description, lines) in depreciation.iter().chain([&disposal]) { + for (i, (description, lines)) in depreciation.iter().chain([&disposal]).enumerate() { let e = l.try_create_entry(date, description)?; for (account, side, amount) in lines { l.add_line(e, *account, *side, Money::new(*amount, base))?; } l.post_entry(e)?; + if i == 0 && depreciation.is_some() { + l.check_depreciation_run( + asset_id, + &preview.period, + e, + preview.final_depreciation, + )?; + } } l.dispose_asset(asset_id, date, proceeds) })?; @@ -2518,7 +2514,7 @@ impl Store { // depreciation would be in the disposal entry): its line again, // which needs a journal feature, asked for (and refused in books // that haven't declared it) before the first line is journaled. - if depreciation.is_some() && self.depreciation_period_was_run(asset_id, &preview.period)? { + if depreciation.is_some() && self.depreciation_period_was_run(asset_id, &preview.period) { self.ensure_feature(crate::format::MONTH_REPOSTING)?; } if let Some((description, lines)) = &depreciation { @@ -2536,6 +2532,12 @@ impl Store { "amount": preview.final_depreciation.to_string(), }), )?; + self.ledger.record_depreciation_run( + asset_id, + &preview.period, + eid, + preview.final_depreciation, + )?; apply_line_to_sqlite(&self.conn, &dline)?; } let entry_id = self.create_entry(date, &disposal.0)?; @@ -4741,7 +4743,11 @@ impl Store { // The live ledger equals the replay (checked above, in full); the // SQLite read-back's asset register, classes, and payroll runs // must equal it too (the accounting snapshot ignores them). - let assets_of = |l: &Ledger| l.registered_assets().cloned().collect::>(); + let assets_of = |l: &Ledger| { + l.registered_assets() + .map(|a| (a.clone(), l.depreciation_runs(a.id).to_vec())) + .collect::>() + }; if assets_of(&replayed) != assets_of(&c) { return Err(StoreError::VerifyMismatch( "asset register diverged between journal, live ledger, and sqlite projection" @@ -5058,7 +5064,7 @@ impl Store { }; let got = ledger.replay_register_asset(NewAsset { description, - purchase_date: parse_date(&purchase_date)?, + in_service_date: parse_date(&purchase_date)?, cost: parse_amt(&cost_amount, &cost_currency)?, salvage_value: parse_amt(&salvage_amount, &salvage_currency)?, useful_life_months: life_months as u32, @@ -5103,20 +5109,56 @@ impl Store { let (id, serial) = row?; ledger.set_asset_serial(id as u64, Some(&serial))?; } + // Basis records: a pre-P3 one (no value at contribution) as its + // event replays, a newer one with its facts. let mut xstmt = self.conn.prepare( - "SELECT id, tax_rule, contributor_basis FROM assets WHERE tax_rule IS NOT NULL ORDER BY id", + "SELECT id, tax_rule, contributor_basis, fmv_at_contribution, sec704c_method, basis_source_json 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>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, )) })?; for row in taxes { - let (id, rule, basis) = row?; - let tax = tax_basis_from_journal(&json!({"rule": rule, "contributor_basis": basis}))?; - ledger.replay_set_asset_tax_basis(id as u64, tax)?; + let (id, rule, basis, fmv, method, source) = row?; + let corrupt = |what: &str| StoreError::JournalCorrupt(format!("assets.{what}")); + let dec = |v: Option, what: &str| { + v.map(|s| s.parse::().map_err(|_| corrupt(what))) + .transpose() + }; + let fmv = dec(fmv, "fmv_at_contribution")?; + if fmv.is_none() + && method.is_none() + && source.is_none() + && let Some(legacy) = ledger_core::BasisRule::parse_legacy(&rule) + { + ledger.replay_set_asset_tax_basis( + id as u64, + legacy, + dec(basis, "contributor_basis")?, + )?; + continue; + } + let basis = ledger_core::AssetBasis { + rule: ledger_core::BasisRule::parse(&rule).ok_or_else(|| corrupt("tax_rule"))?, + contributor_adjusted_basis: dec(basis, "contributor_basis")?, + fmv_at_contribution: fmv, + sec704c_method: method + .map(|m| { + ledger_core::Sec704cMethod::parse(&m) + .ok_or_else(|| corrupt("sec704c_method")) + }) + .transpose()?, + source: source + .map(|j| serde_json::from_str(&j).map_err(|_| corrupt("basis_source_json"))) + .transpose()?, + }; + ledger.replay_set_asset_basis(id as u64, basis)?; } let mut qstmt = self.conn.prepare( "SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id", @@ -5126,6 +5168,26 @@ impl Store { let (id, entry) = row?; ledger.restore_asset_acquisition(id as u64, entry as u64)?; } + // Depreciation runs, each asset's in the order they were posted + // (entry order, as replay records them). + let mut dstmt = self.conn.prepare( + "SELECT asset_id, period, entry_id, amount FROM asset_depreciation ORDER BY asset_id, entry_id", + )?; + let runs = dstmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, String>(3)?, + )) + })?; + for row in runs { + let (asset, period, entry, amount) = row?; + let amount = amount.parse::().map_err(|_| { + StoreError::JournalCorrupt(format!("asset_depreciation amount {amount}")) + })?; + ledger.record_depreciation_run(asset as u64, &period, entry as u64, amount)?; + } // Payroll runs: id order; lines in insertion order. Amounts are // TEXT in SQLite, so they parse here with exact Decimal math. let parse_payroll_amount = |s: String| { @@ -5725,7 +5787,8 @@ pub(crate) fn replay_line_into_ledger( let want = get_u64(p, "id")?; let got = ledger.replay_register_asset(NewAsset { description: get_str(p, "description")?.to_string(), - purchase_date: parse_date(get_str(p, "purchase_date")?)?, + // `purchase_date` has always meant the in-service date. + in_service_date: parse_date(get_str(p, "purchase_date")?)?, cost: parse_money(p, "cost")?, salvage_value: parse_money(p, "salvage_value")?, useful_life_months: get_u64(p, "useful_life_months")? as u32, @@ -5824,19 +5887,24 @@ pub(crate) fn replay_line_into_ledger( cost: parse_money(p, "cost")?, salvage_value: parse_money(p, "salvage_value")?, useful_life_months: get_u64(p, "useful_life_months")? as u32, - purchase_date: parse_date(get_str(p, "purchase_date")?)?, + in_service_date: parse_date(get_str(p, "purchase_date")?)?, }, )?; } "asset.tax_basis_set" => { - let tax = tax_basis_from_journal(p)?; - ledger.replay_set_asset_tax_basis(get_u64(p, "asset_id")?, tax)?; + let (rule, contributor_basis) = tax_basis_from_journal(p)?; + ledger.replay_set_asset_tax_basis(get_u64(p, "asset_id")?, rule, contributor_basis)?; + } + "asset.basis_set" => { + let basis = asset_basis_from_journal(line, p)?; + ledger.replay_set_asset_basis(get_u64(p, "asset_id")?, basis)?; } "asset.described" => { ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?; } "asset.acquisition_linked" => { - ledger.set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?; + ledger + .replay_set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?; } "asset.serial_set" => { let serial = @@ -5850,7 +5918,19 @@ pub(crate) fn replay_line_into_ledger( } "asset.depreciation_posted" => { // The entry itself was journaled as entry.created/entry.posted; - // this line is idempotency bookkeeping for the projection. + // this line says it was the asset's run for the month, which is + // what the books carry for the asset (Ledger::accumulated_posted). + // Replay checks only that it is well formed and names an asset + // and entry that exist (rules.md B-28). + let amount = get_str(p, "amount")?.parse::().map_err(|_| { + StoreError::JournalCorrupt("asset.depreciation_posted amount".into()) + })?; + ledger.record_depreciation_run( + get_u64(p, "asset_id")?, + get_str(p, "period")?, + get_u64(p, "entry_id")?, + amount, + )?; } "asset.disposed" => { let id = get_u64(p, "asset_id")?; @@ -6143,21 +6223,69 @@ fn apply_document_payment( /// 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 { +/// A pre-P3 `asset.tax_basis_set`: the rule by its pre-P3 name (`cost`, +/// `carryover`, `personal_use_conversion`) and the contributor basis. +fn tax_basis_from_journal( + p: &Value, +) -> Result<(ledger_core::BasisRule, Option), 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::().ok()) - .ok_or_else(|| corrupt("contributor_basis"))?, - ), - }; - Ok(ledger_core::TaxBasis { + ledger_core::BasisRule::parse_legacy(get_str(p, "rule")?).ok_or_else(|| corrupt("rule"))?; + Ok(( rule, - contributor_basis, + optional_decimal(p, "contributor_basis").map_err(|_| corrupt("contributor_basis"))?, + )) +} + +/// An optional decimal string: absent or null is None; anything else must +/// parse. +fn optional_decimal(p: &Value, key: &str) -> Result, ()> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_str() + .and_then(|s| s.parse::().ok()) + .map(Some) + .ok_or(()), + } +} + +/// An `asset.basis_set`'s facts. A rule or §704(c) method this version +/// doesn't know was written by a newer one: NewerJournal, never corrupt +/// (rules.md B-27). The figures the line carries are type-checked and +/// otherwise left to its readers: replay never recomputes them (B-28). +fn asset_basis_from_journal( + line: &JournalLine, + p: &Value, +) -> Result { + let corrupt = |what: &str| StoreError::JournalCorrupt(format!("asset.basis_set {what}")); + let key = get_str(p, "rule")?; + let rule = ledger_core::BasisRule::parse(key) + .ok_or_else(|| crate::format::newer_content(line, format!("the basis rule {key:?}")))?; + let sec704c_method = match p.get("sec704c_method") { + None | Some(Value::Null) => None, + Some(v) => { + let key = v.as_str().ok_or_else(|| corrupt("sec704c_method"))?; + Some(ledger_core::Sec704cMethod::parse(key).ok_or_else(|| { + crate::format::newer_content(line, format!("the §704(c) method {key:?}")) + })?) + } + }; + let source = match p.get("source") { + None | Some(Value::Null) => None, + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| corrupt("source"))?), + }; + for figure in ["tax_basis_723", "depreciable_basis", "built_in_gain_704c"] { + optional_decimal(p, figure).map_err(|_| corrupt(figure))?; + } + Ok(ledger_core::AssetBasis { + rule, + contributor_adjusted_basis: optional_decimal(p, "contributor_adjusted_basis") + .map_err(|_| corrupt("contributor_adjusted_basis"))?, + fmv_at_contribution: optional_decimal(p, "fmv_at_contribution") + .map_err(|_| corrupt("fmv_at_contribution"))?, + sec704c_method, + source, }) } @@ -6544,8 +6672,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr )?; } "asset.tax_basis_set" => { + // A pre-P3 record: its rule by its pre-P3 name, no value at + // contribution, method or source. conn.execute( - "UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?", + "UPDATE assets SET tax_rule = ?, contributor_basis = ?, fmv_at_contribution = NULL, sec704c_method = NULL, basis_source_json = NULL WHERE id = ?", params![ get_str(p, "rule")?, p.get("contributor_basis").and_then(|v| v.as_str()), @@ -6553,6 +6683,26 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; } + "asset.basis_set" => { + // The facts, the rule by its wire name (tax_rule holds either + // set: the names differ but for personal_use_conversion, which + // means the same in both). + let source = match p.get("source") { + None | Some(Value::Null) => None, + Some(v) => Some(v.to_string()), + }; + conn.execute( + "UPDATE assets SET tax_rule = ?, contributor_basis = ?, fmv_at_contribution = ?, sec704c_method = ?, basis_source_json = ? WHERE id = ?", + params![ + get_str(p, "rule")?, + p.get("contributor_adjusted_basis").and_then(|v| v.as_str()), + p.get("fmv_at_contribution").and_then(|v| v.as_str()), + p.get("sec704c_method").and_then(|v| v.as_str()), + source, + get_u64(p, "asset_id")? as i64 + ], + )?; + } "asset.described" => { conn.execute( "UPDATE assets SET description = ? WHERE id = ?", @@ -6766,13 +6916,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr )?; } "asset.depreciation_posted" => { - // A period run again after its run was reversed takes the row - // (feature month_reposting); a period whose run stands keeps - // it, as before. + // Every run, a period run again after its run was reversed + // (feature month_reposting) beside the first: keyed by its + // entry too (schema v13), as the ledger records them and the + // projection's read-back restores them. conn.execute( - "INSERT INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?) \ - ON CONFLICT(asset_id, period) DO UPDATE SET entry_id = excluded.entry_id, amount = excluded.amount \ - WHERE EXISTS (SELECT 1 FROM entries r WHERE r.reversal_of = asset_depreciation.entry_id)", + "INSERT OR IGNORE INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?)", params![ get_u64(p, "asset_id")? as i64, get_str(p, "period")?, diff --git a/crates/ledger-store/tests/asset_orders.rs b/crates/ledger-store/tests/asset_orders.rs index b883e5c..142659e 100644 --- a/crates/ledger-store/tests/asset_orders.rs +++ b/crates/ledger-store/tests/asset_orders.rs @@ -111,7 +111,7 @@ fn books(dir: &Path) -> u64 { } s.register_asset(NewAsset { description: "Asset".into(), - purchase_date: day(2026, 1, 16), + in_service_date: day(2026, 1, 16), cost: usd(300_000), salvage_value: usd(0), useful_life_months: 3, @@ -219,7 +219,7 @@ fn check(store: &Store, asset: u64, ops: &[Op], verify: bool) { let a = store.ledger().get_asset(asset).unwrap().clone(); let mut active = a.clone(); active.status = AssetStatus::Active; - let sched = |on: NaiveDate| ledger_core::accumulated_depreciation(&active, on).unwrap(); + let sched = |on: NaiveDate| ledger_core::accumulated_scheduled_through(&active, on).unwrap(); // INVARIANT 3: one run in effect per month. let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap(); let mut live: Vec = Vec::new(); @@ -294,7 +294,7 @@ fn check_completion(dir: &Path, asset: u64, ops: &[Op]) { for e in ends() { let x = balance(&store, EXPENSE, e); assert!( - x <= ledger_core::accumulated_depreciation(&a, e).unwrap(), + x <= ledger_core::accumulated_scheduled_through(&a, e).unwrap(), "{ops:?} then every month: {x} on {e}" ); } diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs index a25e8aa..6985a62 100644 --- a/crates/ledger-store/tests/asset_tags.rs +++ b/crates/ledger-store/tests/asset_tags.rs @@ -4,10 +4,12 @@ //! 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; and tax basis (B-25: -//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the -//! v10 schema, corrupt payloads). A database at an older schema under this -//! version's file name is rebuilt, never migrated (each schema keeps a file -//! of its own: rules.md B-27, tests/schema_files.rs). +//! `asset.basis_set`, pre-P3 `asset.tax_basis_set` {asset_id, rule, +//! contributor_basis | null} replayed, the v10 schema and v13's, corrupt +//! and newer payloads). A database at an older schema under this +//! version's file name is rebuilt, never migrated, and P2's own file is +//! left as it is (each schema keeps a file of its own: rules.md B-27, +//! tests/schema_files.rs). //! //! Expectations come from the doc comments on `Store::set_asset_tag`, //! `Store::set_asset_serial`, `Store::open`, `Store::verify`, @@ -90,7 +92,7 @@ fn laptop(store: &mut Store, a: &Accts, name: &str) -> AssetId { store .register_asset(NewAsset { description: name.to_string(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: usd("1200.00"), salvage_value: usd("0.00"), useful_life_months: 12, @@ -571,98 +573,123 @@ fn a_description_change_replays_and_verifies() { // ------------------------------------------------- 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). +// Expectations from `Store::set_asset_basis` (journaled as +// `asset.basis_set`, the feature `asset_basis` declared first), +// `Ledger::set_asset_basis` INVARIANTS and SCENARIOS, the replay of +// pre-P3 `asset.tax_basis_set` lines, and the schema v10 and v13 +// migration comments (tax_rule, contributor_basis, fmv_at_contribution as +// TEXT, exact decimal). -fn tax(rule: ledger_core::TaxBasisRule, basis: Option<&str>) -> ledger_core::TaxBasis { - ledger_core::TaxBasis { +fn dec(s: &str) -> rust_decimal::Decimal { + s.parse().unwrap() +} + +fn contribution( + rule: ledger_core::BasisRule, + adjusted: &str, + fmv: &str, +) -> ledger_core::AssetBasis { + ledger_core::AssetBasis::contribution(rule, dec(adjusted), dec(fmv)) +} + +/// A pre-P3 record, as `asset.tax_basis_set` replays it. +fn legacy(rule: ledger_core::BasisRule, basis: Option<&str>) -> ledger_core::AssetBasis { + ledger_core::AssetBasis { rule, - contributor_basis: basis.map(|b| b.parse().unwrap()), + contributor_adjusted_basis: basis.map(dec), + fmv_at_contribution: None, + sec704c_method: None, + source: None, } } -fn tax_of(s: &Store, id: AssetId) -> Option { - s.ledger().get_asset(id).unwrap().tax +fn basis_of(s: &Store, id: AssetId) -> Option { + s.ledger().get_asset(id).unwrap().basis.clone() } -/// Journaled as `asset.tax_basis_set` {asset_id, rule, contributor_basis}: -/// the basis a decimal string, or null for a purchase. +/// Journaled as `asset.basis_set` {asset_id, rule, contributor_adjusted_basis, +/// fmv_at_contribution, sec704c_method, source, tax_basis_723, +/// depreciable_basis, built_in_gain_704c}: the facts, and the figures for +/// the line's readers (a purchase's depreciable basis null: the cost, +/// whatever it becomes). The first declares the feature asset_basis. #[test] -fn tax_basis_journals_asset_tax_basis_set() { - use ledger_core::TaxBasisRule::*; +fn a_basis_journals_asset_basis_set() { + use ledger_core::BasisRule::*; 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"))) + s.set_asset_basis(x, ledger_core::AssetBasis::purchase()) .unwrap(); + let mut b = contribution(PersonalUseConversion, "1500.25", "1200.00"); + b.sec704c_method = Some(ledger_core::Sec704cMethod::Traditional); + s.set_asset_basis(y, b.clone()).unwrap(); let j = journal(&dir); + let with_feature = |p: &Value| { + p["features"] + .as_array() + .is_some_and(|f| f.contains(&json!("asset_basis"))) + }; + let declared: Vec = j + .iter() + .enumerate() + .filter(|(_, (t, p))| t == "journal.format_set" && with_feature(p)) + .map(|(i, _)| i) + .collect(); + let first = j.iter().position(|(t, _)| t == "asset.basis_set").unwrap(); + assert_eq!(declared.len(), 1, "declared once"); + assert!(declared[0] < first, "before the first asset.basis_set"); let set: Vec<&Value> = j .iter() - .filter(|(t, _)| t == "asset.tax_basis_set") + .filter(|(t, _)| t == "asset.basis_set") .map(|(_, p)| p) .collect(); - assert_eq!(set.len(), 3); + assert_eq!(set.len(), 2); assert_eq!( set[0], - &json!({"asset_id": x, "rule": "cost", "contributor_basis": null}) + &json!({"asset_id": x, "rule": "purchase", "contributor_adjusted_basis": null, + "fmv_at_contribution": null, "sec704c_method": null, "source": null, + "tax_basis_723": null, "depreciable_basis": null, "built_in_gain_704c": 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::().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::().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" + set[1], + &json!({"asset_id": y, "rule": "personal_use_conversion", + "contributor_adjusted_basis": "1500.25", "fmv_at_contribution": "1200.00", + "sec704c_method": "traditional", "source": null, + "tax_basis_723": "1500.25", "depreciable_basis": "1200.00", + "built_in_gain_704c": "-300.25"}) ); + assert_eq!(basis_of(&s, y), Some(b)); s.verify().unwrap(); } -/// A refused tax basis (Ledger::set_asset_tax_basis INVARIANT 3) journals -/// nothing and changes nothing, now or on reopen. +/// A refused basis (Ledger::set_asset_basis INVARIANT 3) journals nothing +/// (not even the feature declaration) and changes nothing, now or on +/// reopen. A contribution's value is required. #[test] -fn a_refused_tax_basis_journals_nothing() { - use ledger_core::TaxBasisRule::*; +fn a_refused_basis_journals_nothing() { + use ledger_core::BasisRule::*; 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(); + let mut purchase_with_basis = ledger_core::AssetBasis::purchase(); + purchase_with_basis.contributor_adjusted_basis = Some(dec("100.00")); + let mut no_fmv = contribution(Carryover723, "100.00", "100.00"); + no_fmv.fmv_at_contribution = None; for bad in [ - tax(Cost, Some("100.00")), - tax(Carryover, None), - tax(PersonalUseConversion, None), - tax(Carryover, Some("-0.01")), - tax(PersonalUseConversion, Some("10.001")), + purchase_with_basis, + no_fmv, + legacy(PersonalUseConversion, None), + contribution(Carryover723, "-0.01", "1.00"), + contribution(PersonalUseConversion, "10.001", "1.00"), ] { assert!( matches!( - s.set_asset_tax_basis(x, bad), + s.set_asset_basis(x, bad.clone()), Err(StoreError::Ledger(LedgerError::InvalidTaxBasis(_))) ), "{bad:?}" ); } assert!(matches!( - s.set_asset_tax_basis(x + 99, tax(Cost, None)), + s.set_asset_basis(x + 99, ledger_core::AssetBasis::purchase()), Err(StoreError::Ledger(LedgerError::UnknownAsset(_))) )); assert_eq!(journal(&dir).len(), lines, "nothing journaled"); @@ -674,18 +701,27 @@ fn a_refused_tax_basis_journals_nothing() { s.verify().unwrap(); } -/// Tax basis replays on reopen, with the database and rebuilt from the -/// journal alone, and verifies each time. +/// A basis replays on reopen, with the database and rebuilt from the +/// journal alone, and in the viewer, and verifies each time (its facts and +/// source included). #[test] -fn tax_basis_replays_on_reopen() { - use ledger_core::TaxBasisRule::*; +fn asset_basis_set_journals_replays_and_reloads() { + use ledger_core::BasisRule::*; 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"))) + s.set_asset_basis(x, contribution(Carryover723, "900.00", "1200.00")) .unwrap(); - s.set_asset_tax_basis(x, tax(PersonalUseConversion, Some("2000.00"))) + let mut b = contribution(PersonalUseConversion, "2000.00", "1200.00"); + b.sec704c_method = Some(ledger_core::Sec704cMethod::Remedial); + b.source = Some(ledger_core::settings::Source { + decided_by: "Tobin Harlow".into(), + decided_on: date(2026, 10, 20), + citation: Some("entry 3".into()), + note: Some("value as booked".into()), + }); + s.set_asset_basis(x, b.clone()).unwrap(); + s.set_asset_basis(y, ledger_core::AssetBasis::purchase()) .unwrap(); - s.set_asset_tax_basis(y, tax(Cost, None)).unwrap(); s.verify().unwrap(); let live = s.ledger().clone(); drop(s); @@ -696,34 +732,223 @@ fn tax_basis_replays_on_reopen() { 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); + assert_eq!(basis_of(&s, x), Some(b.clone())); + assert_eq!(basis_of(&s, y), Some(ledger_core::AssetBasis::purchase())); + assert_eq!(basis_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())); + assert_eq!(ax.depreciable_basis(), Some(dec("1200.00"))); + assert_eq!(ax.built_in_gain_704c(), Some(dec("-800.00"))); drop(s); } + let snap = ledger_store::Snapshot::load(&dir).unwrap(); + assert_eq!(snap.ledger(), &live, "the viewer"); +} + +/// Pre-P3 `asset.tax_basis_set` lines replay to the figures that version +/// gave, with the database and rebuilt from the journal. +#[test] +fn legacy_tax_basis_set_lines_replay_to_todays_figures() { + use ledger_core::BasisRule::*; + let (dir, s, _, x, y) = fixture("legacy-tax"); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "asset.tax_basis_set", + json!({"asset_id": x, "rule": "personal_use_conversion", "contributor_basis": "1500.25"}), + ) + .unwrap(); + w.append( + "asset.tax_basis_set", + json!({"asset_id": y, "rule": "cost", "contributor_basis": null}), + ) + .unwrap(); + drop(w); + 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!( + basis_of(&s, x), + Some(legacy(PersonalUseConversion, Some("1500.25"))) + ); + assert_eq!(basis_of(&s, y), Some(legacy(Purchase, None))); + let ax = s.ledger().get_asset(x).unwrap(); + // The lesser of the 1,200.00 book cost and the 1,500.25 basis. + assert_eq!(ax.depreciable_basis(), Some(dec("1200.00"))); + assert_eq!(ax.tax_basis_723(), Some(dec("1500.25"))); + assert_eq!(ax.built_in_gain_704c(), Some(dec("-300.25"))); + assert!(!ax.fmv_is_recorded()); + drop(s); + } +} + +/// Review R1: a line's figures are for its readers; replay never +/// recomputes them, so a line whose figures disagree with today's formula +/// (a later version's, or a CPA's correction) still replays, and the +/// books show today's figures from its facts. +#[test] +fn a_basis_set_whose_figures_disagree_still_replays() { + let (dir, s, _, x, _) = fixture("figures-disagree"); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "journal.format_set", + json!({"format": 2, "features": ["asset_basis"]}), + ) + .unwrap(); + w.append( + "asset.basis_set", + json!({"asset_id": x, "rule": "personal_use_conversion", + "contributor_adjusted_basis": "1500.00", "fmv_at_contribution": "1200.00", + "sec704c_method": null, "source": null, + "tax_basis_723": "1.00", "depreciable_basis": "1500.00", "built_in_gain_704c": "0"}), + ) + .unwrap(); + drop(w); + 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(); + let a = s.ledger().get_asset(x).unwrap(); + assert_eq!(a.depreciable_basis(), Some(dec("1200.00"))); + assert_eq!(a.tax_basis_723(), Some(dec("1500.00"))); + assert_eq!(a.built_in_gain_704c(), Some(dec("-300.00"))); + drop(s); + } +} + +/// A basis rule or §704(c) method this version doesn't know was written +/// by a newer one: NewerJournal, never corrupt (rules.md B-27); a figure +/// or fact that isn't a decimal string (or null) is corrupt. +#[test] +fn an_unknown_basis_rule_or_method_is_newer() { + let base = json!({"rule": "carryover_723", "contributor_adjusted_basis": "1.00", + "fmv_at_contribution": "1.00", "sec704c_method": null, "source": null, + "tax_basis_723": "1.00", "depreciable_basis": "1.00", "built_in_gain_704c": "0.00"}); + let with = |k: &str, v: Value| { + let mut p = base.clone(); + p[k] = v; + p + }; + for (bad, newer) in [ + (with("rule", json!("gift_1015")), true), + (with("sec704c_method", json!("aggregated")), true), + (with("rule", json!(7)), false), + (with("sec704c_method", json!(1)), false), + (with("fmv_at_contribution", json!(1.5)), false), + (with("tax_basis_723", json!("one")), false), + (with("source", json!({"decided_by": 1})), false), + (with("rule", json!("cost")), true), + ] { + let (dir, s, _, x, _) = fixture("newer-basis"); + drop(s); + let mut p = bad.clone(); + p["asset_id"] = json!(x); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "journal.format_set", + json!({"format": 2, "features": ["asset_basis"]}), + ) + .unwrap(); + w.append("asset.basis_set", p).unwrap(); + drop(w); + match (Store::open(&dir), newer) { + (Err(StoreError::NewerJournal { .. }), true) => {} + (Err(StoreError::JournalCorrupt(_)), false) => {} + (Err(other), _) => panic!("{bad}: got {other:?}"), + (Ok(_), _) => panic!("{bad}: opened"), + } + } +} + +/// Downgrade the database to schema v12 (none of v13's 1099 decisions or +/// payment methods, nor its basis columns; runs keyed by asset and +/// period), as P2 left it. +fn downgrade_db_to_v12(db: &Path) { + let conn = rusqlite::Connection::open(db).unwrap(); + conn.execute_batch( + "DROP TABLE vendor_1099_decisions; + DROP TABLE instruments; + DROP TABLE entry_instruments; + ALTER TABLE assets DROP COLUMN fmv_at_contribution; + ALTER TABLE assets DROP COLUMN sec704c_method; + ALTER TABLE assets DROP COLUMN basis_source_json; + CREATE TABLE asset_depreciation_v12(asset_id INTEGER NOT NULL, period TEXT NOT NULL, + entry_id INTEGER NOT NULL, amount TEXT NOT NULL, PRIMARY KEY (asset_id, period)); + INSERT INTO asset_depreciation_v12 SELECT asset_id, period, entry_id, amount FROM asset_depreciation; + DROP TABLE asset_depreciation; + ALTER TABLE asset_depreciation_v12 RENAME TO asset_depreciation; + UPDATE meta SET value = '12' WHERE key = 'schema_version';", + ) + .unwrap(); } /// Downgrade the database to schema v10 (none of v11's attachment links, /// statements, reconciliations, or payroll_lines.employee_id). fn downgrade_db_to_v10(dir: &Path) { + downgrade_db_to_v12(&dir.join(db_file())); let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap(); conn.execute_batch( "DROP TABLE attachment_links; DROP TABLE statements; DROP TABLE reconciliations; DROP TABLE reconciliation_lines; + DROP TABLE settings; + DROP TABLE conventions; + DROP TABLE decisions; + DROP TABLE open_items; + DROP TABLE entry_approvals; ALTER TABLE payroll_lines DROP COLUMN employee_id; UPDATE meta SET value = '10' WHERE key = 'schema_version';", ) .unwrap(); } +/// P2's database (`wanderbooks.db`, schema v12) is left as it is, byte +/// for byte: this version builds its own v13 file from the journal, its +/// runs table keyed by entry with every run kept and the new columns +/// empty, and it verifies; a basis set after then reloads from it (each +/// schema in a file of its own: rules.md B-27). +#[test] +fn a_p2_database_is_left_and_v13_built_from_the_journal() { + use ledger_core::BasisRule::*; + let (dir, mut s, _, x, y) = fixture("v12"); + s.run_depreciation(x, "2026-01").unwrap(); + drop(s); + // As P2 left the books: its schema in the legacy file, none of this + // version's. + let legacy = dir.join(ledger_store::LEGACY_DB_FILE); + std::fs::rename(dir.join(db_file()), &legacy).unwrap(); + downgrade_db_to_v12(&legacy); + let p2 = std::fs::read(&legacy).unwrap(); + let mut s = Store::open(&dir).expect("P2's books open"); + assert_eq!(s.database_file(), db_file()); + s.verify().unwrap(); + assert_eq!(s.ledger().depreciation_runs(x).len(), 1); + assert_eq!(basis_of(&s, y), None); + s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.00")) + .unwrap(); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!( + basis_of(&s, y), + Some(contribution(Carryover723, "640.00", "1200.00")) + ); + assert_eq!(s.ledger().depreciation_runs(x).len(), 1); + drop(s); + assert_eq!( + std::fs::read(&legacy).unwrap(), + p2, + "P2's file, byte for byte" + ); +} + /// A database at schema v10 under this version's own file name is rebuilt /// from the journal as the books open (as the v8 one above), and verifies. #[test] @@ -752,10 +977,10 @@ fn downgrade_db_to_v9(dir: &Path) { } /// A database at schema v9 under this version's own file name is rebuilt -/// as the books open (as the v8 one above), verifies, and takes tax bases. +/// as the books open (as the v8 one above), verifies, and takes bases. #[test] fn a_v9_database_migrates_and_verifies() { - use ledger_core::TaxBasisRule::*; + use ledger_core::BasisRule::*; let (dir, mut s, _, x, y) = fixture("v9"); s.set_asset_tag(x, "FL-0001").unwrap(); drop(s); @@ -764,21 +989,24 @@ fn a_v9_database_migrates_and_verifies() { assert!(kept_aside(&dir), "rebuilt, not migrated"); s.verify().unwrap(); assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001")); - assert_eq!(tax_of(&s, x), None); - s.set_asset_tax_basis(y, tax(Carryover, Some("640.00"))) + assert_eq!(basis_of(&s, x), None); + s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.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")))); + assert_eq!( + basis_of(&s, y), + Some(contribution(Carryover723, "640.00", "1200.00")) + ); } /// A v9 database behind a journal with a tax basis line is rebuilt from /// the whole journal, that line with it, and verifies. #[test] fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() { - use ledger_core::TaxBasisRule::*; + use ledger_core::BasisRule::*; let (dir, s, _, x, _) = fixture("v9-behind"); drop(s); downgrade_db_to_v9(&dir); @@ -792,7 +1020,7 @@ fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() { let s = Store::open(&dir).expect("opens and catches up"); assert!(kept_aside(&dir), "rebuilt, not migrated"); s.verify().unwrap(); - assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("300.00")))); + assert_eq!(basis_of(&s, x), Some(legacy(Carryover723, Some("300.00")))); } /// An `asset.tax_basis_set` line with an unknown rule, or a contributor @@ -841,7 +1069,7 @@ fn an_asset_is_corrected_until_depreciation_is_posted() { cost: usd(cost), salvage_value: usd("0.00"), useful_life_months: 12, - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), }; let before = journal(&dir).len(); assert!(matches!( @@ -903,3 +1131,85 @@ fn reversing_a_linked_acquisition_still_opens_and_verifies() { drop(s); Store::open(&dir).unwrap().verify().unwrap(); } + +// ------------------------------------------------- depreciation runs (rules.md B-24) + +/// A run is the books' record of what they carry for an asset +/// (Ledger::accumulated_posted): recorded live, replayed from the journal, +/// reloaded from the database, the same in the viewer, and verify +/// catches a run the database holds differently from the journal (review +/// R5). +#[test] +fn runs_replay_reload_and_verify_catches_a_tampered_run() { + let (dir, mut s, _, x, _) = fixture("runs"); + s.run_depreciation(x, "2026-01").unwrap(); + s.run_depreciation(x, "2026-02").unwrap(); + let live = s.ledger().depreciation_runs(x).to_vec(); + assert_eq!(live.len(), 2); + assert_eq!( + s.ledger().accumulated_posted(x, date(2026, 2, 28)).unwrap(), + dec("200.00") + ); + s.verify().unwrap(); + drop(s); + for rebuild in [false, true] { + if rebuild { + std::fs::remove_file(dir.join(db_file())).unwrap(); + } + let s = Store::open(&dir).unwrap(); + assert_eq!(s.ledger().depreciation_runs(x), live.as_slice()); + s.verify().unwrap(); + drop(s); + } + let snap = ledger_store::Snapshot::load(&dir).unwrap(); + assert_eq!(snap.ledger().depreciation_runs(x), live.as_slice()); + let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap(); + conn.execute( + "UPDATE asset_depreciation SET amount = '99.00' WHERE period = '2026-02'", + [], + ) + .unwrap(); + drop(conn); + let s = Store::open(&dir).unwrap(); + assert!(matches!(s.verify(), Err(StoreError::VerifyMismatch(_)))); +} + +/// The writer and the viewer read an `asset.depreciation_posted` line the +/// same way: well formed and naming an asset and an entry that exist, it +/// replays (any period an earlier version journaled, rules.md B-28); +/// otherwise both refuse it as corrupt. +#[test] +fn the_viewer_and_writer_agree_on_a_bad_depreciation_posted_payload() { + for (amount, asset_known, ok) in [ + ("lots", true, false), + ("1.00", false, false), + ("0.001", true, true), + ] { + let (dir, mut s, a, x, _) = fixture("bad-run"); + let e = s.create_entry(date(2026, 1, 31), "depreciation").unwrap(); + s.add_line(e, a.expense, ledger_core::Side::Debit, usd("1.00")) + .unwrap(); + s.add_line(e, a.accum, ledger_core::Side::Credit, usd("1.00")) + .unwrap(); + s.post_entry(e).unwrap(); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "asset.depreciation_posted", + json!({"asset_id": if asset_known { x } else { x + 99 }, "period": "10000-01", + "entry_id": e, "amount": amount}), + ) + .unwrap(); + drop(w); + let _ = std::fs::remove_file(dir.join(db_file())); + let writer = Store::open(&dir).map(|s| s.ledger().depreciation_runs(x).len()); + let viewer = + ledger_store::Snapshot::load(&dir).map(|s| s.ledger().depreciation_runs(x).len()); + assert_eq!(writer.is_ok(), ok, "{amount} {asset_known}: {writer:?}"); + assert_eq!(viewer.is_ok(), ok, "{amount} {asset_known}: {viewer:?}"); + if ok { + assert_eq!(writer.unwrap(), 1); + assert_eq!(viewer.unwrap(), 1); + } + } +} diff --git a/crates/ledger-store/tests/assets.rs b/crates/ledger-store/tests/assets.rs index 2b7bebd..a26b78a 100644 --- a/crates/ledger-store/tests/assets.rs +++ b/crates/ledger-store/tests/assets.rs @@ -61,7 +61,7 @@ fn van(store: &mut Store, dollars: u64, cost_acct: u64, accum_acct: u64, expense store .register_asset(NewAsset { description: "Sprinter van".to_string(), - purchase_date: date(2026, 1, 15), + in_service_date: date(2026, 1, 15), cost: usd(&dollars.to_string()), salvage_value: usd("0"), useful_life_months: 60, @@ -230,7 +230,7 @@ fn rejected_asset_registration_journals_nothing() { let (cost, accum, expense, _, _) = asset_accounts(&mut store); let bad = NewAsset { description: "Bad".to_string(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: usd("1000"), salvage_value: usd("1000"), // salvage >= cost: invalid useful_life_months: 12, @@ -255,7 +255,7 @@ fn asset_x(store: &mut Store, cost: u64, accum: u64, expense: u64) -> u64 { store .register_asset(NewAsset { description: "Asset X".to_string(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: usd("12000.00"), salvage_value: usd("0"), useful_life_months: 12, @@ -313,7 +313,7 @@ fn defect22_a_disposal_in_the_final_month_posts_depreciation_by_days() { // The books' accumulated equals the disposed schedule's. let a = store.ledger().get_asset(x).unwrap(); assert_eq!( - ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(), + ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(), dec("11322.58") ); store.verify().unwrap(); @@ -552,7 +552,7 @@ fn run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing() { let tiny = store .register_asset(NewAsset { description: "Cable".to_string(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: usd("0.01"), salvage_value: usd("0"), useful_life_months: 3, @@ -587,7 +587,7 @@ fn jpy_depreciation_runs_post_whole_yen() { let id = store .register_asset(NewAsset { description: "Lathe".to_string(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: yen("100000"), salvage_value: yen("0"), useful_life_months: 12, diff --git a/crates/ledger-store/tests/attachments.rs b/crates/ledger-store/tests/attachments.rs index 0b7a8e1..44b5a08 100644 --- a/crates/ledger-store/tests/attachments.rs +++ b/crates/ledger-store/tests/attachments.rs @@ -302,7 +302,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec { let asset = s .register_asset(NewAsset { description: "Laptop".into(), - purchase_date: date(2026, 1, 5), + in_service_date: date(2026, 1, 5), cost: usd("3000.00"), salvage_value: usd("0"), useful_life_months: 36, diff --git a/crates/ledger-store/tests/bounds.rs b/crates/ledger-store/tests/bounds.rs index 0da33e5..c3266e6 100644 --- a/crates/ledger-store/tests/bounds.rs +++ b/crates/ledger-store/tests/bounds.rs @@ -199,7 +199,7 @@ fn disposal_proceeds_larger_than_a_line_are_refused() { let id = s .register_asset(ledger_core::NewAsset { description: "Laptop".into(), - purchase_date: d(2026, 1, 1), + in_service_date: d(2026, 1, 1), cost: usd(Decimal::new(240000, 2)), salvage_value: usd(Decimal::ZERO), useful_life_months: 24, diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index 0b59620..6d05628 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -1280,7 +1280,7 @@ fn a_write_waiting_for_the_upgrade_journals_nothing() { let lathe = |s: &mut Store, what: &str| { s.register_asset(ledger_core::NewAsset { description: what.into(), - purchase_date: day(2026, 1, 1), + in_service_date: day(2026, 1, 1), cost: usd(1_200_000), salvage_value: usd(0), useful_life_months: 12, diff --git a/crates/ledger-store/tests/spec_store.rs b/crates/ledger-store/tests/spec_store.rs index 6cab714..2c0fe05 100644 --- a/crates/ledger-store/tests/spec_store.rs +++ b/crates/ledger-store/tests/spec_store.rs @@ -465,7 +465,7 @@ fn inv5_refused_disposal_writes_nothing() { .store .register_asset(NewAsset { description: "Laptop".into(), - purchase_date: date(2026, 1, 1), + in_service_date: date(2026, 1, 1), cost: usd(3_600), salvage_value: usd(0), useful_life_months: 36, diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 9d1dce2..d89a147 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1365,7 +1365,7 @@ mod refusals { Op::RegisterAsset(c, d, x, cost, m) => store .register_asset(ledger_core::NewAsset { description: "van".into(), - purchase_date: day(0), + in_service_date: day(0), cost: usd(cost), salvage_value: usd(0), useful_life_months: m, @@ -1448,7 +1448,7 @@ mod refusals { Op::GoodAsset(c, m) => store .register_asset(ledger_core::NewAsset { description: "machine".into(), - purchase_date: day(0), + in_service_date: day(0), cost: usd(c), salvage_value: usd(0), useful_life_months: m, @@ -1939,7 +1939,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) { let dates: Vec = (1..l.next_entry_id()) .filter_map(|id| l.get_entry(id)) .map(|e| e.date()) - .chain(l.registered_assets().map(|a| a.purchase_date)) + .chain(l.registered_assets().map(|a| a.in_service_date)) .chain(l.list_periods().iter().flat_map(|p| [p.start, p.end])) .chain(l.schedules().map(|s| s.start)) .collect(); @@ -1995,7 +1995,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) { } for a in l.registered_assets() { tally(ledger_core::depreciation_schedule(a, d).map(|_| ())); - tally(ledger_core::book_value(a, d).map(|_| ())); + tally(ledger_core::scheduled_book_value(a, d).map(|_| ())); tally( ledger_core::disposal_amounts( a, @@ -2253,7 +2253,7 @@ fn scrapping_an_asset_for_nothing_disposes_it() { let asset = store .register_asset(ledger_core::NewAsset { description: "laptop".into(), - purchase_date: day(1, 1), + in_service_date: day(1, 1), cost: usd(1200), salvage_value: usd(0), useful_life_months: 12, @@ -2442,7 +2442,7 @@ fn laptop(dir: &std::path::Path) -> (Store, u64, u64, u64) { let asset = store .register_asset(ledger_core::NewAsset { description: "laptop".into(), - purchase_date: day(1, 1), + in_service_date: day(1, 1), cost: usd(1200), salvage_value: usd(0), useful_life_months: 12, diff --git a/docs/glossary.md b/docs/glossary.md index 221e3d9..e61972f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -30,3 +30,35 @@ definitions exactly. into retained earnings so the new period starts clean. (Future: R-FUTURE-2.) - **Current earnings** — Net income to date on an unclosed book. We fold it into equity so the accounting equation holds before close. +- **Placed in service (in-service date)** — The day an asset is ready and + available for its use. Depreciation starts here (`Asset::in_service_date`; + journaled under its old name `purchase_date`). +- **Acquired date** — The day the company got the asset: the date of the + entry that put it on the books (its purchase or a member's contribution), + never later than the in-service date. The register holds the asset from + here (`Ledger::asset_acquired_date`). +- **Accumulated depreciation, posted and scheduled** — What the books carry + for an asset (its depreciation runs, less any reversed: + `Ledger::accumulated_posted`), and what its schedule says is due by a date + (`accumulated_depreciation`, `accumulated_scheduled_through` in the + tools). Book value is cost less the posted figure. +- **§723 basis** — The company's tax basis in property a member contributed: + the member's adjusted basis at contribution (IRC §723). +- **Adjusted basis** — Cost plus improvements, less depreciation allowed or + allowable (IRC §1016(a)). +- **Depreciable basis** — What tax depreciation starts from: the cost for + something bought; the §723 basis for business property contributed; for + property contributed from personal use, the lesser of its fair market + value and the member's adjusted basis (Treas. Reg. §1.168(i)-4(b)). +- **Built-in gain or loss (§704(c))** — Property's fair market value less + its §723 basis when contributed (Treas. Reg. §1.704-3(a)(3)(ii)). A + built-in loss counts only for the member who contributed it (IRC + §704(c)(1)(C)). +- **§704(c) method** — How the tax items of contributed property are shared + among the members so the built-in gain or loss stays with its + contributor: traditional, traditional with curative allocations, or + remedial (Treas. Reg. §1.704-3(b)-(d)), chosen property by property. +- **Tax-basis capital** — A member's capital account as Schedule K-1 Item L + reports it: cash plus the adjusted tax basis of property contributed, + plus their share of income as figured for tax, less cash and the adjusted + tax basis of property distributed (Form 1065 instructions, Item L). diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 5ebc474..4b73cbe 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -595,11 +595,23 @@ the lifetime total is exactly (cost − salvage), rounded to the currency's minor unit. A new asset's cost and salvage are in the minor unit, and its life's last day is 9999-12-31 at the latest. -- `books.register_asset { description, purchase_date, cost, salvage_value?, - useful_life_months, cost_account, accum_depr_account, expense_account, - tag?, serial? }` → `{ asset_id, tag }`. Accounts resolved by id or code. - Without `tag`, the next free `PREFIX-0001` (`[books] asset_tag_prefix`, - else the company name's initials without LLC/Inc; "FA" when none). +- `books.register_asset { description, in_service_date, cost, + salvage_value?, useful_life_months, cost_account, accum_depr_account, + expense_account, tag?, serial?, acquisition_entry?, basis_rule?, + contributor_adjusted_basis?, fmv_at_contribution?, sec704c_method?, + basis_source? }` → `{ asset_id, tag, in_service_date, acquired_date, + basis?, notes? }`. Accounts resolved by id or code. Without `tag`, the + next free `PREFIX-0001` (`[books] asset_tag_prefix`, else the company + name's initials without LLC/Inc; "FA" when none). `in_service_date` (the + day it is ready and available for its use) starts depreciation (P3, + defect 13; B-15); `purchase_date` is its alias for one release (both + given and different: refused; the alias noted in `notes`). The + `acquisition_entry` (posted, debiting the cost account, dated on or + before the in-service date) gives `acquired_date`, which puts the asset + on the register; with none, the acquired date is the in-service date. + The basis facts are `books.set_asset_tax_basis`'s (`tax_basis_rule` and + `contributor_basis`, with the old rule names, are aliases for one + release); a bad one registers nothing. - `books.label_asset { asset, tag?, serial?, clear_serial? }` → `{ asset_id, tag, serial }`: set or change the tag (unique ignoring case, never reused by another asset even after a retag or disposal) or the manufacturer's @@ -614,11 +626,55 @@ life's last day is 9999-12-31 at the latest. a disposed asset's ends on its disposal day. A schedule an earlier version's asset can't compute is a tool error saying why, never a panic. -- `books.list_assets {}` → assets with status, accumulated depreciation - by schedule and book value as of today (a disposed asset's through its - disposal day); `accumulated` and `book_value` are null, with - `schedule_error` saying why, for an asset whose schedule can't be - computed (one bad asset never fails the list). +- `books.list_assets { as_of? }` → `{ as_of, assets }` (default today, the + company's time zone): per asset `in_service_date`, `acquired_date`, + `cost`, `status`, `held` (acquired by `as_of` and not disposed), + `accumulated_posted` (what the books carry: its depreciation runs, less + reversals, a reversal of a reversal reinstating), + `accumulated_scheduled_through` (what the schedule says is due by + `as_of`), `unposted_depreciation` (the difference: run + `books.run_depreciation`), `book_value` (cost less posted: what the + books carry), and `basis` (as `books.set_asset_tax_basis` reports it). + Disposed by `as_of`: every figure 0.00; acquired after `as_of`: the + figures null. The rows are the register's (`fixed_asset_register`). An + asset whose figures can't be computed (one an earlier version took) + lists with them null and `schedule_error` saying why: one bad asset + never fails the list. + Before P3, `accumulated` was the schedule's figure and `book_value` + cost less it. +- `books.set_asset_tax_basis { asset, rule, contributor_adjusted_basis?, + fmv_at_contribution?, sec704c_method?, basis_source? }` → `{ asset_id, basis: + { rule, contributor_adjusted_basis, fmv_at_contribution, fmv_recorded, + tax_basis_723, depreciable_basis, built_in_gain_704c, sec704c_1c_note?, + sec704c_method, source?, sources }, notes? }`. `rule`: `purchase` (the + company bought it: depreciable basis is its cost, IRC §1012), + `carryover_723` (a member contributed business property: the company's + §723 basis is the member's adjusted basis, depreciated from), or + `personal_use_conversion` (a member contributed personal-use property: + the §723 basis as for carryover, depreciable basis the lesser of fair + market value and that basis, Treas. Reg. §1.168(i)-4(b); D-41). A + contribution needs `contributor_adjusted_basis` (cost plus improvements, + less depreciation allowed or allowable, IRC §1016(a)) and + `fmv_at_contribution` (a fact the human gives: never assumed from the + cost); `built_in_gain_704c` = value − §723 basis at contribution (Treas. + Reg. §1.704-3(a)(3)(ii)), with the IRC §704(c)(1)(C) note when it is a + loss. `sec704c_method`: `traditional`, `traditional_with_curative`, + `remedial` (Treas. Reg. §1.704-3(b)-(d)). `basis_source` `{ decided_by, + decided_on?, citation?, note? }`: who gave the facts. `sources` names + each figure's authority. The figures are at contribution or purchase; + tax depreciation and what remains of a built-in gain later are the + CPA's. The old names (`cost`, `carryover`, `contributor_basis`) are + aliases for one release, noted. Journaled as `asset.basis_set` (the + journal feature `asset_basis`: books an older version wrote refuse it, + `format_upgrade_pending`, until the owner's format upgrade); a record + written before P3 (`asset.tax_basis_set`) reads its value as the book + cost (`fmv_recorded: false`). +- `books.correct_asset { asset, cost?, salvage_value?, useful_life_months?, + in_service_date?, reason }` → `{ asset_id, tag, cost, salvage_value, + useful_life_months, in_service_date, acquired_date, basis, notes? }`. + Refused while the books carry depreciation for it (a run posted and not + reversed), and for an in-service date before its acquisition entry's + date; `in_service` is the alias of `in_service_date` for one release. - `books.run_depreciation { period: "YYYY-MM", asset_id? }` → `runs: [{ asset_id, description, period, amount, caught_up, entry_id, already_posted }]` and `skipped: [{ asset_id, description, reason }]`. @@ -635,7 +691,8 @@ life's last day is 9999-12-31 at the latest. run-all's assets not yet in service and any whose schedule can't be computed. A run before the in-service date is refused. - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, - gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` → + gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` (the + date never before the acquired date) → without confirmation: `{ status: "preview", accumulated, book_value, proceeds, gain_loss, final_depreciation, excess_depreciation, catch_up_depreciation, lines, receipt_required }` and posts nothing; @@ -658,12 +715,23 @@ life's last day is 9999-12-31 at the latest. posts (`party_required`); a retirement with no proceeds and no buyer is marked as having no counterparty. - `books.export { report: "fixed_asset_register", format: json | csv, - as_of? }`: every asset registered by the date (tag, serial, cost, - depreciation by schedule, book value, disposal), totals, and the - general ledger's balances of the assets' cost and accumulated - depreciation accounts, with `unposted_depreciation` and - `unregistered_cost` (zero when register and books agree; - `ledger_core::fixed_asset_register`). + as_of? }`: every asset acquired by the date (tag, serial, acquired and + in-service dates, cost, accumulated depreciation scheduled and posted, + book value as cost less posted, disposal, the basis facts and figures: + rule, contributor's adjusted basis, value at contribution and whether + it was recorded, §723 basis, depreciable basis, §704(c) built-in gain, + method, who gave the facts), totals (`total_accumulated_scheduled`, + `total_accumulated_posted`, `total_book_value`, `total_tax_basis_723`, + `total_depreciable_basis`, `missing_basis`), and the general ledger's + balances of the assets' cost and accumulated depreciation accounts, with + `unposted_depreciation` (scheduled less books), `unattributed_accumulated` + (books less the runs: an entry made by hand) and `unregistered_cost` + (zero when register and books agree; `ledger_core::fixed_asset_register`). + CSV columns: `tag, description, serial, acquired, in_service, cost, + salvage, useful_life_months, accumulated_scheduled, accumulated_posted, + book_value, status, proceeds, basis_rule, contributor_adjusted_basis, + fmv_at_contribution, fmv_recorded, tax_basis_723, depreciable_basis, + built_in_gain_704c, sec704c_method, basis_source`, then a TOTAL row. - `books.show { asset }` opens an asset in the viewer; `page: "assets"` and `report: "fixed_asset_register"` open the register. @@ -1918,7 +1986,20 @@ entries. year cancel: neither is a contribution or a distribution. A member's party is the party with the member's name (not a display name) and the member role, as the viewer's capital statement finds it; with none, the - member's TIN is a gap. + member's TIN is a gap. **Tax-basis capital** (`k1-tax-basis-capital.csv`, + P3, D-41, rules.md B-36; Form 1065 instructions, Item L, the tax-basis + method): per member, `member, item, book, tax_basis, book_less_tax, + status, note` for each Item L line (beginning; capital contributed, + cash and property; current year net income (loss); other increase + (decrease); withdrawals and distributions; ending), the property at its + §723 basis (`ledger_core::tax_capital`), then the reconciliation of book + to tax capital by cause (the §704(c) built-in gain or loss share, + property without a tax basis in the books, property distributed). A + line the books can't know (tax depreciation of the fixed assets in + service, a disposal's tax gain, the basis of property distributed or of + contributed property no asset holds) has `tax_basis` blank, status + `open: the CPA's`, and a note with what the books know so far and the + book amount the CPA's figure replaces; never a guessed number. **1099 worksheet** (`1099-worksheet-.csv`, `as_of`'s calendar year) is `books.vendor_1099`'s CSV (above) for the package's cash and financing accounts: the payments from January 1 through `as_of` (the diff --git a/docs/parity.md b/docs/parity.md index afea3d3..0dfdb80 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -27,7 +27,7 @@ No other statuses. | Tax line mapping + tax summary | Accounts → Tax line mapping; Tax reports | partial | hand-verified fixture (50% meals, nondeductible, contra income); no golden file yet | | Reversing entries | Reverse transaction | done | linked via `reversal_of` | | Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` | -| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss | +| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss; posted (the books') vs scheduled, in-service vs acquired dates, the §723, depreciable and §704(c) figures (P3) | | Cash flow statement (direct, with the reconciliation schedule and noncash disclosures) | Reports → Cash Flow | done | R-FUTURE-3; rules.md B-14 (P3 defect 1) | | Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 | | Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 | diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 3eba10d..876f01a 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -754,16 +754,47 @@ The rule, exactly (P3, defect 22; the engineer settled it from ASC reversed on a day before its own (a correction comes after what it corrects, B-9: a run of March reversed on Jan 31 would leave January and February with negative depreciation; commands only, journals - replay as written). The viewer and the store read every run the - journal holds, not a month's latest (a run re-instated after its month - was run again and that run reversed is the month's run). + replay as written). The viewer and the store read every run the books + recorded (`Ledger::depreciation_runs`: each `asset.depreciation_posted`, + as replay records it), not a month's latest (a run re-instated after its + month was run again and that run reversed is the month's run). +7. Two dates (P3, defect 13). Depreciation starts on the day the asset is + placed in service, never the day it was bought: ASC 360-10-35-4 + spreads cost over the periods the asset serves, and an asset serves no + period before it is in service. (For tax, Pub. 946's "ready and + available for a specific use", `sources/irs-partnership-basis.md`, is + the CPA's.) The register holds an asset from the day the company + acquired it: the date of the entry it is linked to (its purchase or + contribution), never later than the in-service date, or the in-service + date when no entry is linked (settled in design, 2026-10-20: "an + asset's acquired date comes from the entry it is linked to"; one + source of truth, so the register and the general ledger can't + disagree, B-24). A new link to an entry dated after the in-service date + or the disposal is refused, as is a new in-service date before the + linked entry's date; a disposal can't come before the acquired date (a + command's bound; replay accepts what earlier versions did, B-28), and + one before the in-service date takes no depreciation. +8. Posted and scheduled. What the schedule says is due through a date + (`assets::accumulated_scheduled_through`, the tools' + `accumulated_scheduled_through`) and what the books carry + (`Ledger::accumulated_posted`: the runs read entry by entry as rule 6 + reads them, each on its own day: a run adds its amount, a reversal of + it takes it away, a reversal of that reversal adds it back, B-8, B-9; + zero once the asset is disposed, its disposal entry having cleared it) + are two figures, never one: book value is cost less the posted figure + (`assets::scheduled_book_value` is the schedule's own, for the + schedule only). **Enforced:** `assets::depreciation_schedule`, `assets::run_amount`, `assets::catch_up_room`, `assets::depreciation_effects`, `kernel::prorated_through`, `kernel::catch_up`, `kernel::true_up`, `calendar::month_parts`, `Store::run_depreciation`, `Store::dispose_preview` (`check_nothing_after_disposal`), -`Store::check_reverse_entry`. **Checked:** +`Store::check_reverse_entry`, `Ledger::asset_acquired_date`, +`set_asset_acquisition` (`replay_set_asset_acquisition` for replay), +`check_correct_asset`, `dispose_asset` (strict: the acquired date), +`record_depreciation_run` / `check_depreciation_run`, +`accumulated_posted`, `live_depreciation_run`. **Checked:** `schedule_sums_exactly_to_depreciable_amount`, `salvage_value_is_never_depreciated`, `prop_depreciation_schedule_exact`, `defect22_depreciation_through_mid_final_month_is_prorated_not_plugged`, @@ -804,7 +835,14 @@ The rule, exactly (P3, defect 22; the engineer settled it from ASC Lean `Prorate.kernel_parts_total`, `Prorate.kernel_parts_nonneg`, `Prorate.part_near`, `Prorate.cum_near`, `Prorate.chargeMid_eq_cum`, `Prorate.chargeLast_eq_cum`, `Prorate.chargeLast_start`, -`Prorate.chargeLast_mono`, `Prorate.cum_mono` (`docs/verification.md`). +`Prorate.chargeLast_mono`, `Prorate.cum_mono` (`docs/verification.md`); +`ledger-core` `tests/spec_asset_basis.rs` (the in-service and acquired +dates, `accumulated_posted` and its edges, +`a_reinstated_run_counts_and_is_live`); `ledger-store` +`tests/asset_tags.rs` (`runs_replay_reload_and_verify_catches_a_tampered_run`, +`the_viewer_and_writer_agree_on_a_bad_depreciation_posted_payload`); +`ledger-mcp` `tests/spec_assets.rs` (`a_reversed_run_is_not_posted`, +`list_assets_book_value_is_what_the_books_carry`). ## B-16 Prepaid expenses and deferred revenue are recognized over the period benefited or earned @@ -1189,44 +1227,96 @@ Each fixed asset may carry a tag, the label stuck on the thing itself `- _ . /`), and the manufacturer's serial number. Tags are unique ignoring case and are never reused by another asset, even after a retag or a disposal, so an old label can never point at the wrong thing. Both are -journaled. The fixed asset register lists every asset as of a date and -reconciles to the general ledger's cost and accumulated depreciation -accounts. +journaled. The fixed asset register lists every asset acquired by a date +(B-15) and reconciles to the general ledger's cost and accumulated +depreciation accounts: cost per register and per books +(`unregistered_cost`), accumulated depreciation by schedule and per +books (`unposted_depreciation`: run depreciation for it), and from the +assets' runs and per books (`unattributed_accumulated`: an entry made by +hand). Book value on the register is cost less the depreciation posted, +the books' figure, never the schedule's (P3, defect 13). **Enforced:** `Ledger::set_asset_tag` / `check_asset_tag_for`, `assets::check_asset_tag`, `assets::check_serial`. **Checked:** `ledger-core` `tests/spec_asset_tags.rs`; `ledger-store` `tests/asset_tags.rs`; `ledger-mcp` `tests/spec_assets.rs`. -## B-25 Tax basis beside book value +## B-25 Tax figures beside book value: the §723 basis, the depreciable basis, the §704(c) built-in gain **Status:** AUTHORITY (`sources/irs-partnership-basis.md`); DECISION -(the owner, 2026-10-15: the register carries tax basis) +(the owner, 2026-10-15: the register carries tax basis); DECISION D-41 +(the owner, 2026-10-20: the depreciable basis of property a member +contributed from personal use is the lesser of its fair market value and +the member's adjusted basis, the CPA to confirm at year end; the +§704(c) method is traditional, recorded as a choice the CPA confirms; +the contributed property's values on the contribution date are the +books' recorded amounts) -Book value and tax basis are different numbers for the same asset. The +Book value and tax figures are different numbers for the same asset. The book value of a member's contribution is its fair market value (Treas. -Reg. §1.704-1(b)(2)(iv)(d)(1), cited). Its federal tax basis follows the -rule that applies: property the company buys has a cost basis ("The basis -of property shall be the cost of such property", IRC §1012(a)); property a -member contributes keeps "the adjusted basis of such property to the -contributing partner at the time of the contribution" (IRC §723, -carryover); property a member converts from personal use depreciates from -"the lesser of its fair market value or its adjusted depreciable basis" -(Treas. Reg. §1.168(i)-4(b)(1)). The difference between fair market value -and the contributor's adjusted basis is the built-in gain (positive) or -loss (negative) that IRC §704(c) makes the partnership account for; a -built-in loss counts only for the contributing member (§704(c)(1)(C)). -wanderbooks records the rule, the contributor's adjusted basis, the -resulting tax basis, and the built-in gain or loss; tax depreciation -(MACRS, conventions, elections) stays with the CPA (`sources/irs-depreciation.md`). +Reg. §1.704-1(b)(2)(iv)(d)(1), cited; Treas. Reg. §1.704-3(a)(3)(i): +"book value is equal to fair market value at the time of contribution"). +Three tax figures, each named for what it is, follow from the rule and +the facts recorded (`asset.basis_set`: the rule, the contributing +member's adjusted basis, the fair market value at contribution, the +§704(c) method if chosen, and who gave them, B-29): -**Enforced:** `Ledger::set_asset_tax_basis` / `check_asset_tax_basis` -(cost takes no contributor basis, a contribution requires one, -non-negative, in cents), `Asset::tax_basis`, `Asset::built_in_gain`, -`reports::fixed_asset_register` (per-row tax fields, `total_tax_basis`, -`missing_tax_basis`). **Checked:** `ledger-core` `tests/spec_tax_basis.rs`; -`ledger-store` `tests/asset_tags.rs` (`asset.tax_basis_set` journaling, -replay, v9 migration, corrupt payloads); `ledger-mcp` `tests/spec_assets.rs`. +- **Purchase** (`purchase`): "The basis of property shall be the cost of + such property" (IRC §1012(a)); depreciable basis is the cost. Not + §704(c) property: no §723 basis and no built-in gain. +- **§723 basis** of contributed property: "the adjusted basis of such + property to the contributing partner at the time of the contribution" + (IRC §723; Pub. 541: "the partnership's basis for determining + depreciation, depletion, gain, or loss"). Adjusted basis is cost plus + improvements, less depreciation allowed but not less than allowable + (IRC §1016(a)(1)-(2)). wanderbooks does not model §721(b) gain (an + investment-company partnership only), so the §723 basis is the + member's adjusted basis. +- **Depreciable basis:** for business property contributed + (`carryover_723`), the §723 basis; for property a member contributed + from personal use (`personal_use_conversion`), "the lesser of its fair + market value or its adjusted depreciable basis" (Treas. Reg. + §1.168(i)-4(b)(1); Pub. 946, "Property changed from personal use"; + D-41). +- **§704(c) built-in gain or loss at contribution:** fair market value + less the §723 basis ("the excess of the property's book value over the + contributing partner's adjusted tax basis upon contribution", Treas. + Reg. §1.704-3(a)(3)(ii)); fixed at contribution, so correcting the book + cost later never moves it. A built-in loss counts only for the + contributing member (IRC §704(c)(1)(C)). The §704(c) method + (traditional, traditional with curative allocations, remedial: Treas. + Reg. §1.704-3(b)-(d)) is chosen property by property (§1.704-3(a)(2)). + +The figures are at contribution (or purchase): tax depreciation reduces +the basis afterwards (§1016(a)(2)) and the built-in gain or loss "is +thereafter reduced" (§1.704-3(a)(3)(ii)); both are the CPA's (MACRS, +conventions, elections: `sources/irs-depreciation.md`). The facts are +kept, never the figures: the figures are derived when read, under the +rule in force, so a later change to a rule changes the reports without +refusing a line an earlier version wrote (B-28). A record made before +P3 (`asset.tax_basis_set`, with the old names `cost` and `carryover`) +has no fair market value recorded: it reads as the book cost, which +reproduces that version's figures exactly, and Needs attention asks for +the value. wanderbooks' book depreciation is GAAP straight line, not +§704(b) book depreciation, which must keep the tax depreciation's ratio +(Treas. Reg. §1.704-1(b)(2)(iv)(g)(3), cited): neither is the other. + +**Enforced:** `kernel::basis_figures` (Kani `basis_figures_laws`), +`AssetBasis::check` (a purchase takes no contribution facts; a +contribution needs the adjusted basis and the fair market value, each +non-negative, in cents, at most one quadrillion) and its replay pairs +`check_journaled` and `check_legacy_journaled`, `Ledger::set_asset_basis` +/ `check_asset_basis` / `replay_set_asset_basis` / +`replay_set_asset_tax_basis`, `Asset::basis_figures`, +`reports::fixed_asset_register` (per-row tax fields, +`total_tax_basis_723`, `total_depreciable_basis`, `missing_basis`); +`ledger-store` `Store::set_asset_basis` (the journal feature +`asset_basis`); `ledger-mcp` `books.set_asset_tax_basis` and +`books.register_asset` (a contribution's fair market value is required, +never assumed). **Checked:** `ledger-core` `tests/spec_asset_basis.rs`, +`tests/kernel_bridge.rs` (`basis_figures_agree`), `tests/properties.rs` +(`prop_basis_figures_laws`); `ledger-store` `tests/asset_tags.rs`; +`ledger-mcp` `tests/spec_assets.rs`. ## B-26 Parties: one directory, no full TIN in the books @@ -1426,15 +1516,18 @@ decided, a `not_reportable` decision, its reason or the date it takes effect, and `vendor.w9_flag_set`: defects 18 and 23, D-40, B-19), `schedule_convention` (a daily schedule's `schedule.registered` carries `convention: daily`; an older version would read it as equal months, -B-16) and `month_reposting` (a schedule month or a depreciation period +B-16), `month_reposting` (a schedule month or a depreciation period posted again after a reversal un-posted it; an older version would refuse the second `schedule.posted`, and its projection ignore the second -`asset.depreciation_posted`, B-15). A command asks for the feature it -needs (`Store::ensure_feature`) before its first journaled line, and only -when the line it is about to write uses it, so a write refused for -waiting on the upgrade journals nothing, never an entry without the line -that says what it is (`a_write_waiting_for_the_upgrade_journals_nothing`), -and a write that needs nothing new is never refused. A tool journaling +`asset.depreciation_posted`, B-15) and `asset_basis` (an +`asset.basis_set`, a new event type: an asset's basis facts with the +value at contribution and the §704(c) method, B-25). A command asks for +the feature it needs (`Store::ensure_feature`) before its first journaled +line, and only when the line it is about to write uses it, so a write +refused for waiting on the upgrade journals nothing, never an entry +without the line that says what it is +(`a_write_waiting_for_the_upgrade_journals_nothing`), and a write that +needs nothing new is never refused. A tool journaling several commands as one asks before the first of them, never between them (a refusal there would leave the tool's work half done): `books.set_w9` asks for `vendor_1099_decisions` before the W-9 when the @@ -1570,9 +1663,9 @@ document line, a payment, a payroll run and disposal proceeds carry no upper bound beyond what their own rules always said (positive, cents, balanced, and so on); a schedule's months are 1 to 600 and an asset's life ends on a date chrono holds. The checks split in two: the -command's (`Ledger::add_line`, `register_asset`, `TaxBasis::check`, +command's (`Ledger::add_line`, `register_asset`, `AssetBasis::check`, `shares::check_shares`, ...) and replay's (`replay_add_line`, -`replay_register_asset`, `TaxBasis::check_journaled`, +`replay_register_asset`, `AssetBasis::check_journaled`, `AssetBasis::check_legacy_journaled`, `shares::check_shares_journaled`, ...), and every bound has its pair of tests: the command refuses past it; replay of an old line past it is accepted. What an earlier version left beside its journal is kept for it @@ -1602,17 +1695,17 @@ books open, never a panic. `schedules::validate_schedule`, `validate_recurring`, `periods::validate_period_range`, `arap::validate_lines`, `validate_due_date`, `payroll::check_payroll_ranges`, `payout_plan`, -`shares::check_shares`, `TaxBasis::check`, `reconciliation_report`; for +`shares::check_shares`, `AssetBasis::check`, `reconciliation_report`; for replay, `replay_add_line`, `replay_add_line_fx`, `replay_register_asset`, `replay_correct_asset`, -`replay_dispose_asset`, `replay_set_asset_tax_basis`, +`replay_dispose_asset`, `replay_set_asset_tax_basis`, `replay_set_asset_basis`, `replay_set_entry_parties`, `replay_register_recurring`, `replay_register_schedule` (`validate_schedule_shape`), `replay_create_period` (`validate_period_order`), `replay_invoice`, `replay_bill`, `replay_invoice_payment`, `replay_bill_payment`, `replay_payroll_run`, `validate_new_asset_journaled`, `validate_recurring_journaled`, `check_shares_journaled`, -`TaxBasis::check_journaled`; derived: `Invoice::total`/`balance`, +`AssetBasis::check_journaled`, `AssetBasis::check_legacy_journaled`; derived: `Invoice::total`/`balance`, `Bill::total`/`balance`, `AgingRow::total`, `ar_aging`, `ap_aging`, `PayrollLine::net_pay`, `payroll_entry_plan`, `parties::entry_flow`, `shares::share_target`, `shares::resolve`, `fixed_asset_register`, @@ -2298,3 +2391,47 @@ distributions declared or of depreciated property, the properties), Kani `reverseTarget_involutive`, `capitalTarget_odd`, `partyTarget_odd`, `sumOver_movement_reversal`; `ledger-store` `tests/party_creditors.rs`; `ledger-mcp` `tests/spec_party_totals.rs`; `ledger-gui` `tests/parties.rs`. + +## B-36 The K-1 worksheet reports tax-basis capital beside book capital + +**Status:** AUTHORITY (`sources/irs-partnerships.md`: Instructions for +Form 1065 (2025), Item L, "Tax-basis method"; required from tax year +2020: Instructions for Form 1065 (2020), Item L, "you must use the tax +basis method this year"); DECISION D-41 (the owner, 2026-10-20: "the +K-1 worksheet reports tax-basis capital ... the contributed property at +its §723 carryover basis, beside the book figures") + +Schedule K-1 Item L reports each member's capital account on the tax +basis, by the transactional approach: beginning capital (last year's +ending), "the amount of cash plus the adjusted tax basis of all property +contributed", the member's share of income "as figured for tax +purposes", other increases and decreases "for tax purposes", and "the +amount of cash plus the adjusted tax basis of all property distributed". +The K-1 worksheet shows, per member, each Item L line on the book basis +(the members' equity roll-forward, B-13) beside the tax basis, and how +book capital reconciles to tax capital: + +- Property contributed enters at its §723 basis (B-25): the member's + share of the property's book value less their share of its built-in + gain or loss at contribution, shared within the entry as its book value + is (B-13). Property contributed with no basis in the books (no + registered asset linked to the entry holds a contribution basis for it) + is open. +- What the books can't know is never guessed: the tax depreciation of + every fixed asset in service (MACRS, and for contributed property the + §704(c) method's allocation: the CPA's, B-15), the tax gain on a + disposal, the adjusted tax basis of property distributed, the tax + treatment of a change in book capital the books can't explain, and + last year's tax ending when an earlier year left a figure open. Each + such line is left blank and marked open, with what the books know so + far and the book amount the CPA's figure replaces. +- The reconciliation: book ending less tax ending (as far as the books + know it) = beginning (book less tax) + the built-in gain or loss share + + property without a basis (at book) − property distributed (at book), + exactly. + +**Enforced:** `ledger_core::tax_capital::tax_basis_capital`; +`ledger-package` `k1_tax` (`k1-tax-basis-capital.csv` in the CPA +package). **Checked:** `ledger-core` `tests/tax_capital.rs`; +`ledger-package` `tests/package.rs` +(`the_k1_worksheet_reports_tax_basis_capital`). diff --git a/docs/spec/sources/irs-partnership-basis.md b/docs/spec/sources/irs-partnership-basis.md index f9bafe7..5253610 100644 --- a/docs/spec/sources/irs-partnership-basis.md +++ b/docs/spec/sources/irs-partnership-basis.md @@ -1,5 +1,5 @@ # Tax basis of fixed assets: cost, contributed property, and personal-use conversion -Governs the tax basis the fixed asset register carries beside book value (rules.md B-25). Property the partnership buys has a cost basis (IRC §1012); property a member contributes keeps the member's adjusted basis (IRC §723), and the difference from its fair market value is the built-in gain or loss that §704(c) makes the partnership account for; property a member converts from personal use has a depreciable basis of the lesser of fair market value and adjusted basis (Treas. Reg. §1.168(i)-4(b)(1)). wanderbooks records the basis; tax depreciation (MACRS, conventions, elections) stays with the CPA. +Governs the tax figures the fixed asset register carries beside book value (rules.md B-25): the §723 basis, the depreciable basis, and the §704(c) built-in gain or loss at contribution; and the property contributed at its adjusted basis on the K-1 worksheet's tax-basis capital (rules.md B-36). Property the partnership buys has a cost basis (IRC §1012); property a member contributes keeps the member's adjusted basis (IRC §723), and the difference from its fair market value is the built-in gain or loss that §704(c) makes the partnership account for; property a member converts from personal use has a depreciable basis of the lesser of fair market value and adjusted basis (Treas. Reg. §1.168(i)-4(b)(1)). wanderbooks records the basis; tax depreciation (MACRS, conventions, elections) stays with the CPA. ## 26 U.S.C. §1012(a): basis is cost Source: @@ -38,3 +38,67 @@ Retrieved: 2026-10-15 SHA-256 of the retrieved file (`ecfr168i4.html`): `284efb83eeb11442ab86e050f6d630bd468fd75463e27e3ec81dbc8de455e9be` > Depreciation deduction allowable. This paragraph (b) applies to property that is converted from personal use to use in a taxpayer's trade or business, or for the production of income, during a taxable year. This conversion includes property that was previously used by the taxpayer for personal purposes, including real property (other than land) that is acquired before 1987 and converted from personal use to business or income-producing use after 1986, and depreciable property that was previously used by a tax-exempt entity before the entity changed to a taxable entity. Except as otherwise provided by the Internal Revenue Code or regulations under the Internal Revenue Code, upon a conversion to business or income-producing use, the depreciation allowance for the year of change and any subsequent taxable year is determined as though the property is placed in service by the taxpayer on the date on which the conversion occurs. Thus, except as otherwise provided by the Internal Revenue Code or regulations under the Internal Revenue Code, the taxpayer must use any applicable depreciation method, recovery period, and convention prescribed under section 168 for the property in the year of change, consistent with any election made under section 168 by the taxpayer for that year (see, for example, section 168(b)(5)). See §§ 1.168(k)-1(f)(6)(iii) or 1.168(k)-2(g)(6)(iii), as applicable, and 1.1400L(b)-1(f)(6) for the additional first year depreciation deduction rules applicable to a conversion to business or income-producing use. The depreciable basis of the property for the year of change is the lesser of its fair market value or its adjusted depreciable basis (as defined in § 1.168(b)-1T(a)(4)), as applicable, at the time of the conversion to business or income-producing use. + +## 26 U.S.C. §1016(a)(1)-(2): adjusted basis +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`usc1016.html`): `fe4bb279ed8d661b06d997eab76ed699cbdc51ce87eb72ccf2d0174a5d08188e` + +What "the contributing member's adjusted basis" adds and takes away: improvements up, depreciation down (allowed, but never less than allowable). + +> Proper adjustment in respect of the property shall in all cases be made— (1) for expenditures, receipts, losses, or other items, properly chargeable to capital account, [...] (2) in respect of any period since February 28, 1913, for exhaustion, wear and tear, obsolescence, amortization, and depletion, to the extent of the amount— (A) allowed as deductions in computing taxable income under this subtitle or prior income tax laws, and (B) resulting (by reason of the deductions so allowed) in a reduction for any taxable year of the taxpayer’s taxes under this subtitle [...], but not less than the amount allowable under this subtitle or prior income tax laws. + +## Treas. Reg. §1.704-3: contributed property (§704(c)) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr704-3.html`): `b927010356e8673c1d31ac56255c942f77a32818e9f85761e86eb4f7ee12eda7` + +**§1.704-3(a)(1) General principles** (sentences three, five and six) + +> Notwithstanding any other provision of this section, the allocations must be made using a reasonable method that is consistent with the purpose of section 704(c). [...] An allocation method is not necessarily unreasonable merely because another allocation method would result in a higher aggregate tax liability. Paragraphs (b), (c), and (d) of this section describe allocation methods that are generally reasonable. + +**§1.704-3(a)(2) Operating rules** (first two sentences) + +> Except as provided in paragraphs (e)(2) and (e)(3) of this section, section 704(c) and this section apply on a property-by-property basis. Therefore, in determining whether there is a disparity between adjusted tax basis and fair market value, the built-in gains and built-in losses on items of contributed property cannot be aggregated. + +**§1.704-3(a)(3)(i) Section 704(c) property** (first three sentences) + +> Property contributed to a partnership is section 704(c) property if at the time of contribution its book value differs from the contributing partner's adjusted tax basis. For purposes of this section, book value is determined as contemplated by § 1.704-1(b). Therefore, book value is equal to fair market value at the time of contribution and is subsequently adjusted for cost recovery and other events that affect the basis of the property. + +**§1.704-3(a)(3)(ii) Built-in gain and built-in loss** (the first four sentences) + +> The built-in gain on section 704(c) property is the excess of the property's book value over the contributing partner's adjusted tax basis upon contribution. The built-in gain is thereafter reduced by decreases in the difference between the property's book value and adjusted tax basis. The built-in loss on section 704(c) property is the excess of the contributing partner's adjusted tax basis over the property's book value upon contribution. The built-in loss is thereafter reduced by decreases in the difference between the property's adjusted tax basis and book value. + +**§1.704-3(b)(1) Traditional method** (sentences one, two, five to seven) + +> This paragraph (b) describes the traditional method of making section 704(c) allocations. In general, the traditional method requires that when the partnership has income, gain, loss, or deduction attributable to section 704(c) property, it must make appropriate allocations to the partners to avoid shifting the tax consequences of the built-in gain or loss. [...] For section 704(c) property subject to amortization, depletion, depreciation, or other cost recovery, the allocation of deductions attributable to these items takes into account built-in gain or loss on the property. For example, tax allocations to the noncontributing partners of cost recovery deductions with respect to section 704(c) property generally must, to the extent possible, equal book allocations to those partners. However, the total income, gain, loss, or deduction allocated to the partners for a taxable year with respect to a property cannot exceed the total partnership income, gain, loss, or deduction with respect to that property for the taxable year (the ceiling rule). + +## IRS Publication 541 (12/2025), Partnerships: basis of contributed property +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`p541.html`): `d19b1ede5efb1e7e9f25584ee2034912ed4a499833d15854545761953c5eab39` + +**Basis of contributed property.** + +> If a partner contributes property to a partnership, the partnership’s basis for determining depreciation, depletion, gain, or loss for the property is the same as the partner’s adjusted basis for the property when it was contributed, increased by any gain recognized by the partner at the time of contribution. + +**Allocations to account for built-in gain or loss.** (first two sentences) + +> The FMV of property at the time it is contributed may be different from the partner’s adjusted basis. The partnership must allocate among the partners any income, deduction, gain, or loss on the property in a manner that will account for the difference. + +## IRS Publication 946 (2025), How To Depreciate Property: personal-use property and the placed-in-service date +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`p946.html`): `bcc1d04f59bbfc4e74181831c112745e629e671445dab78e39f17051a4a74eb1` + +**Property changed from personal use.** + +> If you held property for personal use and later use it in your business or income-producing activity, your depreciable basis is the lesser of the following. +> - The FMV of the property on the date of the change in use. +> - Your original cost or other basis adjusted as follows. +> - Increased by the cost of any permanent improvements or additions and other costs that must be added to basis. +> - Decreased by any deductions you claimed for casualty and theft losses and other items that reduced your basis. + +**Placed in Service** (for tax depreciation, the CPA's; the book in-service date rests on ASC 360-10-35-4, rules.md B-15) + +> You place property in service when it is ready and available for a specific use, whether in a business activity, an income-producing activity, a tax-exempt activity, or a personal activity. Even if you are not using the property, it is in service when it is ready and available for its specific use. diff --git a/docs/spec/sources/irs-partnerships.md b/docs/spec/sources/irs-partnerships.md index 885916f..c426716 100644 --- a/docs/spec/sources/irs-partnerships.md +++ b/docs/spec/sources/irs-partnerships.md @@ -1,5 +1,5 @@ # Partnerships: shares of income and capital accounts -Governs `CloseTo::Members` and the members' equity statement. Partners' shares come from the partnership agreement (IRC §704(a)); a partner's capital account is one running balance of what they contributed, their allocations of income and loss, and what was distributed to them (Treas. Reg. §1.704-1(b)(2)(iv)(b)); when a partner's interest changes during the year, the distributive shares take the varying interests into account, period by period (IRC §706(d)(1), Treas. Reg. §1.706-4(a)); Form 1065 reports each partner's capital account on the tax basis (Schedule K-1 item L) and the changes in them (Schedule M-2). No authority prescribes how to round a share to the cent: that rule is the owner's decision (largest remainder, 2026-10-13), counted from the company's first close (D-10, 2026-10-16). +Governs `CloseTo::Members`, the members' equity statement, and the K-1 worksheet's capital accounts on the tax basis (`ledger_core::tax_capital`, rules.md B-36). Partners' shares come from the partnership agreement (IRC §704(a)); a partner's capital account is one running balance of what they contributed, their allocations of income and loss, and what was distributed to them (Treas. Reg. §1.704-1(b)(2)(iv)(b)); when a partner's interest changes during the year, the distributive shares take the varying interests into account, period by period (IRC §706(d)(1), Treas. Reg. §1.706-4(a)); Form 1065 reports each partner's capital account on the tax basis (Schedule K-1 item L) and the changes in them (Schedule M-2). No authority prescribes how to round a share to the cent: that rule is the owner's decision (largest remainder, 2026-10-13), counted from the company's first close (D-10, 2026-10-16). ## 26 U.S.C. §704(a)-(b) Source: @@ -68,3 +68,55 @@ SHA-256 of the retrieved file (`i1065sk1.html`): `d7d6298286601fd95ab8e445c10fd6 **Basis Limitations, its third paragraph of five** (where "based on the partnership's books and records" comes from: not Item L) > Although the partnership provides an analysis of the partner’s capital account in item L of Schedule K-1, that information is based on the partnership’s books and records and can’t be used to figure the partner’s adjusted basis. + +## Instructions for Form 1065 (2025): Item L, the tax-basis method +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`i1065.html`): `3485e936c1f6532a3a8efda78d45db38c70892abe9c532704d605a4bc45714fe` + +Under "Item L. Partner’s Capital Account Analysis" (Specific Instructions, Schedule K-1 only): + +**Tax-basis method.** + +> Figure each partner’s capital account for the partnership’s tax year using the transactional approach, discussed below, for the tax-basis method. + +**How to report partnership events or transactions.** (first two sentences) + +> If you’re uncertain how to report a partnership event or transaction, you should account for the event or transaction in a manner generally consistent with figuring the partner’s adjusted tax basis in its partnership interest (without regard to partnership liabilities), taking into account the rules and principles of sections 705, 722, 733, and 742 and by reporting the amount on the line for other increase (decrease). The partner’s ending capital account as reported using the tax-basis method in item L might not equal the partner’s adjusted tax basis in its partnership interest. + +**Beginning capital account.** + +> Enter the partner’s ending capital account as determined for last year on the line for beginning capital account. If a partner joined the partnership through a contribution to the partnership this year, enter zero as the partner’s beginning capital account. + +**Capital contributed during the year.** + +> On the line “Capital contributed during the year,” enter the amount of cash plus the adjusted tax basis of all property contributed by the partner to the partnership during the year. The amount you enter on this line should be reduced by any liabilities assumed by the partnership in connection with, or liabilities to which the property is subject immediately before, the contribution. This amount might be negative. + +**Current year net income (loss).** + +> On the line “Current year net income (loss),” enter the partner’s distributive share of partnership income and gain (including tax-exempt income) as figured for tax purposes for the year, minus the partner’s distributive share of partnership loss and deductions (including nondeductible, noncapital expenditures) as figured for tax purposes for the year. + +**Other increase (decrease).** (first sentence) + +> On the line “Other increase (decrease),” enter the sum of all other increases or decreases that affected the partner’s capital account for tax purposes during the year and attach a statement explaining each adjustment. + +**Withdrawals and distributions.** + +> On the line “Withdrawals and distributions,” enter the amount of cash plus the adjusted tax basis of all property distributed by the partnership to the partner during the year. The amount you enter on this line should be reduced by any liabilities assumed by the partner in connection with, or liabilities to which the property is subject immediately before, the distribution. This amount might be negative. + +**Ending capital account.** + +> The sum of the amounts shown on the lines in item L above the line “Ending capital account” must equal the amount reported on the line “Ending capital account.” A partner’s ending capital account determined under the tax-basis method may be negative if the sum of a partner’s losses and distributions exceeds the sum of the partner’s contributions and share of income. + +Under "Item M. Did the Partner Contribute Property With a Built-in Gain or Loss?": + +> A property’s built-in gain is the amount by which the FMV of the property exceeds its adjusted tax basis at the time the property is contributed to the partnership. A property’s built-in loss is the amount by which the FMV of the property is less than its adjusted tax basis at the time the property is contributed to the partnership. Partnerships are required to keep track of this information; see Regulations section 1.704-3. + +## Instructions for Form 1065 (2020): the tax-basis method required +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`i1065--2020.pdf`): `64a48b6e8a9d713eaf641ffdd9a5a09e9811692d84a4be2c45f458e69eb7491a` + +Under "Item L. Partner's Capital Account Analysis", from tax year 2020: + +> Tax basis method. Figure each partner's capital account for the partnership's tax year using the transactional approach, discussed below, for the tax basis method. If you reported the partner's capital account last year using any other method (for example, GAAP, section 704(b), or other), you must use the tax basis method this year. diff --git a/docs/testing.md b/docs/testing.md index b085676..a1d8d5b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ promise. | Differential: GnuCash | another implementation, adjudicated (3) | `tests/oracle_gnucash.rs`, `oracle/gnucash/` | `just oracle` | ~2 min | weekly | | Formal proofs | Kani (bounded) and Lean (unbounded) | `src/proofs.rs`, `verify/lean/` | `just prove` | ~15-20 min (bounded by `entry_flows_of_a_reversal_mirror`, ~14 min; all 29 harnesses ~43 min of CPU, run in parallel) | weekly | | Fuzzing | crash, hang, broken books (4); for `fuzz_store_replay` (validly chained journals with fuzz-chosen events): books that open must verify, and the viewer must accept exactly what the writer does | `fuzz/fuzz_targets/`, corpora in `fuzz/corpus/` | `just fuzz ` | 10 min each | weekly | -| Miri | undefined behavior in our use of dependencies (4) | the core's unit, bridge, invariant tests | `just miri` | ~2 min | on demand | +| Miri | undefined behavior in our use of dependencies (4) | the core's unit, bridge, invariant tests (the exhaustive calendar walk aside) | `just miri` | ~4 min | on demand | | Golden | `tests/golden/fixture_company.json` (5) | `invariants.rs` | `just check` | <1s | every change | | Benchmarks | a baseline (6) | `ledger-core/benches/books.rs`, `ledger-store/benches/store.rs` | `just bench` | ~2 min | none (no budget, by decision) | | Mutation | can the tests fail? | `ledger-core` (2026-10-13: 1,011 caught + 13 timeouts of 1,024 viable, 0 missed) | `just mutants` | ~3 h | weekly | @@ -1554,6 +1554,92 @@ version's journal format (rules.md B-27). Mutation testing waits for P12 `upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`, `ASKED_UPGRADES`). None run. +## P3: asset basis and tax-basis capital (2026-10-20) + +Defect 13 (assets: posted vs scheduled, the in-service and acquired dates, +the contributed property's bases) and D-41's K-1 worksheet on the tax +basis. Mutation runs wait for P12 (D-33); none was run. + +- **Named regressions** (the brief's reproductions, anonymized): + `list_assets_book_value_is_what_the_books_carry` and + `a_reversed_run_is_not_posted` (`ledger-mcp/tests/spec_assets.rs`), + `an_asset_bought_before_it_is_in_service_is_on_the_register_from_acquisition`, + `personal_use_contribution_keeps_its_723_basis_apart_from_depreciable_basis`, + `built_in_gain_is_fixed_at_contribution_not_book_cost` + (`ledger-core/tests/spec_asset_basis.rs`, which replaces + `spec_tax_basis.rs`). Each is also a bookspec example + (`bookspec/examples/assets-*.toml`). +- **ledger-core:** `spec_asset_basis.rs` (every `AssetBasis::check` + refusal and its replay pairs, the figure table for each rule, legacy + records giving the figures they gave, the four-contributed-devices + shape, the acquired date from the link and its refusals, the disposal + floor, `accumulated_posted` and its edges: a run dated after `as_of`, a + reversal dated after it, a reversal dated before its run, a reinstated + run, disposal on and after `as_of`, `check_depreciation_run` and + `record_depreciation_run`); `tax_capital.rs` (tax-basis capital per + member with a cash contribution, a contribution of property, a + distribution and a year of depreciation; next year's beginning; cash-only + books; property without a basis; property distributed; the roll-forward + and reconciliation over spans); `properties.rs` + `prop_basis_figures_laws` (5,000 cases); `kernel_bridge.rs` + `basis_figures_agree`; Kani `basis_figures_laws` (`src/proofs.rs`, 83 + checks, its cover satisfied); `spec_asset_tags.rs` (the register's + posted and scheduled figures, runs recorded as a store records them). +- **ledger-store:** `asset_tags.rs` (`asset.basis_set` journaled under the + feature `asset_basis` (new books declare it as they are created), + replayed, reloaded from the database and rebuilt, and in the viewer; a + line whose figures disagree with today's formula still replays; an + unknown rule or method is newer, a malformed figure corrupt; pre-P3 + `asset.tax_basis_set` lines give the figures they gave; P2's v12 + database left as it is while this version builds its v13 file, runs + keyed by entry (`a_p2_database_is_left_and_v13_built_from_the_journal`); + v9 and v10 databases under this version's name rebuilt). The old books + (`books_an_earlier_version_wrote_open_to_its_figures`) open to exactly + their figures. +- **ledger-mcp:** `spec_assets.rs` (the aliases `purchase_date`, + `in_service`, `tax_basis_rule`, `contributor_basis` and the old rule + names, noted, and refused when they disagree; the acquired date from the + acquisition entry; a value at contribution required; every refusal + changing nothing; list_assets as of a date, disposed and not yet + acquired; the register export's columns and totals). +- **ledger-package:** `package.rs` + `the_k1_worksheet_reports_tax_basis_capital` (the CSV line by line). +- **bookspec:** the one loader (`ledger-core/tests/bookspec.rs`) runs the + assets' examples in the shared shape: `setup.assets` with `acquired_by` + and `basis`, a `[[changes]]` `correct_asset`, and the queries + `register_row`, `register_totals`, `asset_basis`, `k1_tax_basis` and + `k1_reconciliation` (keyed `"."`, beside `k1`'s book + figures); refused: a register row before the asset was acquired + (`a_register_row_before_the_asset_was_acquired_is_refused`), an + `acquired_by` naming an event with no entry + (`an_asset_acquired_by_an_event_with_no_entry_is_refused`), a figure for + a tax line the books can't know (`a_tax_line_the_books_cant_know_is_open`) + and a member's reconciliation leaving a cause out + (`a_members_reconciliation_shows_every_cause`). +- **ledger-import:** `asset_register.rs` (`pdf_shows_723_and_depreciable_basis` + and the other PDF checks) and `spec_import.rs` (XLSX columns, totals, + reconciliation and notes). +- **ledger-gui:** `tests/assets.rs` (`basis_figures_are_ledger_cores`, + `journal_text_names_each_basis_figure`, the "Confirm its value" and + "Choose a §704(c) method" items); `pages/assets.rs` + (`an_assets_page_shows_its_own_book_value`: cost less posted, the + schedule beside it); `pages/reports.rs` + (`asset_register_report_carries_tax_basis`). + +**For P12 (mutation targets):** `kernel::basis_figures` (min for max, the +sign of the gain, purchase to a contribution); `Ledger::accumulated_posted` +and `assets::run_chain_entries` (`<=` for `<` on the entry and reversal +dates, the disposed → 0 branch, the alternating sign); `live_depreciation_run`'s +reversal test; `check_depreciation_run`'s arms; the register filter +`acquired <= as_of` and `asset_acquired_date`'s min; `AssetBasis::check`, +`check_journaled` and `check_legacy_journaled` arms; the acquisition and +in-service date refusals; the disposal floor; the MCP `aliased` logic +(both given and differing); `asset_basis_from_journal` (newer vs corrupt); +the projection loader's two key sets; `tax_capital::share` and +`entry_property` (the mismatch branch, the unknown sum), the open-item +conditions (`in_service`, the disposal window), and the reconciliation's +signs. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/docs/verification.md b/docs/verification.md index 9b8664d..b263ba3 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -419,6 +419,22 @@ months); an exhaustive test walks the kernel itself over every 1-to-3-month span of two calendar years, and the bridge and the exact unit tests check the rest. +### An asset's basis figures (P3, defect 13) + +The register's tax figures beside book value (rules.md B-25) come from +one kernel function, `kernel::basis_figures`, for every rule: + +27. **Each figure follows from the rule and the facts, and none + overflows**: a purchase depreciates from its cost and has no §723 basis + or built-in gain; a contribution's §723 basis is the member's adjusted + basis and its built-in gain the value less it (a loss exactly when the + basis exceeds the value); carryover property depreciates from the + basis, personal-use property from the lesser of value and basis (at + most each, equal to one). · Kani `basis_figures_laws` (every rule, + costs, bases and values within the bounds) · bridge `basis_figures_agree` + (the `Decimal` instantiation agrees, rule by rule) · property + `prop_basis_figures_laws` + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, diff --git a/plan/served.md b/plan/served.md index ae98a91..0f23f0d 100644 --- a/plan/served.md +++ b/plan/served.md @@ -519,7 +519,7 @@ Report results also carry `as_of_status {drafts_open_on_or_before, unreconciled, - `cash_flow`: `reconciles`, `noncash_disclosures`, `operating_reconciliation`. - `vendor_1099`: instrument-aware; `include_tins` and `save_csv_to` dropped. - `set_vendor_1099` takes an explicit `not_reportable` plus a reason. -- `register_asset`: `in_service_date` + `acquired_date`, with `purchase_date` kept as an alias for one release; basis fields. +- `register_asset`: `in_service_date` (with `purchase_date` kept as an alias for one release) and basis fields; it reports `acquired_date`, which comes from the acquisition entry it is linked to (settled in design, 2026-10-20), so it takes none. - `list_assets`: `accumulated_posted` and `accumulated_scheduled_through`. - `list_schedules`: start, end, convention, next, remaining. - `parties`: last four from `party.tin_set`. @@ -567,7 +567,7 @@ Every event may carry an additive `actor {principal, client_id, kind}`, which re | `open_item.raised` / `open_item.resolved` | P1 | `Conventions` | | `account.renamed {account_id, code, name, previous, reason}` (keyed by id, as `account.renumbered`: a code can change, an id can't; `code` is informational; the journal feature `account_renamed` is declared before the first) | P3 | chart | | `instrument.added {id, kind: bank\|business_card\|member_card\|member_cash_or_check\|platform, label, last4?, owner_party?, account?}` / `entry.instrument_set` | P3 | `Ledger` (1099) | -| `asset.basis_set {asset, rule: purchase\|carryover_723\|personal_use_conversion, fmv_at_contribution?, contributor_adjusted_basis?, tax_basis_723, depreciable_basis, built_in_gain_704c, sec704c_method?}` (legacy `asset.tax_basis_set` still replays) | P3 | assets | +| `asset.basis_set {asset_id, rule: purchase\|carryover_723\|personal_use_conversion, contributor_adjusted_basis, fmv_at_contribution, sec704c_method, source, tax_basis_723, depreciable_basis, built_in_gain_704c}` (the facts; the figures for readers, never recomputed at replay; a purchase's `depreciable_basis` null; the journal feature `asset_basis` declared before the first; legacy `asset.tax_basis_set` still replays) | P3 | assets | | `schedule.registered` + `convention` (absent = `monthly_even`) | P3 | schedules | | `vendor.1099_set` value `not_reportable{reason}`; legacy `null` replays as `undetermined` | P3 | vendors | | `period.closed` with `close_to: MembersCumulative{since_seq}` (old variants replay as before) | P3 | periods | @@ -942,7 +942,7 @@ The first company keeps working between phases. - `depreciable_basis` = lesser of FMV and adjusted basis for personal-use conversion (Treas. Reg. §1.168(i)-4(b)); - `built_in_gain_704c = fmv_at_contribution − tax_basis_723` (IRC §704(c), Treas. Reg. §1.704-3), with §704(c)(1)(C) noted when the result is negative; - `sec704c_method`. -- `built_in_gain()` (`assets.rs:204`) is redefined to match. +- `built_in_gain()` (`assets.rs:219`) is redefined to match. **Defect 14, party shares:** `resolved` uses the line sign convention (debit-positive) plus an explicit `direction`. Totals take `basis: cash|all`, default cash, with the basis labelled.