P3: assets' posted and scheduled depreciation, their bases, and the K-1 on the tax basis (defect 13, D-41)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Book value is cost less what the books posted; the schedule's figure is apart. An asset's acquired date comes from its entry. Contributed property keeps its section 723 carryover basis apart from its depreciable basis (the lower of value and adjusted basis), with its 704(c) built-in gain and method. The K-1 worksheet reports tax-basis capital (Form 1065 Item L) beside the book figures, leaving open what only the CPA knows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
81 files changed
+9391
-2604
No files matched your search
+39
-10
@@ -124,11 +124,14 @@ the side gives the direction.
|
|||||||
1099-K; rules.md B-19); with the cards, what party money is, for
|
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
|
`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
|
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,
|
- `[[setup.fixed_assets]]` `{ cost_account, accumulated_account,
|
||||||
expense_account }`: accounts that hold property and equipment (their
|
expense_account }`: accounts that hold property and equipment (their
|
||||||
cost and accumulated depreciation are investing; ASC 230-10-45-12(c),
|
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,
|
- `[[setup.assets]]`: fixed assets registered before the first event,
|
||||||
each named by its `id` in the events and queries that ask about it
|
each named by its `id` in the events and queries that ask about it
|
||||||
(rules.md B-15):
|
(rules.md B-15):
|
||||||
@@ -145,10 +148,24 @@ the side gives the direction.
|
|||||||
cost_account = "1500"
|
cost_account = "1500"
|
||||||
accumulated_account = "1510"
|
accumulated_account = "1510"
|
||||||
expense_account = "6200"
|
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
|
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
|
- `[[setup.schedules]]`, and `register_schedule = { ... }` as an event: a
|
||||||
prepaid expense or deferred revenue recognized over its months (rules.md
|
prepaid expense or deferred revenue recognized over its months (rules.md
|
||||||
B-16), named by its `id`:
|
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`
|
distributions_account? }`: the members of a multi-member LLC. `share`
|
||||||
is the member's share of income, an exact decimal fraction (`"0.5"`,
|
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
|
`"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.
|
1. A close to the members, `members_equity`, `k1`, `k1_tax_basis` and
|
||||||
`distributions_account`: the member's distributions account (B-13). A
|
`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`
|
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
|
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
|
parties' shares resolve with those members (B-35), and so does the
|
||||||
@@ -239,10 +257,14 @@ the side gives the direction.
|
|||||||
- `refused = "<reason>"` on a reversal or a command: the books refuse it,
|
- `refused = "<reason>"` on a reversal or a command: the books refuse it,
|
||||||
for that reason (below); it changes nothing, posts nothing, and the
|
for that reason (below); it changes nothing, posts nothing, and the
|
||||||
example goes on without it.
|
example goes on without it.
|
||||||
- `[[changes]]` `{ after, rename?, require_tax_category? }`: a change
|
- `[[changes]]` `{ after, rename?, require_tax_category?, correct_asset?
|
||||||
that posts no entry, exactly one of `rename = { code, name }` (rename
|
}`: a change that posts no entry, exactly one of `rename = { code, name
|
||||||
the account `code`, B-31) and `require_tax_category = "warn"` or
|
}` (rename the account `code`, B-31), `require_tax_category = "warn"`
|
||||||
`"refuse"` (the policy, B-32; the books start at `warn`). It is made
|
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
|
right after event `after` (1 to the number of events), once the
|
||||||
expectations asked after that event are checked, in the order listed.
|
expectations asked after that event are checked, in the order listed.
|
||||||
- `after = N` on an expectation: asked of the books after event N (before
|
- `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` |
|
| `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): `"<member>.beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` |
|
| `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `"<member>.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): `"<member>.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` | `from`, `to` | the K-1 worksheet (B-13; Schedule K-1 Item L): `"<member>.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: `"<member>.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 `"<member>.known_<line>"`; 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): `"<member>.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_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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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_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` |
|
| `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 = [ ...
|
format query, and `uncategorized_accounts`, an array (`values = [ ...
|
||||||
]`). `period`, `day` and `calendar_year` give dates and a year,
|
]`). `period`, `day` and `calendar_year` give dates and a year,
|
||||||
`account_names` names, `party_part`'s `direction`, `vendor_1099`'s
|
`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
|
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
|
does it, and changes nothing checked after it. It shows no values: it is
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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" }
|
||||||
@@ -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" }
|
||||||
@@ -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" }
|
||||||
@@ -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" }
|
||||||
@@ -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" }
|
||||||
@@ -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" }
|
||||||
+411
-128
@@ -10,6 +10,16 @@
|
|||||||
//! for replay. Depreciation itself is pure math — [`depreciation_schedule`]
|
//! for replay. Depreciation itself is pure math — [`depreciation_schedule`]
|
||||||
//! — and postings go through the normal entry machinery, so every
|
//! — and postings go through the normal entry machinery, so every
|
||||||
//! accounting invariant holds.
|
//! 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::money::Steps;
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -51,14 +61,22 @@ pub enum AssetStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A fixed asset on the books.
|
/// 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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Asset {
|
pub struct Asset {
|
||||||
/// Opaque handle.
|
/// Opaque handle.
|
||||||
pub id: AssetId,
|
pub id: AssetId,
|
||||||
/// What it is, e.g. "2023 Sprinter van".
|
/// What it is, e.g. "2023 Sprinter van".
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// Purchase date. Depreciation starts here.
|
/// The day it was placed in service: depreciation starts here (B-15).
|
||||||
pub purchase_date: NaiveDate,
|
/// 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.
|
/// Capitalized cost.
|
||||||
pub cost: Money,
|
pub cost: Money,
|
||||||
/// Expected residual value at end of life.
|
/// Expected residual value at end of life.
|
||||||
@@ -84,14 +102,16 @@ pub struct Asset {
|
|||||||
pub serial: Option<String>,
|
pub serial: Option<String>,
|
||||||
/// The posted entry that put it on the books (its purchase, or a
|
/// The posted entry that put it on the books (its purchase, or a
|
||||||
/// member's contribution of it), if linked
|
/// 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)]
|
#[serde(default)]
|
||||||
pub acquired_by: Option<crate::EntryId>,
|
pub acquired_by: Option<crate::EntryId>,
|
||||||
/// Its federal tax basis, if recorded ([`crate::Ledger::set_asset_tax_basis`]).
|
/// How the company got it and the facts its tax figures follow from,
|
||||||
/// Book cost is [`Asset::cost`]; tax basis can differ (a member's
|
/// if recorded ([`crate::Ledger::set_asset_basis`]). Book cost is
|
||||||
/// contribution carries the member's basis, not its market value).
|
/// [`Asset::cost`]; the tax figures can differ (a member's contribution
|
||||||
#[serde(default)]
|
/// carries the member's basis, not its market value).
|
||||||
pub tax: Option<TaxBasis>,
|
#[serde(default, rename = "tax")]
|
||||||
|
pub basis: Option<AssetBasis>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]).
|
/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]).
|
||||||
@@ -104,88 +124,255 @@ pub struct AssetCorrection {
|
|||||||
/// Useful life in months.
|
/// Useful life in months.
|
||||||
pub useful_life_months: u32,
|
pub useful_life_months: u32,
|
||||||
/// In-service date (depreciation starts here).
|
/// 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
|
/// ```text
|
||||||
/// SOURCE: docs/spec/sources/irs-partnership-basis.md: IRC §1012(a) (cost),
|
/// SOURCE: docs/spec/sources/irs-partnership-basis.md: IRC §1012(a) (cost),
|
||||||
/// IRC §723 (property a partner contributes keeps the partner's
|
/// IRC §723 (property a partner contributes keeps the partner's
|
||||||
/// adjusted basis), Treas. Reg. §1.168(i)-4(b) (property converted
|
/// adjusted basis), Treas. Reg. §1.168(i)-4(b) (property converted
|
||||||
/// from personal use depreciates from the lesser of its fair market
|
/// 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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
pub enum BasisRule {
|
||||||
pub enum TaxBasisRule {
|
/// Bought by the company: its basis is its cost (IRC §1012(a)), the
|
||||||
/// Bought by the company: its cost (IRC §1012(a)), the book cost.
|
/// book cost.
|
||||||
Cost,
|
#[serde(rename = "cost")]
|
||||||
/// Contributed by a member from business use: the member's adjusted
|
Purchase,
|
||||||
/// basis carries over (IRC §723).
|
/// Contributed by a member from business use: the company's basis is
|
||||||
Carryover,
|
/// the member's adjusted basis (IRC §723), and depreciation starts from
|
||||||
/// Contributed by a member from personal use: depreciable basis is the
|
/// it.
|
||||||
/// lesser of fair market value (the book cost) and the member's
|
#[serde(rename = "carryover")]
|
||||||
/// adjusted basis (Treas. Reg. §1.168(i)-4(b), with IRC §723).
|
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,
|
PersonalUseConversion,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TaxBasisRule {
|
impl BasisRule {
|
||||||
/// Its wire name: `cost`, `carryover`, `personal_use_conversion`.
|
/// 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 {
|
pub fn key(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
TaxBasisRule::Cost => "cost",
|
BasisRule::Purchase => "purchase",
|
||||||
TaxBasisRule::Carryover => "carryover",
|
BasisRule::Carryover723 => "carryover_723",
|
||||||
TaxBasisRule::PersonalUseConversion => "personal_use_conversion",
|
BasisRule::PersonalUseConversion => "personal_use_conversion",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rule named `key`.
|
/// The rule named `key` (a wire name).
|
||||||
pub fn parse(key: &str) -> Option<Self> {
|
pub fn parse(key: &str) -> Option<Self> {
|
||||||
[Self::Cost, Self::Carryover, Self::PersonalUseConversion]
|
Self::ALL.into_iter().find(|r| r.key() == key)
|
||||||
.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<Self> {
|
||||||
|
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
|
/// How the company allocates the tax items of a contributed property
|
||||||
/// contribution, the member's adjusted basis at the time.
|
/// 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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TaxBasis {
|
#[serde(rename_all = "snake_case")]
|
||||||
/// The rule.
|
pub enum Sec704cMethod {
|
||||||
pub rule: TaxBasisRule,
|
/// The traditional method, Treas. Reg. §1.704-3(b): tax items follow
|
||||||
/// The contributing member's adjusted basis at contribution (cost less
|
/// the book allocation to the noncontributing members as far as the
|
||||||
/// any depreciation they took). `None` for [`TaxBasisRule::Cost`].
|
/// property's tax items allow (the ceiling rule).
|
||||||
pub contributor_basis: Option<Decimal>,
|
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 {
|
impl Sec704cMethod {
|
||||||
/// Whether the record fits its rule: Cost takes no contributor basis;
|
/// Every method.
|
||||||
/// Carryover and PersonalUseConversion require a non-negative one in
|
pub const ALL: [Self; 3] = [
|
||||||
/// cents at most, and no larger than a cost may be
|
Self::Traditional,
|
||||||
/// (kernel::MAX_LINE_AMOUNT: the register sums them; P0 review round 3,
|
Self::TraditionalWithCurative,
|
||||||
/// S1). Returns why not. Needs no asset, so a caller can check before
|
Self::Remedial,
|
||||||
/// registering one. Replay checks only [`Self::check_journaled`].
|
];
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
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<Decimal>,
|
||||||
|
/// 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<Decimal>,
|
||||||
|
/// The §704(c) method chosen for it, if chosen (contributions only).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sec704c_method: Option<Sec704cMethod>,
|
||||||
|
/// Who gave the facts, when, and on what (rules.md B-29).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<crate::settings::Source>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
pub fn check(&self) -> Result<(), String> {
|
||||||
self.check_journaled()?;
|
self.check_journaled()?;
|
||||||
match self.contributor_basis {
|
if let Some(source) = &self.source {
|
||||||
Some(b) if b > crate::kernel::MAX_LINE_AMOUNT => {
|
source.check().map_err(|why| format!("source: {why}"))?;
|
||||||
Err("a basis is at most one quadrillion".into())
|
}
|
||||||
|
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(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The checks every journaled `asset.tax_basis_set` passed when it was
|
/// The checks every journaled `asset.tax_basis_set` passed when it was
|
||||||
/// written (the rule through 2026-10-16): [`Self::check`] without the
|
/// written (the rule through 2026-10-16), for a record with no value
|
||||||
/// upper bound. Replay applies exactly these (rules.md B-28).
|
/// at contribution: a purchase takes no contributor basis; a
|
||||||
pub fn check_journaled(&self) -> Result<(), String> {
|
/// contribution needs one, non-negative, in cents at most. Replay of
|
||||||
use TaxBasisRule::*;
|
/// that event applies exactly these (rules.md B-28).
|
||||||
match (self.rule, self.contributor_basis) {
|
pub fn check_legacy_journaled(&self) -> Result<(), String> {
|
||||||
(Cost, Some(_)) => {
|
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())
|
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())
|
Err("a contribution needs the contributing member's adjusted basis".into())
|
||||||
}
|
}
|
||||||
(_, Some(b)) if b.is_sign_negative() && !b.is_zero() => {
|
(_, Some(b)) if b.is_sign_negative() && !b.is_zero() => {
|
||||||
@@ -198,34 +385,83 @@ impl TaxBasis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Asset {
|
impl Asset {
|
||||||
/// Its depreciable federal tax basis, by its rule, or `None` if not
|
/// Its fair market value at contribution: the value recorded, or for a
|
||||||
/// recorded.
|
/// 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<Decimal> {
|
||||||
|
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
|
/// ```text
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. Cost: the book cost.
|
/// 1. A purchase's depreciable basis is the book cost now.
|
||||||
/// 2. Carryover: the contributor's adjusted basis.
|
/// 2. A contribution's figures follow its facts: the member's
|
||||||
/// 3. PersonalUseConversion: min(book cost, contributor's basis); the
|
/// adjusted basis and the value at contribution (the book cost for
|
||||||
/// book cost of a contribution is its fair market value.
|
/// 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<Decimal> {
|
pub fn basis_figures(
|
||||||
let t = self.tax?;
|
&self,
|
||||||
Some(match (t.rule, t.contributor_basis) {
|
) -> Result<Option<crate::kernel::BasisFigures<Decimal>>, LedgerError> {
|
||||||
(TaxBasisRule::Cost, _) => self.cost.amount,
|
let Some(b) = &self.basis else {
|
||||||
(TaxBasisRule::Carryover, Some(b)) => b,
|
return Ok(None);
|
||||||
(TaxBasisRule::PersonalUseConversion, Some(b)) => b.min(self.cost.amount),
|
};
|
||||||
(_, None) => return 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
|
/// Its §723 basis (a contribution: the member's adjusted basis), if
|
||||||
/// IRC §704(c): fair market value (the book cost) less the contributor's
|
/// recorded.
|
||||||
/// adjusted basis. `None` for a purchase or when not recorded. A
|
pub fn tax_basis_723(&self) -> Option<Decimal> {
|
||||||
/// built-in loss counts only for the contributing member
|
self.basis_figures().ok().flatten()?.tax_basis_723
|
||||||
/// (§704(c)(1)(C)).
|
}
|
||||||
pub fn built_in_gain(&self) -> Option<Decimal> {
|
|
||||||
let b = self.tax?.contributor_basis?;
|
/// Its depreciable tax basis, if recorded.
|
||||||
Some(self.cost.amount - b)
|
pub fn depreciable_basis(&self) -> Option<Decimal> {
|
||||||
|
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<Decimal> {
|
||||||
|
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 {
|
pub struct NewAsset {
|
||||||
/// What it is.
|
/// What it is.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// Purchase date.
|
/// The day it was placed in service: depreciation starts here. The
|
||||||
pub purchase_date: NaiveDate,
|
/// 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.
|
/// Capitalized cost. Must be positive, in the ledger's base currency.
|
||||||
pub cost: Money,
|
pub cost: Money,
|
||||||
/// Residual value. Non-negative, less than cost, base currency.
|
/// Residual value. Non-negative, less than cost, base currency.
|
||||||
@@ -316,6 +554,21 @@ pub struct NewAsset {
|
|||||||
pub expense_account: AccountId,
|
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.
|
/// One month of a depreciation schedule.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ScheduleLine {
|
pub struct ScheduleLine {
|
||||||
@@ -331,15 +584,15 @@ pub struct ScheduleLine {
|
|||||||
|
|
||||||
/// The end of an asset's life, exclusive: its in-service date plus its
|
/// 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
|
/// 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
|
/// Refused (DateOutOfRange), never a panic, past chrono's last day: replay
|
||||||
/// validation keeps every journaled asset's end a date, so this is for
|
/// validation keeps every journaled asset's end a date, so this is for
|
||||||
/// figures asked of a value no command took.
|
/// figures asked of a value no command took.
|
||||||
pub fn life_end(asset: &Asset) -> Result<NaiveDate, LedgerError> {
|
pub fn life_end(asset: &Asset) -> Result<NaiveDate, LedgerError> {
|
||||||
asset
|
asset
|
||||||
.purchase_date
|
.in_service_date
|
||||||
.checked_add_months(Months::new(asset.useful_life_months))
|
.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
|
/// One month of a schedule, with its month as numbers (the line names it
|
||||||
@@ -350,7 +603,7 @@ struct MonthLine {
|
|||||||
line: ScheduleLine,
|
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:
|
/// `through`, inclusive, and through the disposal day of a disposed asset:
|
||||||
/// one line per month, the last prorated by days when the schedule stops
|
/// one line per month, the last prorated by days when the schedule stops
|
||||||
/// mid-month.
|
/// mid-month.
|
||||||
@@ -365,8 +618,8 @@ struct MonthLine {
|
|||||||
/// date prorates that month by days, as partial first and last
|
/// date prorates that month by days, as partial first and last
|
||||||
/// months do.
|
/// months do.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. The life is [purchase, purchase + life months), cut into calendar
|
/// 1. The life is [in service, in service + life months), cut into
|
||||||
/// months (calendar::month_parts). Through day k of a month but the
|
/// calendar months (calendar::month_parts). Through day k of a month but the
|
||||||
/// last, accumulated is (cost − salvage) / life × (the months held
|
/// last, accumulated is (cost − salvage) / life × (the months held
|
||||||
/// before it, each as days held over days in the month, + k / days
|
/// before it, each as days held over days in the month, + k / days
|
||||||
/// in this month): the monthly rate by days.
|
/// in this month): the monthly rate by days.
|
||||||
@@ -426,7 +679,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
|||||||
AssetStatus::Active => through,
|
AssetStatus::Active => through,
|
||||||
AssetStatus::Disposed { date, .. } => through.min(*date),
|
AssetStatus::Disposed { date, .. } => through.min(*date),
|
||||||
};
|
};
|
||||||
if stop < asset.purchase_date {
|
if stop < asset.in_service_date {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let too_large =
|
let too_large =
|
||||||
@@ -443,7 +696,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
|||||||
let steps = Steps::of(depreciable, asset.cost.currency).ok_or_else(too_large)?;
|
let steps = Steps::of(depreciable, asset.cost.currency).ok_or_else(too_large)?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let (mut before, mut charged) = (0i128, 0i128);
|
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 {
|
if p.first > stop {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -486,21 +739,31 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accumulated depreciation through `as_of` (inclusive), exact to the
|
/// Accumulated depreciation the schedule says is due through `as_of`
|
||||||
/// minor unit ([`depreciation_schedule`]).
|
/// (inclusive), exact to the minor unit ([`depreciation_schedule`]): the
|
||||||
pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Result<Decimal, LedgerError> {
|
/// 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<Decimal, LedgerError> {
|
||||||
Ok(depreciation_schedule(asset, as_of)?
|
Ok(depreciation_schedule(asset, as_of)?
|
||||||
.last()
|
.last()
|
||||||
.map(|l| l.accumulated)
|
.map(|l| l.accumulated)
|
||||||
.unwrap_or(Decimal::ZERO))
|
.unwrap_or(Decimal::ZERO))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Book value (cost − accumulated depreciation) as of a date.
|
/// Cost less the depreciation the *schedule* says is due through `as_of`:
|
||||||
pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Result<Decimal, LedgerError> {
|
/// 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<Decimal, LedgerError> {
|
||||||
asset
|
asset
|
||||||
.cost
|
.cost
|
||||||
.amount
|
.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)))
|
.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.
|
/// 2. A run in effect sums to its amount; one reversed, to zero.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn depreciation_effects(ledger: &Ledger, runs: &[(EntryId, Decimal)]) -> Vec<PostedRun> {
|
pub fn depreciation_effects(ledger: &Ledger, runs: &[(EntryId, Decimal)]) -> Vec<PostedRun> {
|
||||||
|
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<EntryId, EntryId>,
|
||||||
|
) -> Vec<(NaiveDate, Decimal)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for &(run, amount) in runs {
|
for &(run, amount) in runs {
|
||||||
let chain = ledger.reversal_chain(run);
|
let (mut link, mut adds) = (Some(run), true);
|
||||||
let from = chain.iter().position(|&e| e == run).unwrap_or(0);
|
// A chain has no more links than there are reversals: the bound
|
||||||
for (i, e) in chain[from..].iter().enumerate() {
|
// is a guard, never reached.
|
||||||
let Some(entry) = ledger
|
for _ in 0..=reversals.len() {
|
||||||
.get_entry(*e)
|
let Some(id) = link else { break };
|
||||||
|
if let Some(e) = ledger
|
||||||
|
.get_entry(id)
|
||||||
.filter(|x| x.status() == EntryStatus::Posted)
|
.filter(|x| x.status() == EntryStatus::Posted)
|
||||||
else {
|
{
|
||||||
continue;
|
out.push((e.date(), if adds { amount } else { -amount }));
|
||||||
};
|
}
|
||||||
let d = entry.date();
|
link = reversals.get(&id).copied();
|
||||||
out.push(PostedRun {
|
adds = !adds;
|
||||||
year: d.year(),
|
|
||||||
month: d.month(),
|
|
||||||
amount: if i % 2 == 0 { amount } else { -amount },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -598,7 +882,7 @@ pub fn run_amounts(
|
|||||||
through: (i32, u32),
|
through: (i32, u32),
|
||||||
posted: &[PostedRun],
|
posted: &[PostedRun],
|
||||||
) -> Result<Vec<(i32, u32, RunAmount)>, LedgerError> {
|
) -> Result<Vec<(i32, u32, RunAmount)>, 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 {
|
if through < first {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -763,10 +1047,10 @@ pub fn run_amount(
|
|||||||
posted: &[PostedRun],
|
posted: &[PostedRun],
|
||||||
) -> Result<RunAmount, LedgerError> {
|
) -> Result<RunAmount, LedgerError> {
|
||||||
let period = format!("{year:04}-{month:02}");
|
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!(
|
return Err(LedgerError::InvalidDepreciationPeriod(format!(
|
||||||
"{period} ends before asset {} was placed in service on {}",
|
"{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)
|
catch_up_room(asset, year, month, posted)
|
||||||
@@ -838,38 +1122,37 @@ pub struct DisposalAmounts {
|
|||||||
pub gain_loss: Decimal,
|
pub gain_loss: Decimal,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an asset can be disposed of on `date` for `proceeds`: it is
|
/// Whether an asset can be disposed of for `proceeds`: it is active and
|
||||||
/// active, the proceeds aren't negative, and the date isn't before it was
|
/// the proceeds aren't negative. The checks every journaled
|
||||||
/// placed in service (a disposal on that date holds it one day, accrual
|
/// disposal passed, so replaying one never depends on a figure (rules.md
|
||||||
/// being inclusive, B-15). The checks only: no depreciation is computed,
|
/// B-28). The date's floor, the day the company acquired the asset, needs
|
||||||
/// so replaying a journaled disposal never depends on a figure (rules.md
|
/// its acquisition entry, so the ledger checks it for new disposals only
|
||||||
/// B-28).
|
/// (`Ledger::dispose_asset`): a disposal may fall on the in-service date
|
||||||
pub fn check_disposal(asset: &Asset, date: NaiveDate, proceeds: Money) -> Result<(), LedgerError> {
|
/// (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 {
|
if asset.status != AssetStatus::Active {
|
||||||
return Err(LedgerError::AssetAlreadyDisposed(asset.id));
|
return Err(LedgerError::AssetAlreadyDisposed(asset.id));
|
||||||
}
|
}
|
||||||
if proceeds.amount < Decimal::ZERO {
|
if proceeds.amount < Decimal::ZERO {
|
||||||
return Err(LedgerError::NegativeProceeds(proceeds.amount));
|
return Err(LedgerError::NegativeProceeds(proceeds.amount));
|
||||||
}
|
}
|
||||||
if date < asset.purchase_date {
|
|
||||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
/// Compute disposal amounts by the schedule. Pure math; posting is the
|
||||||
/// A disposal may fall on the purchase date (one day held, accrual being
|
/// caller's job. The checks are [`check_disposal`]'s (the acquired-date
|
||||||
/// inclusive, B-15 in docs/spec/rules.md); one before it is an error
|
/// floor is the ledger's, for new disposals). Accumulated depreciation is
|
||||||
/// ([`check_disposal`]). Accumulated depreciation is the schedule through
|
/// the schedule through the disposal day, inclusive: what the schedule of
|
||||||
/// the disposal day, inclusive: what the schedule of the asset, once
|
/// the asset, once disposed, shows ([`depreciation_schedule`] INVARIANT 4);
|
||||||
/// disposed, shows ([`depreciation_schedule`] INVARIANT 4).
|
/// nothing before the in-service date.
|
||||||
pub fn disposal_amounts(
|
pub fn disposal_amounts(
|
||||||
asset: &Asset,
|
asset: &Asset,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
proceeds: Money,
|
proceeds: Money,
|
||||||
) -> Result<DisposalAmounts, LedgerError> {
|
) -> Result<DisposalAmounts, LedgerError> {
|
||||||
check_disposal(asset, date, proceeds)?;
|
check_disposal(asset, proceeds)?;
|
||||||
let accumulated = accumulated_depreciation(asset, date)?;
|
let accumulated = accumulated_scheduled_through(asset, date)?;
|
||||||
let too_large = || LedgerError::TooLargeToCompute(format!("asset {}'s disposal", asset.id));
|
let too_large = || LedgerError::TooLargeToCompute(format!("asset {}'s disposal", asset.id));
|
||||||
let book = asset
|
let book = asset
|
||||||
.cost
|
.cost
|
||||||
|
|||||||
@@ -166,11 +166,35 @@ pub enum LedgerError {
|
|||||||
why: String,
|
why: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// An asset's tax basis record doesn't fit its rule (see
|
/// An asset's basis record doesn't fit its rule (see
|
||||||
/// `Ledger::set_asset_tax_basis`).
|
/// `Ledger::set_asset_basis`).
|
||||||
#[error("bad tax basis: {0}")]
|
#[error("bad tax basis: {0}")]
|
||||||
InvalidTaxBasis(String),
|
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.
|
/// No party has this id.
|
||||||
#[error("unknown party {0}")]
|
#[error("unknown party {0}")]
|
||||||
UnknownParty(u64),
|
UnknownParty(u64),
|
||||||
@@ -411,17 +435,24 @@ pub enum LedgerError {
|
|||||||
#[error("disposal proceeds must not be negative, got {0}")]
|
#[error("disposal proceeds must not be negative, got {0}")]
|
||||||
NegativeProceeds(Decimal),
|
NegativeProceeds(Decimal),
|
||||||
|
|
||||||
/// Disposal date must not precede the purchase date.
|
/// A disposal can't come before the company acquired the asset (its
|
||||||
#[error("disposal date {0} is before the purchase date")]
|
/// acquisition entry's date, or its in-service date when none is
|
||||||
DisposalBeforePurchase(String),
|
/// 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.
|
/// The three asset account links must be three distinct accounts.
|
||||||
#[error("asset account links must be distinct; account id {0} is linked twice")]
|
#[error("asset account links must be distinct; account id {0} is linked twice")]
|
||||||
DuplicateAssetAccount(u64),
|
DuplicateAssetAccount(u64),
|
||||||
|
|
||||||
/// purchase_date + useful life overflows the date range: a new asset's
|
/// in-service date + useful life overflows the date range: a new
|
||||||
/// last day of depreciation is 9999-12-31 at the latest.
|
/// asset's last day of depreciation is 9999-12-31 at the latest.
|
||||||
#[error("purchase date plus useful life overflows the calendar")]
|
#[error("in-service date plus useful life overflows the calendar")]
|
||||||
AssetDateOverflow,
|
AssetDateOverflow,
|
||||||
|
|
||||||
/// A depreciation run for a month that can't be run: not a month, or
|
/// A depreciation run for a month that can't be run: not a month, or
|
||||||
|
|||||||
@@ -169,17 +169,9 @@ pub fn k1_detail(
|
|||||||
}
|
}
|
||||||
let me = members_equity(ledger, start, end, members)?;
|
let me = members_equity(ledger, start, end, members)?;
|
||||||
let places = ledger.base_currency().allocation_places();
|
let places = ledger.base_currency().allocation_places();
|
||||||
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
|
||||||
let closings = ledger.closing_entry_ids();
|
let closings = ledger.closing_entry_ids();
|
||||||
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||||
let property_account = |id: AccountId| {
|
let property_account = |id: AccountId| is_property_account(ledger, cash, id);
|
||||||
!cash.contains(&id)
|
|
||||||
&& Some(id) != receivables
|
|
||||||
&& ledger
|
|
||||||
.chart()
|
|
||||||
.get(id)
|
|
||||||
.is_some_and(|a| a.account_type == AccountType::Asset)
|
|
||||||
};
|
|
||||||
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
|
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
|
||||||
e.lines()
|
e.lines()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -274,6 +266,18 @@ pub fn k1_detail(
|
|||||||
Ok(K1Detail { rows, lines })
|
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
|
/// One entry's property shared among the members' credits in it
|
||||||
/// (INVARIANT 4): at most their total credit, in proportion to each
|
/// (INVARIANT 4): at most their total credit, in proportion to each
|
||||||
/// credit, rounded toward zero to `places` (the base currency's
|
/// credit, rounded toward zero to `places` (the base currency's
|
||||||
|
|||||||
@@ -48,8 +48,14 @@
|
|||||||
//! times the monthly rate within one unit, the last the remainder;
|
//! times the monthly rate within one unit, the last the remainder;
|
||||||
//! * a catch-up run brings the books to the schedule without ever carrying
|
//! * 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
|
//! 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 crate::{AccountType, Side};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
@@ -1402,6 +1408,72 @@ pub fn true_up<T: Amount>(target: T, posted: T) -> Option<(T, T)> {
|
|||||||
columns(target.checked_sub(posted)?)
|
columns(target.checked_sub(posted)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An asset's three basis figures ([`basis_figures`]).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct BasisFigures<T> {
|
||||||
|
/// The partnership's basis in contributed property, IRC §723: the
|
||||||
|
/// contributor's adjusted basis. `None` for a purchase.
|
||||||
|
pub tax_basis_723: Option<T>,
|
||||||
|
/// 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<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<T: Amount>(
|
||||||
|
rule: BasisRule,
|
||||||
|
cost: T,
|
||||||
|
adjusted: T,
|
||||||
|
fmv: T,
|
||||||
|
) -> Option<BasisFigures<T>> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1750,6 +1822,46 @@ mod tests {
|
|||||||
assert_eq!(true_up(5_500i64, 5_500), Some((0, 0)));
|
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]
|
#[test]
|
||||||
fn max_line_amount_is_one_quadrillion() {
|
fn max_line_amount_is_one_quadrillion() {
|
||||||
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
|
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
|
||||||
|
|||||||
@@ -270,6 +270,10 @@ pub struct Ledger {
|
|||||||
/// Every asset tag ever held ([`assets::tag_key`] → the asset that
|
/// Every asset tag ever held ([`assets::tag_key`] → the asset that
|
||||||
/// held it): a tag never moves to another asset.
|
/// held it): a tag never moves to another asset.
|
||||||
asset_tags: std::collections::BTreeMap<String, AssetId>,
|
asset_tags: std::collections::BTreeMap<String, AssetId>,
|
||||||
|
/// 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<AssetId, Vec<assets::DepreciationPosting>>,
|
||||||
/// The party directory ([`crate::parties`]).
|
/// The party directory ([`crate::parties`]).
|
||||||
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
|
parties: std::collections::BTreeMap<crate::PartyId, crate::Party>,
|
||||||
next_party_id: crate::PartyId,
|
next_party_id: crate::PartyId,
|
||||||
@@ -325,6 +329,7 @@ impl Ledger {
|
|||||||
bill_payments: HashMap::new(),
|
bill_payments: HashMap::new(),
|
||||||
roles: std::collections::BTreeMap::new(),
|
roles: std::collections::BTreeMap::new(),
|
||||||
asset_tags: std::collections::BTreeMap::new(),
|
asset_tags: std::collections::BTreeMap::new(),
|
||||||
|
asset_runs: std::collections::BTreeMap::new(),
|
||||||
parties: std::collections::BTreeMap::new(),
|
parties: std::collections::BTreeMap::new(),
|
||||||
next_party_id: 1,
|
next_party_id: 1,
|
||||||
entry_parties: HashMap::new(),
|
entry_parties: HashMap::new(),
|
||||||
@@ -720,6 +725,7 @@ impl Ledger {
|
|||||||
("bill_payments", self.bill_payments == other.bill_payments),
|
("bill_payments", self.bill_payments == other.bill_payments),
|
||||||
("roles", self.roles == other.roles),
|
("roles", self.roles == other.roles),
|
||||||
("asset_tags", self.asset_tags == other.asset_tags),
|
("asset_tags", self.asset_tags == other.asset_tags),
|
||||||
|
("asset_runs", self.asset_runs == other.asset_runs),
|
||||||
("parties", self.parties == other.parties),
|
("parties", self.parties == other.parties),
|
||||||
("next_party_id", self.next_party_id == other.next_party_id),
|
("next_party_id", self.next_party_id == other.next_party_id),
|
||||||
("entry_parties", self.entry_parties == other.entry_parties),
|
("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
|
// its exclusive end) inside the calendar the books keep, so every
|
||||||
// month of the life is a month the books can post.
|
// month of the life is a month the books can post.
|
||||||
if strict {
|
if strict {
|
||||||
crate::calendar::check(new.purchase_date)?;
|
crate::calendar::check(new.in_service_date)?;
|
||||||
}
|
}
|
||||||
let end = new
|
let end = new
|
||||||
.purchase_date
|
.in_service_date
|
||||||
.checked_add_months(Months::new(new.useful_life_months));
|
.checked_add_months(Months::new(new.useful_life_months));
|
||||||
if !end
|
if !end
|
||||||
.is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar))
|
.is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar))
|
||||||
@@ -1981,7 +1987,7 @@ impl Ledger {
|
|||||||
Asset {
|
Asset {
|
||||||
id,
|
id,
|
||||||
description: new.description,
|
description: new.description,
|
||||||
purchase_date: new.purchase_date,
|
in_service_date: new.in_service_date,
|
||||||
cost: new.cost,
|
cost: new.cost,
|
||||||
salvage_value: new.salvage_value,
|
salvage_value: new.salvage_value,
|
||||||
useful_life_months: new.useful_life_months,
|
useful_life_months: new.useful_life_months,
|
||||||
@@ -1993,7 +1999,7 @@ impl Ledger {
|
|||||||
tag: None,
|
tag: None,
|
||||||
serial: None,
|
serial: None,
|
||||||
acquired_by: None,
|
acquired_by: None,
|
||||||
tax: None,
|
basis: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
@@ -2092,19 +2098,25 @@ impl Ledger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Link an asset to the posted entry that put it on the books: its
|
/// 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
|
/// ```text
|
||||||
/// GOAL: from an asset, its source document and capital entry; from
|
/// GOAL: from an asset, its source document and capital entry; from
|
||||||
/// the entry, the asset.
|
/// the entry, the asset; and the day the company acquired it.
|
||||||
/// SOURCE: DECISION (owner, 2026-10-15).
|
/// 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:
|
/// INVARIANTS:
|
||||||
/// 1. The linked entry is posted, is not a reversal, has not been
|
/// 1. The linked entry is posted, is not a reversal, has not been
|
||||||
/// reversed, and debits the asset's cost account.
|
/// 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:
|
/// SCENARIOS:
|
||||||
/// 1. IF the asset or entry is unknown, or the entry fails 1: SHOULD
|
/// 1. IF the asset or entry is unknown, or the entry fails 1 or 2:
|
||||||
/// refuse with AcquisitionEntryMismatch (or UnknownAsset /
|
/// SHOULD refuse with AcquisitionEntryMismatch (or UnknownAsset /
|
||||||
/// UnknownEntry).
|
/// UnknownEntry).
|
||||||
/// 2. IF a later correction reverses the linked entry: link the
|
/// 2. IF a later correction reverses the linked entry: link the
|
||||||
/// replacement (a reversed link stays as history in the journal).
|
/// replacement (a reversed link stays as history in the journal).
|
||||||
@@ -2119,6 +2131,19 @@ impl Ledger {
|
|||||||
Ok(())
|
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
|
/// Restore a link recorded earlier (a projection read-back): it was
|
||||||
/// checked when made, and a later correction (reversing that entry)
|
/// checked when made, and a later correction (reversing that entry)
|
||||||
/// doesn't undo it; it only becomes a link to fix, by linking the
|
/// 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)))
|
.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<NaiveDate> {
|
||||||
|
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> {
|
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 asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||||||
let e = self
|
let e = self
|
||||||
.entries
|
.entries
|
||||||
@@ -2221,69 +2305,109 @@ impl Ledger {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an asset's federal tax basis: the rule, and for a member's
|
/// Record how the company got an asset and the facts its tax figures
|
||||||
/// contribution the member's adjusted basis. Book figures don't change.
|
/// 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
|
/// ```text
|
||||||
/// GOAL: the register carries tax basis beside book value, so book and
|
/// GOAL: the register carries the tax figures beside book value, each
|
||||||
/// tax never get confused and the CPA sees both.
|
/// 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/
|
/// 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:
|
/// INVARIANTS:
|
||||||
/// 1. Cost takes no contributor basis; Carryover and
|
/// 1. The record passes AssetBasis::check: a purchase takes no
|
||||||
/// PersonalUseConversion require one.
|
/// contribution facts; a contribution needs the adjusted basis and
|
||||||
/// 2. A contributor basis is non-negative, in cents at most.
|
/// 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.
|
/// 3. A refusal MUST leave the books unchanged.
|
||||||
/// SCENARIOS:
|
/// 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.
|
/// UnknownAsset or InvalidTaxBasis.
|
||||||
/// 2. IF set again: the new record replaces the old (the journal
|
/// 2. IF set again: the new record replaces the old (the journal
|
||||||
/// keeps both).
|
/// keeps both).
|
||||||
/// ```
|
/// ```
|
||||||
pub fn set_asset_tax_basis(
|
pub fn set_asset_basis(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: AssetId,
|
id: AssetId,
|
||||||
tax: assets::TaxBasis,
|
basis: assets::AssetBasis,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
self.check_asset_tax_basis(id, tax)?;
|
self.check_asset_basis(id, &basis)?;
|
||||||
self.assets.get_mut(&id).expect("checked").tax = Some(tax);
|
self.assets.get_mut(&id).expect("checked").basis = Some(basis);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Self::set_asset_tax_basis`] with the checks every journaled
|
/// The validation behind [`Self::set_asset_basis`].
|
||||||
/// `asset.tax_basis_set` passed when it was written
|
pub fn check_asset_basis(
|
||||||
/// (TaxBasis::check_journaled: no upper bound), for replay only
|
&self,
|
||||||
/// (rules.md B-28). Never for new work.
|
id: AssetId,
|
||||||
pub fn replay_set_asset_tax_basis(
|
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,
|
&mut self,
|
||||||
id: AssetId,
|
id: AssetId,
|
||||||
tax: assets::TaxBasis,
|
basis: assets::AssetBasis,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
let asset = self
|
let asset = self
|
||||||
.assets
|
.assets
|
||||||
.get_mut(&id)
|
.get_mut(&id)
|
||||||
.ok_or(LedgerError::UnknownAsset(id))?;
|
.ok_or(LedgerError::UnknownAsset(id))?;
|
||||||
tax.check_journaled()
|
basis
|
||||||
|
.check_journaled()
|
||||||
.map_err(LedgerError::InvalidTaxBasis)?;
|
.map_err(LedgerError::InvalidTaxBasis)?;
|
||||||
asset.tax = Some(tax);
|
asset.basis = Some(basis);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The validation behind [`Self::set_asset_tax_basis`].
|
/// Replay a pre-P3 `asset.tax_basis_set` (rule and contributor basis,
|
||||||
pub fn check_asset_tax_basis(
|
/// no value at contribution, which reads as the book cost): the checks
|
||||||
&self,
|
/// 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,
|
id: AssetId,
|
||||||
tax: assets::TaxBasis,
|
rule: assets::BasisRule,
|
||||||
|
contributor_basis: Option<Decimal>,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
if !self.assets.contains_key(&id) {
|
let asset = self
|
||||||
return Err(LedgerError::UnknownAsset(id));
|
.assets
|
||||||
}
|
.get_mut(&id)
|
||||||
tax.check().map_err(LedgerError::InvalidTaxBasis)
|
.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,
|
/// Correct a registered asset's economics (cost, salvage, useful life,
|
||||||
/// in-service date) when it was registered wrong. Its accounts, tag,
|
/// 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
|
/// ```text
|
||||||
/// GOAL: fix a registration mistake without re-registering (which would
|
/// 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
|
/// 2. Only an active asset is corrected; a store also refuses once any
|
||||||
/// depreciation for it is posted (the posted entries would disagree
|
/// depreciation for it is posted (the posted entries would disagree
|
||||||
/// with the schedule; correct with entries instead).
|
/// 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(
|
pub fn correct_asset(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -2328,16 +2457,31 @@ impl Ledger {
|
|||||||
a.cost = fix.cost;
|
a.cost = fix.cost;
|
||||||
a.salvage_value = fix.salvage_value;
|
a.salvage_value = fix.salvage_value;
|
||||||
a.useful_life_months = fix.useful_life_months;
|
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(
|
pub fn check_correct_asset(
|
||||||
&self,
|
&self,
|
||||||
id: AssetId,
|
id: AssetId,
|
||||||
fix: &assets::AssetCorrection,
|
fix: &assets::AssetCorrection,
|
||||||
) -> Result<(), LedgerError> {
|
) -> 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
|
/// The active asset `id` as `fix` would leave it, as a registration to
|
||||||
@@ -2353,7 +2497,7 @@ impl Ledger {
|
|||||||
}
|
}
|
||||||
Ok(NewAsset {
|
Ok(NewAsset {
|
||||||
description: a.description.clone(),
|
description: a.description.clone(),
|
||||||
purchase_date: fix.purchase_date,
|
in_service_date: fix.in_service_date,
|
||||||
cost: fix.cost,
|
cost: fix.cost,
|
||||||
salvage_value: fix.salvage_value,
|
salvage_value: fix.salvage_value,
|
||||||
useful_life_months: fix.useful_life_months,
|
useful_life_months: fix.useful_life_months,
|
||||||
@@ -2381,6 +2525,232 @@ impl Ledger {
|
|||||||
.expect("some number is free")
|
.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<EntryId, EntryId> {
|
||||||
|
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::PostedRun> {
|
||||||
|
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<Decimal, LedgerError> {
|
||||||
|
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<std::collections::BTreeMap<AssetId, Decimal>, 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<EntryId, EntryId>,
|
||||||
|
) -> Result<Decimal, LedgerError> {
|
||||||
|
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<Decimal, LedgerError> {
|
||||||
|
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<EntryId, EntryId>,
|
||||||
|
) -> Result<Decimal, LedgerError> {
|
||||||
|
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
|
/// Mark an asset disposed. The disposal *entry* is posted through the
|
||||||
/// normal machinery by the caller; this records the lifecycle change.
|
/// 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))?;
|
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||||||
// Activeness, proceeds sign, and date order; no depreciation is
|
// Activeness and the proceeds' sign; no depreciation is computed,
|
||||||
// computed, so a journaled disposal replays even where the figure
|
// so a journaled disposal replays even where the figure can't be
|
||||||
// can't be (rules.md B-28).
|
// (rules.md B-28).
|
||||||
assets::check_disposal(asset, date, proceeds)?;
|
assets::check_disposal(asset, proceeds)?;
|
||||||
if strict {
|
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
|
// Proceeds are a line of the disposal entry, and its date one
|
||||||
// the books keep (P0 review round 3, S1).
|
// the books keep (P0 review round 3, S1).
|
||||||
if !kernel::Amount::in_line_range(proceeds.amount) {
|
if !kernel::Amount::in_line_range(proceeds.amount) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub mod schedules;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod tax;
|
pub mod tax;
|
||||||
|
pub mod tax_capital;
|
||||||
|
|
||||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||||
pub use arap::{
|
pub use arap::{
|
||||||
@@ -51,10 +52,11 @@ pub use arap::{
|
|||||||
Vendor, VendorId,
|
Vendor, VendorId,
|
||||||
};
|
};
|
||||||
pub use assets::{
|
pub use assets::{
|
||||||
Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset,
|
Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod,
|
||||||
PostedRun, RunAmount, ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation,
|
DepreciationPosting, DisposalAmounts, NewAsset, PostedRun, RunAmount, ScheduleLine,
|
||||||
book_value, catch_up_room, check_disposal, depreciation_effects, depreciation_schedule,
|
Sec704cMethod, accumulated_scheduled_through, catch_up_room, check_disposal,
|
||||||
disposal_amounts, life_end, run_amount, run_amounts,
|
depreciation_effects, depreciation_schedule, disposal_amounts, life_end, run_amount,
|
||||||
|
run_amounts, scheduled_book_value,
|
||||||
};
|
};
|
||||||
pub use cashflow::{
|
pub use cashflow::{
|
||||||
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
|
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
|
||||||
|
|||||||
@@ -1675,6 +1675,50 @@ fn true_up_reaches_the_target() {
|
|||||||
kani::cover!(down > 0, "depreciation past the day, credited back");
|
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
|
// The checker itself
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1166,30 +1166,53 @@ pub struct AssetRegisterRow {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
/// The manufacturer's serial number, if recorded.
|
/// The manufacturer's serial number, if recorded.
|
||||||
pub serial: Option<String>,
|
pub serial: Option<String>,
|
||||||
/// Placed in service (depreciation starts).
|
/// The day the company acquired it: its acquisition entry's date, or
|
||||||
pub in_service: NaiveDate,
|
/// 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.
|
/// Capitalized cost.
|
||||||
pub cost: Decimal,
|
pub cost: Decimal,
|
||||||
/// Salvage value.
|
/// Salvage value.
|
||||||
pub salvage: Decimal,
|
pub salvage: Decimal,
|
||||||
/// Useful life in months.
|
/// Useful life in months.
|
||||||
pub useful_life_months: u32,
|
pub useful_life_months: u32,
|
||||||
/// Accumulated depreciation by its schedule through the date (zero once
|
/// Accumulated depreciation its schedule says is due through the date
|
||||||
/// disposed: it left the books with the asset).
|
/// (zero once disposed: it left the books with the asset).
|
||||||
pub accumulated: Decimal,
|
pub accumulated_scheduled: Decimal,
|
||||||
/// Cost less accumulated (zero once disposed).
|
/// 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,
|
pub book_value: Decimal,
|
||||||
/// Disposal date and proceeds, if disposed by the date.
|
/// Disposal date and proceeds, if disposed by the date.
|
||||||
pub disposed: Option<(NaiveDate, Decimal)>,
|
pub disposed: Option<(NaiveDate, Decimal)>,
|
||||||
/// The federal tax basis rule, if recorded.
|
/// How the company got it (the basis rule), if recorded.
|
||||||
pub tax_rule: Option<crate::TaxBasisRule>,
|
pub basis_rule: Option<crate::BasisRule>,
|
||||||
/// The contributing member's adjusted basis (a contribution only).
|
/// The contributing member's adjusted basis (a contribution only).
|
||||||
pub contributor_basis: Option<Decimal>,
|
pub contributor_adjusted_basis: Option<Decimal>,
|
||||||
/// The depreciable federal tax basis (`Asset::tax_basis`), if recorded.
|
/// Fair market value at contribution (a contribution only): recorded,
|
||||||
pub tax_basis: Option<Decimal>,
|
/// or the book cost for a record from before P3 (`fmv_recorded`
|
||||||
/// Built-in gain (+) or loss (−) at contribution, IRC §704(c)
|
/// false).
|
||||||
/// (`Asset::built_in_gain`).
|
pub fmv_at_contribution: Option<Decimal>,
|
||||||
pub built_in_gain: Option<Decimal>,
|
/// 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<Decimal>,
|
||||||
|
/// The depreciable tax basis at contribution or purchase.
|
||||||
|
pub depreciable_basis: Option<Decimal>,
|
||||||
|
/// The §704(c) built-in gain (+) or loss (−) at contribution.
|
||||||
|
pub built_in_gain_704c: Option<Decimal>,
|
||||||
|
/// 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<crate::Sec704cMethod>,
|
||||||
|
/// Who gave its basis facts, when, and on what.
|
||||||
|
pub basis_source: Option<crate::settings::Source>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fixed asset register as of a date, reconciled to the general ledger.
|
/// The fixed asset register as of a date, reconciled to the general ledger.
|
||||||
@@ -1197,20 +1220,24 @@ pub struct AssetRegisterRow {
|
|||||||
pub struct AssetRegister {
|
pub struct AssetRegister {
|
||||||
/// The date.
|
/// The date.
|
||||||
pub as_of: NaiveDate,
|
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.
|
/// ones carry their disposal and no cost on the books.
|
||||||
pub rows: Vec<AssetRegisterRow>,
|
pub rows: Vec<AssetRegisterRow>,
|
||||||
/// Total cost of the assets still held.
|
/// Total cost of the assets still held.
|
||||||
pub total_cost: Decimal,
|
pub total_cost: Decimal,
|
||||||
/// Their accumulated depreciation by schedule.
|
/// Their accumulated depreciation by schedule.
|
||||||
pub total_accumulated: Decimal,
|
pub total_accumulated_scheduled: Decimal,
|
||||||
/// Their book value.
|
/// 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,
|
pub total_book_value: Decimal,
|
||||||
/// The total depreciable tax basis of the assets still held that have
|
/// The total depreciable tax basis of the assets still held that have
|
||||||
/// one recorded.
|
/// a basis recorded.
|
||||||
pub total_tax_basis: Decimal,
|
pub total_depreciable_basis: Decimal,
|
||||||
/// How many assets still held have no tax basis recorded.
|
/// The total §723 basis of the contributed assets still held.
|
||||||
pub missing_tax_basis: usize,
|
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
|
/// The general ledger's balance (debit positive) of the assets' cost
|
||||||
/// accounts on the date.
|
/// accounts on the date.
|
||||||
pub ledger_cost: Decimal,
|
pub ledger_cost: Decimal,
|
||||||
@@ -1224,7 +1251,15 @@ impl AssetRegister {
|
|||||||
/// don't carry yet (run `run_depreciation`); negative if the books
|
/// don't carry yet (run `run_depreciation`); negative if the books
|
||||||
/// carry more.
|
/// carry more.
|
||||||
pub fn unposted_depreciation(&self) -> Decimal {
|
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
|
/// 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<crate::AssetId, Decimal>,
|
||||||
|
) -> Result<Option<AssetRegisterRow>, 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.
|
/// The fixed asset register as of a date.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: one list of everything the company owns as a fixed asset: what,
|
/// GOAL: one list of everything the company owns as a fixed asset: what,
|
||||||
/// which label, cost, depreciation, book value, and whether the
|
/// which label, cost, depreciation due and posted, book value, tax
|
||||||
/// books agree.
|
/// figures, and whether the books agree.
|
||||||
/// SOURCE: docs/spec/rules.md B-15 (depreciation); DECISION (owner,
|
/// SOURCE: docs/spec/rules.md B-15 (depreciation), B-24 (the register
|
||||||
/// 2026-10-15): a fully featured asset register.
|
/// reconciles to the books), B-25 (tax figures beside book);
|
||||||
|
/// DECISION (owner, 2026-10-15): a fully featured asset register.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. Rows: the assets registered on or before as_of, in registration
|
/// 1. Rows: the assets acquired on or before as_of
|
||||||
/// order; an asset disposed on or before as_of shows its disposal,
|
/// (Ledger::asset_acquired_date), in registration order; an asset
|
||||||
/// zero accumulated and book value, and counts in no total.
|
/// disposed on or before as_of shows its disposal, zero accumulated
|
||||||
/// 2. accumulated is the asset's schedule through as_of
|
/// and book value, and counts in no total.
|
||||||
/// (assets::accumulated_depreciation); book_value = cost −
|
/// 2. accumulated_scheduled is the asset's schedule through as_of
|
||||||
/// accumulated.
|
/// (assets::accumulated_scheduled_through); accumulated_posted is
|
||||||
/// 3. Totals are the sums over the held assets, exactly; tax basis
|
/// what the books carry (Ledger::accumulated_posted); book_value =
|
||||||
/// totals over those with one recorded, and missing_tax_basis counts
|
/// cost − accumulated_posted: the books' figure, never the
|
||||||
/// the rest.
|
/// schedule's.
|
||||||
/// 5. Each row's tax fields are the asset's (Asset::tax_basis,
|
/// 3. Totals are the sums over the held assets, exactly; the
|
||||||
/// Asset::built_in_gain), never re-derived here.
|
/// 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
|
/// 4. ledger_cost and ledger_accumulated are the general ledger's
|
||||||
/// balances on as_of of the distinct cost and accumulated
|
/// balances on as_of of the distinct cost and accumulated
|
||||||
/// depreciation accounts of the registered assets, so
|
/// depreciation accounts of the registered assets, so
|
||||||
/// unposted_depreciation and unregistered_cost are zero exactly when
|
/// unregistered_cost, unposted_depreciation and
|
||||||
/// register and books agree.
|
/// 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
|
/// 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
|
/// (rules.md B-28), and a total past exact arithmetic, or a schedule
|
||||||
/// that can't be computed, is refused (TooLargeToCompute,
|
/// that can't be computed, is refused (TooLargeToCompute,
|
||||||
@@ -1270,66 +1380,44 @@ pub fn fixed_asset_register(
|
|||||||
as_of: NaiveDate,
|
as_of: NaiveDate,
|
||||||
) -> Result<AssetRegister, LedgerError> {
|
) -> Result<AssetRegister, LedgerError> {
|
||||||
let too_large = |what: &str| LedgerError::TooLargeToCompute(format!("the register's {what}"));
|
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 rows = Vec::new();
|
||||||
let (mut cost, mut accumulated) = (Decimal::ZERO, Decimal::ZERO);
|
let (mut cost, mut scheduled, mut posted_total) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||||
let (mut tax_total, mut tax_missing) = (Decimal::ZERO, 0usize);
|
let (mut depreciable_total, mut total_723, mut missing) =
|
||||||
|
(Decimal::ZERO, Decimal::ZERO, 0usize);
|
||||||
let mut cost_accounts = std::collections::BTreeSet::new();
|
let mut cost_accounts = std::collections::BTreeSet::new();
|
||||||
let mut accum_accounts = std::collections::BTreeSet::new();
|
let mut accum_accounts = std::collections::BTreeSet::new();
|
||||||
for a in ledger
|
for a in ledger.registered_assets() {
|
||||||
.registered_assets()
|
let Some(row) = register_row(ledger, a, as_of, &posted)? else {
|
||||||
.filter(|a| a.purchase_date <= as_of)
|
continue;
|
||||||
{
|
};
|
||||||
cost_accounts.insert(a.cost_account);
|
cost_accounts.insert(a.cost_account);
|
||||||
accum_accounts.insert(a.accum_depr_account);
|
accum_accounts.insert(a.accum_depr_account);
|
||||||
let disposed = match &a.status {
|
if row.disposed.is_none() {
|
||||||
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() {
|
|
||||||
cost = cost
|
cost = cost
|
||||||
.checked_add(a.cost.amount)
|
.checked_add(row.cost)
|
||||||
.ok_or_else(|| too_large("cost"))?;
|
.ok_or_else(|| too_large("cost"))?;
|
||||||
accumulated = accumulated
|
scheduled = scheduled
|
||||||
.checked_add(acc)
|
.checked_add(row.accumulated_scheduled)
|
||||||
.ok_or_else(|| too_large("accumulated depreciation"))?;
|
.ok_or_else(|| too_large("accumulated depreciation"))?;
|
||||||
match a.tax_basis() {
|
posted_total = posted_total
|
||||||
Some(t) => {
|
.checked_add(row.accumulated_posted)
|
||||||
tax_total = tax_total
|
.ok_or_else(|| too_large("posted depreciation"))?;
|
||||||
.checked_add(t)
|
match row.depreciable_basis {
|
||||||
.ok_or_else(|| too_large("tax 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 {
|
rows.push(row);
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let tb = trial_balance_as_of(ledger, as_of);
|
let tb = trial_balance_as_of(ledger, as_of);
|
||||||
let net = |id: crate::AccountId| -> Decimal {
|
let net = |id: crate::AccountId| -> Decimal {
|
||||||
@@ -1347,10 +1435,14 @@ pub fn fixed_asset_register(
|
|||||||
as_of,
|
as_of,
|
||||||
rows,
|
rows,
|
||||||
total_cost: cost,
|
total_cost: cost,
|
||||||
total_accumulated: accumulated,
|
total_accumulated_scheduled: scheduled,
|
||||||
total_book_value: cost - accumulated,
|
total_accumulated_posted: posted_total,
|
||||||
total_tax_basis: tax_total,
|
total_book_value: cost
|
||||||
missing_tax_basis: tax_missing,
|
.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_cost: cost_accounts.iter().map(|&id| net(id)).sum(),
|
||||||
ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
|
ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<Open>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Decimal> {
|
||||||
|
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<Decimal> {
|
||||||
|
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<TaxCapitalRow>,
|
||||||
|
/// The items open for every member (tax depreciation, disposals).
|
||||||
|
pub open: Vec<Open>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<TaxCapital, LedgerError> {
|
||||||
|
// 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<Vec<Decimal>, 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<Decimal> = weights
|
||||||
|
.iter()
|
||||||
|
.map(|w| magnitude.checked_mul(*w).and_then(|x| x.checked_div(total)))
|
||||||
|
.collect::<Option<_>>()
|
||||||
|
.ok_or_else(too_large)?;
|
||||||
|
let mut parts: Vec<Decimal> = exact
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
||||||
|
.collect();
|
||||||
|
let remainders: Vec<Decimal> = 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<EntryProperty, LedgerError> {
|
||||||
|
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<TaxCapital, LedgerError> {
|
||||||
|
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<Open>> = vec![Vec::new(); n];
|
||||||
|
let mut dist_open: Vec<Vec<Open>> = vec![Vec::new(); n];
|
||||||
|
// Entries in journal order, each once.
|
||||||
|
let mut entries: Vec<EntryId> = 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<Decimal> {
|
||||||
|
(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<Decimal> = 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")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset,
|
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;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ fn van_asset(
|
|||||||
ledger
|
ledger
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Sprinter van".to_string(),
|
description: "Sprinter van".to_string(),
|
||||||
purchase_date: date(2026, 1, 15),
|
in_service_date: date(2026, 1, 15),
|
||||||
cost: usd(&dollars.to_string()),
|
cost: usd(&dollars.to_string()),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 60,
|
useful_life_months: 60,
|
||||||
@@ -112,7 +112,7 @@ fn salvage_value_is_never_depreciated() {
|
|||||||
let id = ledger
|
let id = ledger
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Machine".to_string(),
|
description: "Machine".to_string(),
|
||||||
purchase_date: date(2026, 3, 1),
|
in_service_date: date(2026, 3, 1),
|
||||||
cost: usd("50000"),
|
cost: usd("50000"),
|
||||||
salvage_value: usd("5000"),
|
salvage_value: usd("5000"),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
@@ -133,11 +133,11 @@ fn book_value_tracks_accumulated() {
|
|||||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||||
let asset = ledger.get_asset(id).unwrap();
|
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.
|
// 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!(acc, Decimal::new(54839, 2) + Decimal::from(11_000));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
book_value(asset, date(2026, 12, 31)).unwrap(),
|
scheduled_book_value(asset, date(2026, 12, 31)).unwrap(),
|
||||||
Decimal::from(60_000) - acc
|
Decimal::from(60_000) - acc
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ fn registration_validation() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let base = NewAsset {
|
let base = NewAsset {
|
||||||
description: "X".to_string(),
|
description: "X".to_string(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: usd("1000"),
|
cost: usd("1000"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -222,7 +222,7 @@ fn registration_rejects_date_overflow() {
|
|||||||
let err = ledger
|
let err = ledger
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Time machine".to_string(),
|
description: "Time machine".to_string(),
|
||||||
purchase_date: NaiveDate::MAX,
|
in_service_date: NaiveDate::MAX,
|
||||||
cost: usd("1000"),
|
cost: usd("1000"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -240,7 +240,7 @@ fn registration_rejects_date_overflow() {
|
|||||||
// could still reach (P0 review round 3, S1).
|
// could still reach (P0 review round 3, S1).
|
||||||
let new = |date: NaiveDate, months: u32| NewAsset {
|
let new = |date: NaiveDate, months: u32| NewAsset {
|
||||||
description: "Late machine".to_string(),
|
description: "Late machine".to_string(),
|
||||||
purchase_date: date,
|
in_service_date: date,
|
||||||
cost: usd("1000"),
|
cost: usd("1000"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: months,
|
useful_life_months: months,
|
||||||
@@ -270,7 +270,7 @@ fn schedule_through_purchase_date_covers_one_day() {
|
|||||||
let id = ledger
|
let id = ledger
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "X".to_string(),
|
description: "X".to_string(),
|
||||||
purchase_date: date(2026, 1, 15),
|
in_service_date: date(2026, 1, 15),
|
||||||
cost: usd("62000"),
|
cost: usd("62000"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 62,
|
useful_life_months: 62,
|
||||||
@@ -335,7 +335,7 @@ fn registration_rejects_wrong_typed_accounts() {
|
|||||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||||
let base = || NewAsset {
|
let base = || NewAsset {
|
||||||
description: "X".to_string(),
|
description: "X".to_string(),
|
||||||
purchase_date: date(2026, 1, 15),
|
in_service_date: date(2026, 1, 15),
|
||||||
cost: usd("1000"),
|
cost: usd("1000"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -415,7 +415,7 @@ fn one_month_useful_life_is_valid() {
|
|||||||
let id = ledger
|
let id = ledger
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Cheap tool".to_string(),
|
description: "Cheap tool".to_string(),
|
||||||
purchase_date: date(2026, 3, 10),
|
in_service_date: date(2026, 3, 10),
|
||||||
cost: usd("300"),
|
cost: usd("300"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 1,
|
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.accumulated, Decimal::new(3226, 2));
|
||||||
assert_eq!(d.book_value, Decimal::new(5_996_774, 2));
|
assert_eq!(d.book_value, Decimal::new(5_996_774, 2));
|
||||||
assert_eq!(d.gain_loss, Decimal::new(3226, 2));
|
assert_eq!(d.gain_loss, Decimal::new(3226, 2));
|
||||||
// The day before purchase is not a disposal.
|
// The day before it is in service nothing has accrued (whether the
|
||||||
assert!(matches!(
|
// company held it by then is its acquisition entry's: the ledger
|
||||||
disposal_amounts(&asset, date(2026, 1, 14), usd("60000")),
|
// checks that floor, Ledger::dispose_asset).
|
||||||
Err(ledger_core::LedgerError::DisposalBeforePurchase(_))
|
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
|
/// An asset links to the posted entry that acquired it, if that entry
|
||||||
|
|||||||
@@ -11,24 +11,27 @@
|
|||||||
//! due: a depreciation run, a disposal, a schedule run or registration),
|
//! due: a depreciation run, a disposal, a schedule run or registration),
|
||||||
//! each accepted or refused as it says, with the parties, payee and
|
//! 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
|
//! 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
|
//! (a rename, a policy, an asset's correction). The books are the
|
||||||
//! (`ledger_store::Store`, in a directory of their own), so every event
|
//! engine's own store (`ledger_store::Store`, in a directory of their
|
||||||
//! goes through the command the books take it by, journaled, and the
|
//! own), so every event goes through the command the books take it by,
|
||||||
//! books must replay to what was kept (`Store::verify`). The queries the
|
//! journaled, and the books must replay to what was kept
|
||||||
|
//! (`Store::verify`). The queries the
|
||||||
//! core answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
|
//! core answers (`cash_flow`, `trial_balance`, `profit_and_loss`,
|
||||||
//! `vendor_1099_paid`, `vendor_1099`, `entry`, `balances`,
|
//! `vendor_1099_paid`, `vendor_1099`, `entry`, `balances`,
|
||||||
//! `members_equity`, `k1`, `party_part`, `party_totals`,
|
//! `members_equity`, `k1`, `k1_tax_basis`, `k1_reconciliation`,
|
||||||
//! `party_tin_last4`, `period`, `day`, `calendar_year`, `account_names`,
|
//! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`,
|
||||||
//! `uncategorized_accounts`, `run`, `disposal`, `depreciation_schedule`,
|
//! `calendar_year`, `account_names`, `uncategorized_accounts`, `run`,
|
||||||
//! `schedule_plan`) are then checked value by value, and the commands it
|
//! `disposal`, `depreciation_schedule`, `schedule_plan`, `register_row`,
|
||||||
//! tries
|
//! `register_totals`, `asset_basis`) are then checked value by value, and
|
||||||
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
|
//! the commands it tries (`rename_account`, `add_account`,
|
||||||
//! refused on a copy of the books, each after the event it names (`after`)
|
//! `set_tax_category`) accepted or refused on a copy of the books, each
|
||||||
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
|
//! after the event it names (`after`) or the last; the formats appendix's
|
||||||
//! checked by ledger-package (`the_bookspec_iif_example_matches_the_engine`),
|
//! (`iif_lines`, `iif_changes`) are checked by ledger-package
|
||||||
//! and a query no crate answers is refused. A key the shape doesn't have
|
//! (`the_bookspec_iif_example_matches_the_engine`), and a query no crate
|
||||||
//! is refused too, never ignored.
|
//! 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::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ use chrono::NaiveDate;
|
|||||||
use ledger_core::calendar::{PeriodDefaults, period_defaults};
|
use ledger_core::calendar::{PeriodDefaults, period_defaults};
|
||||||
use ledger_core::parties::{Basis, MoneyAccounts};
|
use ledger_core::parties::{Basis, MoneyAccounts};
|
||||||
use ledger_core::settings::{Company, Source};
|
use ledger_core::settings::{Company, Source};
|
||||||
|
use ledger_core::tax_capital::TaxCapitalRow;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountType, AssetId, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
|
AccountType, AssetId, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
|
||||||
Dated1099, Decision1099, DocumentLine, EntryId, EntryStatus, ExemptReason, Form1099,
|
Dated1099, Decision1099, DocumentLine, EntryId, EntryStatus, ExemptReason, Form1099,
|
||||||
@@ -170,6 +174,32 @@ struct AssetSpec {
|
|||||||
cost_account: String,
|
cost_account: String,
|
||||||
accumulated_account: String,
|
accumulated_account: String,
|
||||||
expense_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<usize>,
|
||||||
|
/// 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<BasisSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
fmv_at_contribution: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
sec704c_method: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A prepaid expense or deferred revenue recognized over `months` from
|
/// 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
|
/// A change to the books that posts no entry, made right after event
|
||||||
/// `after` (counting from 1) once the expectations asked after that event
|
/// `after` (counting from 1) once the expectations asked after that event
|
||||||
/// are checked: a rename (`rename`) or the tax-category policy
|
/// are checked: a rename (`rename`), the tax-category policy
|
||||||
/// (`require_tax_category`), exactly one of them.
|
/// (`require_tax_category`) or an asset's correction (`correct_asset`),
|
||||||
|
/// exactly one of them.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct ChangeSpec {
|
struct ChangeSpec {
|
||||||
@@ -388,6 +419,25 @@ struct ChangeSpec {
|
|||||||
/// "refuse" (rules.md B-32).
|
/// "refuse" (rules.md B-32).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
require_tax_category: Option<String>,
|
require_tax_category: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
correct_asset: Option<CorrectSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
salvage: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
life_months: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
in_service: Option<String>,
|
||||||
|
reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rename the account `code` to `name` (rules.md B-31).
|
/// Rename the account `code` to `name` (rules.md B-31).
|
||||||
@@ -431,8 +481,9 @@ struct Expect {
|
|||||||
entry: Option<usize>,
|
entry: Option<usize>,
|
||||||
/// The `run_depreciation` event a `run` query asks about.
|
/// The `run_depreciation` event a `run` query asks about.
|
||||||
event: Option<usize>,
|
event: Option<usize>,
|
||||||
/// The fixed asset (`setup.assets` id) a `disposal` or
|
/// The fixed asset (`setup.assets` id) a `disposal`,
|
||||||
/// `depreciation_schedule` query asks about.
|
/// `depreciation_schedule`, `register_row` or `asset_basis` query asks
|
||||||
|
/// about.
|
||||||
asset: Option<String>,
|
asset: Option<String>,
|
||||||
/// The schedule (its id) a `schedule_plan` query asks about.
|
/// The schedule (its id) a `schedule_plan` query asks about.
|
||||||
schedule: Option<String>,
|
schedule: Option<String>,
|
||||||
@@ -792,6 +843,22 @@ fn add_parties(ex: &Example, store: &mut Store) -> BTreeMap<String, PartyId> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An asset's basis facts as the books record them.
|
||||||
|
fn basis(b: &BasisSpec, what: &str) -> ledger_core::AssetBasis {
|
||||||
|
let amount = |s: &Option<String>| 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.
|
/// A schedule as the books register it.
|
||||||
fn new_schedule(l: &Ledger, s: &ScheduleSpec, currency: Currency, what: &str) -> NewSchedule {
|
fn new_schedule(l: &Ledger, s: &ScheduleSpec, currency: Currency, what: &str) -> NewSchedule {
|
||||||
let account = |code: &str| {
|
let account = |code: &str| {
|
||||||
@@ -892,7 +959,7 @@ impl<'a> Books<'a> {
|
|||||||
for fa in &s.fixed_assets {
|
for fa in &s.fixed_assets {
|
||||||
let new = NewAsset {
|
let new = NewAsset {
|
||||||
description: format!("Fixed asset on {}", fa.cost_account),
|
description: format!("Fixed asset on {}", fa.cost_account),
|
||||||
purchase_date: first,
|
in_service_date: first,
|
||||||
cost: Money::new(Decimal::ONE, currency),
|
cost: Money::new(Decimal::ONE, currency),
|
||||||
salvage_value: Money::new(Decimal::ZERO, currency),
|
salvage_value: Money::new(Decimal::ZERO, currency),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -911,7 +978,7 @@ impl<'a> Books<'a> {
|
|||||||
assert_eq!(a.method, "straight_line", "{what}: method");
|
assert_eq!(a.method, "straight_line", "{what}: method");
|
||||||
let new = NewAsset {
|
let new = NewAsset {
|
||||||
description: a.description.clone(),
|
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),
|
cost: Money::new(exact(&a.cost, &what), currency),
|
||||||
salvage_value: Money::new(exact(&a.salvage, &what), currency),
|
salvage_value: Money::new(exact(&a.salvage, &what), currency),
|
||||||
useful_life_months: a.life_months,
|
useful_life_months: a.life_months,
|
||||||
@@ -924,6 +991,15 @@ impl<'a> Books<'a> {
|
|||||||
assets.insert(a.id.clone(), asset).is_none(),
|
assets.insert(a.id.clone(), asset).is_none(),
|
||||||
"{what}: id used twice"
|
"{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();
|
let mut schedules = BTreeMap::new();
|
||||||
for sc in &s.schedules {
|
for sc in &s.schedules {
|
||||||
@@ -1190,6 +1266,17 @@ impl<'a> Books<'a> {
|
|||||||
assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry");
|
assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry");
|
||||||
}
|
}
|
||||||
self.posted.push(posted);
|
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() {
|
if !e.parties.is_empty() {
|
||||||
let entry = self.entry_of(n, &what);
|
let entry = self.entry_of(n, &what);
|
||||||
let shares: Vec<PartyShare> = e
|
let shares: Vec<PartyShare> = e
|
||||||
@@ -1391,12 +1478,30 @@ impl Books<'_> {
|
|||||||
let ex = &self.ex.id;
|
let ex = &self.ex.id;
|
||||||
let after = c.after;
|
let after = c.after;
|
||||||
let what = format!("{ex} change after {after}");
|
let what = format!("{ex} change after {after}");
|
||||||
match (&c.rename, &c.require_tax_category) {
|
match (&c.rename, &c.require_tax_category, &c.correct_asset) {
|
||||||
(Some(r), None) => {
|
(Some(r), None, None) => {
|
||||||
let account = self.id(&r.code);
|
let account = self.id(&r.code);
|
||||||
took(self.store.rename_account(account, &r.name, None), &what);
|
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<String>, 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!(
|
assert!(
|
||||||
matches!(policy.as_str(), "warn" | "refuse"),
|
matches!(policy.as_str(), "warn" | "refuse"),
|
||||||
"{what}: require_tax_category {policy:?}"
|
"{what}: require_tax_category {policy:?}"
|
||||||
@@ -1411,7 +1516,7 @@ impl Books<'_> {
|
|||||||
&what,
|
&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) {
|
for (key, want) in x.figures(&ex.id) {
|
||||||
let want = exact(want, &format!("{what}: {key}"));
|
let want = exact(want, &format!("{what}: {key}"));
|
||||||
let got = match key.as_str() {
|
let got = match key.as_str() {
|
||||||
"accumulated" => ledger_core::accumulated_depreciation(asset, as_of),
|
"accumulated" => ledger_core::accumulated_scheduled_through(asset, as_of),
|
||||||
"book_value" => ledger_core::book_value(asset, as_of),
|
"book_value" => ledger_core::scheduled_book_value(asset, as_of),
|
||||||
month => {
|
month => {
|
||||||
month_key(month, &what);
|
month_key(month, &what);
|
||||||
Ok(lines
|
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<K: Borrow<str> + Ord>(
|
||||||
|
ex: &Example,
|
||||||
|
x: &Expect,
|
||||||
|
what: &str,
|
||||||
|
figures: &BTreeMap<K, String>,
|
||||||
|
) {
|
||||||
|
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<Decimal>| 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<TaxCapitalRow> {
|
||||||
|
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), `"<member>.<line>"` 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
|
||||||
|
/// `"<member>.known_<line>"`. 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), `"<member>.<cause>"` 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
|
/// The commands an example may try: each on a copy of the books, so it
|
||||||
/// changes nothing that is checked after it.
|
/// changes nothing that is checked after it.
|
||||||
const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"];
|
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(
|
only(
|
||||||
x.asset.is_some(),
|
x.asset.is_some(),
|
||||||
"asset",
|
"asset",
|
||||||
&["disposal", "depreciation_schedule"],
|
&[
|
||||||
|
"disposal",
|
||||||
|
"depreciation_schedule",
|
||||||
|
"register_row",
|
||||||
|
"asset_basis",
|
||||||
|
],
|
||||||
);
|
);
|
||||||
only(x.schedule.is_some(), "schedule", &["schedule_plan"]);
|
only(x.schedule.is_some(), "schedule", &["schedule_plan"]);
|
||||||
only(
|
only(
|
||||||
@@ -2535,6 +2893,11 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
|
|||||||
"disposal" => check_disposal(b, x),
|
"disposal" => check_disposal(b, x),
|
||||||
"depreciation_schedule" => check_depreciation_schedule(b, x),
|
"depreciation_schedule" => check_depreciation_schedule(b, x),
|
||||||
"schedule_plan" => check_schedule_plan(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),
|
"balances" => check_balances(ex, l, x),
|
||||||
"members_equity" => check_members_equity(ex, l, &b.members, x),
|
"members_equity" => check_members_equity(ex, l, &b.members, x),
|
||||||
"k1" => check_k1(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
|
// Each query the examples use ran: the cash flow's six examples at
|
||||||
// least, the as-of and 1099 examples', the members closes', the
|
// least, the as-of and 1099 examples', the members closes', the
|
||||||
// formats appendix's, and P3's default period, rename, tax category,
|
// 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 [
|
for (q, at_least) in [
|
||||||
("cash_flow", 6),
|
("cash_flow", 6),
|
||||||
("trial_balance", 2),
|
("trial_balance", 2),
|
||||||
@@ -2642,7 +3006,7 @@ fn every_bookspec_example_holds() {
|
|||||||
("entry", 8),
|
("entry", 8),
|
||||||
("balances", 4),
|
("balances", 4),
|
||||||
("members_equity", 10),
|
("members_equity", 10),
|
||||||
("k1", 2),
|
("k1", 3),
|
||||||
("party_part", 28),
|
("party_part", 28),
|
||||||
("party_totals", 23),
|
("party_totals", 23),
|
||||||
("party_tin_last4", 5),
|
("party_tin_last4", 5),
|
||||||
@@ -2660,6 +3024,11 @@ fn every_bookspec_example_holds() {
|
|||||||
("disposal", 5),
|
("disposal", 5),
|
||||||
("depreciation_schedule", 5),
|
("depreciation_schedule", 5),
|
||||||
("schedule_plan", 3),
|
("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);
|
let n = checked.get(q).copied().unwrap_or(0);
|
||||||
assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}");
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1035,7 +1035,7 @@ fn a_sale_to_a_contractor_does_not_lower_its_1099() {
|
|||||||
let depr = acct(&mut m.l, "6800", AccountType::Expense);
|
let depr = acct(&mut m.l, "6800", AccountType::Expense);
|
||||||
m.l.register_asset(NewAsset {
|
m.l.register_asset(NewAsset {
|
||||||
description: "Monitor".into(),
|
description: "Monitor".into(),
|
||||||
purchase_date: d(2024, 1, 1),
|
in_service_date: d(2024, 1, 1),
|
||||||
cost: Money::new(dec("1000"), Currency::USD),
|
cost: Money::new(dec("1000"), Currency::USD),
|
||||||
salvage_value: Money::new(dec("0"), Currency::USD),
|
salvage_value: Money::new(dec("0"), Currency::USD),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
|
|||||||
@@ -863,7 +863,7 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
|||||||
let asset = ledger
|
let asset = ledger
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Van".to_string(),
|
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),
|
cost: usd(60_000),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 60,
|
useful_life_months: 60,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,13 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_scheduled_through,
|
||||||
depreciation_schedule,
|
depreciation_schedule,
|
||||||
reports::{
|
reports::{
|
||||||
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
|
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
|
||||||
unclosed_earnings,
|
unclosed_earnings,
|
||||||
},
|
},
|
||||||
|
scheduled_book_value,
|
||||||
};
|
};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -290,7 +291,7 @@ proptest! {
|
|||||||
let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap();
|
let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap();
|
||||||
let id = ledger.register_asset(NewAsset {
|
let id = ledger.register_asset(NewAsset {
|
||||||
description: "prop".to_string(),
|
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),
|
cost: Money::new(cost, Currency::USD),
|
||||||
salvage_value: Money::new(salvage, Currency::USD),
|
salvage_value: Money::new(salvage, Currency::USD),
|
||||||
useful_life_months: life,
|
useful_life_months: life,
|
||||||
@@ -299,7 +300,7 @@ proptest! {
|
|||||||
expense_account: ex,
|
expense_account: ex,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
let asset = ledger.get_asset(id).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();
|
let sched = depreciation_schedule(asset, far).unwrap();
|
||||||
// The lifetime total is exactly cost minus salvage: never a penny off.
|
// The lifetime total is exactly cost minus salvage: never a penny off.
|
||||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||||
@@ -314,8 +315,8 @@ proptest! {
|
|||||||
}
|
}
|
||||||
// Book value at any date matches the schedule.
|
// Book value at any date matches the schedule.
|
||||||
if let Some(last) = sched.last() {
|
if let Some(last) = sched.last() {
|
||||||
prop_assert_eq!(book_value(asset, far).unwrap(), last.book_value);
|
prop_assert_eq!(scheduled_book_value(asset, far).unwrap(), last.book_value);
|
||||||
prop_assert_eq!(accumulated_depreciation(asset, far).unwrap(), last.accumulated);
|
prop_assert_eq!(accumulated_scheduled_through(asset, far).unwrap(), last.accumulated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,7 +507,7 @@ mod p3 {
|
|||||||
let id = ledger
|
let id = ledger
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "prop".to_string(),
|
description: "prop".to_string(),
|
||||||
purchase_date: start,
|
in_service_date: start,
|
||||||
cost: Money::new(Decimal::new(cost_cents as i64, 2), Currency::USD),
|
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),
|
salvage_value: Money::new(Decimal::new(salvage_cents as i64, 2), Currency::USD),
|
||||||
useful_life_months: life,
|
useful_life_months: life,
|
||||||
@@ -631,7 +632,7 @@ proptest! {
|
|||||||
let mut prev = Decimal::ZERO;
|
let mut prev = Decimal::ZERO;
|
||||||
for day in days {
|
for day in days {
|
||||||
let s = depreciation_schedule(a, day).unwrap();
|
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::<Decimal>(), acc);
|
prop_assert_eq!(s.iter().map(|l| l.amount).sum::<Decimal>(), acc);
|
||||||
prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO));
|
prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO));
|
||||||
prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev);
|
prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev);
|
||||||
@@ -673,10 +674,10 @@ proptest! {
|
|||||||
let full = depreciation_schedule(a, NaiveDate::MAX).unwrap();
|
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!(full.last().map_or(Decimal::ZERO, |l| l.accumulated), want.accumulated);
|
||||||
prop_assert_eq!(
|
prop_assert_eq!(
|
||||||
accumulated_depreciation(a, on + chrono::Days::new(later)).unwrap(),
|
accumulated_scheduled_through(a, on + chrono::Days::new(later)).unwrap(),
|
||||||
want.accumulated
|
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
|
/// B-15 rule 3 (defect 22), against arithmetic written here: through
|
||||||
@@ -711,7 +712,7 @@ proptest! {
|
|||||||
x / y + i128::from(2 * (x % y) >= y)
|
x / y + i128::from(2 * (x % y) >= y)
|
||||||
};
|
};
|
||||||
prop_assert_eq!(
|
prop_assert_eq!(
|
||||||
accumulated_depreciation(a, through).unwrap(),
|
accumulated_scheduled_through(a, through).unwrap(),
|
||||||
Decimal::new(want as i64, 2),
|
Decimal::new(want as i64, 2),
|
||||||
"{} of {}", through, last_day
|
"{} of {}", through, last_day
|
||||||
);
|
);
|
||||||
@@ -882,3 +883,53 @@ proptest! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn arb_basis_rule() -> impl Strategy<Value = ledger_core::BasisRule> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,7 @@
|
|||||||
//! `AssetRegister`, and `docs/spec/rules.md` B-15 and B-24. Depreciation
|
//! `AssetRegister`, and `docs/spec/rules.md` B-15 and B-24. Depreciation
|
||||||
//! amounts are worked by hand (straight line, whole months) or, for
|
//! amounts are worked by hand (straight line, whole months) or, for
|
||||||
//! mid-month dates, taken from the already-tested building block
|
//! 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.
|
//! test and then asserted.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -21,7 +21,7 @@ use ledger_core::assets::{
|
|||||||
};
|
};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
|
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
|
||||||
accumulated_depreciation, fixed_asset_register,
|
accumulated_scheduled_through, fixed_asset_register,
|
||||||
};
|
};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
use rust_decimal::Decimal;
|
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 {
|
fn equipment(a: &Accts, description: &str, cost: &str, life: u32, on: NaiveDate) -> NewAsset {
|
||||||
NewAsset {
|
NewAsset {
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
purchase_date: on,
|
in_service_date: on,
|
||||||
cost: usd(cost),
|
cost: usd(cost),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: life,
|
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.
|
/// Buy equipment for cash: Dr cost account / Cr 1000.
|
||||||
fn buy(l: &mut Ledger, a: &Accts, account: AccountId, on: NaiveDate, amt: &str) {
|
fn buy(l: &mut Ledger, a: &Accts, account: AccountId, on: NaiveDate, amt: &str) {
|
||||||
post(
|
post(
|
||||||
@@ -747,7 +763,8 @@ fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) {
|
|||||||
.register_asset(equipment(&a, "Server", "2400.00", 24, date(2026, 1, 1)))
|
.register_asset(equipment(&a, "Server", "2400.00", 24, date(2026, 1, 1)))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for m in 1..=3 {
|
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)
|
(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.
|
/// INVARIANT 3: totals are the sums over held (not disposed) rows.
|
||||||
fn assert_totals_are_sums(r: &ledger_core::AssetRegister) {
|
fn assert_totals_are_sums(r: &ledger_core::AssetRegister) {
|
||||||
let held = r.rows.iter().filter(|x| x.disposed.is_none());
|
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 {
|
for x in held {
|
||||||
c += x.cost;
|
c += x.cost;
|
||||||
acc += x.accumulated;
|
acc += x.accumulated_scheduled;
|
||||||
|
posted += x.accumulated_posted;
|
||||||
bv += x.book_value;
|
bv += x.book_value;
|
||||||
}
|
}
|
||||||
assert_eq!(r.total_cost, c);
|
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);
|
assert_eq!(r.total_book_value, bv);
|
||||||
for x in &r.rows {
|
for x in &r.rows {
|
||||||
// INVARIANT 2 (held) / INVARIANT 1 (disposed).
|
// INVARIANT 2 (held) / INVARIANT 1 (disposed).
|
||||||
if x.disposed.is_some() {
|
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);
|
assert_eq!(x.book_value, Decimal::ZERO);
|
||||||
} else {
|
} 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.tag.as_deref(), Some("FL-0001"));
|
||||||
assert_eq!(x.serial.as_deref(), Some("C02XG0FDJGH5"));
|
assert_eq!(x.serial.as_deref(), Some("C02XG0FDJGH5"));
|
||||||
assert_eq!(x.description, "Laptop");
|
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.cost, d("12000.00"));
|
||||||
assert_eq!(x.salvage, d("0.00"));
|
assert_eq!(x.salvage, d("0.00"));
|
||||||
assert_eq!(x.useful_life_months, 12);
|
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.book_value, d("9000.00"));
|
||||||
assert_eq!(x.disposed, None);
|
assert_eq!(x.disposed, None);
|
||||||
let y = &r.rows[1];
|
let y = &r.rows[1];
|
||||||
assert_eq!(y.tag, None);
|
assert_eq!(y.tag, None);
|
||||||
assert_eq!(y.serial, 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!(y.book_value, d("2100.00"));
|
||||||
assert_eq!(r.total_cost, d("14400.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"));
|
assert_eq!(r.total_book_value, d("11100.00"));
|
||||||
// INVARIANT 4: the GL, each shared account counted once.
|
// INVARIANT 4: the GL, each shared account counted once.
|
||||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||||
assert_eq!(r.ledger_accumulated, d("3300.00"));
|
assert_eq!(r.ledger_accumulated, d("3300.00"));
|
||||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||||
|
assert_eq!(r.unattributed_accumulated(), Decimal::ZERO);
|
||||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||||
assert_totals_are_sums(&r);
|
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]
|
#[test]
|
||||||
fn register_shows_unposted_depreciation() {
|
fn register_shows_unposted_depreciation() {
|
||||||
let (l, _, _, _) = register_fixture();
|
let (l, _, _, _) = register_fixture();
|
||||||
let r = fixed_asset_register(&l, date(2026, 4, 30)).unwrap();
|
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.ledger_accumulated, d("3300.00"));
|
||||||
assert_eq!(r.unposted_depreciation(), d("1100.00"));
|
assert_eq!(r.unposted_depreciation(), d("1100.00"));
|
||||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||||
assert_totals_are_sums(&r);
|
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]
|
#[test]
|
||||||
fn register_shows_overposted_depreciation_as_negative() {
|
fn register_shows_overposted_depreciation_as_negative() {
|
||||||
let (mut l, a, _, _) = register_fixture();
|
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();
|
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
|
||||||
assert_eq!(r.ledger_accumulated, d("3350.00"));
|
assert_eq!(r.ledger_accumulated, d("3350.00"));
|
||||||
assert_eq!(r.unposted_depreciation(), d("-50.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.
|
/// 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() {
|
fn register_ledger_balances_are_as_of_the_date() {
|
||||||
let (l, _, _, _) = register_fixture();
|
let (l, _, _, _) = register_fixture();
|
||||||
let r = fixed_asset_register(&l, date(2026, 2, 28)).unwrap();
|
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_accumulated, d("2200.00"));
|
||||||
assert_eq!(r.ledger_cost, d("14400.00"));
|
assert_eq!(r.ledger_cost, d("14400.00"));
|
||||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// INVARIANT 2 mid-month: accumulated is accumulated_depreciation through
|
/// INVARIANT 2 mid-month: accumulated_scheduled is accumulated_scheduled_through
|
||||||
/// as_of.
|
/// through as_of; book value stays cost less what is posted.
|
||||||
#[test]
|
#[test]
|
||||||
fn register_accumulated_mid_month_follows_the_schedule() {
|
fn register_accumulated_mid_month_follows_the_schedule() {
|
||||||
let (l, _, laptop, server) = register_fixture();
|
let (l, _, laptop, server) = register_fixture();
|
||||||
let on = date(2026, 5, 17);
|
let on = date(2026, 5, 17);
|
||||||
let r = fixed_asset_register(&l, on).unwrap();
|
let r = fixed_asset_register(&l, on).unwrap();
|
||||||
let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on).unwrap();
|
let la = accumulated_scheduled_through(l.get_asset(laptop).unwrap(), on).unwrap();
|
||||||
let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on).unwrap();
|
let sa = accumulated_scheduled_through(l.get_asset(server).unwrap(), on).unwrap();
|
||||||
assert_eq!(r.rows[0].accumulated, la);
|
assert_eq!(r.rows[0].accumulated_scheduled, la);
|
||||||
assert_eq!(r.rows[1].accumulated, sa);
|
assert_eq!(r.rows[1].accumulated_scheduled, sa);
|
||||||
assert_eq!(r.rows[0].book_value, d("12000.00") - la);
|
assert_eq!(r.rows[0].accumulated_posted, d("3000.00"));
|
||||||
assert_eq!(r.total_accumulated, la + sa);
|
assert_eq!(r.rows[0].book_value, d("9000.00"));
|
||||||
|
assert_eq!(r.total_accumulated_scheduled, la + sa);
|
||||||
assert_totals_are_sums(&r);
|
assert_totals_are_sums(&r);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,7 +917,8 @@ fn register_shows_unregistered_cost() {
|
|||||||
assert_eq!(r2.unposted_depreciation(), d("100.00"));
|
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]
|
#[test]
|
||||||
fn register_leaves_out_assets_not_yet_in_service() {
|
fn register_leaves_out_assets_not_yet_in_service() {
|
||||||
let (mut l, a, laptop, server) = register_fixture();
|
let (mut l, a, laptop, server) = register_fixture();
|
||||||
@@ -897,14 +934,14 @@ fn register_leaves_out_assets_not_yet_in_service() {
|
|||||||
let ids: Vec<AssetId> = r.rows.iter().map(|x| x.asset_id).collect();
|
let ids: Vec<AssetId> = r.rows.iter().map(|x| x.asset_id).collect();
|
||||||
assert_eq!(ids, vec![laptop, server, chair]);
|
assert_eq!(ids, vec![laptop, server, chair]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
r.rows[2].accumulated,
|
r.rows[2].accumulated_scheduled,
|
||||||
accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap()
|
accumulated_scheduled_through(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap()
|
||||||
);
|
);
|
||||||
// Before anything: empty and zero.
|
// Before anything: empty and zero.
|
||||||
let r = fixed_asset_register(&l, date(2025, 12, 31)).unwrap();
|
let r = fixed_asset_register(&l, date(2025, 12, 31)).unwrap();
|
||||||
assert!(r.rows.is_empty());
|
assert!(r.rows.is_empty());
|
||||||
assert_eq!(r.total_cost, Decimal::ZERO);
|
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);
|
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,
|
/// depreciation: book value 1,800, gain 200. Entry: Dr 1000 2,000,
|
||||||
/// Dr 1510 600, Cr 1500 2,400, Cr 4900 200.
|
/// Dr 1510 600, Cr 1500 2,400, Cr 4900 200.
|
||||||
fn dispose_server(l: &mut Ledger, a: &Accts, server: AssetId) {
|
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 {
|
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"))
|
l.dispose_asset(server, date(2026, 6, 30), usd("2000.00"))
|
||||||
.unwrap();
|
.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");
|
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();
|
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.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.book_value, Decimal::ZERO);
|
||||||
assert_eq!(s.tag.as_deref(), Some("FL-0002"));
|
assert_eq!(s.tag.as_deref(), Some("FL-0002"));
|
||||||
assert_eq!(s.cost, d("2400.00"));
|
assert_eq!(s.cost, d("2400.00"));
|
||||||
let lp = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
|
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_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.total_book_value, d("6000.00"));
|
||||||
assert_eq!(r.ledger_cost, d("12000.00"));
|
assert_eq!(r.ledger_cost, d("12000.00"));
|
||||||
assert_eq!(r.ledger_accumulated, d("6000.00"));
|
assert_eq!(r.ledger_accumulated, d("6000.00"));
|
||||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
||||||
|
assert_eq!(r.unattributed_accumulated(), Decimal::ZERO);
|
||||||
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
|
||||||
assert_totals_are_sums(&r);
|
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 r = fixed_asset_register(&l, date(2026, 5, 31)).unwrap();
|
||||||
let s = r.rows.iter().find(|x| x.asset_id == server).unwrap();
|
let s = r.rows.iter().find(|x| x.asset_id == server).unwrap();
|
||||||
assert_eq!(s.disposed, None);
|
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!(s.book_value, d("1900.00"));
|
||||||
assert_eq!(r.total_cost, d("14400.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_cost, d("14400.00"));
|
||||||
assert_eq!(r.ledger_accumulated, d("5500.00"));
|
assert_eq!(r.ledger_accumulated, d("5500.00"));
|
||||||
assert_eq!(r.unposted_depreciation(), Decimal::ZERO);
|
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() {
|
fn register_sums_the_gl_over_distinct_asset_accounts() {
|
||||||
let (mut l, a, _, _) = register_fixture();
|
let (mut l, a, _, _) = register_fixture();
|
||||||
buy(&mut l, &a, a.vehicles, date(2026, 1, 1), "36000.00");
|
buy(&mut l, &a, a.vehicles, date(2026, 1, 1), "36000.00");
|
||||||
l.register_asset(NewAsset {
|
let van = l
|
||||||
description: "Van".to_string(),
|
.register_asset(NewAsset {
|
||||||
purchase_date: date(2026, 1, 1),
|
description: "Van".to_string(),
|
||||||
cost: usd("36000.00"),
|
in_service_date: date(2026, 1, 1),
|
||||||
salvage_value: usd("6000.00"),
|
cost: usd("36000.00"),
|
||||||
useful_life_months: 60,
|
salvage_value: usd("6000.00"),
|
||||||
cost_account: a.vehicles,
|
useful_life_months: 60,
|
||||||
accum_depr_account: a.vehicles_accum,
|
cost_account: a.vehicles,
|
||||||
expense_account: a.depr_expense,
|
accum_depr_account: a.vehicles_accum,
|
||||||
})
|
expense_account: a.depr_expense,
|
||||||
.unwrap();
|
})
|
||||||
|
.unwrap();
|
||||||
// (36,000 − 6,000) / 60 = 500/month; Jan–Mar posted.
|
// (36,000 − 6,000) / 60 = 500/month; Jan–Mar posted.
|
||||||
for m in 1..=3 {
|
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();
|
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].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.rows[2].book_value, d("34500.00"));
|
||||||
assert_eq!(r.total_cost, d("50400.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.total_book_value, d("45600.00"));
|
||||||
assert_eq!(r.ledger_cost, d("50400.00"));
|
assert_eq!(r.ledger_cost, d("50400.00"));
|
||||||
assert_eq!(r.ledger_accumulated, d("4800.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
|
/// 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
|
/// the same month as `as_of` is held on `as_of`, its scheduled
|
||||||
/// depreciation prorated by days through `as_of`, never plugged to the
|
/// accumulated depreciation prorated by days through `as_of`, never
|
||||||
/// whole. Hand check: the 12,000.00 laptop (12 months from 2026-01-01),
|
/// plugged to the whole. Hand check: the 12,000.00 laptop (12 months from
|
||||||
/// disposed of 2026-06-20; on 2026-06-10, five months of 1,000.00 and
|
/// 2026-01-01), disposed of 2026-06-20; on 2026-06-10, five months of
|
||||||
/// June's 10/30: 5,333.33, book value 6,666.67. Before P3 the disposed
|
/// 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.
|
/// 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]
|
#[test]
|
||||||
fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
|
fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
|
||||||
let (mut l, _, laptop, _) = register_fixture();
|
let (mut l, _, laptop, _) = register_fixture();
|
||||||
@@ -1045,19 +1097,21 @@ fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let r = fixed_asset_register(&l, date(2026, 6, 10)).unwrap();
|
let r = fixed_asset_register(&l, date(2026, 6, 10)).unwrap();
|
||||||
assert_eq!(r.rows[0].disposed, None);
|
assert_eq!(r.rows[0].disposed, None);
|
||||||
assert_eq!(r.rows[0].accumulated, d("5333.33"));
|
assert_eq!(r.rows[0].accumulated_scheduled, d("5333.33"));
|
||||||
assert_eq!(r.rows[0].book_value, d("6666.67"));
|
// 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);
|
assert_totals_are_sums(&r);
|
||||||
// On the disposal day it is gone: no figures, in no total.
|
// On the disposal day it is gone: no figures, in no total.
|
||||||
let r = fixed_asset_register(&l, date(2026, 6, 20)).unwrap();
|
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());
|
assert!(r.rows[0].disposed.is_some());
|
||||||
|
|
||||||
let (l, _, laptop, _) = register_fixture();
|
let (l, _, laptop, _) = register_fixture();
|
||||||
let r = fixed_asset_register(&l, date(2026, 12, 15)).unwrap();
|
let r = fixed_asset_register(&l, date(2026, 12, 15)).unwrap();
|
||||||
let row = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
|
let row = r.rows.iter().find(|x| x.asset_id == laptop).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(row.accumulated, row.book_value),
|
(row.accumulated_scheduled, row.book_value),
|
||||||
(d("11483.87"), d("516.13"))
|
(d("11483.87"), d("9000.00"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,8 +14,8 @@ use ledger_core::payroll::{
|
|||||||
};
|
};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, Asset, AssetId, AssetStatus, Currency, DepreciationMethod, EntryId,
|
AccountId, AccountType, Asset, AssetId, AssetStatus, Currency, DepreciationMethod, EntryId,
|
||||||
Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_depreciation, book_value,
|
Ledger, LedgerError, Money, NewAsset, PayrollLine, Side, accumulated_scheduled_through,
|
||||||
depreciation_schedule, disposal_amounts,
|
depreciation_schedule, disposal_amounts, scheduled_book_value,
|
||||||
};
|
};
|
||||||
use proptest::prelude::*;
|
use proptest::prelude::*;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -49,7 +49,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
|
|||||||
Asset {
|
Asset {
|
||||||
id: 1,
|
id: 1,
|
||||||
description: "Test asset".to_string(),
|
description: "Test asset".to_string(),
|
||||||
purchase_date: purchase,
|
in_service_date: purchase,
|
||||||
cost: usd(cost),
|
cost: usd(cost),
|
||||||
salvage_value: usd(salvage),
|
salvage_value: usd(salvage),
|
||||||
useful_life_months: life,
|
useful_life_months: life,
|
||||||
@@ -61,7 +61,7 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
|
|||||||
tag: None,
|
tag: None,
|
||||||
serial: None,
|
serial: None,
|
||||||
acquired_by: None,
|
acquired_by: None,
|
||||||
tax: None,
|
basis: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ fn fixture() -> (Ledger, Accts) {
|
|||||||
fn new_asset(a: &Accts) -> NewAsset {
|
fn new_asset(a: &Accts) -> NewAsset {
|
||||||
NewAsset {
|
NewAsset {
|
||||||
description: "Laptop".to_string(),
|
description: "Laptop".to_string(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: usd("12000.00"),
|
cost: usd("12000.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -377,9 +377,12 @@ fn salvage_value_is_never_depreciated_b15() {
|
|||||||
let amounts: Vec<Decimal> = s.iter().map(|l| l.amount).collect();
|
let amounts: Vec<Decimal> = s.iter().map(|l| l.amount).collect();
|
||||||
assert_eq!(amounts, vec![d("1000.00"); 4]);
|
assert_eq!(amounts, vec![d("1000.00"); 4]);
|
||||||
assert_eq!(s.last().unwrap().book_value, d("1000.00"));
|
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!(
|
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")
|
d("4000.00")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -418,10 +421,13 @@ fn nothing_depreciates_before_purchase() {
|
|||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
accumulated_depreciation(&a, date(2025, 12, 31)).unwrap(),
|
accumulated_scheduled_through(&a, date(2025, 12, 31)).unwrap(),
|
||||||
d("0")
|
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 =
|
/// 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() {
|
fn accumulated_and_book_value_mid_life() {
|
||||||
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
||||||
assert_eq!(
|
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")
|
d("6000.00")
|
||||||
);
|
);
|
||||||
assert_eq!(book_value(&a, date(2026, 6, 30)).unwrap(), d("6000.00"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------- defect 22: the final month, disposals
|
// ------------------------------- 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.amount, d("483.87"));
|
||||||
assert_eq!(dec.accumulated, d("11483.87"));
|
assert_eq!(dec.accumulated, d("11483.87"));
|
||||||
assert_eq!(dec.book_value, d("516.13"));
|
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.
|
// Through its last day, the whole of it.
|
||||||
let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap();
|
let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap();
|
||||||
assert_eq!(
|
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!(jun.accumulated, before.accumulated);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||||||
d("5500.00")
|
d("5500.00")
|
||||||
);
|
);
|
||||||
// A through-date before the disposal is unchanged by it.
|
// A through-date before the disposal is unchanged by it.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
accumulated_depreciation(a, date(2026, 5, 31)).unwrap(),
|
accumulated_scheduled_through(a, date(2026, 5, 31)).unwrap(),
|
||||||
d("5000.00")
|
d("5000.00")
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -680,12 +692,13 @@ fn disposal_refuses_negative_proceeds() {
|
|||||||
/// Oracle: LedgerError::DisposalBeforePurchase doc "disposal date must not
|
/// Oracle: LedgerError::DisposalBeforePurchase doc "disposal date must not
|
||||||
/// precede the purchase date".
|
/// precede the purchase date".
|
||||||
#[test]
|
#[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));
|
let a = asset("12000.00", "0.00", 12, date(2026, 1, 1));
|
||||||
assert!(matches!(
|
let got = disposal_amounts(&a, date(2025, 12, 31), usd("100.00")).unwrap();
|
||||||
disposal_amounts(&a, date(2025, 12, 31), usd("100.00")),
|
assert_eq!(got.accumulated, d("0"));
|
||||||
Err(LedgerError::DisposalBeforePurchase(_))
|
assert_eq!(got.gain_loss, d("-11900.00"));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------- register / dispose
|
// ------------------------------------------------- register / dispose
|
||||||
@@ -702,7 +715,7 @@ fn register_asset_records_inputs_under_next_id() {
|
|||||||
assert_eq!(id, expected_id);
|
assert_eq!(id, expected_id);
|
||||||
let got = l.get_asset(id).unwrap();
|
let got = l.get_asset(id).unwrap();
|
||||||
assert_eq!(got.description, "Laptop");
|
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.cost, usd("12000.00"));
|
||||||
assert_eq!(got.salvage_value, usd("0.00"));
|
assert_eq!(got.salvage_value, usd("0.00"));
|
||||||
assert_eq!(got.useful_life_months, 12);
|
assert_eq!(got.useful_life_months, 12);
|
||||||
@@ -900,7 +913,7 @@ fn dispose_asset_refuses_bad_disposals_unchanged() {
|
|||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
l.dispose_asset(id, date(2025, 12, 1), usd("1.00")),
|
l.dispose_asset(id, date(2025, 12, 1), usd("1.00")),
|
||||||
Err(LedgerError::DisposalBeforePurchase(_))
|
Err(LedgerError::DisposalBeforeAcquisition { .. })
|
||||||
));
|
));
|
||||||
assert_eq!(snapshot(&l), before);
|
assert_eq!(snapshot(&l), before);
|
||||||
}
|
}
|
||||||
@@ -1317,7 +1330,7 @@ proptest! {
|
|||||||
prop_assert_eq!(total, cost - salvage);
|
prop_assert_eq!(total, cost - salvage);
|
||||||
let life = a.useful_life_months as usize;
|
let life = a.useful_life_months as usize;
|
||||||
prop_assert!(s.len() == life || s.len() == life + 1, "len {} life {}", s.len(), life);
|
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 acc = Decimal::ZERO;
|
||||||
let mut prev_bv = cost;
|
let mut prev_bv = cost;
|
||||||
for (i, l) in s.iter().enumerate() {
|
for (i, l) in s.iter().enumerate() {
|
||||||
@@ -1336,7 +1349,7 @@ proptest! {
|
|||||||
prop_assert_eq!(s.last().unwrap().book_value, salvage);
|
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
|
/// schedule at each month end, a shorter schedule is a prefix of the full
|
||||||
/// one, and nothing is depreciated before purchase.
|
/// one, and nothing is depreciated before purchase.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1344,14 +1357,14 @@ proptest! {
|
|||||||
let full = depreciation_schedule(&a, date(2050, 12, 31)).unwrap();
|
let full = depreciation_schedule(&a, date(2050, 12, 31)).unwrap();
|
||||||
for (i, l) in full.iter().enumerate() {
|
for (i, l) in full.iter().enumerate() {
|
||||||
let end = period_end(&l.period);
|
let end = period_end(&l.period);
|
||||||
prop_assert_eq!(accumulated_depreciation(&a, end).unwrap(), l.accumulated);
|
prop_assert_eq!(accumulated_scheduled_through(&a, end).unwrap(), l.accumulated);
|
||||||
prop_assert_eq!(book_value(&a, end).unwrap(), l.book_value);
|
prop_assert_eq!(scheduled_book_value(&a, end).unwrap(), l.book_value);
|
||||||
let prefix = depreciation_schedule(&a, end).unwrap();
|
let prefix = depreciation_schedule(&a, end).unwrap();
|
||||||
prop_assert_eq!(&prefix[..], &full[..=i]);
|
prop_assert_eq!(&prefix[..], &full[..=i]);
|
||||||
}
|
}
|
||||||
let before = a.purchase_date.pred_opt().unwrap();
|
let before = a.in_service_date.pred_opt().unwrap();
|
||||||
prop_assert_eq!(accumulated_depreciation(&a, before).unwrap(), Decimal::ZERO);
|
prop_assert_eq!(accumulated_scheduled_through(&a, before).unwrap(), Decimal::ZERO);
|
||||||
prop_assert_eq!(book_value(&a, before).unwrap(), a.cost.amount);
|
prop_assert_eq!(scheduled_book_value(&a, before).unwrap(), a.cost.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Oracle: B-15 partial months by days + interface "the last month absorbs
|
/// Oracle: B-15 partial months by days + interface "the last month absorbs
|
||||||
@@ -1386,12 +1399,12 @@ proptest! {
|
|||||||
offset in 0u32..=200,
|
offset in 0u32..=200,
|
||||||
proceeds in 0i64..=20_000_000,
|
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 pr = Decimal::new(proceeds, 2);
|
||||||
let got = disposal_amounts(&a, when, Money::new(pr, Currency::USD)).unwrap();
|
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, 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_eq!(got.gain_loss, pr - got.book_value);
|
||||||
prop_assert!(got.book_value >= a.salvage_value.amount);
|
prop_assert!(got.book_value >= a.salvage_value.amount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
|||||||
let equipment = add("1500", AccountType::Asset);
|
let equipment = add("1500", AccountType::Asset);
|
||||||
let accum = add("1501", AccountType::Asset);
|
let accum = add("1501", AccountType::Asset);
|
||||||
let depreciation = add("6100", AccountType::Expense);
|
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(),
|
description: "Late".into(),
|
||||||
purchase_date,
|
in_service_date,
|
||||||
cost: usd(Decimal::new(120000, 2)),
|
cost: usd(Decimal::new(120000, 2)),
|
||||||
salvage_value: usd(Decimal::ZERO),
|
salvage_value: usd(Decimal::ZERO),
|
||||||
useful_life_months,
|
useful_life_months,
|
||||||
@@ -267,11 +267,11 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
|
|||||||
.ledger
|
.ledger
|
||||||
.replay_register_asset(asset(d(2026, 1, 1), 12))
|
.replay_register_asset(asset(d(2026, 1, 1), 12))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let fix = |purchase_date: NaiveDate| ledger_core::AssetCorrection {
|
let fix = |in_service_date: NaiveDate| ledger_core::AssetCorrection {
|
||||||
cost: usd(Decimal::new(120000, 2)),
|
cost: usd(Decimal::new(120000, 2)),
|
||||||
salvage_value: usd(Decimal::ZERO),
|
salvage_value: usd(Decimal::ZERO),
|
||||||
useful_life_months: 60,
|
useful_life_months: 60,
|
||||||
purchase_date,
|
in_service_date,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
b.ledger.correct_asset(id, fix(d(9999, 1, 1))).unwrap_err(),
|
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
|
b.ledger
|
||||||
.replay_correct_asset(id, fix(d(9999, 1, 1)))
|
.replay_correct_asset(id, fix(d(9999, 1, 1)))
|
||||||
.unwrap();
|
.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.
|
// A disposal dated past the calendar.
|
||||||
let proceeds = usd(Decimal::ZERO);
|
let proceeds = usd(Decimal::ZERO);
|
||||||
assert_eq!(
|
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())
|
ledger_core::payout_plan(&payout(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT), b.ledger.chart())
|
||||||
.is_ok()
|
.is_ok()
|
||||||
);
|
);
|
||||||
// A contributor's basis.
|
// A contributor's basis and a value at contribution.
|
||||||
let basis = |b: Decimal| ledger_core::TaxBasis {
|
let basis = |b: Decimal, v: Decimal| {
|
||||||
rule: ledger_core::TaxBasisRule::Carryover,
|
ledger_core::AssetBasis::contribution(ledger_core::BasisRule::Carryover723, b, v)
|
||||||
contributor_basis: Some(b),
|
|
||||||
};
|
};
|
||||||
assert!(basis(big).check().is_err());
|
assert!(basis(big, Decimal::ONE).check().is_err());
|
||||||
assert!(basis(MAX_LINE_AMOUNT).check().is_ok());
|
assert!(basis(Decimal::ONE, big).check().is_err());
|
||||||
// On replay: every journaled basis passed check_journaled.
|
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;
|
let cents_max = Decimal::MAX.round_dp(2) / Decimal::ONE_HUNDRED;
|
||||||
assert!(basis(big.round_dp(2)).check_journaled().is_ok());
|
assert!(
|
||||||
assert!(basis(cents_max.round_dp(2)).check_journaled().is_ok());
|
basis(big.round_dp(2), big.round_dp(2))
|
||||||
assert!(basis(-Decimal::ONE).check_journaled().is_err());
|
.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.
|
// A party's share of an entry.
|
||||||
let share = |a: Decimal| {
|
let share = |a: Decimal| {
|
||||||
vec![ledger_core::PartyShare {
|
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
|
// Decimal's largest is a whole number of cents, as every version
|
||||||
// required of a basis and a share.
|
// required of a basis and a share.
|
||||||
let cents_max = Decimal::MAX;
|
let cents_max = Decimal::MAX;
|
||||||
let basis = ledger_core::TaxBasis {
|
let basis = ledger_core::AssetBasis {
|
||||||
rule: ledger_core::TaxBasisRule::Carryover,
|
rule: ledger_core::BasisRule::Carryover723,
|
||||||
contributor_basis: Some(cents_max),
|
contributor_adjusted_basis: Some(cents_max),
|
||||||
|
fmv_at_contribution: None,
|
||||||
|
sec704c_method: None,
|
||||||
|
source: None,
|
||||||
};
|
};
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let id = b
|
let id = b
|
||||||
.ledger
|
.ledger
|
||||||
.register_asset(ledger_core::NewAsset {
|
.register_asset(ledger_core::NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: d(2026, 1, 1),
|
in_service_date: d(2026, 1, 1),
|
||||||
cost: usd(Decimal::new(240000, 2)),
|
cost: usd(Decimal::new(240000, 2)),
|
||||||
salvage_value: usd(Decimal::ZERO),
|
salvage_value: usd(Decimal::ZERO),
|
||||||
useful_life_months: 24,
|
useful_life_months: 24,
|
||||||
@@ -526,12 +550,16 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
|
|||||||
expense_account: depreciation,
|
expense_account: depreciation,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let mut new = basis.clone();
|
||||||
|
new.fmv_at_contribution = Some(Decimal::new(240000, 2));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
b.ledger.set_asset_tax_basis(id, basis),
|
b.ledger.set_asset_basis(id, new),
|
||||||
Err(LedgerError::InvalidTaxBasis(_))
|
Err(LedgerError::InvalidTaxBasis(_))
|
||||||
));
|
));
|
||||||
b.ledger.replay_set_asset_tax_basis(id, basis).unwrap();
|
b.ledger
|
||||||
assert_eq!(b.ledger.get_asset(id).unwrap().tax, Some(basis));
|
.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!(
|
assert!(matches!(
|
||||||
ledger_core::fixed_asset_register(&b.ledger, d(2026, 12, 31)),
|
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 depreciation = add("6100", AccountType::Expense);
|
||||||
let laptop = || ledger_core::NewAsset {
|
let laptop = || ledger_core::NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: d(2026, 1, 1),
|
in_service_date: d(2026, 1, 1),
|
||||||
cost: usd(Decimal::new(240000, 2)),
|
cost: usd(Decimal::new(240000, 2)),
|
||||||
salvage_value: usd(Decimal::ZERO),
|
salvage_value: usd(Decimal::ZERO),
|
||||||
useful_life_months: 24,
|
useful_life_months: 24,
|
||||||
@@ -672,8 +700,11 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
|||||||
b.ledger.get_asset(old).unwrap().status,
|
b.ledger.get_asset(old).unwrap().status,
|
||||||
ledger_core::AssetStatus::Disposed { date, .. } if date == NaiveDate::MAX
|
ledger_core::AssetStatus::Disposed { date, .. } if date == NaiveDate::MAX
|
||||||
));
|
));
|
||||||
// Replay keeps what every version checked: no disposal twice, none
|
// Replay keeps what every version checked: no disposal twice, no
|
||||||
// before the purchase, no negative proceeds.
|
// 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!(
|
assert_eq!(
|
||||||
b.ledger
|
b.ledger
|
||||||
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::ONE))
|
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::ONE))
|
||||||
@@ -681,16 +712,19 @@ fn disposal_proceeds_and_dates_are_bounded() {
|
|||||||
LedgerError::AssetAlreadyDisposed(old)
|
LedgerError::AssetAlreadyDisposed(old)
|
||||||
);
|
);
|
||||||
let early = b.ledger.register_asset(laptop()).unwrap();
|
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!(
|
assert!(matches!(
|
||||||
b.ledger
|
b.ledger
|
||||||
.replay_dispose_asset(early, d(2026, 2, 1), usd(-Decimal::ONE)),
|
.replay_dispose_asset(early, d(2026, 2, 1), usd(-Decimal::ONE)),
|
||||||
Err(LedgerError::NegativeProceeds(_))
|
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
|
/// The bounds are the line limit exactly: a new draft line (base or
|
||||||
@@ -808,12 +842,12 @@ fn new_asset(
|
|||||||
accounts: (AccountId, AccountId, AccountId),
|
accounts: (AccountId, AccountId, AccountId),
|
||||||
cost: &str,
|
cost: &str,
|
||||||
salvage: &str,
|
salvage: &str,
|
||||||
purchase_date: NaiveDate,
|
in_service_date: NaiveDate,
|
||||||
life: u32,
|
life: u32,
|
||||||
) -> ledger_core::NewAsset {
|
) -> ledger_core::NewAsset {
|
||||||
ledger_core::NewAsset {
|
ledger_core::NewAsset {
|
||||||
description: "Machine".into(),
|
description: "Machine".into(),
|
||||||
purchase_date,
|
in_service_date,
|
||||||
cost: usd(cost.parse().unwrap()),
|
cost: usd(cost.parse().unwrap()),
|
||||||
salvage_value: usd(salvage.parse().unwrap()),
|
salvage_value: usd(salvage.parse().unwrap()),
|
||||||
useful_life_months: life,
|
useful_life_months: life,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ impl Books {
|
|||||||
self.l
|
self.l
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: format!("Asset on {cost}"),
|
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),
|
cost: Money::new(x(amount), Currency::USD),
|
||||||
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
|
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fn depreciation_reaches_chronos_last_month() {
|
|||||||
let id = l
|
let id = l
|
||||||
.replay_register_asset(NewAsset {
|
.replay_register_asset(NewAsset {
|
||||||
description: "edge".into(),
|
description: "edge".into(),
|
||||||
purchase_date: d(y, 1, 15),
|
in_service_date: d(y, 1, 15),
|
||||||
cost: usd(Decimal::new(120000, 2)),
|
cost: usd(Decimal::new(120000, 2)),
|
||||||
salvage_value: usd(Decimal::ZERO),
|
salvage_value: usd(Decimal::ZERO),
|
||||||
useful_life_months: 11,
|
useful_life_months: 11,
|
||||||
@@ -83,11 +83,11 @@ fn depreciation_reaches_chronos_last_month() {
|
|||||||
assert_eq!(want, Decimal::new(118592, 2));
|
assert_eq!(want, Decimal::new(118592, 2));
|
||||||
assert_eq!(dec.last().unwrap().accumulated, want);
|
assert_eq!(dec.last().unwrap().accumulated, want);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||||||
Decimal::new(120000, 2)
|
Decimal::new(120000, 2)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ledger_core::book_value(a, NaiveDate::MAX).unwrap(),
|
ledger_core::scheduled_book_value(a, NaiveDate::MAX).unwrap(),
|
||||||
Decimal::ZERO
|
Decimal::ZERO
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ fn a_life_past_9999_trues_up_to_the_cent() {
|
|||||||
let id = l
|
let id = l
|
||||||
.replay_register_asset(NewAsset {
|
.replay_register_asset(NewAsset {
|
||||||
description: "far".into(),
|
description: "far".into(),
|
||||||
purchase_date: start,
|
in_service_date: start,
|
||||||
cost: usd(Decimal::new(100_000, 2)),
|
cost: usd(Decimal::new(100_000, 2)),
|
||||||
salvage_value: usd(Decimal::new(1, 2)),
|
salvage_value: usd(Decimal::new(1, 2)),
|
||||||
useful_life_months: months,
|
useful_life_months: months,
|
||||||
@@ -172,7 +172,7 @@ fn february_is_prorated_by_chronos_calendar_in_every_year() {
|
|||||||
let id = l
|
let id = l
|
||||||
.replay_register_asset(NewAsset {
|
.replay_register_asset(NewAsset {
|
||||||
description: format!("{year}"),
|
description: format!("{year}"),
|
||||||
purchase_date: d(year, 2, 1),
|
in_service_date: d(year, 2, 1),
|
||||||
cost: usd(Decimal::from(2900)),
|
cost: usd(Decimal::from(2900)),
|
||||||
salvage_value: usd(Decimal::ZERO),
|
salvage_value: usd(Decimal::ZERO),
|
||||||
useful_life_months: 2,
|
useful_life_months: 2,
|
||||||
|
|||||||
@@ -1523,7 +1523,7 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() {
|
|||||||
);
|
);
|
||||||
co.l.register_asset(NewAsset {
|
co.l.register_asset(NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: d(2026, 2, 1),
|
in_service_date: d(2026, 2, 1),
|
||||||
cost: usd(120000),
|
cost: usd(120000),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
|
|||||||
@@ -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<Value = Decimal> {
|
|
||||||
(0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn positive_cents() -> impl Strategy<Value = Decimal> {
|
|
||||||
(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::<bool>(),
|
|
||||||
) {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1210,7 +1210,7 @@ fn a_loss_on_disposal_moves_out_of_operating_into_investing() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
l.register_asset(ledger_core::NewAsset {
|
l.register_asset(ledger_core::NewAsset {
|
||||||
description: "Laptop".to_string(),
|
description: "Laptop".to_string(),
|
||||||
purchase_date: d(1, 10),
|
in_service_date: d(1, 10),
|
||||||
cost: usd(1_000),
|
cost: usd(1_000),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 10,
|
useful_life_months: 10,
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -651,6 +651,26 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 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());
|
let unregistered = register.map_or(Decimal::ZERO, |r| r.unregistered_cost());
|
||||||
if !unregistered.is_zero() {
|
if !unregistered.is_zero() {
|
||||||
let (title, why) = if unregistered > Decimal::ZERO {
|
let (title, why) = if unregistered > Decimal::ZERO {
|
||||||
@@ -681,16 +701,25 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for a in b.ledger.registered_assets().filter(|a| {
|
let held = |a: &&ledger_core::Asset| {
|
||||||
a.tag.is_none() && a.status == ledger_core::AssetStatus::Active && a.purchase_date <= today
|
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 {
|
out.push(Attention {
|
||||||
severity: Severity::Todo,
|
severity: Severity::Todo,
|
||||||
title: format!("No tag: {}", a.description),
|
title: format!("No tag: {}", a.description),
|
||||||
detail: format!(
|
detail: format!(
|
||||||
"Asset {}, in service {}. A tag is the label on the thing itself.",
|
"Asset {}, in service {}. A tag is the label on the thing itself.",
|
||||||
a.id,
|
a.id,
|
||||||
fmt::date(a.purchase_date)
|
fmt::date(a.in_service_date)
|
||||||
),
|
),
|
||||||
target: Some(Route::Asset(a.id)),
|
target: Some(Route::Asset(a.id)),
|
||||||
prompt: format!(
|
prompt: format!(
|
||||||
@@ -700,27 +729,62 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for a in b.ledger.registered_assets().filter(|a| {
|
for a in b.ledger.registered_assets().filter(held) {
|
||||||
a.tax_basis().is_none()
|
|
||||||
&& a.status == ledger_core::AssetStatus::Active
|
|
||||||
&& a.purchase_date <= today
|
|
||||||
}) {
|
|
||||||
let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id));
|
let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id));
|
||||||
out.push(Attention {
|
let Some(basis) = &a.basis else {
|
||||||
severity: Severity::Todo,
|
out.push(Attention {
|
||||||
title: format!("No tax basis: {}", b.asset_label(a.id)),
|
severity: Severity::Todo,
|
||||||
detail: format!(
|
title: format!("No tax basis: {}", b.asset_label(a.id)),
|
||||||
"Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.",
|
detail: format!(
|
||||||
fmt::dollars(a.cost.amount),
|
"Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.",
|
||||||
fmt::date(a.purchase_date)
|
fmt::dollars(a.cost.amount),
|
||||||
),
|
fmt::date(a.in_service_date)
|
||||||
target: Some(Route::Asset(a.id)),
|
),
|
||||||
prompt: format!(
|
target: Some(Route::Asset(a.id)),
|
||||||
"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.",
|
prompt: format!(
|
||||||
a.description,
|
"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.",
|
||||||
fmt::dollars(a.cost.amount)
|
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;
|
use ledger_store::DiskCheck;
|
||||||
@@ -861,7 +925,7 @@ mod tests {
|
|||||||
b.post(d(2026, 2, 1), cash, capital, 500_000);
|
b.post(d(2026, 2, 1), cash, capital, 500_000);
|
||||||
b.s.register_asset(NewAsset {
|
b.s.register_asset(NewAsset {
|
||||||
description: "Laptop".into(),
|
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),
|
cost: Money::new(rust_decimal::Decimal::new(100_000, 2), Currency::USD),
|
||||||
salvage_value: Money::new(rust_decimal::Decimal::ZERO, Currency::USD),
|
salvage_value: Money::new(rust_decimal::Decimal::ZERO, Currency::USD),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
|
|||||||
@@ -1238,11 +1238,11 @@ impl Books {
|
|||||||
};
|
};
|
||||||
let amount = self.asset_register(through)?.unposted_depreciation();
|
let amount = self.asset_register(through)?.unposted_depreciation();
|
||||||
let mut periods = std::collections::BTreeSet::new();
|
let mut periods = std::collections::BTreeSet::new();
|
||||||
for a in self
|
for a in self.ledger.registered_assets().filter(|a| {
|
||||||
.ledger
|
self.ledger
|
||||||
.registered_assets()
|
.asset_acquired_date(a.id)
|
||||||
.filter(|a| a.purchase_date <= through)
|
.is_some_and(|d| d <= through)
|
||||||
{
|
}) {
|
||||||
if matches!(a.status, AssetStatus::Disposed { date, .. } if date <= through) {
|
if matches!(a.status, AssetStatus::Disposed { date, .. } if date <= through) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2091,26 +2091,22 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, As
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
t.events.push(i);
|
t.events.push(i);
|
||||||
match line.line_type.as_str() {
|
if line.line_type == "asset.disposed" {
|
||||||
"asset.depreciation_posted" => {
|
t.disposal_entry = u(p, "entry_id");
|
||||||
if let (Some(period), Some(entry), Some(amount)) = (
|
if let Some(a) = asset {
|
||||||
p.get("period").and_then(|v| v.as_str()),
|
gain_loss_accounts.insert(a, u(p, "gain_loss_account"));
|
||||||
u(p, "entry_id"),
|
|
||||||
p.get("amount")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.and_then(|s| s.parse::<Decimal>().ok()),
|
|
||||||
) {
|
|
||||||
t.runs.push((entry, amount));
|
|
||||||
t.periods.push(period.to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"asset.disposed" => {
|
}
|
||||||
t.disposal_entry = u(p, "entry_id");
|
}
|
||||||
if let Some(a) = asset {
|
// Every run journaled, reversed or not, in the order recorded: the
|
||||||
gain_loss_accounts.insert(a, u(p, "gain_loss_account"));
|
// 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)
|
// 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<AssetId, As
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let days = (e.date() - a.purchase_date).num_days().abs();
|
let days = (e.date() - a.in_service_date).num_days().abs();
|
||||||
if days > 31 {
|
if days > 31 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2214,7 +2210,8 @@ fn disposal_true_up(
|
|||||||
})
|
})
|
||||||
.sum();
|
.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.
|
/// Entry ids a journal line refers to.
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
|||||||
)?;
|
)?;
|
||||||
let asset = s.register_asset(NewAsset {
|
let asset = s.register_asset(NewAsset {
|
||||||
description: "MacBook Pro 16\"".into(),
|
description: "MacBook Pro 16\"".into(),
|
||||||
purchase_date: laptop_date,
|
in_service_date: laptop_date,
|
||||||
cost: usd(laptop),
|
cost: usd(laptop),
|
||||||
salvage_value: usd(dec(400)),
|
salvage_value: usd(dec(400)),
|
||||||
useful_life_months: 36,
|
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_tag(asset, "JS-0001")?;
|
||||||
s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?;
|
s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?;
|
||||||
// Bought by the company, so its federal tax basis is its cost.
|
// Bought by the company, so its federal tax basis is its cost.
|
||||||
s.set_asset_tax_basis(
|
s.set_asset_basis(asset, ledger_core::AssetBasis::purchase())?;
|
||||||
asset,
|
|
||||||
ledger_core::TaxBasis {
|
|
||||||
rule: ledger_core::TaxBasisRule::Cost,
|
|
||||||
contributor_basis: None,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Prepaid insurance, spread over a year.
|
// Prepaid insurance, spread over a year.
|
||||||
let ins_date = day(start + Months::new(2), 15);
|
let ins_date = day(start + Months::new(2), 15);
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
//! asset, reconciled to the books) and one asset completely (its label,
|
//! asset, reconciled to the books) and one asset completely (its label,
|
||||||
//! its schedule month by month, the entries and documents behind it).
|
//! its schedule month by month, the entries and documents behind it).
|
||||||
//! Every number comes from `ledger-core` (`fixed_asset_register`,
|
//! Every number comes from `ledger-core` (`fixed_asset_register`,
|
||||||
//! `depreciation_schedule`, `Asset::tax_basis`, `Asset::built_in_gain`);
|
//! `depreciation_schedule`, `Asset::basis_figures`), what is posted too
|
||||||
//! what is posted comes from the journal. Book value and federal tax
|
//! (`Ledger::accumulated_posted`, its runs). Book value is what the books
|
||||||
//! basis sit side by side and are never confused: book cost is what was
|
//! carry (cost less the depreciation posted), never the schedule's figure,
|
||||||
//! paid or, for a member's contribution, its fair market value; tax basis
|
//! which sits beside it as "scheduled" (B-24). Book value and the federal
|
||||||
//! follows its rule (docs/spec/rules.md B-25).
|
//! 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 super::page;
|
||||||
use crate::app::Shell;
|
use crate::app::Shell;
|
||||||
@@ -18,7 +21,9 @@ use crate::widgets;
|
|||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2};
|
use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2};
|
||||||
use egui_phosphor::regular as icon;
|
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;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
/// The register's date (None: today), kept across navigation.
|
/// 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
|
/// 2. unposted_depreciation() < 0, unregistered_cost() != 0: a problem
|
||||||
/// each, with the exact amount.
|
/// each, with the exact amount.
|
||||||
/// 3. Both zero: one note that the register agrees with the books.
|
/// 3. Both zero: one note that the register agrees with the books.
|
||||||
/// 4. missing_tax_basis > 0: a problem, last, from
|
/// 4. unattributed_accumulated() != 0: a problem (accumulated
|
||||||
/// [`missing_tax_notice`].
|
/// 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<Notice> {
|
pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -125,7 +131,17 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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 {
|
out.push(Notice {
|
||||||
problem: false,
|
problem: false,
|
||||||
text: format!(
|
text: format!(
|
||||||
@@ -134,7 +150,7 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(text) = missing_tax_notice(r.missing_tax_basis) {
|
if let Some(text) = missing_tax_notice(r.missing_basis) {
|
||||||
out.push(Notice {
|
out.push(Notice {
|
||||||
problem: true,
|
problem: true,
|
||||||
text,
|
text,
|
||||||
@@ -148,118 +164,141 @@ pub fn notices(b: &Books, r: &AssetRegister) -> Vec<Notice> {
|
|||||||
pub fn missing_tax_notice(missing: usize) -> Option<String> {
|
pub fn missing_tax_notice(missing: usize) -> Option<String> {
|
||||||
(missing > 0).then(|| {
|
(missing > 0).then(|| {
|
||||||
format!(
|
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"),
|
fmt::count(missing, "asset", "assets"),
|
||||||
if missing == 1 { "it" } else { "each" }
|
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".
|
/// conversion".
|
||||||
pub fn rule_words(r: TaxBasisRule) -> &'static str {
|
pub fn rule_words(r: BasisRule) -> &'static str {
|
||||||
match r {
|
match r {
|
||||||
TaxBasisRule::Cost => "cost",
|
BasisRule::Purchase => "purchase",
|
||||||
TaxBasisRule::Carryover => "carryover",
|
BasisRule::Carryover723 => "carryover",
|
||||||
TaxBasisRule::PersonalUseConversion => "personal-use conversion",
|
BasisRule::PersonalUseConversion => "personal-use conversion",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a rule means, in plain words, with the law it comes from.
|
/// 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 {
|
match r {
|
||||||
TaxBasisRule::Cost => {
|
BasisRule::Purchase => {
|
||||||
"Bought by the company: its tax basis is what it paid (IRC §1012(a))."
|
"Bought by the company: its tax basis is what it paid (IRC §1012(a))."
|
||||||
}
|
}
|
||||||
TaxBasisRule::Carryover => {
|
BasisRule::Carryover723 => {
|
||||||
"Contributed by a member from business use: it keeps the member's adjusted basis — what they paid less any depreciation they took (IRC §723)."
|
"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 => {
|
BasisRule::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))."
|
"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
|
/// Book value against the tax figures, in one sentence, for an asset
|
||||||
/// `rule` (or none recorded).
|
/// under `rule` (or none recorded).
|
||||||
pub fn book_vs_tax(rule: Option<TaxBasisRule>) -> &'static str {
|
pub fn book_vs_tax(rule: Option<BasisRule>) -> &'static str {
|
||||||
match rule {
|
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 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
|
/// An asset's basis facts and tax figures as the viewer shows them: every
|
||||||
/// ledger-core's (`Asset::tax_basis`, `Asset::built_in_gain`), never
|
/// figure is ledger-core's (`Asset::basis_figures`), never re-derived.
|
||||||
/// re-derived.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct TaxFacts {
|
pub struct TaxFacts {
|
||||||
/// The rule, if recorded.
|
/// The rule, if recorded.
|
||||||
pub rule: Option<TaxBasisRule>,
|
pub rule: Option<BasisRule>,
|
||||||
/// The contributing member's adjusted basis (a contribution only).
|
/// The contributing member's adjusted basis (a contribution only).
|
||||||
pub contributor_basis: Option<Decimal>,
|
pub contributor_adjusted_basis: Option<Decimal>,
|
||||||
|
/// The value at contribution (a contribution only).
|
||||||
|
pub fmv_at_contribution: Option<Decimal>,
|
||||||
|
/// 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<Decimal>,
|
||||||
/// The depreciable tax basis, if recorded.
|
/// The depreciable tax basis, if recorded.
|
||||||
pub tax_basis: Option<Decimal>,
|
pub depreciable_basis: Option<Decimal>,
|
||||||
/// Built-in gain (+) or loss (−) at contribution, if any.
|
/// Built-in gain (+) or loss (−) at contribution, if any.
|
||||||
pub built_in_gain: Option<Decimal>,
|
pub built_in_gain_704c: Option<Decimal>,
|
||||||
|
/// The §704(c) method, if chosen.
|
||||||
|
pub sec704c_method: Option<Sec704cMethod>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TaxFacts {
|
impl TaxFacts {
|
||||||
/// An asset's.
|
/// An asset's.
|
||||||
pub fn of(a: &Asset) -> Self {
|
pub fn of(a: &Asset) -> Self {
|
||||||
|
let f = a.basis_figures().ok().flatten();
|
||||||
TaxFacts {
|
TaxFacts {
|
||||||
rule: a.tax.map(|t| t.rule),
|
rule: a.basis.as_ref().map(|b| b.rule),
|
||||||
contributor_basis: a.tax.and_then(|t| t.contributor_basis),
|
contributor_adjusted_basis: a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis),
|
||||||
tax_basis: a.tax_basis(),
|
fmv_at_contribution: a.fmv_at_contribution(),
|
||||||
built_in_gain: a.built_in_gain(),
|
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.
|
/// A register row's.
|
||||||
pub fn of_row(x: &AssetRegisterRow) -> Self {
|
pub fn of_row(x: &AssetRegisterRow) -> Self {
|
||||||
TaxFacts {
|
TaxFacts {
|
||||||
rule: x.tax_rule,
|
rule: x.basis_rule,
|
||||||
contributor_basis: x.contributor_basis,
|
contributor_adjusted_basis: x.contributor_adjusted_basis,
|
||||||
tax_basis: x.tax_basis,
|
fmv_at_contribution: x.fmv_at_contribution,
|
||||||
built_in_gain: x.built_in_gain,
|
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
|
/// "Carryover · §723 basis $900.00", or "Tax basis not recorded": the
|
||||||
/// recorded": the line under an asset in the register.
|
/// line under an asset in the register.
|
||||||
pub fn summary(&self) -> String {
|
pub fn summary(&self) -> String {
|
||||||
match self.rule {
|
match self.rule {
|
||||||
None => "Tax basis not recorded".into(),
|
None => "Tax basis not recorded".into(),
|
||||||
Some(r) => {
|
Some(r) => {
|
||||||
let mut s = ledger_import::asset_register::rule_label(r).to_string();
|
let mut s = ledger_import::asset_register::rule_label(r).to_string();
|
||||||
if let Some(b) = self.contributor_basis {
|
if let Some(b) = self.tax_basis_723 {
|
||||||
s.push_str(&format!(" · contributor's basis {}", fmt::dollars(b)));
|
s.push_str(&format!(" · §723 basis {}", fmt::dollars(b)));
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything, with the rule's citation, for a tooltip: "Tax basis
|
/// Everything, with the rule's citation, for a tooltip: "Depreciable
|
||||||
/// $900.00: Carryover (IRC §723); contributor's basis $1,000.00;
|
/// basis $900.00: Carryover (IRC §723); §723 basis $1,000.00; value at
|
||||||
/// built-in gain $100.00." or "Tax basis not recorded."
|
/// contribution $1,100.00; built-in gain $100.00." or "Tax basis not
|
||||||
|
/// recorded."
|
||||||
pub fn detail(&self) -> String {
|
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();
|
return "Tax basis not recorded.".into();
|
||||||
};
|
};
|
||||||
let mut s = format!(
|
let mut s = format!(
|
||||||
"Tax basis {}: {}",
|
"Depreciable basis {}: {}",
|
||||||
fmt::dollars(t),
|
fmt::dollars(t),
|
||||||
ledger_import::asset_register::rule_cited(r)
|
ledger_import::asset_register::rule_cited(r)
|
||||||
);
|
);
|
||||||
if let Some(b) = self.contributor_basis {
|
if let Some(b) = self.tax_basis_723 {
|
||||||
s.push_str(&format!("; contributor's basis {}", fmt::dollars(b)));
|
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);
|
let (label, amount) = gain_or_loss(g);
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"; {} {}",
|
"; {} {}",
|
||||||
@@ -267,6 +306,12 @@ impl TaxFacts {
|
|||||||
fmt::dollars(amount)
|
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.push('.');
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
@@ -481,26 +526,31 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
1 => (
|
1 => (
|
||||||
"Accumulated depreciation",
|
"Depreciation posted",
|
||||||
r.total_accumulated,
|
r.total_accumulated_posted,
|
||||||
"Their book depreciation by schedule through the date.",
|
"The book depreciation the books carry for them on the date: their depreciation runs, less any reversed.",
|
||||||
None,
|
(r.total_accumulated_scheduled != r.total_accumulated_posted).then(|| {
|
||||||
|
(
|
||||||
|
format!("{} scheduled", fmt::dollars(r.total_accumulated_scheduled)),
|
||||||
|
p.muted,
|
||||||
|
)
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
2 => (
|
2 => (
|
||||||
"Book value",
|
"Book value",
|
||||||
r.total_book_value,
|
r.total_book_value,
|
||||||
"Cost less accumulated depreciation.",
|
"Cost less the depreciation posted: what the books carry.",
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
_ => (
|
_ => (
|
||||||
"Tax basis",
|
"Depreciable tax basis",
|
||||||
r.total_tax_basis,
|
r.total_depreciable_basis,
|
||||||
"The federal tax basis of the assets held that have one recorded: what tax depreciation (the CPA's) starts from.",
|
"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_tax_basis > 0).then(|| {
|
(r.missing_basis > 0).then(|| {
|
||||||
(
|
(
|
||||||
format!(
|
format!(
|
||||||
"{} not recorded",
|
"{} not recorded",
|
||||||
fmt::count(r.missing_tax_basis, "asset", "assets")
|
fmt::count(r.missing_basis, "asset", "assets")
|
||||||
),
|
),
|
||||||
p.warning,
|
p.warning,
|
||||||
)
|
)
|
||||||
@@ -528,13 +578,15 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
widgets::empty(
|
widgets::empty(
|
||||||
ui,
|
ui,
|
||||||
icon::PACKAGE,
|
icon::PACKAGE,
|
||||||
"Nothing in service yet",
|
"Nothing acquired yet",
|
||||||
&format!("No asset was in service by {}.", fmt::date(as_of)),
|
&format!("No asset was acquired by {}.", fmt::date(as_of)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
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);
|
let w = ui.available_width().max(fixed + 200.0);
|
||||||
widgets::min_width(ui, "asset_register", w, |ui| {
|
widgets::min_width(ui, "asset_register", w, |ui| {
|
||||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
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("Description", 0.0, false),
|
||||||
head("In service", 104.0, false),
|
head("In service", 104.0, false),
|
||||||
head("Cost", amt, true),
|
head("Cost", amt, true),
|
||||||
head("Accumulated", amt, true),
|
head("Posted", amt, true),
|
||||||
head("Book value", amt, true),
|
head("Book value", amt, true),
|
||||||
head("Tax basis", amt, true),
|
head("Scheduled", amt, true),
|
||||||
head("Built-in gain", amt, true),
|
head("Depreciable basis", wide, true),
|
||||||
head("Status", 190.0, false),
|
head("Status", 190.0, false),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
@@ -572,7 +624,16 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
Some(s) => format!("Serial {s} · {}", tax.summary()),
|
Some(s) => format!("Serial {s} · {}", tax.summary()),
|
||||||
None => 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),
|
_ if disposed => (String::new(), text),
|
||||||
Some(t) => (fmt::money(t), text),
|
Some(t) => (fmt::money(t), text),
|
||||||
None => ("not recorded".to_string(), p.warning),
|
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 },
|
if x.tag.is_some() { text } else { p.warning },
|
||||||
),
|
),
|
||||||
(x.description.clone(), 0.0, false, text),
|
(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.cost), amt, true, text),
|
||||||
(money(x.accumulated), amt, true, text),
|
(money(x.accumulated_posted), amt, true, text),
|
||||||
(money(x.book_value), amt, true, text),
|
(money(x.book_value), amt, true, text),
|
||||||
(tax_text, amt, true, tax_color),
|
(money(x.accumulated_scheduled), amt, true, p.muted),
|
||||||
(
|
(tax_text, wide, true, tax_color),
|
||||||
x.built_in_gain.map(money).unwrap_or_default(),
|
|
||||||
amt,
|
|
||||||
true,
|
|
||||||
text,
|
|
||||||
),
|
|
||||||
(status_text(x.disposed), 190.0, false, p.muted),
|
(status_text(x.disposed), 190.0, false, p.muted),
|
||||||
],
|
],
|
||||||
Some((sub.as_str(), p.faint)),
|
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 {
|
let resp = resp.on_hover_text(if disposed {
|
||||||
format!(
|
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),
|
fmt::dollars(x.cost),
|
||||||
tax.detail()
|
tax.detail()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("Open this asset. {}", tax.detail())
|
format!("Open this asset. {dates} {}", tax.detail())
|
||||||
});
|
});
|
||||||
resp.context_menu(|ui| {
|
resp.context_menu(|ui| {
|
||||||
if let Some(t) = &x.tag {
|
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 serial number", s);
|
||||||
}
|
}
|
||||||
widgets::copy_item(ui, "Copy book value", &x.book_value.to_string());
|
widgets::copy_item(ui, "Copy book value", &x.book_value.to_string());
|
||||||
if let Some(t) = x.tax_basis {
|
if let Some(t) = x.depreciable_basis {
|
||||||
widgets::copy_item(ui, "Copy tax basis", &t.to_string());
|
widgets::copy_item(ui, "Copy depreciable basis", &t.to_string());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if resp.clicked() {
|
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(), 0.0, false, p.text),
|
||||||
(String::new(), 104.0, false, p.text),
|
(String::new(), 104.0, false, p.text),
|
||||||
(fmt::money(r.total_cost), amt, true, 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_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),
|
(String::new(), 190.0, false, p.text),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
@@ -655,7 +716,7 @@ pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
RichText::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))
|
.font(theme::regular(11.5))
|
||||||
.color(p.faint),
|
.color(p.faint),
|
||||||
@@ -722,7 +783,7 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<'
|
|||||||
line(
|
line(
|
||||||
ui,
|
ui,
|
||||||
"Accumulated depreciation by schedule",
|
"Accumulated depreciation by schedule",
|
||||||
r.total_accumulated,
|
r.total_accumulated_scheduled,
|
||||||
false,
|
false,
|
||||||
p.text,
|
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);
|
.is_ok_and(|due| due.amount <= Decimal::ZERO);
|
||||||
let color = if accruing { p.muted } else { diff_color(d) };
|
let color = if accruing { p.muted } else { diff_color(d) };
|
||||||
line(ui, "Difference (not yet posted)", d, true, color);
|
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.add_space(8.0);
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
@@ -1011,7 +1088,7 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
|
|||||||
|_| {},
|
|_| {},
|
||||||
|ui| {
|
|ui| {
|
||||||
let end = a
|
let end = a
|
||||||
.purchase_date
|
.in_service_date
|
||||||
.checked_add_months(chrono::Months::new(a.useful_life_months))
|
.checked_add_months(chrono::Months::new(a.useful_life_months))
|
||||||
.and_then(|d| d.pred_opt());
|
.and_then(|d| d.pred_opt());
|
||||||
facts(ui, |ui, k| {
|
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());
|
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");
|
k(ui, "In service");
|
||||||
ui.label(fmt::date(a.purchase_date));
|
ui.label(fmt::date(a.in_service_date));
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
k(ui, "Cost");
|
k(ui, "Cost");
|
||||||
money(ui, a.cost.amount, false);
|
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();
|
ui.end_row();
|
||||||
match &today {
|
match &today {
|
||||||
Ok(Some(r)) => {
|
Ok(Some(r)) => {
|
||||||
k(ui, &format!("Accumulated, {}", fmt::date_short(b.today)));
|
k(ui, &format!("Posted, {}", fmt::date_short(b.today)));
|
||||||
money(ui, r.accumulated, false);
|
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();
|
ui.end_row();
|
||||||
k(ui, "Book value today");
|
k(ui, "Book value today");
|
||||||
money(ui, r.book_value, true);
|
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();
|
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 {
|
if let AssetStatus::Disposed { date, proceeds } = &a.status {
|
||||||
k(ui, "Disposed");
|
k(ui, "Disposed");
|
||||||
ui.label(fmt::date(*date));
|
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("Month", 100.0, false),
|
||||||
head("Amount", amt, true),
|
head("Amount", amt, true),
|
||||||
head("Accumulated", 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("", 24.0, false),
|
||||||
head("In the books", 0.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");
|
k(ui, "Book cost");
|
||||||
money(ui, a.cost.amount, false);
|
money(ui, a.cost.amount, false);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
if let Some(c) = t.contributor_basis {
|
if let Some(v) = t.fmv_at_contribution {
|
||||||
k(ui, "Contributor's basis");
|
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);
|
money(ui, c, false);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
if let Some(tb) = t.tax_basis {
|
if let Some(b) = t.tax_basis_723 {
|
||||||
k(ui, "Tax basis");
|
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);
|
money(ui, tb, true);
|
||||||
ui.end_row();
|
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);
|
let (label, amount) = gain_or_loss(g);
|
||||||
k(ui, label);
|
k(ui, &format!("{label} §704(c)"));
|
||||||
money(ui, amount, false);
|
money(ui, amount, false);
|
||||||
ui.end_row();
|
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);
|
ui.add_space(6.0);
|
||||||
let mut why = rule_explained(rule).to_string();
|
let mut why = rule_explained(rule).to_string();
|
||||||
if t.built_in_gain.is_some_and(|g| g < Decimal::ZERO) {
|
if t.built_in_gain_704c.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)).");
|
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.is_some_and(|g| g > Decimal::ZERO) {
|
} else if t.built_in_gain_704c.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.");
|
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))] {
|
for line in [why.as_str(), book_vs_tax(Some(rule))] {
|
||||||
ui.add(
|
ui.add(
|
||||||
@@ -1451,7 +1575,7 @@ mod tests {
|
|||||||
.map(|(cost, months)| {
|
.map(|(cost, months)| {
|
||||||
b.s.register_asset(NewAsset {
|
b.s.register_asset(NewAsset {
|
||||||
description: format!("Thing {months}"),
|
description: format!("Thing {months}"),
|
||||||
purchase_date: d(2026, 1, 1),
|
in_service_date: d(2026, 1, 1),
|
||||||
cost: Money::new(cents(cost), Currency::USD),
|
cost: Money::new(cents(cost), Currency::USD),
|
||||||
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
|
salvage_value: Money::new(Decimal::ZERO, Currency::USD),
|
||||||
useful_life_months: months,
|
useful_life_months: months,
|
||||||
@@ -1501,17 +1625,26 @@ mod tests {
|
|||||||
assert_eq!(none.color, positive);
|
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]
|
#[test]
|
||||||
fn an_assets_page_shows_its_own_book_value() {
|
fn an_assets_page_shows_its_own_book_value() {
|
||||||
for i in 0..2 {
|
for i in 0..2 {
|
||||||
let (_dir, books, ids) = two_assets(&format!("asset-page-{i}"));
|
let (_dir, books, ids) = two_assets(&format!("asset-page-{i}"));
|
||||||
let a = books.ledger.get_asset(ids[i]).unwrap().clone();
|
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]), |_| {});
|
let (texts, _) = render(books, Route::Asset(ids[i]), |_| {});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
beside(&texts, "Book value today").text,
|
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}"
|
"asset {i}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ use crate::theme::{self, pal};
|
|||||||
use crate::widgets;
|
use crate::widgets;
|
||||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
use ledger_core::AssetStatus;
|
use ledger_core::assets::accumulated_scheduled_through;
|
||||||
use ledger_core::PeriodStatus;
|
use ledger_core::{AssetStatus, PeriodStatus};
|
||||||
use ledger_core::assets::{accumulated_depreciation, book_value};
|
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
fn kv(ui: &mut Ui, k: &str, v: &str) {
|
fn kv(ui: &mut Ui, k: &str, v: &str) {
|
||||||
@@ -356,18 +355,17 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
},
|
},
|
||||||
|ui| {
|
|ui| {
|
||||||
for a in &assets {
|
for a in &assets {
|
||||||
// What the books carry (runs in effect), so this ties to the
|
// What the books carry (its runs, less reversals:
|
||||||
// balance sheet; the schedule may run ahead of the books.
|
// Ledger::accumulated_posted; zero once disposed, when the
|
||||||
let acc: Decimal = b.asset_trail(a.id).posted();
|
// asset left the books), so this ties to the balance
|
||||||
let bv = a.cost.amount - acc;
|
// sheet; the schedule may run ahead of the books.
|
||||||
let scheduled = accumulated_depreciation(a, b.today);
|
let disposed = match &a.status {
|
||||||
let _ = book_value;
|
AssetStatus::Disposed { date, .. } if *date <= b.today => Some(*date),
|
||||||
let dep = a.cost.amount - a.salvage_value.amount;
|
_ => None,
|
||||||
let frac: f32 = if dep.is_zero() {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
(acc / dep).try_into().unwrap_or(0.0)
|
|
||||||
};
|
};
|
||||||
|
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| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
if widgets::link(
|
if widgets::link(
|
||||||
ui,
|
ui,
|
||||||
@@ -379,8 +377,8 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
}
|
}
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(format!(
|
RichText::new(format!(
|
||||||
"bought {} · {} months · {}",
|
"in service {} · {} months · {}",
|
||||||
fmt::date(a.purchase_date),
|
fmt::date(a.in_service_date),
|
||||||
a.useful_life_months,
|
a.useful_life_months,
|
||||||
match &a.status {
|
match &a.status {
|
||||||
AssetStatus::Active => "active".to_string(),
|
AssetStatus::Active => "active".to_string(),
|
||||||
@@ -392,29 +390,51 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
|||||||
.color(p.muted),
|
.color(p.muted),
|
||||||
);
|
);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.label(
|
let text = match (&acc, disposed) {
|
||||||
RichText::new(format!("book value {}", fmt::dollars(bv)))
|
(_, Some(_)) => format!("book value {}", fmt::dollars(Decimal::ZERO)),
|
||||||
.font(theme::semibold(13.0)),
|
(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 {
|
match (&acc, disposed) {
|
||||||
Ok(s) if s > acc => {
|
(_, Some(date)) => progress(
|
||||||
format!(" · {} more scheduled through today", fmt::dollars(s - acc))
|
ui,
|
||||||
}
|
1.0,
|
||||||
Ok(_) => String::new(),
|
&format!("disposed {}: it left the books", fmt::date(date)),
|
||||||
// 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)
|
|
||||||
),
|
),
|
||||||
);
|
(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);
|
ui.add_space(8.0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,41 +60,80 @@ fn attachment_name(b: &Books, id: u64) -> String {
|
|||||||
.unwrap_or_else(|| format!("attachment {id}"))
|
.unwrap_or_else(|| format!("attachment {id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Tax basis of FL-0001: personal-use conversion, contributor's basis
|
/// "Tax basis of FL-0001: personal-use conversion, value $1,440.00,
|
||||||
/// $2,000.00 → tax basis $1,700.00". The tax basis is ledger-core's
|
/// contributor's adjusted basis $1,688.40: §723 basis $1,688.40,
|
||||||
/// (`Asset::tax_basis`) for the event's rule and basis on the asset's
|
/// depreciable basis $1,440.00, built-in loss $248.40". The figures are
|
||||||
/// book cost, which never changes once registered.
|
/// ledger-core's (`Asset::basis_figures`) for the event's facts on the
|
||||||
fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
|
/// 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<u64>, p: &Value, legacy: bool) -> String {
|
||||||
let asset = asset_id.and_then(|id| b.ledger.get_asset(id));
|
let asset = asset_id.and_then(|id| b.ledger.get_asset(id));
|
||||||
let name = match (asset, asset_id) {
|
let name = match (asset, asset_id) {
|
||||||
(Some(a), _) => a.tag.clone().unwrap_or_else(|| a.description.clone()),
|
(Some(a), _) => a.tag.clone().unwrap_or_else(|| a.description.clone()),
|
||||||
(None, Some(id)) => format!("asset {id}"),
|
(None, Some(id)) => format!("asset {id}"),
|
||||||
(None, None) => "an asset".to_string(),
|
(None, None) => "an asset".to_string(),
|
||||||
};
|
};
|
||||||
let rule = ledger_core::TaxBasisRule::parse(s(p, "rule"));
|
let dec = |k: &str| -> Option<rust_decimal::Decimal> {
|
||||||
let contributor: Option<rust_decimal::Decimal> = p
|
p.get(k)
|
||||||
.get("contributor_basis")
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.parse().ok())
|
||||||
.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 {
|
let Some(rule) = rule else {
|
||||||
return format!("Tax basis of {name} recorded");
|
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();
|
let mut a = a.clone();
|
||||||
a.tax = Some(ledger_core::TaxBasis {
|
a.basis = Some(ledger_core::AssetBasis {
|
||||||
rule,
|
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));
|
let mut text = format!("Tax basis of {name}: {}", super::assets::rule_words(rule));
|
||||||
if let Some(c) = contributor {
|
if let Some(v) = fmv {
|
||||||
text.push_str(&format!(", contributor's basis {}", fmt::dollars(c)));
|
text.push_str(&format!(", value {}", fmt::dollars(v)));
|
||||||
}
|
}
|
||||||
match (tax, contributor.is_some()) {
|
if let Some(c) = adjusted {
|
||||||
(Some(t), true) => text.push_str(&format!(" → tax basis {}", fmt::dollars(t))),
|
text.push_str(&format!(
|
||||||
(Some(t), false) => text.push_str(&format!(", tax basis {}", fmt::dollars(t))),
|
", contributor's adjusted basis {}",
|
||||||
(None, _) => {}
|
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
|
text
|
||||||
}
|
}
|
||||||
@@ -785,7 +824,12 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
|||||||
}
|
}
|
||||||
"asset.tax_basis_set" => ev(
|
"asset.tax_basis_set" => ev(
|
||||||
icon::SCALES,
|
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_id.map(Route::Asset),
|
||||||
),
|
),
|
||||||
"asset.corrected" => ev(
|
"asset.corrected" => ev(
|
||||||
|
|||||||
@@ -1156,20 +1156,21 @@ fn aging(
|
|||||||
fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
|
fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
|
||||||
let mut t = Table::new(vec![
|
let mut t = Table::new(vec![
|
||||||
"Cost".into(),
|
"Cost".into(),
|
||||||
"Accumulated".into(),
|
"Scheduled".into(),
|
||||||
|
"Posted".into(),
|
||||||
"Book value".into(),
|
"Book value".into(),
|
||||||
"Contributor's basis".into(),
|
// Short enough for the columns: the note says what each is.
|
||||||
"Tax basis".into(),
|
"§723 basis".into(),
|
||||||
"Built-in gain (loss)".into(),
|
"Depr. basis".into(),
|
||||||
|
"Built-in gain".into(),
|
||||||
]);
|
]);
|
||||||
let r = match b.asset_register(as_of) {
|
let r = match b.asset_register(as_of) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return cant_compute(t, &e),
|
Err(e) => return cant_compute(t, &e),
|
||||||
};
|
};
|
||||||
let none3 = || vec![Cell::none(), Cell::none(), Cell::none()];
|
|
||||||
if r.rows.is_empty() {
|
if r.rows.is_empty() {
|
||||||
t.note(format!(
|
t.note(format!(
|
||||||
"No fixed asset was in service by {}.",
|
"No fixed asset was acquired by {}.",
|
||||||
fmt::date(as_of)
|
fmt::date(as_of)
|
||||||
));
|
));
|
||||||
return t;
|
return t;
|
||||||
@@ -1184,7 +1185,10 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
|
|||||||
if let Some(s) = &x.serial {
|
if let Some(s) = &x.serial {
|
||||||
detail.push(format!("serial {s}"));
|
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"));
|
detail.push(fmt::count(x.useful_life_months as usize, "month", "months"));
|
||||||
if !x.salvage.is_zero() {
|
if !x.salvage.is_zero() {
|
||||||
detail.push(format!("salvage {}", fmt::dollars(x.salvage)));
|
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(),
|
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<Decimal>| Cell {
|
let opt = |v: Option<Decimal>| Cell {
|
||||||
value: v,
|
value: v,
|
||||||
drill: None,
|
drill: None,
|
||||||
@@ -1208,15 +1228,16 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
|
|||||||
super::assets::status_text(x.disposed),
|
super::assets::status_text(x.disposed),
|
||||||
fmt::dollars(x.cost)
|
fmt::dollars(x.cost)
|
||||||
));
|
));
|
||||||
[none3(), none3()].concat()
|
vec![Cell::none(); 7]
|
||||||
}
|
}
|
||||||
None => vec![
|
None => vec![
|
||||||
Cell::of(x.cost),
|
Cell::of(x.cost),
|
||||||
Cell::of(x.accumulated),
|
Cell::of(x.accumulated_scheduled),
|
||||||
|
Cell::of(x.accumulated_posted),
|
||||||
Cell::of(x.book_value),
|
Cell::of(x.book_value),
|
||||||
opt(tax.contributor_basis),
|
opt(tax.tax_basis_723),
|
||||||
opt(tax.tax_basis),
|
opt(tax.depreciable_basis),
|
||||||
opt(tax.built_in_gain),
|
opt(tax.built_in_gain_704c),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
t.rows.push(Row {
|
t.rows.push(Row {
|
||||||
@@ -1231,10 +1252,11 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
|
|||||||
"Total held",
|
"Total held",
|
||||||
vec![
|
vec![
|
||||||
Cell::of(r.total_cost),
|
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::of(r.total_book_value),
|
||||||
Cell::none(),
|
Cell::of(r.total_tax_basis_723),
|
||||||
Cell::of(r.total_tax_basis),
|
Cell::of(r.total_depreciable_basis),
|
||||||
Cell::none(),
|
Cell::none(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1270,6 +1292,10 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
|
|||||||
value: Some(r.ledger_accumulated),
|
value: Some(r.ledger_accumulated),
|
||||||
drill: accum_drill,
|
drill: accum_drill,
|
||||||
},
|
},
|
||||||
|
Cell {
|
||||||
|
value: Some(r.ledger_accumulated),
|
||||||
|
drill: accum_drill,
|
||||||
|
},
|
||||||
Cell::of(r.ledger_cost - r.ledger_accumulated),
|
Cell::of(r.ledger_cost - r.ledger_accumulated),
|
||||||
Cell::none(),
|
Cell::none(),
|
||||||
Cell::none(),
|
Cell::none(),
|
||||||
@@ -1282,7 +1308,8 @@ fn asset_register(b: &Books, as_of: NaiveDate) -> Table {
|
|||||||
vec![
|
vec![
|
||||||
Cell::of(-r.unregistered_cost()),
|
Cell::of(-r.unregistered_cost()),
|
||||||
Cell::of(r.unposted_depreciation()),
|
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(),
|
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(n.text);
|
||||||
}
|
}
|
||||||
t.note(
|
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
|
t
|
||||||
}
|
}
|
||||||
@@ -2389,10 +2416,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fixed asset register report's tax columns are the register's:
|
/// The fixed asset register report's columns are the register's: each
|
||||||
/// each held asset's contributor's basis, tax basis, and built-in gain
|
/// held asset's cost, depreciation scheduled and posted, book value,
|
||||||
/// (ledger-core's Asset::tax_basis and built_in_gain), the total its
|
/// §723 basis, depreciable basis and built-in gain (ledger-core's
|
||||||
/// total_tax_basis, and its rule (cited) in the detail.
|
/// Asset::basis_figures), the totals its totals, and its rule (cited)
|
||||||
|
/// in the detail.
|
||||||
#[test]
|
#[test]
|
||||||
fn asset_register_report_carries_tax_basis() {
|
fn asset_register_report_carries_tax_basis() {
|
||||||
let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
||||||
@@ -2407,21 +2435,23 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let r = ledger_core::fixed_asset_register(&b.ledger, today).unwrap();
|
let r = ledger_core::fixed_asset_register(&b.ledger, today).unwrap();
|
||||||
assert!(!r.rows.is_empty(), "the demo has an asset");
|
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);
|
let t = asset_register(&b, today);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
t.columns,
|
t.columns,
|
||||||
[
|
[
|
||||||
"Cost",
|
"Cost",
|
||||||
"Accumulated",
|
"Scheduled",
|
||||||
|
"Posted",
|
||||||
"Book value",
|
"Book value",
|
||||||
"Contributor's basis",
|
"§723 basis",
|
||||||
"Tax basis",
|
"Depr. basis",
|
||||||
"Built-in gain (loss)"
|
"Built-in gain"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
for x in &r.rows {
|
for x in &r.rows {
|
||||||
let a = b.ledger.get_asset(x.asset_id).unwrap();
|
let a = b.ledger.get_asset(x.asset_id).unwrap();
|
||||||
|
let f = a.basis_figures().unwrap().unwrap();
|
||||||
let row = t
|
let row = t
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2432,22 +2462,25 @@ mod tests {
|
|||||||
v,
|
v,
|
||||||
[
|
[
|
||||||
Some(x.cost),
|
Some(x.cost),
|
||||||
Some(x.accumulated),
|
Some(x.accumulated_scheduled),
|
||||||
|
Some(x.accumulated_posted),
|
||||||
Some(x.book_value),
|
Some(x.book_value),
|
||||||
a.tax.and_then(|t| t.contributor_basis),
|
f.tax_basis_723,
|
||||||
a.tax_basis(),
|
Some(f.depreciable_basis),
|
||||||
a.built_in_gain(),
|
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();
|
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[5].value, Some(r.total_depreciable_basis));
|
||||||
assert_eq!(total.cells[4].value, r.rows[0].tax_basis);
|
assert_eq!(total.cells[5].value, r.rows[0].depreciable_basis);
|
||||||
|
assert_eq!(total.cells[2].value, Some(r.total_accumulated_posted));
|
||||||
assert!(
|
assert!(
|
||||||
t.rows
|
t.rows
|
||||||
.iter()
|
.iter()
|
||||||
.all(|row| row.cells.is_empty() || row.cells.len() == 6)
|
.all(|row| row.cells.is_empty() || row.cells.len() == 7)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
t.rows
|
t.rows
|
||||||
@@ -2527,7 +2560,7 @@ mod tests {
|
|||||||
b.post_as("Bank loan", d(2026, 3, 27), cash, loan, 250_000);
|
b.post_as("Bank loan", d(2026, 3, 27), cash, loan, 250_000);
|
||||||
b.s.register_asset(ledger_core::NewAsset {
|
b.s.register_asset(ledger_core::NewAsset {
|
||||||
description: "Tablet".into(),
|
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),
|
cost: Money::new(Decimal::new(63_000, 2), ledger_core::Currency::USD),
|
||||||
salvage_value: Money::new(Decimal::ZERO, ledger_core::Currency::USD),
|
salvage_value: Money::new(Decimal::ZERO, ledger_core::Currency::USD),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
|||||||
if !serial.is_empty() {
|
if !serial.is_empty() {
|
||||||
detail.push(format!("serial {serial}"));
|
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 {
|
out.push(Item {
|
||||||
icon: icon::PACKAGE,
|
icon: icon::PACKAGE,
|
||||||
title: b.asset_label(a.id),
|
title: b.asset_label(a.id),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||||
use egui_kittest::Harness;
|
use egui_kittest::Harness;
|
||||||
use ledger_core::{
|
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,
|
depreciation_schedule, fixed_asset_register,
|
||||||
};
|
};
|
||||||
use ledger_gui::books::Books;
|
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| {
|
let register = |s: &mut Store, what: &str, date, cents, salvage, months| {
|
||||||
s.register_asset(NewAsset {
|
s.register_asset(NewAsset {
|
||||||
description: what.into(),
|
description: what.into(),
|
||||||
purchase_date: date,
|
in_service_date: date,
|
||||||
cost: usd(cents),
|
cost: usd(cents),
|
||||||
salvage_value: usd(salvage),
|
salvage_value: usd(salvage),
|
||||||
useful_life_months: months,
|
useful_life_months: months,
|
||||||
@@ -146,15 +146,13 @@ fn fixture(tag: &str) -> Fixture {
|
|||||||
s.set_asset_tag(old, "FL-0009").unwrap();
|
s.set_asset_tag(old, "FL-0009").unwrap();
|
||||||
s.set_asset_serial(old, Some("X1")).unwrap();
|
s.set_asset_serial(old, Some("X1")).unwrap();
|
||||||
s.set_asset_serial(old, None).unwrap();
|
s.set_asset_serial(old, None).unwrap();
|
||||||
let tax = |rule, basis: Option<i64>| TaxBasis {
|
let given = |rule, adjusted: i64, fmv: i64| {
|
||||||
rule,
|
AssetBasis::contribution(rule, Decimal::new(adjusted, 2), Decimal::new(fmv, 2))
|
||||||
contributor_basis: basis.map(|c| Decimal::new(c, 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();
|
.unwrap();
|
||||||
s.set_asset_tax_basis(laptop, tax(TaxBasisRule::Cost, None))
|
s.set_asset_basis(laptop, AssetBasis::purchase()).unwrap();
|
||||||
.unwrap();
|
s.set_asset_basis(old, given(BasisRule::PersonalUseConversion, 70_000, 60_000))
|
||||||
s.set_asset_tax_basis(old, tax(TaxBasisRule::PersonalUseConversion, Some(70_000)))
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for m in 1..=7 {
|
for m in 1..=7 {
|
||||||
s.run_depreciation(laptop, &format!("2026-{m:02}")).unwrap();
|
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
|
/// 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
|
/// (Asset::basis_figures), each register row's, and the register's totals
|
||||||
/// register's total and missing count, on every date.
|
/// and missing count, on every date.
|
||||||
#[test]
|
#[test]
|
||||||
fn tax_basis_is_ledger_cores() {
|
fn basis_figures_are_ledger_cores() {
|
||||||
let f = fixture("tax");
|
let f = fixture("tax");
|
||||||
let b = &f.books;
|
let b = &f.books;
|
||||||
for id in [f.laptop, f.monitor, f.old] {
|
for id in [f.laptop, f.monitor, f.old] {
|
||||||
let core = f.oracle.get_asset(id).unwrap();
|
let core = f.oracle.get_asset(id).unwrap();
|
||||||
let mine = TaxFacts::of(b.ledger.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!(
|
assert_eq!(
|
||||||
mine.contributor_basis,
|
mine.contributor_adjusted_basis,
|
||||||
core.tax.and_then(|t| t.contributor_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.tax_basis_723, figures.and_then(|x| x.tax_basis_723));
|
||||||
assert_eq!(mine.built_in_gain, core.built_in_gain(), "asset {id}");
|
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
|
// Independently: the laptop's basis is its cost; the old laptop's
|
||||||
// its value ($600.00, under the member's $700.00), a $100.00 loss.
|
// §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());
|
let laptop = TaxFacts::of(b.ledger.get_asset(f.laptop).unwrap());
|
||||||
assert_eq!(laptop.rule, Some(TaxBasisRule::Cost));
|
assert_eq!(laptop.rule, Some(BasisRule::Purchase));
|
||||||
assert_eq!(laptop.tax_basis, Some(Decimal::new(300_000, 2)));
|
assert_eq!(laptop.depreciable_basis, Some(Decimal::new(300_000, 2)));
|
||||||
assert_eq!(laptop.built_in_gain, None);
|
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());
|
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.tax_basis_723, Some(Decimal::new(70_000, 2)));
|
||||||
assert_eq!(old.built_in_gain, Some(Decimal::new(-10_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!(
|
assert_eq!(
|
||||||
old.summary(),
|
old.summary(),
|
||||||
"Personal-use conversion · contributor's basis $700.00"
|
"Personal-use conversion · §723 basis $700.00"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
old.detail(),
|
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!(
|
assert_eq!(
|
||||||
TaxFacts::of(b.ledger.get_asset(f.monitor).unwrap()).detail(),
|
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::of(b.ledger.get_asset(f.monitor).unwrap()),
|
||||||
TaxFacts {
|
TaxFacts {
|
||||||
rule: None,
|
rule: None,
|
||||||
contributor_basis: None,
|
contributor_adjusted_basis: None,
|
||||||
tax_basis: None,
|
fmv_at_contribution: None,
|
||||||
built_in_gain: 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()] {
|
for as_of in [d(2026, 1, 31), d(2026, 6, 30), today()] {
|
||||||
let r = b.asset_register(as_of).unwrap();
|
let r = b.asset_register(as_of).unwrap();
|
||||||
let oracle = fixed_asset_register(&f.oracle, 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!(
|
||||||
assert_eq!(r.missing_tax_basis, oracle.missing_tax_basis, "{as_of}");
|
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 {
|
for x in &r.rows {
|
||||||
let a = f.oracle.get_asset(x.asset_id).unwrap();
|
let a = f.oracle.get_asset(x.asset_id).unwrap();
|
||||||
let facts = TaxFacts::of_row(x);
|
let facts = TaxFacts::of_row(x);
|
||||||
assert_eq!(facts.tax_basis, a.tax_basis());
|
assert_eq!(facts.depreciable_basis, a.depreciable_basis());
|
||||||
assert_eq!(facts.built_in_gain, a.built_in_gain());
|
assert_eq!(facts.built_in_gain_704c, a.built_in_gain_704c());
|
||||||
assert_eq!(facts, TaxFacts::of(a));
|
assert_eq!(facts, TaxFacts::of(a));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Today: the laptop's $3,000.00 (the monitor has none; the old laptop
|
// Today: the laptop's $3,000.00 (the monitor has none; the old laptop
|
||||||
// is gone), and one missing, said in the notices.
|
// is gone), and one missing, said in the notices.
|
||||||
let r = b.asset_register(today()).unwrap();
|
let r = b.asset_register(today()).unwrap();
|
||||||
assert_eq!(r.total_tax_basis, Decimal::new(300_000, 2));
|
assert_eq!(r.total_depreciable_basis, Decimal::new(300_000, 2));
|
||||||
assert_eq!(r.missing_tax_basis, 1);
|
assert_eq!(r.missing_basis, 1);
|
||||||
let want = missing_tax_notice(1).unwrap();
|
let want = missing_tax_notice(1).unwrap();
|
||||||
assert!(want.starts_with("Tax basis not recorded for 1 asset"));
|
assert!(want.starts_with("Tax basis not recorded for 1 asset"));
|
||||||
assert!(want.contains("books.set_asset_tax_basis"));
|
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]
|
#[test]
|
||||||
fn journal_text_for_tax_basis() {
|
fn journal_text_names_each_basis_figure() {
|
||||||
let f = fixture("tax-journal");
|
let f = fixture("tax-journal");
|
||||||
let b = &f.books;
|
let b = &f.books;
|
||||||
let texts: Vec<(String, Option<Route>)> = b
|
let texts: Vec<(String, Option<Route>)> = b
|
||||||
.journal
|
.journal
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| l.line_type == "asset.tax_basis_set")
|
.filter(|l| l.line_type == "asset.basis_set")
|
||||||
.filter_map(|l| describe(b, l))
|
.filter_map(|l| describe(b, l))
|
||||||
.map(|e| (e.text, e.route))
|
.map(|e| (e.text, e.route))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -549,21 +569,44 @@ fn journal_text_for_tax_basis() {
|
|||||||
texts,
|
texts,
|
||||||
vec![
|
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(),
|
.to_string(),
|
||||||
Some(Route::Asset(f.laptop))
|
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))
|
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(),
|
.to_string(),
|
||||||
Some(Route::Asset(f.old))
|
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<String> = 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
|
/// 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();
|
let mut s = Store::open(&data).unwrap();
|
||||||
s.set_asset_tax_basis(
|
s.set_asset_basis(
|
||||||
f.monitor,
|
f.monitor,
|
||||||
TaxBasis {
|
AssetBasis::contribution(
|
||||||
rule: TaxBasisRule::Carryover,
|
BasisRule::Carryover723,
|
||||||
contributor_basis: Some(Decimal::new(90_000, 2)),
|
Decimal::new(90_000, 2),
|
||||||
},
|
Decimal::new(120_000, 2),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(s);
|
drop(s);
|
||||||
@@ -618,14 +662,27 @@ fn attention_asks_for_tax_basis() {
|
|||||||
.any(|a| a.title.starts_with("No tax basis"))
|
.any(|a| a.title.starts_with("No tax basis"))
|
||||||
);
|
);
|
||||||
let r = b.asset_register(today()).unwrap();
|
let r = b.asset_register(today()).unwrap();
|
||||||
assert_eq!(r.missing_tax_basis, 0);
|
assert_eq!(r.missing_basis, 0);
|
||||||
assert!(
|
assert!(
|
||||||
!notices(&b, &r)
|
!notices(&b, &r)
|
||||||
.iter()
|
.iter()
|
||||||
.any(|n| n.text.contains("Tax basis not recorded"))
|
.any(|n| n.text.contains("Tax basis not recorded"))
|
||||||
);
|
);
|
||||||
// $3,000.00 laptop + the monitor's $900.00 carryover.
|
// $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]
|
#[test]
|
||||||
@@ -763,7 +820,7 @@ proptest! {
|
|||||||
s.post_entry(e).unwrap();
|
s.post_entry(e).unwrap();
|
||||||
let id = s.register_asset(NewAsset {
|
let id = s.register_asset(NewAsset {
|
||||||
description: format!("Asset {i}"),
|
description: format!("Asset {i}"),
|
||||||
purchase_date: date,
|
in_service_date: date,
|
||||||
cost: usd(*cents),
|
cost: usd(*cents),
|
||||||
salvage_value: usd(cents * salvage_pct / 100),
|
salvage_value: usd(cents * salvage_pct / 100),
|
||||||
useful_life_months: *months,
|
useful_life_months: *months,
|
||||||
@@ -856,7 +913,7 @@ fn asset_x(tag: &str) -> (TempDir, PathBuf, Store, AssetId, (u64, u64)) {
|
|||||||
let x = s
|
let x = s
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Asset X".into(),
|
description: "Asset X".into(),
|
||||||
purchase_date: d(2026, 1, 1),
|
in_service_date: d(2026, 1, 1),
|
||||||
cost: usd(1_200_000),
|
cost: usd(1_200_000),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -1119,13 +1176,13 @@ proptest! {
|
|||||||
if disposed {
|
if disposed {
|
||||||
prop_assert_eq!(
|
prop_assert_eq!(
|
||||||
trail.posted(),
|
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);
|
prop_assert_eq!(balance_on(&s, accum, NaiveDate::MAX), Decimal::ZERO);
|
||||||
} else {
|
} else {
|
||||||
prop_assert_eq!(trail.posted(), -balance_on(&s, accum, NaiveDate::MAX));
|
prop_assert_eq!(trail.posted(), -balance_on(&s, accum, NaiveDate::MAX));
|
||||||
let june = d(2026, 6, 30);
|
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);
|
- balance_on(&s, expense, june);
|
||||||
prop_assert_eq!(
|
prop_assert_eq!(
|
||||||
ledger_gui::pages::assets::unposted(&b, x).unwrap(),
|
ledger_gui::pages::assets::unposted(&b, x).unwrap(),
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ fn fixture(tag: &str) -> Fixture {
|
|||||||
let asset = s
|
let asset = s
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: d(2026, 7, 6),
|
in_service_date: d(2026, 7, 6),
|
||||||
cost: usd(200_000),
|
cost: usd(200_000),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
|
|||||||
@@ -9,38 +9,46 @@
|
|||||||
//! of [`crate::xlsx`] for XLSX).
|
//! of [`crate::xlsx`] for XLSX).
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! GOAL: one list of what the company owns, its cost, depreciation, and
|
//! GOAL: one list of what the company owns, its cost, depreciation due and
|
||||||
//! book value, that a CPA can file, and that shows at a glance
|
//! posted, and book value, with its tax figures, that a CPA can
|
||||||
//! whether the register and the books agree.
|
//! file, and that shows at a glance whether the register and the
|
||||||
//! SOURCE: ledger_core::fixed_asset_register (INVARIANTS 1-4); CONVENTION
|
//! books agree.
|
||||||
|
//! SOURCE: ledger_core::fixed_asset_register (INVARIANTS 1-6); CONVENTION
|
||||||
//! (layout, the statements' header and footer).
|
//! (layout, the statements' header and footer).
|
||||||
//! INVARIANTS:
|
//! INVARIANTS:
|
||||||
//! 1. One line per register row, in the register's order, disposed ones
|
//! 1. One line per register row, in the register's order, disposed ones
|
||||||
//! included and marked "Disposed DATE, proceeds AMOUNT"; held ones
|
//! included and marked "Disposed DATE, proceeds AMOUNT"; held ones
|
||||||
//! "Active".
|
//! "Active". Each shows its acquired and in-service dates.
|
||||||
//! 2. The totals are the register's own totals (held assets only),
|
//! 2. Accumulated depreciation twice, never confused: scheduled (due by
|
||||||
//! never re-summed here.
|
//! 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
|
//! 3. Reconciliation: cost per register, per books, and their difference
|
||||||
//! (unregistered_cost); accumulated depreciation per schedule, per
|
//! (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
|
//! 4. An empty register says "No fixed assets" and still shows totals
|
||||||
//! and the reconciliation (the books may carry unregistered cost).
|
//! and the reconciliation (the books may carry unregistered cost).
|
||||||
//! 5. Header and footer as on every statement: company, "Fixed Asset
|
//! 5. Header and footer as on every statement: company, "Fixed Asset
|
||||||
//! Register", "As of DATE"; basis, prepared on, journal seq.
|
//! Register", "As of DATE"; basis, prepared on, journal seq.
|
||||||
//! 6. PDF: deterministic bytes; every text is data, never markup.
|
//! 6. PDF: deterministic bytes; every text is data, never markup.
|
||||||
//! 7. Tax basis beside book value: every line carries its tax basis
|
//! 7. Tax figures beside book value: every line carries its basis rule
|
||||||
//! rule (in words: [`rule_label`], [`rule_cited`]), the contributor's
|
//! (in words: [`rule_label`], [`rule_cited`]), the value at
|
||||||
//! basis, the tax basis, and the built-in gain (loss), each the
|
//! contribution (marked [`FMV_NOT_RECORDED_MARK`] when read as the
|
||||||
//! register row's own (ledger_core::Asset::tax_basis,
|
//! book cost), the contributor's adjusted basis, the §723 basis, the
|
||||||
//! Asset::built_in_gain), never re-derived here; a line with none
|
//! depreciable basis, the §704(c) built-in gain (loss), the §704(c)
|
||||||
//! recorded says "Not recorded" and leaves the amounts blank.
|
//! method, and who gave the facts, each the register row's own
|
||||||
//! 8. The tax basis total is the register's total_tax_basis; when
|
//! (ledger_core::Asset::basis_figures), never re-derived here; a line
|
||||||
//! missing_tax_basis > 0 a line says "Tax basis not recorded for N
|
//! with none recorded says "Not recorded" and leaves them blank.
|
||||||
//! asset(s)" (see [`missing_line`]).
|
//! 8. The tax totals are the register's total_tax_basis_723 and
|
||||||
//! 9. The notes ([`notes`]) say that book value is fair market value at
|
//! total_depreciable_basis; when missing_basis > 0 a line says "Tax
|
||||||
//! contribution and tax basis follows its rule, and cite each rule
|
//! basis not recorded for N asset(s)" (see [`missing_line`]).
|
||||||
//! (IRC §1012(a), §723, Treas. Reg. §1.168(i)-4(b), IRC §704(c)).
|
//! 9. The notes ([`notes`]) say what book value and the scheduled figure
|
||||||
//! The PDF marks each rule in the tax table with its note's number.
|
//! 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;
|
//! SOURCE (7-9): docs/spec/rules.md B-25;
|
||||||
@@ -49,8 +57,8 @@
|
|||||||
use crate::pdf::PdfError;
|
use crate::pdf::PdfError;
|
||||||
use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
|
use crate::report::{ReportMeta, amount_parts, format_amount, long_date};
|
||||||
use crate::xlsx::{Formats, XlsxError, footer, header, number};
|
use crate::xlsx::{Formats, XlsxError, footer, header, number};
|
||||||
use ledger_core::TaxBasisRule;
|
|
||||||
use ledger_core::reports::{AssetRegister, AssetRegisterRow};
|
use ledger_core::reports::{AssetRegister, AssetRegisterRow};
|
||||||
|
use ledger_core::{BasisRule, Sec704cMethod};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use rust_xlsxwriter::{Format, FormatAlign, Workbook};
|
use rust_xlsxwriter::{Format, FormatAlign, Workbook};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -62,46 +70,57 @@ use typst_layout::PagedDocument;
|
|||||||
pub const TITLE: &str = "Fixed Asset Register";
|
pub const TITLE: &str = "Fixed Asset Register";
|
||||||
|
|
||||||
/// Column headings, left to right.
|
/// Column headings, left to right.
|
||||||
pub const COLUMNS: [&str; 10] = [
|
pub const COLUMNS: [&str; 12] = [
|
||||||
"Tag",
|
"Tag",
|
||||||
"Description",
|
"Description",
|
||||||
"Serial",
|
"Serial",
|
||||||
|
"Acquired",
|
||||||
"In service",
|
"In service",
|
||||||
"Cost",
|
"Cost",
|
||||||
"Salvage",
|
"Salvage",
|
||||||
"Life (months)",
|
"Life (months)",
|
||||||
"Accumulated depreciation",
|
"Depreciation scheduled",
|
||||||
|
"Depreciation posted",
|
||||||
"Book value",
|
"Book value",
|
||||||
"Status",
|
"Status",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The PDF's tax basis table's headings, left to right.
|
/// The PDF's tax table's headings, left to right.
|
||||||
pub const TAX_COLUMNS: [&str; 7] = [
|
pub const TAX_COLUMNS: [&str; 9] = [
|
||||||
"Tag",
|
"Tag",
|
||||||
"Description",
|
"Description",
|
||||||
"Tax basis rule",
|
"Basis rule",
|
||||||
"Book cost",
|
"Book cost",
|
||||||
"Contributor's basis",
|
"Value at contribution",
|
||||||
"Tax basis",
|
"Contributor's adjusted basis",
|
||||||
|
"§723 basis",
|
||||||
|
"Depreciable basis",
|
||||||
"Built-in gain (loss)",
|
"Built-in gain (loss)",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The XLSX headings, left to right: the book columns, the tax columns,
|
/// The XLSX headings, left to right: the book columns, the tax columns,
|
||||||
/// then the status.
|
/// then the status.
|
||||||
pub const XLSX_COLUMNS: [&str; 14] = [
|
pub const XLSX_COLUMNS: [&str; 21] = [
|
||||||
"Tag",
|
"Tag",
|
||||||
"Description",
|
"Description",
|
||||||
"Serial",
|
"Serial",
|
||||||
|
"Acquired",
|
||||||
"In service",
|
"In service",
|
||||||
"Cost",
|
"Cost",
|
||||||
"Salvage",
|
"Salvage",
|
||||||
"Life (months)",
|
"Life (months)",
|
||||||
"Accumulated depreciation",
|
"Depreciation scheduled",
|
||||||
|
"Depreciation posted",
|
||||||
"Book value",
|
"Book value",
|
||||||
"Tax basis rule",
|
"Basis rule",
|
||||||
"Contributor's basis",
|
"Value at contribution",
|
||||||
"Tax basis",
|
"Contributor's adjusted basis",
|
||||||
|
"§723 basis",
|
||||||
|
"Depreciable basis",
|
||||||
"Built-in gain (loss)",
|
"Built-in gain (loss)",
|
||||||
|
"Allocation method",
|
||||||
|
"Basis facts from",
|
||||||
|
"Value recorded",
|
||||||
"Status",
|
"Status",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -109,42 +128,55 @@ pub const XLSX_COLUMNS: [&str; 14] = [
|
|||||||
pub const EMPTY: &str = "No fixed assets";
|
pub const EMPTY: &str = "No fixed assets";
|
||||||
|
|
||||||
/// The tax table's title.
|
/// 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.
|
/// The rule cell of an asset with no tax basis recorded.
|
||||||
pub const NOT_RECORDED: &str = "Not 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".
|
/// "Personal-use conversion".
|
||||||
pub fn rule_label(r: TaxBasisRule) -> &'static str {
|
pub fn rule_label(r: BasisRule) -> &'static str {
|
||||||
match r {
|
match r {
|
||||||
TaxBasisRule::Cost => "Cost",
|
BasisRule::Purchase => "Purchase",
|
||||||
TaxBasisRule::Carryover => "Carryover",
|
BasisRule::Carryover723 => "Carryover",
|
||||||
TaxBasisRule::PersonalUseConversion => "Personal-use conversion",
|
BasisRule::PersonalUseConversion => "Personal-use conversion",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A rule in words with its citation: "Cost", "Carryover (IRC §723)",
|
/// A rule in words with its citation: "Purchase (IRC §1012)", "Carryover
|
||||||
/// "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))".
|
/// (IRC §723)", "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))".
|
||||||
pub fn rule_cited(r: TaxBasisRule) -> &'static str {
|
pub fn rule_cited(r: BasisRule) -> &'static str {
|
||||||
match r {
|
match r {
|
||||||
TaxBasisRule::Cost => "Cost",
|
BasisRule::Purchase => "Purchase (IRC §1012)",
|
||||||
TaxBasisRule::Carryover => "Carryover (IRC §723)",
|
BasisRule::Carryover723 => "Carryover (IRC §723)",
|
||||||
TaxBasisRule::PersonalUseConversion => {
|
BasisRule::PersonalUseConversion => "Personal-use conversion (Treas. Reg. §1.168(i)-4(b))",
|
||||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The number of a rule's note in [`notes`].
|
/// 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 {
|
match r {
|
||||||
TaxBasisRule::Cost => "1",
|
BasisRule::Purchase => "1",
|
||||||
TaxBasisRule::Carryover => "2",
|
BasisRule::Carryover723 => "2",
|
||||||
TaxBasisRule::PersonalUseConversion => "3",
|
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.
|
/// One note under the register.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct Note {
|
pub struct Note {
|
||||||
@@ -154,8 +186,9 @@ pub struct Note {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The notes (INVARIANT 9): book value against tax basis, then each
|
/// The notes (INVARIANT 9): book value and the scheduled figure, the tax
|
||||||
/// rule's citation and the built-in gain (loss).
|
/// figures, each rule's citation, the built-in gain (loss), and a value
|
||||||
|
/// read as the book cost.
|
||||||
pub fn notes() -> Vec<Note> {
|
pub fn notes() -> Vec<Note> {
|
||||||
let n = |mark: &str, text: &str| Note {
|
let n = |mark: &str, text: &str| Note {
|
||||||
mark: mark.to_string(),
|
mark: mark.to_string(),
|
||||||
@@ -164,31 +197,41 @@ pub fn notes() -> Vec<Note> {
|
|||||||
vec![
|
vec![
|
||||||
n(
|
n(
|
||||||
"",
|
"",
|
||||||
"Book value is the fair market value at contribution for property a member contributed, \
|
"Book value is cost less the depreciation the books carry (posted); scheduled is what \
|
||||||
and the price paid for property the company bought; tax basis follows its rule and can \
|
the depreciation schedule says is due by the date, and the difference is depreciation \
|
||||||
differ. Depreciation here is book depreciation; tax depreciation starts from the tax \
|
not yet run. Property a member contributed is on the books at its fair market value \
|
||||||
basis and is the CPA's.",
|
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(
|
n(
|
||||||
rule_mark(TaxBasisRule::Cost),
|
rule_mark(BasisRule::Purchase),
|
||||||
"Cost: property the company bought has a basis of its cost (IRC §1012(a)).",
|
"Purchase (IRC §1012): property the company bought has a basis of its cost, which tax \
|
||||||
|
depreciation starts from.",
|
||||||
),
|
),
|
||||||
n(
|
n(
|
||||||
rule_mark(TaxBasisRule::Carryover),
|
rule_mark(BasisRule::Carryover723),
|
||||||
"Carryover (IRC §723): property a member contributes keeps the member's adjusted \
|
"Carryover (IRC §723): the company's basis in property a member contributes is the \
|
||||||
basis at contribution (their cost less any depreciation they took).",
|
member's adjusted basis at contribution (cost plus improvements, less depreciation \
|
||||||
|
allowed or allowable, IRC §1016(a)); tax depreciation starts from it.",
|
||||||
),
|
),
|
||||||
n(
|
n(
|
||||||
rule_mark(TaxBasisRule::PersonalUseConversion),
|
rule_mark(BasisRule::PersonalUseConversion),
|
||||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted from \
|
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): the §723 basis as for \
|
||||||
personal use depreciates from the lesser of its fair market value and the member's \
|
carryover; tax depreciation starts from the lesser of its fair market value and that \
|
||||||
adjusted basis at conversion.",
|
basis at conversion.",
|
||||||
),
|
),
|
||||||
n(
|
n(
|
||||||
"4",
|
"4",
|
||||||
"Built-in gain (loss): fair market value at contribution less the contributor's basis \
|
"Built-in gain (loss), IRC §704(c): fair market value less the §723 basis at \
|
||||||
(IRC §704(c)(1)(A)); a built-in loss is taken into account only for the contributing \
|
contribution (Treas. Reg. §1.704-3(a)(3)(ii)); a built-in loss counts only for the \
|
||||||
member (IRC §704(c)(1)(C)).",
|
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,
|
pub description: String,
|
||||||
/// Serial number, or empty.
|
/// Serial number, or empty.
|
||||||
pub serial: String,
|
pub serial: String,
|
||||||
|
/// Acquired date, ISO 8601.
|
||||||
|
pub acquired: String,
|
||||||
/// In-service date, ISO 8601.
|
/// In-service date, ISO 8601.
|
||||||
pub in_service: String,
|
pub in_service: String,
|
||||||
/// Capitalized cost.
|
/// Capitalized cost.
|
||||||
@@ -222,21 +267,33 @@ pub struct AssetLine {
|
|||||||
/// Useful life in months.
|
/// Useful life in months.
|
||||||
pub life_months: u32,
|
pub life_months: u32,
|
||||||
/// Accumulated depreciation by schedule (zero once disposed).
|
/// Accumulated depreciation by schedule (zero once disposed).
|
||||||
pub accumulated: Decimal,
|
pub accumulated_scheduled: Decimal,
|
||||||
/// Book value (zero once disposed).
|
/// Accumulated depreciation the books carry (zero once disposed).
|
||||||
|
pub accumulated_posted: Decimal,
|
||||||
|
/// Book value: cost less posted (zero once disposed).
|
||||||
pub book_value: Decimal,
|
pub book_value: Decimal,
|
||||||
/// "Active", or "Disposed 2026-09-30, proceeds 450.00".
|
/// "Active", or "Disposed 2026-09-30, proceeds 450.00".
|
||||||
pub status: String,
|
pub status: String,
|
||||||
/// Disposed by the date (shown, not in the totals).
|
/// Disposed by the date (shown, not in the totals).
|
||||||
pub disposed: bool,
|
pub disposed: bool,
|
||||||
/// The tax basis rule, if recorded.
|
/// The basis rule, if recorded.
|
||||||
pub tax_rule: Option<TaxBasisRule>,
|
pub basis_rule: Option<BasisRule>,
|
||||||
|
/// Fair market value at contribution (a contribution only).
|
||||||
|
pub fmv_at_contribution: Option<Decimal>,
|
||||||
|
/// Whether that value was recorded (false: read as the book cost).
|
||||||
|
pub fmv_recorded: bool,
|
||||||
/// The contributing member's adjusted basis (a contribution only).
|
/// The contributing member's adjusted basis (a contribution only).
|
||||||
pub contributor_basis: Option<Decimal>,
|
pub contributor_adjusted_basis: Option<Decimal>,
|
||||||
/// The depreciable federal tax basis, if recorded.
|
/// The §723 basis (a contribution only).
|
||||||
pub tax_basis: Option<Decimal>,
|
pub tax_basis_723: Option<Decimal>,
|
||||||
|
/// The depreciable tax basis, if recorded.
|
||||||
|
pub depreciable_basis: Option<Decimal>,
|
||||||
/// Built-in gain (+) or loss (−) at contribution, if any.
|
/// Built-in gain (+) or loss (−) at contribution, if any.
|
||||||
pub built_in_gain: Option<Decimal>,
|
pub built_in_gain_704c: Option<Decimal>,
|
||||||
|
/// The §704(c) method, in words with its paragraph, if chosen.
|
||||||
|
pub sec704c_method: Option<String>,
|
||||||
|
/// Who gave the basis facts, when, and on what, if recorded.
|
||||||
|
pub basis_source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which register column a reconciliation amount sits under.
|
/// Which register column a reconciliation amount sits under.
|
||||||
@@ -244,7 +301,7 @@ pub struct AssetLine {
|
|||||||
pub enum ReconColumn {
|
pub enum ReconColumn {
|
||||||
/// Under "Cost".
|
/// Under "Cost".
|
||||||
Cost,
|
Cost,
|
||||||
/// Under "Accumulated depreciation".
|
/// Under the accumulated depreciation columns.
|
||||||
Accumulated,
|
Accumulated,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,17 +327,17 @@ pub struct RegisterLayout {
|
|||||||
pub period: String,
|
pub period: String,
|
||||||
/// One per asset, in register order.
|
/// One per asset, in register order.
|
||||||
pub lines: Vec<AssetLine>,
|
pub lines: Vec<AssetLine>,
|
||||||
/// Total cost, accumulated depreciation, and book value of the assets
|
/// Total cost, accumulated depreciation scheduled, posted, and book
|
||||||
/// held.
|
/// value of the assets held.
|
||||||
pub totals: [Decimal; 3],
|
pub totals: [Decimal; 4],
|
||||||
/// Total tax basis of the assets held that have one.
|
/// Total §723 basis and depreciable basis of the assets held.
|
||||||
pub tax_total: Decimal,
|
pub tax_totals: [Decimal; 2],
|
||||||
/// [`missing_line`] of the register's missing_tax_basis.
|
/// [`missing_line`] of the register's missing_basis.
|
||||||
pub missing: Option<String>,
|
pub missing: Option<String>,
|
||||||
/// [`notes`].
|
/// [`notes`].
|
||||||
pub notes: Vec<Note>,
|
pub notes: Vec<Note>,
|
||||||
/// Cost then accumulated depreciation: per register, per books,
|
/// Cost, then accumulated depreciation: per register, per books,
|
||||||
/// difference.
|
/// differences.
|
||||||
pub reconciliation: Vec<ReconLine>,
|
pub reconciliation: Vec<ReconLine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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).
|
/// Lay the register out (see the module spec).
|
||||||
pub fn layout(r: &AssetRegister) -> RegisterLayout {
|
pub fn layout(r: &AssetRegister) -> RegisterLayout {
|
||||||
let recon = |label: &str, amount, column, difference| ReconLine {
|
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(),
|
tag: a.tag.clone().unwrap_or_default(),
|
||||||
description: a.description.clone(),
|
description: a.description.clone(),
|
||||||
serial: a.serial.clone().unwrap_or_default(),
|
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,
|
cost: a.cost,
|
||||||
salvage: a.salvage,
|
salvage: a.salvage,
|
||||||
life_months: a.useful_life_months,
|
life_months: a.useful_life_months,
|
||||||
accumulated: a.accumulated,
|
accumulated_scheduled: a.accumulated_scheduled,
|
||||||
|
accumulated_posted: a.accumulated_posted,
|
||||||
book_value: a.book_value,
|
book_value: a.book_value,
|
||||||
status: status(a),
|
status: status(a),
|
||||||
disposed: a.disposed.is_some(),
|
disposed: a.disposed.is_some(),
|
||||||
tax_rule: a.tax_rule,
|
basis_rule: a.basis_rule,
|
||||||
contributor_basis: a.contributor_basis,
|
fmv_at_contribution: a.fmv_at_contribution,
|
||||||
tax_basis: a.tax_basis,
|
fmv_recorded: a.fmv_recorded,
|
||||||
built_in_gain: a.built_in_gain,
|
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(),
|
.collect(),
|
||||||
totals: [r.total_cost, r.total_accumulated, r.total_book_value],
|
totals: [
|
||||||
tax_total: r.total_tax_basis,
|
r.total_cost,
|
||||||
missing: missing_line(r.missing_tax_basis),
|
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(),
|
notes: notes(),
|
||||||
reconciliation: vec![
|
reconciliation: vec![
|
||||||
recon("Cost per register", r.total_cost, ReconColumn::Cost, false),
|
recon("Cost per register", r.total_cost, ReconColumn::Cost, false),
|
||||||
@@ -340,7 +418,7 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
|
|||||||
),
|
),
|
||||||
recon(
|
recon(
|
||||||
"Accumulated depreciation per schedule",
|
"Accumulated depreciation per schedule",
|
||||||
r.total_accumulated,
|
r.total_accumulated_scheduled,
|
||||||
ReconColumn::Accumulated,
|
ReconColumn::Accumulated,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
@@ -356,6 +434,24 @@ pub fn layout(r: &AssetRegister) -> RegisterLayout {
|
|||||||
ReconColumn::Accumulated,
|
ReconColumn::Accumulated,
|
||||||
true,
|
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,
|
/// Layout as every statement (crate::xlsx INVARIANT 4): company, title,
|
||||||
/// and date on rows 1-3, headings ([`XLSX_COLUMNS`]) on row 5, one row
|
/// 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
|
/// 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
|
/// basis rule and §704(c) method in words with their citations
|
||||||
/// row (double rule) with the tax basis total under Tax basis, the
|
/// ([`rule_cited`], [`method_cited`]); then the totals row (double rule)
|
||||||
/// missing tax basis line when there is one, an empty row, "Reconciliation
|
/// with the §723 and depreciable basis totals under theirs, the missing
|
||||||
/// to the books" and its lines with amounts under Cost and Accumulated
|
/// basis line when there is one, an empty row, "Reconciliation to the
|
||||||
/// depreciation, an empty row, and "Notes" with [`notes`].
|
/// 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<Vec<u8>, XlsxError> {
|
pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||||
const AMOUNTS: [u16; 7] = [4, 5, 7, 8, 10, 11, 12];
|
const AMOUNTS: [u16; 10] = [5, 6, 8, 9, 10, 12, 13, 14, 15, 16];
|
||||||
const TOTALS: [u16; 4] = [4, 7, 8, 11];
|
const TOTALS: [u16; 6] = [5, 8, 9, 10, 14, 15];
|
||||||
let l = layout(r);
|
let l = layout(r);
|
||||||
let mut workbook = Workbook::new();
|
let mut workbook = Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
@@ -381,7 +478,7 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
|||||||
let width = XLSX_COLUMNS.len() as u16;
|
let width = XLSX_COLUMNS.len() as u16;
|
||||||
let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?;
|
let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?;
|
||||||
for (i, h) in XLSX_COLUMNS.iter().enumerate() {
|
for (i, h) in XLSX_COLUMNS.iter().enumerate() {
|
||||||
let fmt = if AMOUNTS.contains(&(i as u16)) || i == 6 {
|
let fmt = if AMOUNTS.contains(&(i as u16)) || i == 7 {
|
||||||
&f.header_right
|
&f.header_right
|
||||||
} else {
|
} else {
|
||||||
&f.header
|
&f.header
|
||||||
@@ -405,36 +502,58 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
|||||||
sheet.write_string_with_format(row, 0, &a.tag, text)?;
|
sheet.write_string_with_format(row, 0, &a.tag, text)?;
|
||||||
sheet.write_string_with_format(row, 1, &a.description, text)?;
|
sheet.write_string_with_format(row, 1, &a.description, text)?;
|
||||||
sheet.write_string_with_format(row, 2, &a.serial, text)?;
|
sheet.write_string_with_format(row, 2, &a.serial, text)?;
|
||||||
sheet.write_string_with_format(row, 3, &a.in_service, text)?;
|
sheet.write_string_with_format(row, 3, &a.acquired, text)?;
|
||||||
number(sheet, row, 4, a.cost, amount)?;
|
sheet.write_string_with_format(row, 4, &a.in_service, text)?;
|
||||||
number(sheet, row, 5, a.salvage, amount)?;
|
number(sheet, row, 5, a.cost, amount)?;
|
||||||
|
number(sheet, row, 6, a.salvage, amount)?;
|
||||||
sheet.write_number_with_format(
|
sheet.write_number_with_format(
|
||||||
row,
|
row,
|
||||||
6,
|
7,
|
||||||
f64::from(a.life_months),
|
f64::from(a.life_months),
|
||||||
&text.clone().set_align(FormatAlign::Right),
|
&text.clone().set_align(FormatAlign::Right),
|
||||||
)?;
|
)?;
|
||||||
number(sheet, row, 7, a.accumulated, amount)?;
|
number(sheet, row, 8, a.accumulated_scheduled, amount)?;
|
||||||
number(sheet, row, 8, a.book_value, amount)?;
|
number(sheet, row, 9, a.accumulated_posted, amount)?;
|
||||||
match a.tax_rule {
|
number(sheet, row, 10, a.book_value, amount)?;
|
||||||
Some(rule) => sheet.write_string_with_format(row, 9, rule_cited(rule), text)?,
|
match a.basis_rule {
|
||||||
|
Some(rule) => sheet.write_string_with_format(row, 11, rule_cited(rule), text)?,
|
||||||
None => sheet.write_string_with_format(
|
None => sheet.write_string_with_format(
|
||||||
row,
|
row,
|
||||||
9,
|
11,
|
||||||
NOT_RECORDED,
|
NOT_RECORDED,
|
||||||
if a.disposed { text } else { &missing },
|
if a.disposed { text } else { &missing },
|
||||||
)?,
|
)?,
|
||||||
};
|
};
|
||||||
for (c, v) in [
|
for (c, v) in [
|
||||||
(10, a.contributor_basis),
|
(12, a.fmv_at_contribution),
|
||||||
(11, a.tax_basis),
|
(13, a.contributor_adjusted_basis),
|
||||||
(12, a.built_in_gain),
|
(14, a.tax_basis_723),
|
||||||
|
(15, a.depreciable_basis),
|
||||||
|
(16, a.built_in_gain_704c),
|
||||||
] {
|
] {
|
||||||
if let Some(v) = v {
|
if let Some(v) = v {
|
||||||
number(sheet, row, c, v, amount)?;
|
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;
|
row += 1;
|
||||||
}
|
}
|
||||||
sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?;
|
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<Vec<u
|
|||||||
}
|
}
|
||||||
for (c, d) in TOTALS
|
for (c, d) in TOTALS
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.zip(l.totals.into_iter().chain([l.tax_total]))
|
.zip(l.totals.into_iter().chain(l.tax_totals))
|
||||||
{
|
{
|
||||||
number(sheet, row, c, d, &f.dollars_total)?;
|
number(sheet, row, c, d, &f.dollars_total)?;
|
||||||
}
|
}
|
||||||
@@ -457,8 +576,8 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
|||||||
row += 1;
|
row += 1;
|
||||||
for line in &l.reconciliation {
|
for line in &l.reconciliation {
|
||||||
let col = match line.column {
|
let col = match line.column {
|
||||||
ReconColumn::Cost => 4,
|
ReconColumn::Cost => 5,
|
||||||
ReconColumn::Accumulated => 7,
|
ReconColumn::Accumulated => 8,
|
||||||
};
|
};
|
||||||
let (label_fmt, amount_fmt) = if line.difference {
|
let (label_fmt, amount_fmt) = if line.difference {
|
||||||
(&f.bold, &f.dollars_sum)
|
(&f.bold, &f.dollars_sum)
|
||||||
@@ -488,16 +607,23 @@ pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result<Vec<u
|
|||||||
(1, 40.0),
|
(1, 40.0),
|
||||||
(2, 20.0),
|
(2, 20.0),
|
||||||
(3, 12.0),
|
(3, 12.0),
|
||||||
(4, 14.0),
|
(4, 12.0),
|
||||||
(5, 12.0),
|
(5, 14.0),
|
||||||
(6, 9.0),
|
(6, 12.0),
|
||||||
(7, 16.0),
|
(7, 9.0),
|
||||||
(8, 14.0),
|
(8, 16.0),
|
||||||
(9, 30.0),
|
(9, 16.0),
|
||||||
(10, 14.0),
|
(10, 14.0),
|
||||||
(11, 14.0),
|
(11, 30.0),
|
||||||
(12, 14.0),
|
(12, 14.0),
|
||||||
(13, 34.0),
|
(13, 16.0),
|
||||||
|
(14, 14.0),
|
||||||
|
(15, 14.0),
|
||||||
|
(16, 16.0),
|
||||||
|
(17, 30.0),
|
||||||
|
(18, 30.0),
|
||||||
|
(19, 16.0),
|
||||||
|
(20, 34.0),
|
||||||
] {
|
] {
|
||||||
sheet.set_column_width(c, w)?;
|
sheet.set_column_width(c, w)?;
|
||||||
}
|
}
|
||||||
@@ -539,27 +665,35 @@ pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value
|
|||||||
"tax_title": TAX_TITLE,
|
"tax_title": TAX_TITLE,
|
||||||
"tax_columns": TAX_COLUMNS,
|
"tax_columns": TAX_COLUMNS,
|
||||||
"empty": EMPTY,
|
"empty": EMPTY,
|
||||||
|
"fmv_mark": FMV_NOT_RECORDED_MARK,
|
||||||
"lines": l.lines.iter().map(|a| serde_json::json!({
|
"lines": l.lines.iter().map(|a| serde_json::json!({
|
||||||
"tag": a.tag,
|
"tag": a.tag,
|
||||||
"description": a.description,
|
"description": a.description,
|
||||||
"serial": a.serial,
|
"serial": a.serial,
|
||||||
|
"acquired": a.acquired,
|
||||||
"in_service": a.in_service,
|
"in_service": a.in_service,
|
||||||
"cost": amount(a.cost),
|
"cost": amount(a.cost),
|
||||||
"salvage": amount(a.salvage),
|
"salvage": amount(a.salvage),
|
||||||
"life": a.life_months.to_string(),
|
"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),
|
"book_value": amount(a.book_value),
|
||||||
"status": a.status,
|
"status": a.status,
|
||||||
"disposed": a.disposed,
|
"disposed": a.disposed,
|
||||||
"rule": a.tax_rule.map_or(NOT_RECORDED, rule_label),
|
"rule": a.basis_rule.map_or(NOT_RECORDED, rule_label),
|
||||||
"rule_mark": a.tax_rule.map_or("", rule_mark),
|
"rule_mark": a.basis_rule.map_or("", rule_mark),
|
||||||
"recorded": a.tax_rule.is_some(),
|
"recorded": a.basis_rule.is_some(),
|
||||||
"contributor_basis": maybe(a.contributor_basis),
|
"fmv_at_contribution": maybe(a.fmv_at_contribution),
|
||||||
"tax_basis": maybe(a.tax_basis),
|
"fmv_recorded": a.fmv_recorded,
|
||||||
"built_in_gain": maybe(a.built_in_gain),
|
"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::<Vec<_>>(),
|
})).collect::<Vec<_>>(),
|
||||||
"totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
|
"totals": l.totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
|
||||||
"tax_total": amount(l.tax_total),
|
"tax_totals": l.tax_totals.iter().map(|d| amount(*d)).collect::<Vec<_>>(),
|
||||||
"missing": l.missing,
|
"missing": l.missing,
|
||||||
"notes": l.notes.iter().map(|n| serde_json::json!({
|
"notes": l.notes.iter().map(|n| serde_json::json!({
|
||||||
"mark": n.mark,
|
"mark": n.mark,
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
// The fixed asset register: every asset, its cost, depreciation, and book
|
// The fixed asset register: every asset, its cost, depreciation scheduled
|
||||||
// value, then the totals and the reconciliation to the general ledger,
|
// and posted, and book value, then the totals and the reconciliation to
|
||||||
// then each asset's federal tax basis beside its book cost, and the notes
|
// the general ledger, then each asset's federal tax figures beside its
|
||||||
// that cite the tax basis rules.
|
// book cost, and the notes that cite the rules.
|
||||||
//
|
//
|
||||||
// Everything arrives as data in sys.inputs.data (JSON), laid out by
|
// Everything arrives as data in sys.inputs.data (JSON), laid out by
|
||||||
// ledger-import::asset_register, with amounts already split into exact
|
// ledger-import::asset_register, with amounts already split into exact
|
||||||
// parts. The template only typesets: no arithmetic, and no text is ever
|
// parts. The template only typesets: no arithmetic, and no text is ever
|
||||||
// parsed as markup.
|
// parsed as markup.
|
||||||
//
|
//
|
||||||
// Shape: { company, title, period, footer, columns: [10 headings],
|
// Shape: { company, title, period, footer, columns: [12 headings],
|
||||||
// tax_title, tax_columns: [7 headings], empty,
|
// tax_title, tax_columns: [9 headings], empty, fmv_mark,
|
||||||
// lines: [{ tag, description, serial, in_service, cost, salvage,
|
// lines: [{ tag, description, serial, acquired, in_service, cost,
|
||||||
// life, accumulated, book_value, status, disposed,
|
// salvage, life, accumulated_scheduled,
|
||||||
// rule, rule_mark, recorded, contributor_basis|null,
|
// accumulated_posted, book_value, status, disposed,
|
||||||
// tax_basis|null, built_in_gain|null }],
|
// rule, rule_mark, recorded, fmv_at_contribution|null,
|
||||||
// totals: [cost, accumulated, book_value], tax_total,
|
// 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 }],
|
// missing: text|null, notes: [{ mark, text }],
|
||||||
// reconciliation: [{ label, amount, difference }] }
|
// reconciliation: [{ label, amount, difference }] }
|
||||||
// where every amount is { digits: "1,234.56", negative }.
|
// where every amount is { digits: "1,234.56", negative }.
|
||||||
@@ -66,14 +70,14 @@
|
|||||||
[#open#sign#a.digits#close]
|
[#open#sign#a.digits#close]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Columns: 4-8 are numbers (right-aligned).
|
// Columns: 5-10 are numbers (right-aligned).
|
||||||
#let numeric = (4, 5, 6, 7, 8)
|
#let numeric = (5, 6, 7, 8, 9, 10)
|
||||||
#let ncols = d.columns.len()
|
#let ncols = d.columns.len()
|
||||||
#let full(body, ..args) = table.cell(colspan: ncols, ..args, body)
|
#let full(body, ..args) = table.cell(colspan: ncols, ..args, body)
|
||||||
|
|
||||||
#table(
|
#table(
|
||||||
// Number columns are fixed widths (headings wrap); text takes the rest.
|
// 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,
|
stroke: none,
|
||||||
inset: (x: 3.5pt, y: 3.4pt),
|
inset: (x: 3.5pt, y: 3.4pt),
|
||||||
align: (col, row) => (if col in numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
|
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.tag),
|
||||||
t(l.description),
|
t(l.description),
|
||||||
t(l.serial),
|
t(l.serial),
|
||||||
|
t(l.acquired),
|
||||||
t(l.in_service),
|
t(l.in_service),
|
||||||
a(l.cost),
|
a(l.cost),
|
||||||
a(l.salvage),
|
a(l.salvage),
|
||||||
t(l.life),
|
t(l.life),
|
||||||
a(l.accumulated),
|
a(l.accumulated_scheduled),
|
||||||
|
a(l.accumulated_posted),
|
||||||
a(l.book_value),
|
a(l.book_value),
|
||||||
t(l.status),
|
t(l.status),
|
||||||
)
|
)
|
||||||
@@ -112,12 +118,13 @@
|
|||||||
let tot(x) = table.cell(stroke: s, text(weight: "bold", amount(x, currency: true)))
|
let tot(x) = table.cell(stroke: s, text(weight: "bold", amount(x, currency: true)))
|
||||||
let blank = table.cell(stroke: s, [])
|
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)),
|
tot(d.totals.at(0)),
|
||||||
blank,
|
blank,
|
||||||
blank,
|
blank,
|
||||||
tot(d.totals.at(1)),
|
tot(d.totals.at(1)),
|
||||||
tot(d.totals.at(2)),
|
tot(d.totals.at(2)),
|
||||||
|
tot(d.totals.at(3)),
|
||||||
blank,
|
blank,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -154,16 +161,19 @@
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Federal tax basis, asset by asset, beside the book cost it may differ
|
// Federal tax figures, asset by asset, beside the book cost they may
|
||||||
// from; each rule marked with the number of the note that cites it.
|
// differ from; each rule marked with the number of the note that cites
|
||||||
#let tax_numeric = (3, 4, 5, 6)
|
// 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 tcols = d.tax_columns.len()
|
||||||
#let maybe(x) = if x == none { [] } else { amount(x) }
|
#let maybe(x) = if x == none { [] } else { amount(x) }
|
||||||
#v(12pt)
|
#v(12pt)
|
||||||
#text(weight: "semibold", d.tax_title)
|
#text(weight: "semibold", d.tax_title)
|
||||||
#v(2pt)
|
#v(2pt)
|
||||||
#table(
|
#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,
|
stroke: none,
|
||||||
inset: (x: 3.5pt, y: 3.4pt),
|
inset: (x: 3.5pt, y: 3.4pt),
|
||||||
align: (col, row) => (if col in tax_numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
|
align: (col, row) => (if col in tax_numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }),
|
||||||
@@ -190,13 +200,29 @@
|
|||||||
} else {
|
} else {
|
||||||
text(fill: rgb("#9a5b00"), emph(l.rule))
|
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.tag),
|
||||||
t(l.description),
|
t({
|
||||||
|
l.description
|
||||||
|
for x in about {
|
||||||
|
linebreak()
|
||||||
|
text(6.5pt, fill: quiet, x)
|
||||||
|
}
|
||||||
|
}),
|
||||||
t(rule),
|
t(rule),
|
||||||
t(amount(l.cost)),
|
t(amount(l.cost)),
|
||||||
t(maybe(l.contributor_basis)),
|
t(fmv),
|
||||||
t(maybe(l.tax_basis)),
|
t(maybe(l.contributor_adjusted_basis)),
|
||||||
|
t(maybe(l.tax_basis_723)),
|
||||||
|
t(maybe(l.depreciable_basis)),
|
||||||
t(maybe(l.built_in_gain)),
|
t(maybe(l.built_in_gain)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -206,8 +232,9 @@
|
|||||||
let s = (top: firm)
|
let s = (top: firm)
|
||||||
let blank = table.cell(stroke: s, [])
|
let blank = table.cell(stroke: s, [])
|
||||||
(
|
(
|
||||||
table.cell(stroke: s, colspan: 5, text(weight: "bold", "Total tax basis, assets held")),
|
table.cell(stroke: s, colspan: 6, text(weight: "bold", "Total, assets held")),
|
||||||
table.cell(stroke: s, text(weight: "bold", amount(d.tax_total, currency: true))),
|
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,
|
blank,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
//! INVARIANT it checks.
|
//! INVARIANT it checks.
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::{AssetRegister, AssetRegisterRow, TaxBasisRule};
|
use ledger_core::{AssetRegister, AssetRegisterRow, BasisRule, Sec704cMethod};
|
||||||
use ledger_import::asset_register::{
|
use ledger_import::asset_register::{
|
||||||
layout, missing_line, notes, rule_cited, rule_label, rule_mark, template_data,
|
FMV_NOT_RECORDED_MARK, layout, method_cited, missing_line, notes, rule_cited, rule_label,
|
||||||
typeset_asset_register,
|
rule_mark, template_data, typeset_asset_register,
|
||||||
};
|
};
|
||||||
use ledger_import::report::ReportMeta;
|
use ledger_import::report::ReportMeta;
|
||||||
use ledger_import::{PdfError, asset_register_pdf};
|
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 {
|
AssetRegisterRow {
|
||||||
asset_id: id,
|
asset_id: id,
|
||||||
tag: Some(tag.into()),
|
tag: Some(tag.into()),
|
||||||
description: desc.into(),
|
description: desc.into(),
|
||||||
serial: None,
|
serial: None,
|
||||||
in_service: d(2026, 1, 15),
|
acquired_date: d(2026, 1, 15),
|
||||||
|
in_service_date: d(2026, 1, 15),
|
||||||
cost: cents(cost),
|
cost: cents(cost),
|
||||||
salvage: Decimal::ZERO,
|
salvage: Decimal::ZERO,
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
accumulated: cents(acc),
|
accumulated_scheduled: cents(scheduled),
|
||||||
book_value: cents(cost - acc),
|
accumulated_posted: cents(posted),
|
||||||
|
book_value: cents(cost - posted),
|
||||||
disposed: None,
|
disposed: None,
|
||||||
tax_rule: None,
|
basis_rule: None,
|
||||||
contributor_basis: None,
|
contributor_adjusted_basis: None,
|
||||||
tax_basis: None,
|
fmv_at_contribution: None,
|
||||||
built_in_gain: 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 {
|
fn bought(mut r: AssetRegisterRow) -> AssetRegisterRow {
|
||||||
r.tax_rule = Some(TaxBasisRule::Cost);
|
r.basis_rule = Some(BasisRule::Purchase);
|
||||||
r.tax_basis = Some(r.cost);
|
r.depreciable_basis = Some(r.cost);
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Contributed under `rule` with the member's basis `basis` (cents); tax
|
/// Contributed under `rule` with the member's adjusted basis `basis`
|
||||||
/// basis and built-in gain as ledger_core::Asset computes them.
|
/// (cents) and its value `fmv` (cents; None: recorded before P3, read as
|
||||||
fn contributed(mut r: AssetRegisterRow, rule: TaxBasisRule, basis: i64) -> AssetRegisterRow {
|
/// the book cost); figures as ledger_core::kernel::basis_figures gives
|
||||||
|
/// them.
|
||||||
|
fn contributed(
|
||||||
|
mut r: AssetRegisterRow,
|
||||||
|
rule: BasisRule,
|
||||||
|
basis: i64,
|
||||||
|
fmv: Option<i64>,
|
||||||
|
) -> AssetRegisterRow {
|
||||||
let b = cents(basis);
|
let b = cents(basis);
|
||||||
r.tax_rule = Some(rule);
|
let v = fmv.map_or(r.cost, cents);
|
||||||
r.contributor_basis = Some(b);
|
let f = ledger_core::kernel::basis_figures(rule, r.cost, b, v).unwrap();
|
||||||
r.tax_basis = Some(match rule {
|
r.basis_rule = Some(rule);
|
||||||
TaxBasisRule::PersonalUseConversion => b.min(r.cost),
|
r.contributor_adjusted_basis = Some(b);
|
||||||
_ => b,
|
r.fmv_at_contribution = Some(v);
|
||||||
});
|
r.fmv_recorded = fmv.is_some();
|
||||||
r.built_in_gain = Some(r.cost - b);
|
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
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register(rows: Vec<AssetRegisterRow>) -> AssetRegister {
|
fn register(rows: Vec<AssetRegisterRow>) -> AssetRegister {
|
||||||
let held = || rows.iter().filter(|r| r.disposed.is_none());
|
let held = || rows.iter().filter(|r| r.disposed.is_none());
|
||||||
let total_cost = held().map(|r| r.cost).sum();
|
let total_cost: Decimal = held().map(|r| r.cost).sum();
|
||||||
let total_accumulated = held().map(|r| r.accumulated).sum();
|
let total_accumulated_scheduled: Decimal = held().map(|r| r.accumulated_scheduled).sum();
|
||||||
let total_tax_basis = held().filter_map(|r| r.tax_basis).sum();
|
let total_accumulated_posted: Decimal = held().map(|r| r.accumulated_posted).sum();
|
||||||
let missing_tax_basis = held().filter(|r| r.tax_basis.is_none()).count();
|
|
||||||
AssetRegister {
|
AssetRegister {
|
||||||
total_tax_basis,
|
|
||||||
missing_tax_basis,
|
|
||||||
as_of: d(2026, 10, 15),
|
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_cost: total_cost,
|
||||||
ledger_accumulated: total_accumulated - cents(1234),
|
ledger_accumulated: total_accumulated_scheduled - cents(1234),
|
||||||
total_cost,
|
total_cost,
|
||||||
total_accumulated,
|
total_accumulated_scheduled,
|
||||||
|
total_accumulated_posted,
|
||||||
rows,
|
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 {
|
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.book_value = Decimal::ZERO;
|
||||||
disposed.disposed = Some((d(2026, 9, 30), cents(4500)));
|
disposed.disposed = Some((d(2026, 9, 30), cents(4500)));
|
||||||
let mut laptop = row(
|
let mut laptop = row(
|
||||||
@@ -101,27 +128,41 @@ fn sample() -> AssetRegister {
|
|||||||
"Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)",
|
"Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)",
|
||||||
250000,
|
250000,
|
||||||
52778,
|
52778,
|
||||||
|
50000,
|
||||||
);
|
);
|
||||||
laptop.serial = Some("FRANBMCP0A1234".into());
|
laptop.serial = Some("FRANBMCP0A1234".into());
|
||||||
laptop.salvage = cents(10000);
|
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![
|
register(vec![
|
||||||
bought(laptop),
|
bought(laptop),
|
||||||
// A member's business laptop: carryover basis, built-in gain.
|
air,
|
||||||
contributed(
|
|
||||||
row(2, "FL-0002", "MacBook Air", 129900, 32475),
|
|
||||||
TaxBasisRule::Carryover,
|
|
||||||
90000,
|
|
||||||
),
|
|
||||||
bought(disposed),
|
bought(disposed),
|
||||||
// A member's personal camera: worth less than its basis, so tax
|
// A member's personal camera, recorded before P3 (value read as
|
||||||
// basis is the fair market value and there's a built-in loss.
|
// the book cost): worth less than its basis, so the depreciable
|
||||||
|
// basis is the value and there's a built-in loss.
|
||||||
contributed(
|
contributed(
|
||||||
row(4, "FL-0004", "Sony A7 IV camera", 175000, 0),
|
row(4, "FL-0004", "Sony A7 IV camera", 175000, 0, 0),
|
||||||
TaxBasisRule::PersonalUseConversion,
|
BasisRule::PersonalUseConversion,
|
||||||
200000,
|
200000,
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
// No tax basis recorded yet.
|
// 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());
|
assert_eq!(a, asset_register_pdf(&r, &meta()).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// INVARIANTS 1, 2, 3, 5: header, every asset, the status of each, the
|
/// INVARIANTS 1, 2, 3, 5: header, every asset with its acquired and
|
||||||
/// register's totals, and the reconciliation with both differences.
|
/// in-service dates, the status of each, depreciation scheduled and
|
||||||
|
/// posted, the register's totals, and the reconciliation with its three
|
||||||
|
/// differences.
|
||||||
#[test]
|
#[test]
|
||||||
fn pdf_shows_assets_totals_and_reconciliation() {
|
fn pdf_shows_assets_totals_and_reconciliation() {
|
||||||
let t = all_text(&sample(), &meta());
|
let t = all_text(&sample(), &meta());
|
||||||
@@ -166,22 +209,26 @@ fn pdf_shows_assets_totals_and_reconciliation() {
|
|||||||
"Fernhill Labs LLC",
|
"Fernhill Labs LLC",
|
||||||
"Fixed Asset Register",
|
"Fixed Asset Register",
|
||||||
"As of October 15, 2026",
|
"As of October 15, 2026",
|
||||||
|
"ACQUIRED",
|
||||||
|
"IN SERVICE",
|
||||||
|
"DEPRECIATION SCHEDULED",
|
||||||
|
"DEPRECIATION POSTED",
|
||||||
"FL-0001",
|
"FL-0001",
|
||||||
"FRANBMCP0A1234",
|
"FRANBMCP0A1234",
|
||||||
|
"2026-01-10 2026-01-15",
|
||||||
"MacBook Air",
|
"MacBook Air",
|
||||||
"Active",
|
"Active",
|
||||||
"Disposed 2026-09-30, proceeds 45.00",
|
"Disposed 2026-09-30, proceeds 45.00",
|
||||||
"Total, assets held",
|
"Total, assets held",
|
||||||
"6,349.00", // cost held: 2,500.00 + 1,299.00 + 1,750.00 + 800.00
|
"$6,349.00 $852.53 $824.75 $5,524.25",
|
||||||
"852.53", // accumulated held
|
|
||||||
"5,496.47", // book value held: 6,349.00 - 852.53
|
|
||||||
"Cost per register",
|
"Cost per register",
|
||||||
"Cost per books",
|
"Cost per books",
|
||||||
"Difference (unregistered cost)",
|
"Difference (unregistered cost)",
|
||||||
"Accumulated depreciation per schedule",
|
"Accumulated depreciation per schedule",
|
||||||
"Accumulated depreciation per books",
|
"Accumulated depreciation per books",
|
||||||
"Difference (not yet posted)",
|
"Difference (not yet posted) $12.34",
|
||||||
"12.34",
|
"Accumulated depreciation from the assets' runs",
|
||||||
|
"Difference (not from runs) $15.44",
|
||||||
"Journal seq 314",
|
"Journal seq 314",
|
||||||
] {
|
] {
|
||||||
assert!(t.contains(s), "{s:?} missing from {t:?}");
|
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.
|
// The disposed asset's cost (400.00) is shown but in no total.
|
||||||
assert!(t.contains("400.00"));
|
assert!(t.contains("400.00"));
|
||||||
let l = layout(&sample());
|
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
|
/// 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
|
/// value at contribution (marked when read as the book cost), the
|
||||||
/// register's tax basis total, the missing line, and the notes with
|
/// contributor's adjusted basis, the §723 basis, the depreciable basis,
|
||||||
/// their citations.
|
/// 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]
|
#[test]
|
||||||
fn pdf_shows_tax_basis() {
|
fn pdf_shows_723_and_depreciable_basis() {
|
||||||
let r = sample();
|
let r = sample();
|
||||||
let t = all_text(&r, &meta());
|
let t = all_text(&r, &meta());
|
||||||
for s in [
|
for s in [
|
||||||
"Federal tax basis",
|
"Federal tax figures, at contribution or purchase",
|
||||||
"TAX BASIS RULE",
|
"BASIS RULE",
|
||||||
"CONTRIBUTOR'S BASIS",
|
"VALUE AT CONTRIBUTION",
|
||||||
|
"CONTRIBUTOR'S ADJUSTED BASIS",
|
||||||
|
"§723 BASIS",
|
||||||
|
"DEPRECIABLE BASIS",
|
||||||
"BUILT-IN GAIN (LOSS)",
|
"BUILT-IN GAIN (LOSS)",
|
||||||
"Carryover",
|
// MacBook: cost, value, adjusted basis, §723, depreciable, gain.
|
||||||
"Personal-use conversion",
|
"Carryover 2 1,299.00 1,299.00 900.00 900.00 900.00 399.00",
|
||||||
"900.00", // MacBook: contributor's basis = tax basis
|
"Traditional (Treas. Reg. §1.704-3(b))",
|
||||||
"399.00", // its built-in gain
|
"Morgan Ellis, 2026-10-20: entry 7",
|
||||||
"2,000.00", // camera: contributor's basis
|
// Camera: value read as cost (marked), basis 2,000.00, the lesser.
|
||||||
"1,750.00", // its tax basis: the fair market value
|
"Personal-use conversion 3 1,750.00 * 1,750.00 2,000.00 2,000.00 1,750.00 (250.00)",
|
||||||
"(250.00)", // its built-in loss: 1,750.00 - 2,000.00
|
"Standing desk Not recorded 800.00",
|
||||||
"Not recorded",
|
"Total, assets held $2,900.00 $5,150.00",
|
||||||
"Total tax basis, assets held",
|
|
||||||
"5,150.00", // 2,500.00 + 900.00 + 1,750.00; the desk has none
|
|
||||||
"Tax basis not recorded for 1 asset",
|
"Tax basis not recorded for 1 asset",
|
||||||
"Notes",
|
"Notes",
|
||||||
"Book value is the fair market value at contribution",
|
"Book value is cost less the depreciation the books carry",
|
||||||
"IRC §1012(a)",
|
"IRC §1012",
|
||||||
"IRC §723",
|
"IRC §723",
|
||||||
|
"IRC §1016(a)",
|
||||||
"Treas. Reg. §1.168(i)-4(b)",
|
"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)",
|
"IRC §704(c)(1)(C)",
|
||||||
|
"Value at contribution not recorded as a fact",
|
||||||
] {
|
] {
|
||||||
assert!(t.contains(s), "{s:?} missing from {t:?}");
|
assert!(t.contains(s), "{s:?} missing from {t:?}");
|
||||||
}
|
}
|
||||||
let l = layout(&r);
|
let l = layout(&r);
|
||||||
assert_eq!(l.tax_total, r.total_tax_basis);
|
assert_eq!(
|
||||||
assert_eq!(l.tax_total, cents(515000));
|
l.tax_totals,
|
||||||
|
[r.total_tax_basis_723, r.total_depreciable_basis]
|
||||||
|
);
|
||||||
|
assert_eq!(l.tax_totals, [cents(290000), cents(515000)]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
l.missing.as_deref(),
|
l.missing.as_deref(),
|
||||||
Some("Tax basis not recorded for 1 asset")
|
Some("Tax basis not recorded for 1 asset")
|
||||||
@@ -235,10 +295,19 @@ fn pdf_shows_tax_basis() {
|
|||||||
assert_eq!(l.notes, notes());
|
assert_eq!(l.notes, notes());
|
||||||
// Each line carries its row's tax fields unchanged.
|
// Each line carries its row's tax fields unchanged.
|
||||||
for (line, row) in l.lines.iter().zip(&r.rows) {
|
for (line, row) in l.lines.iter().zip(&r.rows) {
|
||||||
assert_eq!(line.tax_rule, row.tax_rule);
|
assert_eq!(line.basis_rule, row.basis_rule);
|
||||||
assert_eq!(line.contributor_basis, row.contributor_basis);
|
assert_eq!(line.fmv_at_contribution, row.fmv_at_contribution);
|
||||||
assert_eq!(line.tax_basis, row.tax_basis);
|
assert_eq!(line.fmv_recorded, row.fmv_recorded);
|
||||||
assert_eq!(line.built_in_gain, row.built_in_gain);
|
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.
|
/// (a disposed one without doesn't count), and the plural reads right.
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_line_only_when_missing() {
|
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.book_value = Decimal::ZERO;
|
||||||
gone.disposed = Some((d(2026, 9, 30), cents(4500)));
|
gone.disposed = Some((d(2026, 9, 30), cents(4500)));
|
||||||
let r = register(vec![bought(row(2, "FL-0002", "Desk", 50000, 0)), gone]);
|
let r = register(vec![bought(row(2, "FL-0002", "Desk", 50000, 0, 0)), gone]);
|
||||||
assert_eq!(r.missing_tax_basis, 0);
|
assert_eq!(r.missing_basis, 0);
|
||||||
assert_eq!(layout(&r).missing, None);
|
assert_eq!(layout(&r).missing, None);
|
||||||
assert!(!all_text(&r, &meta()).contains("Tax basis not recorded"));
|
assert!(!all_text(&r, &meta()).contains("Tax basis not recorded"));
|
||||||
assert_eq!(missing_line(0), None);
|
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]
|
#[test]
|
||||||
fn rule_words() {
|
fn rule_words() {
|
||||||
use TaxBasisRule::*;
|
use BasisRule::*;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
[Cost, Carryover, PersonalUseConversion].map(rule_label),
|
[Purchase, Carryover723, PersonalUseConversion].map(rule_label),
|
||||||
["Cost", "Carryover", "Personal-use conversion"]
|
["Purchase", "Carryover", "Personal-use conversion"]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
[Cost, Carryover, PersonalUseConversion].map(rule_cited),
|
[Purchase, Carryover723, PersonalUseConversion].map(rule_cited),
|
||||||
[
|
[
|
||||||
"Cost",
|
"Purchase (IRC §1012)",
|
||||||
"Carryover (IRC §723)",
|
"Carryover (IRC §723)",
|
||||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
|
"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
|
/// 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
|
/// first and unnumbered, the built-in gain note 4, the value read as cost
|
||||||
/// cites that rule, and the PDF marks each recorded line's rule with its
|
/// "*"), each rule's note cites that rule, and the PDF marks each
|
||||||
/// note's number; a line with no rule recorded has no mark.
|
/// recorded line's rule with its note's number; a line with no rule
|
||||||
|
/// recorded has no mark.
|
||||||
#[test]
|
#[test]
|
||||||
fn rule_marks_match_their_notes() {
|
fn rule_marks_match_their_notes() {
|
||||||
use TaxBasisRule::*;
|
use BasisRule::*;
|
||||||
let rules = [Cost, Carryover, PersonalUseConversion];
|
let rules = [Purchase, Carryover723, PersonalUseConversion];
|
||||||
assert_eq!(rules.map(rule_mark), ["1", "2", "3"]);
|
assert_eq!(rules.map(rule_mark), ["1", "2", "3"]);
|
||||||
let n = notes();
|
let n = notes();
|
||||||
let marks: Vec<&str> = n.iter().map(|n| n.mark.as_str()).collect();
|
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 {
|
for rule in rules {
|
||||||
let note = n.iter().find(|n| n.mark == rule_mark(rule)).unwrap();
|
let note = n.iter().find(|n| n.mark == rule_mark(rule)).unwrap();
|
||||||
let cites = format!("{}: ", rule_cited(rule));
|
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.
|
// The PDF: the mark right after the rule, the note under its number.
|
||||||
let t = all_text(&r, &meta());
|
let t = all_text(&r, &meta());
|
||||||
for s in [
|
for s in [
|
||||||
"(Ryzen 7 7840U, 64 GB, 4 TB) Cost 1 2,500.00",
|
"(Ryzen 7 7840U, 64 GB, 4 TB) Purchase 1 2,500.00",
|
||||||
"MacBook Air Carryover 2 1,299.00",
|
"Carryover 2 1,299.00",
|
||||||
"Old monitor Cost 1 400.00",
|
"Old monitor Purchase 1 400.00",
|
||||||
"Sony A7 IV camera Personal-use conversion 3 1,750.00",
|
"Sony A7 IV camera Personal-use conversion 3 1,750.00",
|
||||||
"Standing desk Not recorded 800.00",
|
"Standing desk Not recorded 800.00",
|
||||||
"1 Cost: property the company bought",
|
"1 Purchase (IRC §1012): property the company bought",
|
||||||
"2 Carryover (IRC §723): property a member contributes",
|
"2 Carryover (IRC §723): the company's basis in property a member contributes",
|
||||||
"3 Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): property converted",
|
"3 Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): the §723 basis",
|
||||||
"4 Built-in gain (loss):",
|
"4 Built-in gain (loss), IRC §704(c):",
|
||||||
] {
|
] {
|
||||||
assert!(t.contains(s), "{s:?} missing from {t:?}");
|
assert!(t.contains(s), "{s:?} missing from {t:?}");
|
||||||
}
|
}
|
||||||
@@ -334,6 +413,7 @@ fn empty_register_renders() {
|
|||||||
"No fixed assets",
|
"No fixed assets",
|
||||||
"Total, assets held",
|
"Total, assets held",
|
||||||
"Difference (not yet posted)",
|
"Difference (not yet posted)",
|
||||||
|
"Difference (not from runs)",
|
||||||
] {
|
] {
|
||||||
assert!(t.contains(s), "{s:?} missing from {t:?}");
|
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.
|
/// INVARIANT 6: markup in every text field prints literally and never runs.
|
||||||
#[test]
|
#[test]
|
||||||
fn text_is_data() {
|
fn text_is_data() {
|
||||||
let mut a = row(1, "#FL-*1*", "#panic() *bold* $x$ <l> @r [x] \\", 100, 0);
|
let mut a = row(1, "#FL-*1*", "#panic() *bold* $x$ <l> @r [x] \\", 100, 0, 0);
|
||||||
a.serial = Some("_u_ // /*".into());
|
a.serial = Some("_u_ // /*".into());
|
||||||
let m = ReportMeta {
|
let m = ReportMeta {
|
||||||
company: "#co *Inc*".into(),
|
company: "#co *Inc*".into(),
|
||||||
@@ -357,7 +437,7 @@ fn text_is_data() {
|
|||||||
for s in [
|
for s in [
|
||||||
"#panic()", "*", "_", "$", "[", "]", "\\", "#", "<a>", "@a", "//", "/*", "\"", "`",
|
"#panic()", "*", "_", "$", "[", "]", "\\", "#", "<a>", "@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());
|
a.serial = Some(s.into());
|
||||||
let m = ReportMeta {
|
let m = ReportMeta {
|
||||||
company: s.into(),
|
company: s.into(),
|
||||||
@@ -379,6 +459,7 @@ fn long_register_paginates_landscape() {
|
|||||||
&format!("Asset {i}"),
|
&format!("Asset {i}"),
|
||||||
100_000,
|
100_000,
|
||||||
1_000,
|
1_000,
|
||||||
|
1_000,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -4005,7 +4005,7 @@ fn cash_flow_adjustments_appear_only_when_there_are_some() {
|
|||||||
assert_eq!(section_lines(&plain, "Noncash")[0].label, "None");
|
assert_eq!(section_lines(&plain, "Noncash")[0].label, "None");
|
||||||
l.register_asset(ledger_core::NewAsset {
|
l.register_asset(ledger_core::NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: d(2026, 1, 3),
|
in_service_date: d(2026, 1, 3),
|
||||||
cost: usd(900),
|
cost: usd(900),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 36,
|
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
|
/// A register as ledger_core::fixed_asset_register would return it: two
|
||||||
/// assets held, one disposed (shown, in no total), and books that differ
|
/// assets held, one disposed (shown, in no total), and books that differ
|
||||||
/// from the register on both counts. Tax basis: the laptop a member
|
/// from the register on all three counts. Tax figures: the laptop a
|
||||||
/// converted from personal use (worth less than their basis: tax basis is
|
/// member contributed from personal use (worth less than their basis:
|
||||||
/// its value, a built-in loss), the desk with none recorded, the monitor
|
/// depreciable basis is its value, a built-in loss), the desk with none
|
||||||
/// a carryover with a built-in gain (disposed, in no total).
|
/// 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 {
|
fn asset_register_fixture() -> ledger_core::AssetRegister {
|
||||||
use ledger_core::AssetRegisterRow;
|
use ledger_core::AssetRegisterRow;
|
||||||
let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| {
|
let row =
|
||||||
AssetRegisterRow {
|
|id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, sched, posted, disposed| {
|
||||||
asset_id: id,
|
AssetRegisterRow {
|
||||||
tag: tag.map(Into::into),
|
asset_id: id,
|
||||||
description: desc.into(),
|
tag: tag.map(Into::into),
|
||||||
serial: serial.map(Into::into),
|
description: desc.into(),
|
||||||
in_service: d(2026, 1, 15),
|
serial: serial.map(Into::into),
|
||||||
cost: cents(cost),
|
acquired_date: d(2026, 1, 10),
|
||||||
salvage: cents(10000),
|
in_service_date: d(2026, 1, 15),
|
||||||
useful_life_months: 36,
|
cost: cents(cost),
|
||||||
accumulated: cents(acc),
|
salvage: cents(10000),
|
||||||
book_value: cents(cost - acc),
|
useful_life_months: 36,
|
||||||
disposed,
|
accumulated_scheduled: cents(sched),
|
||||||
tax_rule: None,
|
accumulated_posted: cents(posted),
|
||||||
contributor_basis: None,
|
book_value: cents(cost - posted),
|
||||||
tax_basis: None,
|
disposed,
|
||||||
built_in_gain: None,
|
basis_rule: None,
|
||||||
}
|
contributor_adjusted_basis: None,
|
||||||
};
|
fmv_at_contribution: None,
|
||||||
let taxed = |mut r: AssetRegisterRow, rule, basis: Decimal, tax: Decimal| {
|
fmv_recorded: false,
|
||||||
r.tax_rule = Some(rule);
|
tax_basis_723: None,
|
||||||
r.contributor_basis = Some(basis);
|
depreciable_basis: None,
|
||||||
r.tax_basis = Some(tax);
|
built_in_gain_704c: None,
|
||||||
r.built_in_gain = Some(r.cost - basis);
|
sec704c_1c: false,
|
||||||
|
sec704c_method: None,
|
||||||
|
basis_source: None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let taxed = |mut r: AssetRegisterRow, rule, basis: Decimal, fmv: Option<Decimal>| {
|
||||||
|
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
|
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 {
|
ledger_core::AssetRegister {
|
||||||
as_of: d(2026, 10, 15),
|
as_of: d(2026, 10, 15),
|
||||||
rows: vec![
|
rows: vec![
|
||||||
taxed(
|
laptop,
|
||||||
row(
|
row(2, None, "Desk", None, 120000, 25000, 25000, None),
|
||||||
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),
|
|
||||||
{
|
{
|
||||||
let mut r = row(
|
let mut r = row(
|
||||||
3,
|
3,
|
||||||
@@ -4546,22 +4577,20 @@ fn asset_register_fixture() -> ledger_core::AssetRegister {
|
|||||||
None,
|
None,
|
||||||
40000,
|
40000,
|
||||||
0,
|
0,
|
||||||
|
0,
|
||||||
Some((d(2026, 9, 30), cents(4500))),
|
Some((d(2026, 9, 30), cents(4500))),
|
||||||
);
|
);
|
||||||
r.book_value = Decimal::ZERO;
|
r.book_value = Decimal::ZERO;
|
||||||
taxed(
|
taxed(r, ledger_core::BasisRule::Carryover723, cents(30000), None)
|
||||||
r,
|
|
||||||
ledger_core::TaxBasisRule::Carryover,
|
|
||||||
cents(30000),
|
|
||||||
cents(30000),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
total_cost: cents(370000),
|
total_cost: cents(370000),
|
||||||
total_accumulated: cents(77778),
|
total_accumulated_scheduled: cents(77778),
|
||||||
total_book_value: cents(292222),
|
total_accumulated_posted: cents(75000),
|
||||||
total_tax_basis: cents(250000),
|
total_book_value: cents(295000),
|
||||||
missing_tax_basis: 1,
|
total_depreciable_basis: cents(250000),
|
||||||
|
total_tax_basis_723: cents(280000),
|
||||||
|
missing_basis: 1,
|
||||||
ledger_cost: cents(400000),
|
ledger_cost: cents(400000),
|
||||||
ledger_accumulated: cents(70000),
|
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:
|
/// asset_register INVARIANTS 1-3, 5 and 7-9, read back from the
|
||||||
/// header, headings, one row per asset in order (disposed marked), the
|
/// workbook's XML: header, headings, one row per asset in order (disposed
|
||||||
/// register's own totals, and the reconciliation under Cost and
|
/// marked) with its acquired and in-service dates, depreciation scheduled
|
||||||
/// Accumulated depreciation.
|
/// and posted, its tax figures, method and source; the register's own
|
||||||
|
/// totals, and the reconciliation's three differences.
|
||||||
#[test]
|
#[test]
|
||||||
fn asset_register_xlsx_cells() {
|
fn asset_register_xlsx_cells() {
|
||||||
let r = asset_register_fixture();
|
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, (1, 0)), "Spec Test LLC");
|
||||||
assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register");
|
assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register");
|
||||||
assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026");
|
assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026");
|
||||||
let heads: Vec<String> = (0..14).map(|i| text_cell(&c, (5, i))).collect();
|
let heads: Vec<String> = (0..21).map(|i| text_cell(&c, (5, i))).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
heads,
|
heads,
|
||||||
[
|
[
|
||||||
"TAG",
|
"TAG",
|
||||||
"DESCRIPTION",
|
"DESCRIPTION",
|
||||||
"SERIAL",
|
"SERIAL",
|
||||||
|
"ACQUIRED",
|
||||||
"IN SERVICE",
|
"IN SERVICE",
|
||||||
"COST",
|
"COST",
|
||||||
"SALVAGE",
|
"SALVAGE",
|
||||||
"LIFE (MONTHS)",
|
"LIFE (MONTHS)",
|
||||||
"ACCUMULATED DEPRECIATION",
|
"DEPRECIATION SCHEDULED",
|
||||||
|
"DEPRECIATION POSTED",
|
||||||
"BOOK VALUE",
|
"BOOK VALUE",
|
||||||
"TAX BASIS RULE",
|
"BASIS RULE",
|
||||||
"CONTRIBUTOR'S BASIS",
|
"VALUE AT CONTRIBUTION",
|
||||||
"TAX BASIS",
|
"CONTRIBUTOR'S ADJUSTED BASIS",
|
||||||
|
"§723 BASIS",
|
||||||
|
"DEPRECIABLE BASIS",
|
||||||
"BUILT-IN GAIN (LOSS)",
|
"BUILT-IN GAIN (LOSS)",
|
||||||
|
"ALLOCATION METHOD",
|
||||||
|
"BASIS FACTS FROM",
|
||||||
|
"VALUE RECORDED",
|
||||||
"STATUS"
|
"STATUS"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(text_cell(&c, (6, 0)), "FL-0001");
|
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, 1)), "Framework Laptop 13");
|
||||||
assert_eq!(text_cell(&c, (6, 2)), "FRAN123");
|
assert_eq!(text_cell(&c, (6, 2)), "FRAN123");
|
||||||
assert_eq!(text_cell(&c, (6, 3)), "2026-01-15");
|
assert_eq!(text_cell(&c, (6, 3)), "2026-01-10");
|
||||||
assert_eq!(number_cell(&c, (6, 4)), dec("2500.00"));
|
assert_eq!(text_cell(&c, (6, 4)), "2026-01-15");
|
||||||
assert_eq!(number_cell(&c, (6, 5)), dec("100.00"));
|
assert_eq!(number_cell(&c, (6, 5)), dec("2500.00"));
|
||||||
assert_eq!(number_cell(&c, (6, 6)), dec("36"));
|
assert_eq!(number_cell(&c, (6, 6)), dec("100.00"));
|
||||||
assert_eq!(number_cell(&c, (6, 7)), dec("527.78"));
|
assert_eq!(number_cell(&c, (6, 7)), dec("36"));
|
||||||
assert_eq!(number_cell(&c, (6, 8)), dec("1972.22"));
|
assert_eq!(number_cell(&c, (6, 8)), dec("527.78"));
|
||||||
// Tax basis (INVARIANT 7): rule in words with its citation, the
|
assert_eq!(number_cell(&c, (6, 9)), dec("500.00"));
|
||||||
// contributor's basis, tax basis, built-in loss as a negative.
|
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!(
|
assert_eq!(
|
||||||
text_cell(&c, (6, 9)),
|
text_cell(&c, (6, 11)),
|
||||||
"Personal-use conversion (Treas. Reg. §1.168(i)-4(b))"
|
"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, 12)), dec("2500.00"));
|
||||||
assert_eq!(number_cell(&c, (6, 11)), dec("2500.00"));
|
assert_eq!(number_cell(&c, (6, 13)), dec("2800.00"));
|
||||||
assert_eq!(number_cell(&c, (6, 12)), dec("-300.00"));
|
assert_eq!(number_cell(&c, (6, 14)), dec("2800.00"));
|
||||||
assert_eq!(text_cell(&c, (6, 13)), "Active");
|
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.
|
// The desk: no tag, no serial, no tax basis recorded.
|
||||||
assert_eq!(text_cell(&c, (7, 1)), "Desk");
|
assert_eq!(text_cell(&c, (7, 1)), "Desk");
|
||||||
assert_eq!(number_cell(&c, (7, 4)), dec("1200.00"));
|
assert_eq!(number_cell(&c, (7, 5)), dec("1200.00"));
|
||||||
assert_eq!(number_cell(&c, (7, 7)), dec("250.00"));
|
assert_eq!(number_cell(&c, (7, 8)), dec("250.00"));
|
||||||
assert_eq!(number_cell(&c, (7, 8)), dec("950.00"));
|
assert_eq!(number_cell(&c, (7, 9)), dec("250.00"));
|
||||||
assert_eq!(text_cell(&c, (7, 9)), "Not recorded");
|
assert_eq!(number_cell(&c, (7, 10)), dec("950.00"));
|
||||||
for col in 10..=12 {
|
assert_eq!(text_cell(&c, (7, 11)), "Not recorded");
|
||||||
|
for col in 12..=19 {
|
||||||
assert!(
|
assert!(
|
||||||
!c.contains_key(&(7, col)),
|
!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, 0)), "FL-0003");
|
||||||
assert_eq!(text_cell(&c, (8, 9)), "Carryover (IRC §723)");
|
assert_eq!(text_cell(&c, (8, 11)), "Carryover (IRC §723)");
|
||||||
assert_eq!(number_cell(&c, (8, 10)), dec("300.00"));
|
assert_eq!(number_cell(&c, (8, 12)), dec("400.00"));
|
||||||
assert_eq!(number_cell(&c, (8, 11)), dec("300.00"));
|
assert_eq!(number_cell(&c, (8, 13)), dec("300.00"));
|
||||||
assert_eq!(number_cell(&c, (8, 12)), dec("100.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!(
|
assert_eq!(
|
||||||
text_cell(&c, (8, 13)),
|
text_cell(&c, (8, 20)),
|
||||||
"Disposed 2026-09-30, proceeds 45.00"
|
"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.
|
// 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!(text_cell(&c, (9, 0)), "Total, assets held");
|
||||||
assert_eq!(number_cell(&c, (9, 4)), dec("3700.00"));
|
assert_eq!(number_cell(&c, (9, 5)), dec("3700.00"));
|
||||||
assert_eq!(number_cell(&c, (9, 7)), dec("777.78"));
|
assert_eq!(number_cell(&c, (9, 8)), dec("777.78"));
|
||||||
assert_eq!(number_cell(&c, (9, 8)), dec("2922.22"));
|
assert_eq!(number_cell(&c, (9, 9)), dec("750.00"));
|
||||||
assert_eq!(number_cell(&c, (9, 11)), dec("2500.00"));
|
assert_eq!(number_cell(&c, (9, 10)), dec("2950.00"));
|
||||||
for col in [5, 10, 12] {
|
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}");
|
assert!(!c.contains_key(&(9, col)), "no total in {col}");
|
||||||
}
|
}
|
||||||
// INVARIANT 8: the missing tax basis line.
|
// INVARIANT 8: the missing tax basis line.
|
||||||
@@ -4669,19 +4723,27 @@ fn asset_register_xlsx_cells() {
|
|||||||
// Reconciliation.
|
// Reconciliation.
|
||||||
assert_eq!(text_cell(&c, (12, 0)), "Reconciliation to the books");
|
assert_eq!(text_cell(&c, (12, 0)), "Reconciliation to the books");
|
||||||
let recon = [
|
let recon = [
|
||||||
(13, "Cost per register", 4, "3700.00"),
|
(13, "Cost per register", 5, "3700.00"),
|
||||||
(14, "Cost per books", 4, "4000.00"),
|
(14, "Cost per books", 5, "4000.00"),
|
||||||
(15, "Difference (unregistered cost)", 4, "300.00"),
|
(15, "Difference (unregistered cost)", 5, "300.00"),
|
||||||
(17, "Accumulated depreciation per schedule", 7, "777.78"),
|
(17, "Accumulated depreciation per schedule", 8, "777.78"),
|
||||||
(18, "Accumulated depreciation per books", 7, "700.00"),
|
(18, "Accumulated depreciation per books", 8, "700.00"),
|
||||||
(19, "Difference (not yet posted)", 7, "77.78"),
|
(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 {
|
for (row, label, col, amt) in recon {
|
||||||
assert_eq!(text_cell(&c, (row, 0)), label);
|
assert_eq!(text_cell(&c, (row, 0)), label);
|
||||||
assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}");
|
assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}");
|
||||||
}
|
}
|
||||||
// INVARIANT 9: the notes, numbered as the PDF marks them.
|
// 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();
|
let notes = ledger_import::asset_register::notes();
|
||||||
for (i, n) in notes.iter().enumerate() {
|
for (i, n) in notes.iter().enumerate() {
|
||||||
let want = if n.mark.is_empty() {
|
let want = if n.mark.is_empty() {
|
||||||
@@ -4689,19 +4751,21 @@ fn asset_register_xlsx_cells() {
|
|||||||
} else {
|
} else {
|
||||||
format!("{}. {}", n.mark, n.text)
|
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!(
|
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 [
|
for (row, start) in [
|
||||||
(23, "1. Cost: "),
|
(27, "1. Purchase (IRC §1012): "),
|
||||||
(24, "2. Carryover (IRC §723): "),
|
(28, "2. Carryover (IRC §723): "),
|
||||||
(
|
(
|
||||||
25,
|
29,
|
||||||
"3. Personal-use conversion (Treas. Reg. §1.168(i)-4(b)): ",
|
"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:?}");
|
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 x = ledger_import::asset_register_xlsx(&asset_register_fixture(), &meta()).unwrap();
|
||||||
let c = sheet_cells(&x);
|
let c = sheet_cells(&x);
|
||||||
let styles = cell_styles(&x);
|
let styles = cell_styles(&x);
|
||||||
let right_aligned = [4, 5, 6, 7, 8, 10, 11, 12];
|
let right_aligned = [5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16];
|
||||||
for col in 0..14 {
|
for col in 0..21 {
|
||||||
let right = styles[&(5, col)].halign.as_deref() == Some("right");
|
let right = styles[&(5, col)].halign.as_deref() == Some("right");
|
||||||
assert_eq!(right, right_aligned.contains(&col), "heading column {col}");
|
assert_eq!(right, right_aligned.contains(&col), "heading column {col}");
|
||||||
}
|
}
|
||||||
assert_eq!(text_cell(&c, (9, 0)), "Total, assets held");
|
assert_eq!(text_cell(&c, (9, 0)), "Total, assets held");
|
||||||
assert!(!styles[&(9, 0)].top_border, "the label isn't ruled");
|
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:?}");
|
assert!(total.top_border, "{total:?}");
|
||||||
for col in 1..14 {
|
for col in 1..21 {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
styles.get(&(9, col)),
|
styles.get(&(9, col)),
|
||||||
Some(total),
|
Some(total),
|
||||||
@@ -4738,7 +4802,7 @@ fn asset_register_xlsx_headings_and_totals_rule() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for row in 6..=8 {
|
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);
|
let ruled = styles.get(&(row, col)).is_some_and(|s| s.top_border);
|
||||||
assert!(!ruled, "asset row {row} column {col}");
|
assert!(!ruled, "asset row {row} column {col}");
|
||||||
}
|
}
|
||||||
@@ -4753,15 +4817,18 @@ fn asset_register_xlsx_empty() {
|
|||||||
as_of: d(2026, 10, 15),
|
as_of: d(2026, 10, 15),
|
||||||
rows: vec![],
|
rows: vec![],
|
||||||
total_cost: Decimal::ZERO,
|
total_cost: Decimal::ZERO,
|
||||||
total_accumulated: Decimal::ZERO,
|
total_accumulated_scheduled: Decimal::ZERO,
|
||||||
|
total_accumulated_posted: Decimal::ZERO,
|
||||||
total_book_value: Decimal::ZERO,
|
total_book_value: Decimal::ZERO,
|
||||||
total_tax_basis: Decimal::ZERO,
|
total_depreciable_basis: Decimal::ZERO,
|
||||||
missing_tax_basis: 0,
|
total_tax_basis_723: Decimal::ZERO,
|
||||||
|
missing_basis: 0,
|
||||||
ledger_cost: cents(99900),
|
ledger_cost: cents(99900),
|
||||||
ledger_accumulated: Decimal::ZERO,
|
ledger_accumulated: Decimal::ZERO,
|
||||||
};
|
};
|
||||||
let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap());
|
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!(
|
assert!(
|
||||||
!c.values()
|
!c.values()
|
||||||
.any(|v| matches!(v, Cell::Text(t) if t.starts_with("Tax basis not recorded"))),
|
.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, (6, 0)), "No fixed assets");
|
||||||
assert_eq!(text_cell(&c, (7, 0)), "Total, assets held");
|
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!(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"));
|
||||||
}
|
}
|
||||||
@@ -4,3 +4,4 @@
|
|||||||
- Never file the package, or a file from it, into the books.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -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).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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; books.show asset opens one in the viewer.
|
- 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.
|
||||||
+407
-106
@@ -42,8 +42,7 @@ use chrono::NaiveDate;
|
|||||||
use ledger_config::Company;
|
use ledger_config::Company;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||||
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
Ledger, Money, NewAsset, PeriodStatus, Side, depreciation_schedule, reports,
|
||||||
depreciation_schedule, reports,
|
|
||||||
};
|
};
|
||||||
use ledger_import::ReportMeta;
|
use ledger_import::ReportMeta;
|
||||||
use ledger_package::{csv_escape, form_1099_label, money, parse_date, tin_kind, vault_tin};
|
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<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tax basis record from a tool's rule name and contributor basis.
|
/// A parameter and its deprecated alias (one release): the value given,
|
||||||
fn parse_tax_basis(rule: &str, basis: Option<&str>) -> Result<ledger_core::TaxBasis, String> {
|
/// noting when it came by the alias; both given and different is refused.
|
||||||
let rule = ledger_core::TaxBasisRule::parse(rule).ok_or_else(|| {
|
fn aliased<'a>(
|
||||||
format!("tax basis rule {rule:?}: expected cost, carryover, or personal_use_conversion")
|
name: &str,
|
||||||
})?;
|
value: Option<&'a str>,
|
||||||
Ok(ledger_core::TaxBasis {
|
alias: &str,
|
||||||
rule,
|
old: Option<&'a str>,
|
||||||
contributor_basis: basis.map(parse_amount).transpose()?,
|
notes: &mut Vec<String>,
|
||||||
})
|
) -> Result<Option<&'a str>, 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.
|
/// A basis rule from a tool: its wire name, or its pre-P3 name (an alias
|
||||||
fn tax_out(a: &ledger_core::Asset) -> Option<TaxBasisOut> {
|
/// for one release, noted).
|
||||||
let t = a.tax?;
|
fn parse_basis_rule(rule: &str, notes: &mut Vec<String>) -> Result<ledger_core::BasisRule, String> {
|
||||||
Some(TaxBasisOut {
|
if let Some(r) = ledger_core::BasisRule::parse(rule) {
|
||||||
rule: t.rule.key().to_string(),
|
return Ok(r);
|
||||||
contributor_basis: t.contributor_basis.map(money),
|
}
|
||||||
tax_basis: a.tax_basis().map(money),
|
match ledger_core::BasisRule::parse_legacy(rule) {
|
||||||
built_in_gain: a.built_in_gain().map(money),
|
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<String>,
|
||||||
|
) -> Result<ledger_core::AssetBasis, String> {
|
||||||
|
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<Option<AssetBasisOut>, 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).
|
/// An asset named by id or by tag (exactly one).
|
||||||
@@ -5493,7 +5609,7 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.correct_asset",
|
name = "books.correct_asset",
|
||||||
title = "Correct a fixed 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = true,
|
destructive_hint = true,
|
||||||
@@ -5505,6 +5621,14 @@ impl BooksServer {
|
|||||||
params: Parameters<CorrectAssetParams>,
|
params: Parameters<CorrectAssetParams>,
|
||||||
) -> Result<Json<AssetSummaryOne>, String> {
|
) -> Result<Json<AssetSummaryOne>, String> {
|
||||||
let p = params.0;
|
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 mut store = self.lock()?;
|
||||||
let id = resolve_asset_ref(&store, &p.asset)?;
|
let id = resolve_asset_ref(&store, &p.asset)?;
|
||||||
let a = store.ledger().get_asset(id).expect("resolved").clone();
|
let a = store.ledger().get_asset(id).expect("resolved").clone();
|
||||||
@@ -5519,30 +5643,36 @@ impl BooksServer {
|
|||||||
None => a.salvage_value,
|
None => a.salvage_value,
|
||||||
},
|
},
|
||||||
useful_life_months: p.useful_life_months.unwrap_or(a.useful_life_months),
|
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)?,
|
Some(d) => parse_date(d)?,
|
||||||
None => a.purchase_date,
|
None => a.in_service_date,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
store
|
store
|
||||||
.correct_asset(id, fix, &p.reason)
|
.correct_asset(id, fix, &p.reason)
|
||||||
.map_err(|e| e.to_string())?;
|
.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 {
|
Ok(Json(AssetSummaryOne {
|
||||||
asset_id: id,
|
asset_id: id,
|
||||||
tag: a.tag.clone(),
|
tag: a.tag.clone(),
|
||||||
cost: money(a.cost.amount),
|
cost: money(a.cost.amount),
|
||||||
salvage_value: money(a.salvage_value.amount),
|
salvage_value: money(a.salvage_value.amount),
|
||||||
useful_life_months: a.useful_life_months,
|
useful_life_months: a.useful_life_months,
|
||||||
in_service: a.purchase_date.to_string(),
|
in_service_date: a.in_service_date.to_string(),
|
||||||
tax: tax_out(a),
|
acquired_date: ledger
|
||||||
|
.asset_acquired_date(id)
|
||||||
|
.expect("known asset")
|
||||||
|
.to_string(),
|
||||||
|
basis: basis_out(a)?,
|
||||||
|
notes,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.set_asset_tax_basis",
|
name = "books.set_asset_tax_basis",
|
||||||
title = "Set an asset's 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = false,
|
destructive_hint = false,
|
||||||
@@ -5552,16 +5682,35 @@ impl BooksServer {
|
|||||||
async fn set_asset_tax_basis(
|
async fn set_asset_tax_basis(
|
||||||
&self,
|
&self,
|
||||||
params: Parameters<SetAssetTaxBasisParams>,
|
params: Parameters<SetAssetTaxBasisParams>,
|
||||||
) -> Result<Json<TaxBasisOut>, String> {
|
) -> Result<Json<SetAssetTaxBasisOut>, String> {
|
||||||
let p = params.0;
|
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 mut store = self.lock()?;
|
||||||
let id = resolve_asset_ref(&store, &p.asset)?;
|
let id = resolve_asset_ref(&store, &p.asset)?;
|
||||||
let tax = parse_tax_basis(&p.rule, p.contributor_basis.as_deref())?;
|
|
||||||
store
|
store
|
||||||
.set_asset_tax_basis(id, tax)
|
.set_asset_basis(id, basis)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let a = store.ledger().get_asset(id).expect("resolved");
|
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(
|
#[tool(
|
||||||
@@ -6087,7 +6236,7 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.register_asset",
|
name = "books.register_asset",
|
||||||
title = "Register a fixed 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = false,
|
destructive_hint = false,
|
||||||
@@ -6099,6 +6248,17 @@ impl BooksServer {
|
|||||||
params: Parameters<RegisterAssetParams>,
|
params: Parameters<RegisterAssetParams>,
|
||||||
) -> Result<Json<RegisterAssetOut>, String> {
|
) -> Result<Json<RegisterAssetOut>, String> {
|
||||||
let p = params.0;
|
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 mut store = self.lock()?;
|
||||||
let base = store.ledger().base_currency();
|
let base = store.ledger().base_currency();
|
||||||
let cost_account = resolve_account(&store, &p.cost_account)?;
|
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"
|
"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
|
// Checked before registering, so a bad tax basis registers nothing
|
||||||
// (found by the spec-blind MCP suite, 2026-10-15).
|
// (found by the spec-blind MCP suite, 2026-10-15).
|
||||||
let tax = match &p.tax_basis_rule {
|
let rule = aliased(
|
||||||
Some(rule) => {
|
"basis_rule",
|
||||||
let tax = parse_tax_basis(rule, p.contributor_basis.as_deref())?;
|
p.basis_rule.as_deref(),
|
||||||
tax.check().map_err(|why| format!("bad tax basis: {why}"))?;
|
"tax_basis_rule",
|
||||||
Some(tax)
|
p.tax_basis_rule.as_deref(),
|
||||||
}
|
&mut notes,
|
||||||
None if p.contributor_basis.is_some() => {
|
)?;
|
||||||
return Err("contributor_basis needs tax_basis_rule".into());
|
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,
|
None => None,
|
||||||
};
|
};
|
||||||
let asset_id = store
|
let new = NewAsset {
|
||||||
.register_asset(NewAsset {
|
description: p.description,
|
||||||
description: p.description,
|
in_service_date,
|
||||||
purchase_date: parse_date(&p.purchase_date)?,
|
cost: Money::new(parse_amount(&p.cost)?, base),
|
||||||
cost: Money::new(parse_amount(&p.cost)?, base),
|
salvage_value: Money::new(
|
||||||
salvage_value: Money::new(
|
parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?,
|
||||||
parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?,
|
base,
|
||||||
base,
|
),
|
||||||
),
|
useful_life_months: p.useful_life_months,
|
||||||
useful_life_months: p.useful_life_months,
|
cost_account,
|
||||||
cost_account,
|
accum_depr_account,
|
||||||
accum_depr_account,
|
expense_account,
|
||||||
expense_account,
|
};
|
||||||
})
|
// One registration is several commands: rehearse them all on a
|
||||||
.map_err(|e| e.to_string())?;
|
// 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
|
store
|
||||||
.set_asset_tag(asset_id, &tag)
|
.set_asset_tag(asset_id, &tag)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -6175,12 +6395,24 @@ impl BooksServer {
|
|||||||
.set_asset_acquisition(asset_id, entry)
|
.set_asset_acquisition(asset_id, entry)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
if let Some(tax) = tax {
|
if let Some(basis) = basis {
|
||||||
store
|
store
|
||||||
.set_asset_tax_basis(asset_id, tax)
|
.set_asset_basis(asset_id, basis)
|
||||||
.map_err(|e| e.to_string())?;
|
.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(
|
#[tool(
|
||||||
@@ -6234,45 +6466,72 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.list_assets",
|
name = "books.list_assets",
|
||||||
title = "List fixed 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)
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
)]
|
)]
|
||||||
async fn list_assets(&self) -> Result<Json<ListAssetsOut>, String> {
|
async fn list_assets(
|
||||||
|
&self,
|
||||||
|
params: Parameters<ListAssetsParams>,
|
||||||
|
) -> Result<Json<ListAssetsOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let as_of = match &p.as_of {
|
||||||
|
Some(d) => parse_date(d)?,
|
||||||
|
None => today(),
|
||||||
|
};
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let today = today();
|
let ledger = store.ledger();
|
||||||
let assets = store
|
// The register's rows (one derivation, reports::register_row): an
|
||||||
.ledger()
|
// asset acquired by as_of has one; a later one has no figures yet.
|
||||||
|
let assets = ledger
|
||||||
.registered_assets()
|
.registered_assets()
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
// One asset whose schedule can't be computed (an earlier
|
let acquired = ledger.asset_acquired_date(a.id).expect("registered");
|
||||||
// version's) lists with why, never failing the whole list.
|
let disposed =
|
||||||
let figures = accumulated_depreciation(a, today)
|
matches!(a.status, AssetStatus::Disposed { date, .. } if date <= as_of);
|
||||||
.and_then(|acc| Ok((acc, book_value(a, today)?)));
|
// One asset whose figures can't be computed (one an earlier
|
||||||
let (accumulated, book_value, schedule_error) = match figures {
|
// version took) lists with why, never failing the whole
|
||||||
Ok((acc, bv)) => (Some(money(acc)), Some(money(bv)), None),
|
// list.
|
||||||
Err(e) => (None, None, Some(e.to_string())),
|
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,
|
asset_id: a.id,
|
||||||
tag: a.tag.clone(),
|
tag: a.tag.clone(),
|
||||||
serial: a.serial.clone(),
|
serial: a.serial.clone(),
|
||||||
acquisition_entry: a.acquired_by,
|
acquisition_entry: a.acquired_by,
|
||||||
tax: tax_out(a),
|
basis: basis_out(a)?,
|
||||||
description: a.description.clone(),
|
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),
|
cost: money(a.cost.amount),
|
||||||
useful_life_months: a.useful_life_months,
|
useful_life_months: a.useful_life_months,
|
||||||
status: match a.status {
|
status: match a.status {
|
||||||
AssetStatus::Active => "active".to_string(),
|
AssetStatus::Active => "active".to_string(),
|
||||||
AssetStatus::Disposed { .. } => "disposed".to_string(),
|
AssetStatus::Disposed { .. } => "disposed".to_string(),
|
||||||
},
|
},
|
||||||
accumulated,
|
held: acquired <= as_of && !disposed,
|
||||||
book_value,
|
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,
|
schedule_error,
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Result<_, String>>()?;
|
||||||
Ok(Json(ListAssetsOut { assets }))
|
Ok(Json(ListAssetsOut {
|
||||||
|
as_of: as_of.to_string(),
|
||||||
|
assets,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
@@ -6322,11 +6581,11 @@ impl BooksServer {
|
|||||||
// failing the whole batch.
|
// failing the whole batch.
|
||||||
if p.asset_id.is_none()
|
if p.asset_id.is_none()
|
||||||
&& let Some(asset) = store.ledger().get_asset(id)
|
&& let Some(asset) = store.ledger().get_asset(id)
|
||||||
&& asset.purchase_date > period_end
|
&& asset.in_service_date > period_end
|
||||||
{
|
{
|
||||||
skipped.push(skip(format!(
|
skipped.push(skip(format!(
|
||||||
"placed in service {}, after {}",
|
"placed in service {}, after {}",
|
||||||
asset.purchase_date, p.period
|
asset.in_service_date, p.period
|
||||||
)));
|
)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -8715,8 +8974,9 @@ impl BooksServer {
|
|||||||
/// 2. With members, k1-members.csv has one row per [[company.members]]
|
/// 2. With members, k1-members.csv has one row per [[company.members]]
|
||||||
/// member: name, party, share, capital account, TIN and type, and
|
/// member: name, party, share, capital account, TIN and type, and
|
||||||
/// the K-1 worksheet's capital figures (ledger_core::k1) for the
|
/// the K-1 worksheet's capital figures (ledger_core::k1) for the
|
||||||
/// fiscal year to date. The 1099 worksheet adds each payee's party,
|
/// fiscal year to date; k1-tax-basis-capital.csv the same capital on
|
||||||
/// last four, TIN, and type.
|
/// 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
|
/// 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
|
/// row says why (vault_tin), the README lists it, and so does
|
||||||
/// tin_gaps (members, and 1099 payees that are reportable).
|
/// tin_gaps (members, and 1099 payees that are reportable).
|
||||||
@@ -8725,7 +8985,7 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export_cpa_package",
|
name = "books.export_cpa_package",
|
||||||
title = "CPA handoff 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = false,
|
destructive_hint = false,
|
||||||
@@ -8921,68 +9181,109 @@ impl BooksServer {
|
|||||||
Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)),
|
Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)),
|
||||||
None => "Active".to_string(),
|
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" {
|
if format == "json" {
|
||||||
return serde_json::to_string_pretty(&serde_json::json!({
|
return serde_json::to_string_pretty(&serde_json::json!({
|
||||||
"as_of": as_of.to_string(),
|
"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!({
|
"assets": r.rows.iter().map(|a| serde_json::json!({
|
||||||
"asset_id": a.asset_id,
|
"asset_id": a.asset_id,
|
||||||
"tag": a.tag,
|
"tag": a.tag,
|
||||||
"description": a.description,
|
"description": a.description,
|
||||||
"serial": a.serial,
|
"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),
|
"cost": money(a.cost),
|
||||||
"salvage": money(a.salvage),
|
"salvage": money(a.salvage),
|
||||||
"useful_life_months": a.useful_life_months,
|
"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),
|
"book_value": money(a.book_value),
|
||||||
"status": status(a),
|
"status": status(a),
|
||||||
"proceeds": a.disposed.map(|(_, p)| money(p)),
|
"proceeds": a.disposed.map(|(_, p)| money(p)),
|
||||||
"tax_basis_rule": a.tax_rule.map(|r| r.key()),
|
"basis_rule": a.basis_rule.map(|r| r.key()),
|
||||||
"contributor_basis": a.contributor_basis.map(money),
|
"contributor_adjusted_basis": a.contributor_adjusted_basis.map(money),
|
||||||
"tax_basis": a.tax_basis.map(money),
|
"fmv_at_contribution": a.fmv_at_contribution.map(money),
|
||||||
"built_in_gain": a.built_in_gain.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::<Vec<_>>(),
|
})).collect::<Vec<_>>(),
|
||||||
"total_tax_basis": money(r.total_tax_basis),
|
|
||||||
"missing_tax_basis": r.missing_tax_basis,
|
|
||||||
"total_cost": money(r.total_cost),
|
"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_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_cost": money(r.ledger_cost),
|
||||||
"ledger_accumulated": money(r.ledger_accumulated),
|
"ledger_accumulated": money(r.ledger_accumulated),
|
||||||
"unposted_depreciation": money(r.unposted_depreciation()),
|
"unposted_depreciation": money(r.unposted_depreciation()),
|
||||||
|
"unattributed_accumulated": money(r.unattributed_accumulated()),
|
||||||
"unregistered_cost": money(r.unregistered_cost()),
|
"unregistered_cost": money(r.unregistered_cost()),
|
||||||
}))
|
}))
|
||||||
.map_err(|e| e.to_string());
|
.map_err(|e| e.to_string());
|
||||||
}
|
}
|
||||||
let mut csv = String::from(
|
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 {
|
for a in &r.rows {
|
||||||
csv.push_str(&format!(
|
let cells = [
|
||||||
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
|
|
||||||
csv_escape(a.tag.as_deref().unwrap_or("")),
|
csv_escape(a.tag.as_deref().unwrap_or("")),
|
||||||
csv_escape(&a.description),
|
csv_escape(&a.description),
|
||||||
csv_escape(a.serial.as_deref().unwrap_or("")),
|
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.cost),
|
||||||
money(a.salvage),
|
money(a.salvage),
|
||||||
a.useful_life_months,
|
a.useful_life_months.to_string(),
|
||||||
money(a.accumulated),
|
money(a.accumulated_scheduled),
|
||||||
|
money(a.accumulated_posted),
|
||||||
money(a.book_value),
|
money(a.book_value),
|
||||||
status(a),
|
status(a),
|
||||||
a.disposed.map(|(_, p)| money(p)).unwrap_or_default(),
|
a.disposed.map(|(_, p)| money(p)).unwrap_or_default(),
|
||||||
a.tax_rule.map(|r| r.key()).unwrap_or_default(),
|
a.basis_rule
|
||||||
a.contributor_basis.map(money).unwrap_or_default(),
|
.map(|r| r.key())
|
||||||
a.tax_basis.map(money).unwrap_or_default(),
|
.unwrap_or_default()
|
||||||
a.built_in_gain.map(money).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!(
|
csv.push_str(&format!(
|
||||||
"TOTAL,,,,{},,,{},{},,,,,{},",
|
"TOTAL,,,,,{},,,{},{},{},,,,,,,{},{},,,",
|
||||||
money(r.total_cost),
|
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_book_value),
|
||||||
money(r.total_tax_basis)
|
money(r.total_tax_basis_723),
|
||||||
|
money(r.total_depreciable_basis)
|
||||||
));
|
));
|
||||||
Ok(csv)
|
Ok(csv)
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-45
@@ -2213,8 +2213,16 @@ pub struct ExportOut {
|
|||||||
pub struct RegisterAssetParams {
|
pub struct RegisterAssetParams {
|
||||||
/// What it is, e.g. "2023 Sprinter van".
|
/// What it is, e.g. "2023 Sprinter van".
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// Purchase date, YYYY-MM-DD. Depreciation starts here.
|
/// The day it was placed in service (ready and available for its use),
|
||||||
pub purchase_date: String,
|
/// YYYY-MM-DD: depreciation starts here. Required (or its alias
|
||||||
|
/// purchase_date).
|
||||||
|
#[serde(default)]
|
||||||
|
pub in_service_date: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
/// Capitalized cost, decimal string, e.g. "60000.00".
|
/// Capitalized cost, decimal string, e.g. "60000.00".
|
||||||
pub cost: String,
|
pub cost: String,
|
||||||
/// Expected residual value, decimal string. Defaults to "0".
|
/// Expected residual value, decimal string. Defaults to "0".
|
||||||
@@ -2234,14 +2242,54 @@ pub struct RegisterAssetParams {
|
|||||||
/// The manufacturer's serial number, if known.
|
/// The manufacturer's serial number, if known.
|
||||||
pub serial: Option<String>,
|
pub serial: Option<String>,
|
||||||
/// The posted entry that put it on the books (its purchase, or a
|
/// 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<u64>,
|
pub acquisition_entry: Option<u64>,
|
||||||
/// The federal tax basis rule (see books.set_asset_tax_basis): cost,
|
/// How the company got it (see books.set_asset_tax_basis): purchase,
|
||||||
/// carryover, or personal_use_conversion. Omit to record it later.
|
/// carryover_723, or personal_use_conversion. Omit to record it later.
|
||||||
|
#[serde(default)]
|
||||||
|
pub basis_rule: Option<String>,
|
||||||
|
/// Deprecated alias of basis_rule, with the old names (cost,
|
||||||
|
/// carryover, personal_use_conversion), for one release.
|
||||||
|
#[serde(default)]
|
||||||
pub tax_basis_rule: Option<String>,
|
pub tax_basis_rule: Option<String>,
|
||||||
/// The contributing member's adjusted basis, for carryover and
|
/// The contributing member's adjusted basis at contribution, for
|
||||||
/// personal_use_conversion.
|
/// carryover_723 and personal_use_conversion.
|
||||||
|
#[serde(default)]
|
||||||
|
pub contributor_adjusted_basis: Option<String>,
|
||||||
|
/// Deprecated alias of contributor_adjusted_basis, for one release.
|
||||||
|
#[serde(default)]
|
||||||
pub contributor_basis: Option<String>,
|
pub contributor_basis: Option<String>,
|
||||||
|
/// Fair market value at contribution, for carryover_723 and
|
||||||
|
/// personal_use_conversion (required: ask the human).
|
||||||
|
#[serde(default)]
|
||||||
|
pub fmv_at_contribution: Option<String>,
|
||||||
|
/// The §704(c) method chosen for a contribution: traditional,
|
||||||
|
/// traditional_with_curative, or remedial.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sec704c_method: Option<String>,
|
||||||
|
/// Who gave the basis facts, when, and on what (e.g. the entry that
|
||||||
|
/// records the value).
|
||||||
|
#[serde(default)]
|
||||||
|
pub basis_source: Option<BasisSourceIn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// The document or entry they rest on, if any ("entry 14").
|
||||||
|
#[serde(default)]
|
||||||
|
pub citation: Option<String>,
|
||||||
|
/// Anything else worth keeping with them.
|
||||||
|
#[serde(default)]
|
||||||
|
pub note: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for `books.correct_asset`.
|
/// Parameters for `books.correct_asset`.
|
||||||
@@ -2255,7 +2303,12 @@ pub struct CorrectAssetParams {
|
|||||||
pub salvage_value: Option<String>,
|
pub salvage_value: Option<String>,
|
||||||
/// Corrected useful life in months. Omit to keep it.
|
/// Corrected useful life in months. Omit to keep it.
|
||||||
pub useful_life_months: Option<u32>,
|
pub useful_life_months: Option<u32>,
|
||||||
/// 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<String>,
|
||||||
|
/// Deprecated alias of in_service_date, for one release.
|
||||||
|
#[serde(default)]
|
||||||
pub in_service: Option<String>,
|
pub in_service: Option<String>,
|
||||||
/// Why (required): what was wrong.
|
/// Why (required): what was wrong.
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
@@ -2275,10 +2328,16 @@ pub struct AssetSummaryOne {
|
|||||||
/// Useful life in months.
|
/// Useful life in months.
|
||||||
pub useful_life_months: u32,
|
pub useful_life_months: u32,
|
||||||
/// In-service date, YYYY-MM-DD.
|
/// In-service date, YYYY-MM-DD.
|
||||||
pub in_service: String,
|
pub in_service_date: String,
|
||||||
/// Its federal tax basis (follows the corrected cost under
|
/// Acquired date (its acquisition entry's), YYYY-MM-DD.
|
||||||
/// personal_use_conversion and cost).
|
pub acquired_date: String,
|
||||||
pub tax: Option<TaxBasisOut>,
|
/// 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<AssetBasisOut>,
|
||||||
|
/// Anything the call did beyond the correction (an alias used).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for `books.set_asset_tax_basis`.
|
/// Parameters for `books.set_asset_tax_basis`.
|
||||||
@@ -2286,31 +2345,119 @@ pub struct AssetSummaryOne {
|
|||||||
pub struct SetAssetTaxBasisParams {
|
pub struct SetAssetTaxBasisParams {
|
||||||
/// The asset: its id or tag.
|
/// The asset: its id or tag.
|
||||||
pub asset: String,
|
pub asset: String,
|
||||||
/// "cost" (the company bought it: basis is its cost), "carryover" (a
|
/// How the company got it: "purchase" (bought: basis is cost, IRC
|
||||||
/// member contributed business property: the member's adjusted basis,
|
/// §1012), "carryover_723" (a member contributed business property:
|
||||||
/// IRC §723), or "personal_use_conversion" (a member contributed
|
/// the member's adjusted basis carries over, IRC §723), or
|
||||||
/// personal-use property: the lesser of fair market value and the
|
/// "personal_use_conversion" (a member contributed personal-use
|
||||||
/// member's adjusted basis, Treas. Reg. §1.168(i)-4(b)).
|
/// 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,
|
pub rule: String,
|
||||||
/// The contributing member's adjusted basis at contribution, decimal
|
/// The contributing member's adjusted basis at contribution, decimal
|
||||||
/// string (what they paid, less any depreciation they took). Required
|
/// string: cost plus improvements, less depreciation allowed or
|
||||||
/// for carryover and personal_use_conversion; omit for cost.
|
/// allowable (IRC §1016(a)). Required for a contribution; omit for a
|
||||||
|
/// purchase.
|
||||||
|
#[serde(default)]
|
||||||
|
pub contributor_adjusted_basis: Option<String>,
|
||||||
|
/// Deprecated alias of contributor_adjusted_basis, for one release.
|
||||||
|
#[serde(default)]
|
||||||
pub contributor_basis: Option<String>,
|
pub contributor_basis: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Who gave the facts, when, and on what (e.g. the contribution
|
||||||
|
/// entry).
|
||||||
|
#[serde(default)]
|
||||||
|
pub basis_source: Option<BasisSourceIn>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An asset's federal tax basis, as tools report it.
|
/// Where each basis figure's rule comes from.
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub struct TaxBasisOut {
|
pub struct BasisSourcesOut {
|
||||||
/// The rule: cost, carryover, or personal_use_conversion.
|
/// The §723 basis's: "IRC §723" (contributions).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tax_basis_723: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// Anything kept with them.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
pub rule: String,
|
||||||
/// The contributing member's adjusted basis, decimal string, if a
|
/// The contributing member's adjusted basis at contribution, decimal
|
||||||
/// contribution.
|
/// string (a contribution).
|
||||||
pub contributor_basis: Option<String>,
|
pub contributor_adjusted_basis: Option<String>,
|
||||||
/// The depreciable federal tax basis, decimal string.
|
/// Fair market value at contribution, decimal string (a contribution):
|
||||||
pub tax_basis: Option<String>,
|
/// recorded, or the book cost for a record made before the value was
|
||||||
/// Built-in gain (+) or loss (−) at contribution (fair market value less
|
/// a fact (fmv_recorded false).
|
||||||
/// the contributor's basis), IRC §704(c), decimal string.
|
pub fmv_at_contribution: Option<String>,
|
||||||
pub built_in_gain: Option<String>,
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// The §704(c) method chosen: traditional, traditional_with_curative,
|
||||||
|
/// or remedial; null until chosen.
|
||||||
|
pub sec704c_method: Option<String>,
|
||||||
|
/// Who gave the facts, when, and on what.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<BasisSourceOut>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `books.register_asset`.
|
/// Result of `books.register_asset`.
|
||||||
@@ -2320,6 +2467,17 @@ pub struct RegisterAssetOut {
|
|||||||
pub asset_id: u64,
|
pub asset_id: u64,
|
||||||
/// Its tag (given or generated).
|
/// Its tag (given or generated).
|
||||||
pub tag: String,
|
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<AssetBasisOut>,
|
||||||
|
/// Anything to know (an alias used).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for `books.label_asset`.
|
/// Parameters for `books.label_asset`.
|
||||||
@@ -2389,7 +2547,9 @@ pub struct ScheduleLineOut {
|
|||||||
pub amount: String,
|
pub amount: String,
|
||||||
/// Accumulated depreciation through this month, decimal string.
|
/// Accumulated depreciation through this month, decimal string.
|
||||||
pub accumulated: 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,
|
pub book_value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2424,7 +2584,7 @@ pub struct AssetScheduleOut {
|
|||||||
pub schedule: Vec<ScheduleLineOut>,
|
pub schedule: Vec<ScheduleLineOut>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One asset in `books.list_assets`.
|
/// One asset in `books.list_assets`, as of the date asked.
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub struct AssetSummary {
|
pub struct AssetSummary {
|
||||||
/// Asset id.
|
/// Asset id.
|
||||||
@@ -2435,38 +2595,60 @@ pub struct AssetSummary {
|
|||||||
pub serial: Option<String>,
|
pub serial: Option<String>,
|
||||||
/// The entry that acquired it, if linked.
|
/// The entry that acquired it, if linked.
|
||||||
pub acquisition_entry: Option<u64>,
|
pub acquisition_entry: Option<u64>,
|
||||||
/// Its federal tax basis, if recorded.
|
/// Its basis facts and tax figures, if recorded.
|
||||||
pub tax: Option<TaxBasisOut>,
|
pub basis: Option<AssetBasisOut>,
|
||||||
/// Asset description.
|
/// Asset description.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// Purchase date, YYYY-MM-DD.
|
/// In-service date, YYYY-MM-DD: depreciation starts here.
|
||||||
pub purchase_date: String,
|
pub in_service_date: String,
|
||||||
|
/// Acquired date, YYYY-MM-DD: on the books from here.
|
||||||
|
pub acquired_date: String,
|
||||||
/// Cost, decimal string.
|
/// Cost, decimal string.
|
||||||
pub cost: String,
|
pub cost: String,
|
||||||
/// Useful life in months.
|
/// Useful life in months.
|
||||||
pub useful_life_months: u32,
|
pub useful_life_months: u32,
|
||||||
/// "active" or "disposed".
|
/// "active" or "disposed".
|
||||||
pub status: String,
|
pub status: String,
|
||||||
/// Accumulated depreciation by its schedule through today (a disposed
|
/// Whether the books hold it on as_of: acquired by then and not yet
|
||||||
/// asset's through its disposal day), decimal string. Null when the
|
/// disposed.
|
||||||
/// schedule can't be computed (see `schedule_error`).
|
pub held: bool,
|
||||||
pub accumulated: Option<String>,
|
/// Accumulated depreciation the books carry on as_of (runs posted, less
|
||||||
/// Book value today (cost less `accumulated`), decimal string. Null
|
/// reversals), decimal string; "0.00" once disposed; null before it
|
||||||
/// when the schedule can't be computed.
|
/// was acquired.
|
||||||
|
pub accumulated_posted: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// Scheduled less posted: run books.run_depreciation for it. Decimal
|
||||||
|
/// string; null before it was acquired.
|
||||||
|
pub unposted_depreciation: Option<String>,
|
||||||
|
/// What the books carry: cost less accumulated_posted ("0.00" once
|
||||||
|
/// disposed), decimal string; null before it was acquired.
|
||||||
pub book_value: Option<String>,
|
pub book_value: Option<String>,
|
||||||
/// Why `accumulated` and `book_value` are null, when they are: an
|
/// Why the figures are null for an asset acquired by as_of: one an
|
||||||
/// asset an earlier version took whose schedule the books can't
|
/// earlier version took whose schedule the books can't compute. It
|
||||||
/// compute. It lists, and the rest of the register with it.
|
/// lists, and the rest of the register with it.
|
||||||
pub schedule_error: Option<String>,
|
pub schedule_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of `books.list_assets`.
|
/// Result of `books.list_assets`.
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub struct ListAssetsOut {
|
pub struct ListAssetsOut {
|
||||||
|
/// The date the figures are as of, YYYY-MM-DD.
|
||||||
|
pub as_of: String,
|
||||||
/// All registered assets.
|
/// All registered assets.
|
||||||
pub assets: Vec<AssetSummary>,
|
pub assets: Vec<AssetSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.run_depreciation`.
|
/// Parameters for `books.run_depreciation`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct RunDepreciationParams {
|
pub struct RunDepreciationParams {
|
||||||
|
|||||||
@@ -677,6 +677,7 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
|
|||||||
"ss_",
|
"ss_",
|
||||||
"medicare",
|
"medicare",
|
||||||
"deductions",
|
"deductions",
|
||||||
|
"fmv",
|
||||||
];
|
];
|
||||||
if n.contains("period") && !n.contains("period_") {
|
if n.contains("period") && !n.contains("period_") {
|
||||||
likely.extend(pick(period_like));
|
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",
|
json!({"description": "y", "purchase_date": "2026-01-01", "cost": "100.00",
|
||||||
"useful_life_months": 12, "cost_account": "1500",
|
"useful_life_months": 12, "cost_account": "1500",
|
||||||
"accum_depr_account": "1501", "expense_account": "6100",
|
"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",
|
"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",
|
"books.export",
|
||||||
|
|||||||
@@ -562,7 +562,7 @@ fn rich_books(s: &mut Store) -> Rich {
|
|||||||
let asset = s
|
let asset = s
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: d("2025-03-20"),
|
in_service_date: d("2025-03-20"),
|
||||||
cost: usd("3000.00"),
|
cost: usd("3000.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 36,
|
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| {
|
let reg = |s: &mut Store, desc: &str| {
|
||||||
s.register_asset(NewAsset {
|
s.register_asset(NewAsset {
|
||||||
description: desc.into(),
|
description: desc.into(),
|
||||||
purchase_date: d("2025-01-01"),
|
in_service_date: d("2025-01-01"),
|
||||||
cost: usd("1200.00"),
|
cost: usd("1200.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 12,
|
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| {
|
let reg = |s: &mut Store, desc: &str, date: &str, months: u32| {
|
||||||
s.register_asset(NewAsset {
|
s.register_asset(NewAsset {
|
||||||
description: desc.into(),
|
description: desc.into(),
|
||||||
purchase_date: d(date),
|
in_service_date: d(date),
|
||||||
cost: usd("600.00"),
|
cost: usd("600.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: months,
|
useful_life_months: months,
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ impl Mcp {
|
|||||||
/// Register a $1,200, 12-month laptop from 2026-01-01 with `extra` args
|
/// Register a $1,200, 12-month laptop from 2026-01-01 with `extra` args
|
||||||
/// (tag, serial, description).
|
/// (tag, serial, description).
|
||||||
fn register(&mut self, extra: Value) -> Out {
|
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": "1200.00", "salvage_value": "0.00", "useful_life_months": 12,
|
||||||
"cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"});
|
"cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"});
|
||||||
for (k, v) in extra.as_object().unwrap() {
|
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);
|
let usd = |a: &str| Money::new(a.parse().unwrap(), Currency::USD);
|
||||||
s.register_asset(NewAsset {
|
s.register_asset(NewAsset {
|
||||||
description: "Old laptop".into(),
|
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"),
|
cost: usd("1200.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 12,
|
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() {
|
fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
|
||||||
let mut m = books("export-later");
|
let mut m = books("export-later");
|
||||||
m.register_ok(json!({"tag": "EARLY-1"}));
|
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(
|
let r = m.read_only(
|
||||||
"books.export",
|
"books.export",
|
||||||
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
|
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)
|
// ------------------------------------------------- tax basis (rules.md B-25)
|
||||||
//
|
//
|
||||||
// From the `books.set_asset_tax_basis` description and
|
// From the `books.set_asset_tax_basis` and `books.register_asset`
|
||||||
// `SetAssetTaxBasisParams` / `TaxBasisOut` docs, `RegisterAssetParams`
|
// descriptions, `SetAssetTaxBasisParams` / `AssetBasisOut` /
|
||||||
// (`tax_basis_rule`, `contributor_basis`), `AssetSummary::tax`, and the
|
// `RegisterAssetParams` docs, and the fixed_asset_register export.
|
||||||
// fixed_asset_register export. Cost: the book cost (IRC §1012);
|
// Purchase: the book cost (IRC §1012); carryover_723: the member's
|
||||||
// carryover: the member's adjusted basis (IRC §723);
|
// adjusted basis (IRC §723); personal_use_conversion: §723 basis as for
|
||||||
// personal_use_conversion: the lesser of fair market value (the book
|
// carryover, depreciable basis the lesser of fair market value and that
|
||||||
// cost) and the member's basis (Treas. Reg. §1.168(i)-4(b)); built-in
|
// basis (Treas. Reg. §1.168(i)-4(b)); built_in_gain_704c = value less the
|
||||||
// gain = fair market value less the member's basis (IRC §704(c)).
|
// §723 basis at contribution (Treas. Reg. §1.704-3(a)(3)(ii)).
|
||||||
|
|
||||||
/// A decimal-string field, compared by value; `None` for null.
|
/// A decimal-string field, compared by value; `None` for null.
|
||||||
fn dec(v: &Value, key: &str) -> Option<String> {
|
fn dec(v: &Value, key: &str) -> Option<String> {
|
||||||
@@ -939,146 +939,252 @@ fn dec(v: &Value, key: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tax_basis_lines(m: &Mcp) -> usize {
|
fn basis_lines(m: &Mcp) -> usize {
|
||||||
m.journal_types()
|
m.journal_types()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|t| *t == "asset.tax_basis_set")
|
.filter(|t| *t == "asset.basis_set")
|
||||||
.count()
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// books.set_asset_tax_basis by tag and by id; the output reports rule,
|
/// books.set_asset_tax_basis by tag and by id; the output names each
|
||||||
/// contributor basis, tax basis, and built-in gain; each call journals.
|
/// figure for what it is, with its authority; each call journals an
|
||||||
|
/// asset.basis_set; the old rule names are aliases, noted.
|
||||||
#[test]
|
#[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 mut m = books("tax-set");
|
||||||
let (a, t) = m.register_ok(json!({}));
|
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(
|
let r = m.ok(
|
||||||
"books.set_asset_tax_basis",
|
"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!(r["asset_id"], json!(a));
|
||||||
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("900"));
|
assert!(r.get("notes").is_none(), "{r}");
|
||||||
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("900"));
|
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!(
|
assert_eq!(
|
||||||
dec(&r, "built_in_gain").as_deref(),
|
b["sources"]["built_in_gain_704c"],
|
||||||
Some("300"),
|
"Treas. Reg. §1.704-3(a)(3)(ii)"
|
||||||
"1200 − 900"
|
|
||||||
);
|
);
|
||||||
assert_eq!(tax_basis_lines(&m), 1);
|
assert_eq!(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!(
|
assert_eq!(
|
||||||
dec(&r, "built_in_gain").as_deref(),
|
m.journal_types()
|
||||||
Some("-800"),
|
.iter()
|
||||||
"a built-in loss"
|
.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(
|
let r = m.ok(
|
||||||
"books.set_asset_tax_basis",
|
"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"));
|
let b = &r["basis"];
|
||||||
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("0"));
|
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(
|
let r = m.ok(
|
||||||
"books.set_asset_tax_basis",
|
"books.set_asset_tax_basis",
|
||||||
json!({"asset": t, "rule": "cost"}),
|
json!({"asset": t, "rule": "cost"}),
|
||||||
);
|
);
|
||||||
assert_eq!(r["rule"], "cost");
|
assert_eq!(r["basis"]["rule"], "purchase");
|
||||||
assert_eq!(r["contributor_basis"], Value::Null);
|
let why = m.refuse(
|
||||||
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
|
"books.set_asset_tax_basis",
|
||||||
assert_eq!(r["built_in_gain"], Value::Null);
|
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "1.00",
|
||||||
assert_eq!(tax_basis_lines(&m), 4);
|
"contributor_basis": "2.00", "fmv_at_contribution": "3.00"}),
|
||||||
|
);
|
||||||
// list_assets shows the latest record.
|
assert!(why.contains("disagree"), "{why}");
|
||||||
let tax = m.asset(a)["tax"].clone();
|
let (_, t2) = m.register_ok(json!({"tax_basis_rule": "carryover",
|
||||||
assert_eq!(tax["rule"], "cost");
|
"contributor_basis": "400.00", "fmv_at_contribution": "1200.00"}));
|
||||||
assert_eq!(dec(&tax, "tax_basis").as_deref(), Some("1200"));
|
assert_eq!(m.assets().len(), 2);
|
||||||
|
let _ = t2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refusals: unknown asset, bad rule, cost with a basis, a contribution
|
/// A contribution's fair market value is a fact the human gives, never
|
||||||
/// without one, negative, finer than cents, not a decimal string. Each
|
/// assumed from the cost (review R11): without it the call is refused.
|
||||||
/// leaves the journal byte-identical (the harness checks) and the record.
|
#[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]
|
#[test]
|
||||||
fn set_asset_tax_basis_refusals_change_nothing() {
|
fn set_asset_tax_basis_refusals_change_nothing() {
|
||||||
let mut m = books("tax-refuse");
|
let mut m = books("tax-refuse");
|
||||||
let (a, t) = m.register_ok(json!({}));
|
let (a, t) = m.register_ok(json!({}));
|
||||||
m.ok(
|
m.ok(
|
||||||
"books.set_asset_tax_basis",
|
"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 [
|
for args in [
|
||||||
json!({"asset": "NOPE-1", "rule": "cost"}),
|
json!({"asset": "NOPE-1", "rule": "purchase"}),
|
||||||
json!({"asset": "999", "rule": "cost"}),
|
json!({"asset": "999", "rule": "purchase"}),
|
||||||
json!({"asset": t, "rule": "fmv"}),
|
json!({"asset": t, "rule": "fmv"}),
|
||||||
json!({"asset": t, "rule": "Cost"}),
|
json!({"asset": t, "rule": "Purchase"}),
|
||||||
json!({"asset": t, "rule": ""}),
|
json!({"asset": t, "rule": ""}),
|
||||||
json!({"asset": t, "rule": "cost", "contributor_basis": "100.00"}),
|
json!({"asset": t, "rule": "purchase", "contributor_adjusted_basis": "100.00"}),
|
||||||
json!({"asset": t, "rule": "carryover"}),
|
json!({"asset": t, "rule": "purchase", "fmv_at_contribution": "100.00"}),
|
||||||
json!({"asset": t, "rule": "personal_use_conversion"}),
|
json!({"asset": t, "rule": "purchase", "sec704c_method": "traditional"}),
|
||||||
json!({"asset": t, "rule": "carryover", "contributor_basis": "-1.00"}),
|
json!({"asset": t, "rule": "carryover_723", "fmv_at_contribution": "1.00"}),
|
||||||
json!({"asset": t, "rule": "carryover", "contributor_basis": "1.001"}),
|
c(json!({"contributor_adjusted_basis": "-1.00"})),
|
||||||
json!({"asset": t, "rule": "carryover", "contributor_basis": "lots"}),
|
c(json!({"contributor_adjusted_basis": "1.001"})),
|
||||||
json!({"asset": t, "rule": "carryover", "contributor_basis": "1,000.00"}),
|
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);
|
m.refuse("books.set_asset_tax_basis", args);
|
||||||
}
|
}
|
||||||
assert_eq!(m.asset(a)["tax"], before);
|
assert_eq!(m.asset(a)["basis"], before);
|
||||||
assert_eq!(tax_basis_lines(&m), 1);
|
assert_eq!(basis_lines(&m), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// register_asset with tax_basis_rule / contributor_basis records it at
|
/// register_asset with basis_rule and its facts records it at
|
||||||
/// registration; without them, list_assets shows tax null.
|
/// registration; without, list_assets shows basis null.
|
||||||
#[test]
|
#[test]
|
||||||
fn register_asset_records_a_tax_basis() {
|
fn register_asset_records_a_basis() {
|
||||||
let mut m = books("tax-register");
|
let mut m = books("tax-register");
|
||||||
let (a, _) = m.register_ok(json!({"tax_basis_rule": "cost"}));
|
let (a, _) = m.register_ok(json!({"basis_rule": "purchase"}));
|
||||||
let (b, _) = m.register_ok(json!({"tax_basis_rule": "personal_use_conversion",
|
let (b, _) = m.register_ok(json!({"basis_rule": "personal_use_conversion",
|
||||||
"contributor_basis": "1500.00"}));
|
"contributor_adjusted_basis": "1500.00", "fmv_at_contribution": "1200.00"}));
|
||||||
let (c, _) = m.register_ok(json!({"tax_basis_rule": "carryover",
|
let (c, _) = m.register_ok(json!({"basis_rule": "carryover_723",
|
||||||
"contributor_basis": "400.00"}));
|
"contributor_adjusted_basis": "400.00", "fmv_at_contribution": "1200.00"}));
|
||||||
let (n, _) = m.register_ok(json!({}));
|
let (n, _) = m.register_ok(json!({}));
|
||||||
let ta = m.asset(a)["tax"].clone();
|
let ta = m.asset(a)["basis"].clone();
|
||||||
assert_eq!(ta["rule"], "cost");
|
assert_eq!(ta["rule"], "purchase");
|
||||||
assert_eq!(ta["contributor_basis"], Value::Null);
|
assert_eq!(dec(&ta, "depreciable_basis").as_deref(), Some("1200"));
|
||||||
assert_eq!(dec(&ta, "tax_basis").as_deref(), Some("1200"));
|
let tb = m.asset(b)["basis"].clone();
|
||||||
assert_eq!(ta["built_in_gain"], Value::Null);
|
assert_eq!(dec(&tb, "tax_basis_723").as_deref(), Some("1500"));
|
||||||
let tb = m.asset(b)["tax"].clone();
|
assert_eq!(dec(&tb, "depreciable_basis").as_deref(), Some("1200"));
|
||||||
assert_eq!(tb["rule"], "personal_use_conversion");
|
assert_eq!(dec(&tb, "built_in_gain_704c").as_deref(), Some("-300"));
|
||||||
assert_eq!(dec(&tb, "contributor_basis").as_deref(), Some("1500"));
|
let tc = m.asset(c)["basis"].clone();
|
||||||
assert_eq!(dec(&tb, "tax_basis").as_deref(), Some("1200"));
|
assert_eq!(dec(&tc, "depreciable_basis").as_deref(), Some("400"));
|
||||||
assert_eq!(dec(&tb, "built_in_gain").as_deref(), Some("-300"));
|
assert_eq!(dec(&tc, "built_in_gain_704c").as_deref(), Some("800"));
|
||||||
let tc = m.asset(c)["tax"].clone();
|
assert_eq!(m.asset(n)["basis"], Value::Null);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// contributor_basis without tax_basis_rule, or a bad rule, is refused
|
/// A basis fact without basis_rule, a bad rule, or a record the engine
|
||||||
/// and registers nothing.
|
/// refuses, given at registration, is a refused call: it registers
|
||||||
|
/// nothing (server invariant 2).
|
||||||
#[test]
|
#[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");
|
let mut m = books("tax-register-bad");
|
||||||
m.register_ok(json!({}));
|
m.register_ok(json!({}));
|
||||||
for extra in [
|
for extra in [
|
||||||
json!({"contributor_basis": "500.00"}),
|
json!({"contributor_adjusted_basis": "500.00"}),
|
||||||
json!({"tax_basis_rule": "market_value"}),
|
json!({"fmv_at_contribution": "500.00"}),
|
||||||
json!({"tax_basis_rule": "carryover", "contributor_basis": "five"}),
|
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());
|
let out = m.register(extra.clone());
|
||||||
assert!(out.refused(), "{extra}: {out:?}");
|
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");
|
assert_eq!(m.assets().len(), 1, "nothing registered");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A record the engine refuses (Ledger::set_asset_tax_basis INVARIANTS
|
// ------------------------------------------- the dates (rules.md B-15)
|
||||||
/// 1–2) given at registration is a refused call, so (server invariant 2)
|
|
||||||
/// it registers nothing either.
|
/// purchase_date is an alias for in_service_date for one release, noted.
|
||||||
#[test]
|
#[test]
|
||||||
fn register_asset_with_a_basis_the_engine_refuses_registers_nothing() {
|
fn purchase_date_is_an_alias_for_in_service_date() {
|
||||||
let mut m = books("tax-register-engine");
|
let mut m = books("alias-date");
|
||||||
for extra in [
|
let r = match m.register(json!({"in_service_date": null, "purchase_date": "2026-02-01"})) {
|
||||||
json!({"tax_basis_rule": "carryover"}),
|
Out::Ok(v) => v,
|
||||||
json!({"tax_basis_rule": "personal_use_conversion"}),
|
other => panic!("{other:?}"),
|
||||||
json!({"tax_basis_rule": "cost", "contributor_basis": "100.00"}),
|
};
|
||||||
json!({"tax_basis_rule": "carryover", "contributor_basis": "-1.00"}),
|
assert_eq!(r["in_service_date"], "2026-02-01");
|
||||||
json!({"tax_basis_rule": "carryover", "contributor_basis": "1.001"}),
|
assert_eq!(r["acquired_date"], "2026-02-01");
|
||||||
] {
|
assert!(
|
||||||
let out = m.register(extra.clone());
|
r["notes"]
|
||||||
assert!(out.refused(), "{extra}: {out:?}");
|
.to_string()
|
||||||
}
|
.contains("purchase_date is now in_service_date"),
|
||||||
assert!(m.assets().is_empty(), "nothing registered");
|
"{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`.
|
/// Both given with different dates: refused, nothing registered; neither:
|
||||||
fn objects_with<'a>(v: &'a Value, key: &str, out: &mut Vec<&'a Value>) {
|
/// refused.
|
||||||
match v {
|
#[test]
|
||||||
Value::Object(m) => {
|
fn purchase_date_and_a_different_in_service_date_are_refused() {
|
||||||
if m.contains_key(key) {
|
let mut m = books("alias-differ");
|
||||||
out.push(v);
|
let out = m.register(json!({"in_service_date": "2026-01-01", "purchase_date": "2026-02-01"}));
|
||||||
}
|
assert!(out.refused(), "{out:?}");
|
||||||
m.values().for_each(|c| objects_with(c, key, out));
|
let out = m.register(json!({"in_service_date": null}));
|
||||||
}
|
assert!(out.refused(), "{out:?}");
|
||||||
Value::Array(a) => a.iter().for_each(|c| objects_with(c, key, 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,
|
/// The acquired date is the acquisition entry's (an asset bought before it
|
||||||
/// contributor basis, tax basis, and built-in gain; JSON also the total
|
/// is in service); an entry dated after the in-service date is refused,
|
||||||
/// tax basis of held assets that have one and how many held assets have
|
/// registering nothing.
|
||||||
/// none. A disposed asset is out of the totals.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn export_fixed_asset_register_carries_tax_basis() {
|
fn acquired_date_comes_from_the_acquisition_entry() {
|
||||||
let mut m = books("tax-export");
|
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(
|
m.post(
|
||||||
"2026-01-01",
|
"2026-01-01",
|
||||||
&[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")],
|
&[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")],
|
||||||
);
|
);
|
||||||
m.register_ok(json!({"tag": "BOUGHT-1", "tax_basis_rule": "cost"}));
|
let (id, sold) = m.register_ok(json!({}));
|
||||||
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 doc = m.file_bill_of_sale();
|
let doc = m.file_bill_of_sale();
|
||||||
m.ok(
|
m.ok(
|
||||||
"books.dispose_asset",
|
"books.dispose_asset",
|
||||||
@@ -1146,7 +1380,57 @@ fn export_fixed_asset_register_carries_tax_basis() {
|
|||||||
"proceeds_account": "1000", "gain_loss_account": "7100",
|
"proceeds_account": "1000", "gain_loss_account": "7100",
|
||||||
"confirmed": true, "receipt_attachment_ids": [doc]}),
|
"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 {
|
let export = |m: &mut Mcp, format: &str| -> String {
|
||||||
m.read_only(
|
m.read_only(
|
||||||
"books.export",
|
"books.export",
|
||||||
@@ -1156,95 +1440,101 @@ fn export_fixed_asset_register_carries_tax_basis() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// JSON.
|
|
||||||
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
|
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
|
||||||
let mut rows = Vec::new();
|
let rows = j["assets"].as_array().unwrap();
|
||||||
objects_with(&j, "tax_basis_rule", &mut rows);
|
|
||||||
assert_eq!(rows.len(), 5, "{j}");
|
assert_eq!(rows.len(), 5, "{j}");
|
||||||
let row = |tag: &str| -> Value {
|
let row = |tag: &str| rows.iter().find(|r| r["tag"] == tag).unwrap().clone();
|
||||||
(*rows
|
|
||||||
.iter()
|
|
||||||
.find(|r| r["tag"] == tag)
|
|
||||||
.unwrap_or_else(|| panic!("{tag} row")))
|
|
||||||
.clone()
|
|
||||||
};
|
|
||||||
let r = row("BOUGHT-1");
|
let r = row("BOUGHT-1");
|
||||||
assert_eq!(r["tax_basis_rule"], "cost");
|
assert_eq!(r["basis_rule"], "purchase");
|
||||||
assert_eq!(r["contributor_basis"], Value::Null);
|
assert_eq!(dec(&r, "accumulated_scheduled").as_deref(), Some("600"));
|
||||||
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
|
assert_eq!(dec(&r, "accumulated_posted").as_deref(), Some("100"));
|
||||||
assert_eq!(r["built_in_gain"], Value::Null);
|
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");
|
let r = row("CARRY-1");
|
||||||
assert_eq!(r["tax_basis_rule"], "carryover");
|
assert_eq!(dec(&r, "tax_basis_723").as_deref(), Some("650.25"));
|
||||||
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("650.25"));
|
assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("549.75"));
|
||||||
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("650.25"));
|
assert_eq!(r["sec704c_method"], "traditional");
|
||||||
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("549.75"));
|
assert_eq!(r["fmv_recorded"], true);
|
||||||
let r = row("CONV-1");
|
let r = row("CONV-1");
|
||||||
assert_eq!(r["tax_basis_rule"], "personal_use_conversion");
|
assert_eq!(dec(&r, "depreciable_basis").as_deref(), Some("1200"));
|
||||||
assert_eq!(dec(&r, "contributor_basis").as_deref(), Some("2000"));
|
assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("-800"));
|
||||||
assert_eq!(dec(&r, "tax_basis").as_deref(), Some("1200"));
|
assert_eq!(r["sec704c_1c"], true);
|
||||||
assert_eq!(dec(&r, "built_in_gain").as_deref(), Some("-800"));
|
assert_eq!(r["basis_source"], "Tobin Harlow, 2026-10-20: entry 9");
|
||||||
let r = row("NONE-1");
|
assert_eq!(row("NONE-1")["basis_rule"], Value::Null);
|
||||||
assert_eq!(r["tax_basis_rule"], Value::Null);
|
assert_eq!(the_value(&j, "total_depreciable_basis"), "3050.25");
|
||||||
assert_eq!(r["tax_basis"], Value::Null);
|
assert_eq!(the_value(&j, "total_tax_basis_723"), "2650.25");
|
||||||
let r = row("SOLD-1");
|
assert_eq!(the_value(&j, "total_accumulated_posted"), "100");
|
||||||
assert_eq!(r["tax_basis_rule"], "cost");
|
assert_eq!(the_value(&j, "unattributed_accumulated"), "0");
|
||||||
// Held with a basis: 1200 + 650.25 + 1200; SOLD-1 out, NONE-1 missing.
|
assert_eq!(j["missing_basis"], json!(1));
|
||||||
assert_eq!(the_value(&j, "total_tax_basis"), "3050.25");
|
assert!(
|
||||||
let mut missing = Vec::new();
|
j["tax_figures_are"]
|
||||||
find_key(&j, "missing_tax_basis", &mut missing);
|
.as_str()
|
||||||
assert_eq!(missing, vec![&json!(1)]);
|
.unwrap()
|
||||||
|
.contains("at contribution")
|
||||||
|
);
|
||||||
|
|
||||||
// CSV: the four tax columns, by header.
|
// CSV: by header.
|
||||||
let csv = export(&mut m, "csv");
|
let csv = export(&mut m, "csv");
|
||||||
let mut lines = csv.lines();
|
let mut lines = csv.lines();
|
||||||
let header: Vec<String> = lines
|
let header: Vec<String> = lines
|
||||||
.next()
|
.next()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|h| h.trim().to_ascii_lowercase())
|
.map(str::to_string)
|
||||||
.collect();
|
.collect();
|
||||||
let col = |name: &str| {
|
assert_eq!(
|
||||||
header
|
header,
|
||||||
.iter()
|
[
|
||||||
.position(|h| h == name)
|
"tag",
|
||||||
.unwrap_or_else(|| panic!("{name} column in {header:?}"))
|
"description",
|
||||||
};
|
"serial",
|
||||||
let (c_tag, c_rule, c_basis, c_tax, c_gain) = (
|
"acquired",
|
||||||
col("tag"),
|
"in_service",
|
||||||
col("tax_basis_rule"),
|
"cost",
|
||||||
col("contributor_basis"),
|
"salvage",
|
||||||
col("tax_basis"),
|
"useful_life_months",
|
||||||
col("built_in_gain"),
|
"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<Vec<String>> = lines
|
let body: Vec<Vec<String>> = lines
|
||||||
.map(|l| l.split(',').map(str::to_string).collect())
|
.map(|l| l.split(',').map(str::to_string).collect())
|
||||||
.collect();
|
.collect();
|
||||||
let csv_row = |tag: &str| {
|
let csv_row = |tag: &str| {
|
||||||
body.iter()
|
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}"))
|
.unwrap_or_else(|| panic!("{tag} in {csv}"))
|
||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
let r = csv_row("CARRY-1");
|
let r = csv_row("CARRY-1");
|
||||||
assert_eq!(r[c_rule], "carryover");
|
assert_eq!(r[col("basis_rule")], "carryover_723");
|
||||||
assert_eq!(canon(&r[c_basis]), "650.25");
|
assert_eq!(canon(&r[col("tax_basis_723")]), "650.25");
|
||||||
assert_eq!(canon(&r[c_tax]), "650.25");
|
assert_eq!(canon(&r[col("built_in_gain_704c")]), "549.75");
|
||||||
assert_eq!(canon(&r[c_gain]), "549.75");
|
assert_eq!(r[col("fmv_recorded")], "true");
|
||||||
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");
|
|
||||||
let r = csv_row("BOUGHT-1");
|
let r = csv_row("BOUGHT-1");
|
||||||
assert_eq!(r[c_rule], "cost");
|
assert_eq!(canon(&r[col("accumulated_posted")]), "100");
|
||||||
assert_eq!(r[c_basis], "");
|
assert_eq!(canon(&r[col("book_value")]), "1100");
|
||||||
assert_eq!(canon(&r[c_tax]), "1200");
|
assert_eq!(r[col("contributor_adjusted_basis")], "");
|
||||||
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.
|
|
||||||
let total = csv_row("TOTAL");
|
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
|
// ------------------------------------- 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// books.list_assets lists it as gone: not held, every figure 0.00 (it
|
||||||
/// 7,000.00, accumulated 12,000.00, book value 0.00.
|
/// 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]
|
#[test]
|
||||||
fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() {
|
fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() {
|
||||||
let (mut m, x) = asset_x("disposed-figures");
|
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["accumulated"].as_str().unwrap()), "5500");
|
||||||
assert_eq!(canon(jun["book_value"].as_str().unwrap()), "6500");
|
assert_eq!(canon(jun["book_value"].as_str().unwrap()), "6500");
|
||||||
let listed = m.asset(x);
|
let listed = m.asset(x);
|
||||||
assert_eq!(canon(listed["accumulated"].as_str().unwrap()), "5500");
|
assert_eq!(listed["held"], false);
|
||||||
assert_eq!(canon(listed["book_value"].as_str().unwrap()), "6500");
|
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);
|
assert_eq!(listed["schedule_error"], Value::Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1013,7 +1013,7 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
);
|
);
|
||||||
m.ok(
|
m.ok(
|
||||||
"books.set_asset_tax_basis",
|
"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(
|
m.ok(
|
||||||
"books.correct_asset",
|
"books.correct_asset",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pub fn export_iif(
|
|||||||
use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif};
|
use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif};
|
||||||
let fixed: std::collections::HashSet<AccountId> = ledger
|
let fixed: std::collections::HashSet<AccountId> = ledger
|
||||||
.registered_assets()
|
.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])
|
.flat_map(|a| [a.cost_account, a.accum_depr_account])
|
||||||
.collect();
|
.collect();
|
||||||
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
|
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
|
||||||
|
|||||||
@@ -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<String> =
|
||||||
|
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<Decimal>, 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
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
mod books;
|
mod books;
|
||||||
mod iif;
|
mod iif;
|
||||||
|
mod k1_tax;
|
||||||
mod package;
|
mod package;
|
||||||
mod reports;
|
mod reports;
|
||||||
mod text;
|
mod text;
|
||||||
@@ -33,6 +34,7 @@ mod vendor_1099;
|
|||||||
|
|
||||||
pub use books::PackageBooks;
|
pub use books::PackageBooks;
|
||||||
pub use iif::export_iif;
|
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 package::{Package, PackageRequest, build_package, package_file_name};
|
||||||
pub use reports::{
|
pub use reports::{
|
||||||
Settings, as_of_date, bs_statement, cash_flow_for, cash_flow_statement, member_capitals,
|
Settings, as_of_date, bs_statement, cash_flow_for, cash_flow_statement, member_capitals,
|
||||||
|
|||||||
@@ -23,9 +23,11 @@
|
|||||||
//! share, capital account, TIN and type (or last four without a TIN
|
//! share, capital account, TIN and type (or last four without a TIN
|
||||||
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
|
//! 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
|
//! for the fiscal year to date; the members' total row with more than
|
||||||
//! one. The 1099 worksheet is books.vendor_1099's CSV
|
//! one. Beside it, k1-tax-basis-capital.csv: each member's capital
|
||||||
//! (crate::vendor_1099_csv), each payee's TIN and type added with a
|
//! on the tax basis (Item L), line by line beside the book figures,
|
||||||
//! TIN source.
|
//! 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
|
//! 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
|
//! why, and so do the README and tin_gaps (members, and 1099 payees
|
||||||
//! a 1099 is or may be due for).
|
//! a 1099 is or may be due for).
|
||||||
@@ -182,6 +184,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
let mut k1_csv: Option<(String, Vec<u8>)> = None;
|
let mut k1_csv: Option<(String, Vec<u8>)> = None;
|
||||||
|
let mut k1_tax: Option<(String, Vec<u8>)> = None;
|
||||||
if !company.members.is_empty() {
|
if !company.members.is_empty() {
|
||||||
let capitals = member_capitals(ledger, &company.members)?;
|
let capitals = member_capitals(ledger, &company.members)?;
|
||||||
let me =
|
let me =
|
||||||
@@ -325,6 +328,33 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// 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<String> = 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((
|
statements.push((
|
||||||
"tax-summary",
|
"tax-summary",
|
||||||
@@ -342,9 +372,12 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
files.push((format!("{name}.pdf"), pdf(st)?));
|
files.push((format!("{name}.pdf"), pdf(st)?));
|
||||||
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
||||||
}
|
}
|
||||||
// The fixed asset register, when an asset was bought by as_of (one
|
// The fixed asset register, when an asset was acquired by as_of (one
|
||||||
// bought later is not in it, so neither is an empty register).
|
// acquired later is not in it, so neither is an empty register).
|
||||||
if ledger.registered_assets().any(|a| a.purchase_date <= as_of) {
|
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())?;
|
let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?;
|
||||||
files.push((
|
files.push((
|
||||||
"fixed-asset-register.pdf".into(),
|
"fixed-asset-register.pdf".into(),
|
||||||
@@ -513,6 +546,12 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
if let Some(k1) = k1_csv {
|
if let Some(k1) = k1_csv {
|
||||||
files.push(k1);
|
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();
|
let seq = books.journal_seq();
|
||||||
// The journal through as_of for QuickBooks, closes left out, and what
|
// The journal through as_of for QuickBooks, closes left out, and what
|
||||||
// it writes differently (INVARIANT 8).
|
// it writes differently (INVARIANT 8).
|
||||||
@@ -601,6 +640,11 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
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;
|
let members = &company.members;
|
||||||
for (i, m) in members.iter().enumerate() {
|
for (i, m) in members.iter().enumerate() {
|
||||||
if let Some(j) = &m.files_jointly_with
|
if let Some(j) = &m.files_jointly_with
|
||||||
|
|||||||
@@ -501,8 +501,16 @@ fn members_get_the_k1_worksheet_with_full_tins_only_from_a_source() {
|
|||||||
for p in [&with, &without] {
|
for p in [&with, &without] {
|
||||||
let n = names(p);
|
let n = names(p);
|
||||||
assert!(n.contains(&"members-equity.pdf") && n.contains(&"members-equity.xlsx"));
|
assert!(n.contains(&"members-equity.pdf") && n.contains(&"members-equity.xlsx"));
|
||||||
// The K-1 worksheet comes after the evidence, before the IIF.
|
// The K-1 worksheet (book, then tax basis) comes after the
|
||||||
assert_eq!(&n[n.len() - 2..], ["k1-members.csv", "general-journal.iif"]);
|
// 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);
|
assert_zip_is_the_files(p);
|
||||||
}
|
}
|
||||||
// Capital: 5000 in each; income 4000 - 1200 - 2500 = 300, half each.
|
// 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
|
let id = s
|
||||||
.register_asset(ledger_core::NewAsset {
|
.register_asset(ledger_core::NewAsset {
|
||||||
description: "Desk".into(),
|
description: "Desk".into(),
|
||||||
purchase_date: d("2026-01-10"),
|
in_service_date: d("2026-01-10"),
|
||||||
cost: usd("1200.00"),
|
cost: usd("1200.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 60,
|
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"));
|
let (cost, accum, expense) = (id_of(s, "1500"), id_of(s, "1510"), id_of(s, "6500"));
|
||||||
s.register_asset(ledger_core::NewAsset {
|
s.register_asset(ledger_core::NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: bought,
|
in_service_date: bought,
|
||||||
cost: usd("1800.00"),
|
cost: usd("1800.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
@@ -2123,3 +2131,152 @@ fn the_cash_flow_carries_both_schedules() {
|
|||||||
assert!(!text.contains("Member distributions"));
|
assert!(!text.contains("Member distributions"));
|
||||||
assert!(p.files.iter().any(|(n, _)| n == "cash-flow.pdf"));
|
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"));
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
|||||||
VENDOR_1099_DECISIONS,
|
VENDOR_1099_DECISIONS,
|
||||||
MONTH_REPOSTING,
|
MONTH_REPOSTING,
|
||||||
SCHEDULE_CONVENTION,
|
SCHEDULE_CONVENTION,
|
||||||
|
ASSET_BASIS,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Feature: `period.closed` closes to members with `"rounding":
|
/// 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).
|
/// months (P3 defect 9, plan/served.md D-11).
|
||||||
pub const SCHEDULE_CONVENTION: &str = "schedule_convention";
|
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.
|
/// The line type that declares the journal format.
|
||||||
pub const FORMAT_SET: &str = "journal.format_set";
|
pub const FORMAT_SET: &str = "journal.format_set";
|
||||||
|
|
||||||
@@ -117,6 +122,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
|||||||
"account.renumbered",
|
"account.renumbered",
|
||||||
"account.tax_set",
|
"account.tax_set",
|
||||||
"asset.acquisition_linked",
|
"asset.acquisition_linked",
|
||||||
|
"asset.basis_set",
|
||||||
"asset.corrected",
|
"asset.corrected",
|
||||||
"asset.depreciation_posted",
|
"asset.depreciation_posted",
|
||||||
"asset.described",
|
"asset.described",
|
||||||
|
|||||||
@@ -326,15 +326,18 @@ CREATE TABLE IF NOT EXISTS entry_approvals(
|
|||||||
"#,
|
"#,
|
||||||
r#"
|
r#"
|
||||||
-- v13: 1099 decisions and payment methods (plan P3, defects 12, 18 and
|
-- v13: 1099 decisions and payment methods (plan P3, defects 12, 18 and
|
||||||
-- 23; D-40). Each 1099 decision recorded with who decided (journal
|
-- 23; D-40), and assets' basis facts with their depreciation runs keyed
|
||||||
-- feature vendor_1099_decisions), line by line: the date it takes effect
|
-- by entry (plan P3, defect 13; D-41). One migration: none of P3 had
|
||||||
-- (NULL: from the beginning), nec, misc, not_reportable or NULL
|
-- shipped when they met.
|
||||||
-- (undetermined), and for not_reportable its kind and reason. A decision
|
--
|
||||||
-- from the beginning also sets vendors.form_1099, as every decision
|
-- Each 1099 decision recorded with who decided (journal feature
|
||||||
-- written before these did. Payment methods (feature payment_instruments)
|
-- vendor_1099_decisions), line by line: the date it takes effect (NULL:
|
||||||
-- and the method each entry was paid with. Rows only come from lines
|
-- from the beginning), nec, misc, not_reportable or NULL (undetermined),
|
||||||
-- written under the features, so books written before them project the
|
-- and for not_reportable its kind and reason. A decision from the
|
||||||
-- same as before.
|
-- 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(
|
CREATE TABLE IF NOT EXISTS vendor_1099_decisions(
|
||||||
seq INTEGER PRIMARY KEY,
|
seq INTEGER PRIMARY KEY,
|
||||||
vendor_id INTEGER NOT NULL,
|
vendor_id INTEGER NOT NULL,
|
||||||
@@ -356,5 +359,27 @@ CREATE TABLE IF NOT EXISTS entry_instruments(
|
|||||||
entry_id INTEGER PRIMARY KEY,
|
entry_id INTEGER PRIMARY KEY,
|
||||||
instrument_id INTEGER NOT NULL
|
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;
|
||||||
"#,
|
"#,
|
||||||
];
|
];
|
||||||
+311
-162
@@ -277,8 +277,9 @@ pub struct DisposalPreview {
|
|||||||
pub period: String,
|
pub period: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A depreciation run the books carry for an asset: its row in the
|
/// A depreciation run the books carry for an asset: one the ledger
|
||||||
/// projection, its entry in effect (not reversed).
|
/// recorded (`Ledger::depreciation_runs`), its entry in effect (not
|
||||||
|
/// reversed).
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
struct LiveRun {
|
struct LiveRun {
|
||||||
/// Its month, "YYYY-MM" as journaled.
|
/// Its month, "YYYY-MM" as journaled.
|
||||||
@@ -1489,7 +1490,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
// Reversing `entry` re-instates the run, adding it back in the
|
// Reversing `entry` re-instates the run, adding it back in the
|
||||||
// reversal's month.
|
// 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
|
&& now != top
|
||||||
{
|
{
|
||||||
return refuse(format!(
|
return refuse(format!(
|
||||||
@@ -1504,7 +1505,7 @@ impl Store {
|
|||||||
asset,
|
asset,
|
||||||
date.year(),
|
date.year(),
|
||||||
date.month(),
|
date.month(),
|
||||||
&self.depreciation_effects(asset_id)?,
|
&self.depreciation_effects(asset_id),
|
||||||
)?
|
)?
|
||||||
.amount;
|
.amount;
|
||||||
if amount > room {
|
if amount > room {
|
||||||
@@ -1534,7 +1535,8 @@ impl Store {
|
|||||||
json!({
|
json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"description": new.description,
|
"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() },
|
"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() },
|
"salvage_value": { "amount": new.salvage_value.amount.to_string(), "currency": new.salvage_value.currency.code() },
|
||||||
"useful_life_months": new.useful_life_months,
|
"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
|
/// Correct a registered asset's cost, salvage, life, or in-service date
|
||||||
/// (see `Ledger::correct_asset`), with the reason. Refused once any
|
/// (see `Ledger::correct_asset`), with the reason. Refused while the
|
||||||
/// depreciation for it is posted. Journaled as `asset.corrected`, with
|
/// books carry any depreciation for it (a run posted and not reversed:
|
||||||
|
/// `Ledger::accumulated_posted`). Journaled as `asset.corrected`, with
|
||||||
/// the figures it had.
|
/// the figures it had.
|
||||||
pub fn correct_asset(
|
pub fn correct_asset(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -1751,7 +1754,11 @@ impl Store {
|
|||||||
return Err(StoreError::ReasonRequired);
|
return Err(StoreError::ReasonRequired);
|
||||||
}
|
}
|
||||||
self.ledger.check_correct_asset(id, &fix)?;
|
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));
|
return Err(StoreError::AssetDepreciationPosted(id));
|
||||||
}
|
}
|
||||||
let a = self.ledger.get_asset(id).expect("checked").clone();
|
let a = self.ledger.get_asset(id).expect("checked").clone();
|
||||||
@@ -1764,13 +1771,14 @@ impl Store {
|
|||||||
"cost": money(&fix.cost),
|
"cost": money(&fix.cost),
|
||||||
"salvage_value": money(&fix.salvage_value),
|
"salvage_value": money(&fix.salvage_value),
|
||||||
"useful_life_months": fix.useful_life_months,
|
"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,
|
"reason": reason,
|
||||||
"previous": {
|
"previous": {
|
||||||
"cost": money(&a.cost),
|
"cost": money(&a.cost),
|
||||||
"salvage_value": money(&a.salvage_value),
|
"salvage_value": money(&a.salvage_value),
|
||||||
"useful_life_months": a.useful_life_months,
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an asset's federal tax basis (see
|
/// Record how the company got an asset and the facts its tax figures
|
||||||
/// `Ledger::set_asset_tax_basis`). Journaled as `asset.tax_basis_set`.
|
/// follow from (see `Ledger::set_asset_basis`). Journaled as
|
||||||
pub fn set_asset_tax_basis(
|
/// `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,
|
&mut self,
|
||||||
id: AssetId,
|
id: AssetId,
|
||||||
tax: ledger_core::TaxBasis,
|
basis: ledger_core::AssetBasis,
|
||||||
) -> Result<(), StoreError> {
|
) -> 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<Decimal>| d.map(|d| d.to_string());
|
||||||
|
self.ensure_feature(crate::format::ASSET_BASIS)?;
|
||||||
let line = self.append_journal(
|
let line = self.append_journal(
|
||||||
"asset.tax_basis_set",
|
"asset.basis_set",
|
||||||
json!({
|
json!({
|
||||||
"asset_id": id,
|
"asset_id": id,
|
||||||
"rule": tax.rule.key(),
|
"rule": basis.rule.key(),
|
||||||
"contributor_basis": tax.contributor_basis.map(|b| b.to_string()),
|
"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)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1854,114 +1885,48 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The depreciation runs the books carry for an asset: every run
|
/// The depreciation runs the books carry for an asset: every run
|
||||||
/// journaled for it ([`Self::depreciation_runs_ever`]) whose entry is in
|
/// recorded for it (`asset.depreciation_posted`, its disposal's final
|
||||||
/// effect, in journal order. A reversed run is left out, so its month is
|
/// depreciation included: [`Ledger::depreciation_runs`]) whose entry is
|
||||||
/// due again (P3 depreciation review R3); a reversed reversal counts
|
/// in effect, in journal order. A reversed run is left out, so its
|
||||||
/// again, even after its month was run again and that run reversed (the
|
/// month is due again (P3 depreciation review R3); a reversed reversal
|
||||||
/// projection's row for the month names the latest run only, so it is
|
/// counts again, even after its month was run again and that run
|
||||||
/// not read here: P3 review round 2).
|
/// reversed (P3 review round 2).
|
||||||
fn live_runs(&self, asset_id: AssetId) -> Result<Vec<LiveRun>, StoreError> {
|
fn live_runs(&self, asset_id: AssetId) -> Vec<LiveRun> {
|
||||||
Ok(self
|
self.ledger
|
||||||
.depreciation_runs_ever(asset_id)?
|
.depreciation_runs(asset_id)
|
||||||
.into_iter()
|
.iter()
|
||||||
.filter(|(entry_id, _, _)| self.ledger.in_effect(*entry_id))
|
.filter(|r| self.ledger.in_effect(r.entry))
|
||||||
.map(|(entry_id, amount, period)| LiveRun {
|
.map(|r| LiveRun {
|
||||||
period,
|
period: r.period.clone(),
|
||||||
entry_id,
|
entry_id: r.entry,
|
||||||
amount,
|
amount: r.amount,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The run the books carry for this asset and period, if any: (entry,
|
/// The run the books carry for this asset and period, if any: (entry,
|
||||||
/// amount).
|
/// amount) ([`Ledger::live_depreciation_run`]).
|
||||||
fn depreciation_posted_entry(
|
fn depreciation_posted_entry(&self, asset_id: AssetId, period: &str) -> Option<(u64, Decimal)> {
|
||||||
&self,
|
self.ledger
|
||||||
asset_id: AssetId,
|
.live_depreciation_run(asset_id, period)
|
||||||
period: &str,
|
.map(|r| (r.entry, r.amount))
|
||||||
) -> Result<Option<(u64, Decimal)>, StoreError> {
|
|
||||||
Ok(self
|
|
||||||
.live_runs(asset_id)?
|
|
||||||
.into_iter()
|
|
||||||
.find(|r| r.period == period)
|
|
||||||
.map(|r| (r.entry_id, r.amount)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this asset's period was ever run, reversed or not: running
|
/// Whether this asset's period was ever run, reversed or not: running
|
||||||
/// it again writes a second `asset.depreciation_posted` for it, which an
|
/// it again writes a second `asset.depreciation_posted` for it, which an
|
||||||
/// older version would ignore (feature `month_reposting`).
|
/// older version would ignore (feature `month_reposting`).
|
||||||
fn depreciation_period_was_run(
|
fn depreciation_period_was_run(&self, asset_id: AssetId, period: &str) -> bool {
|
||||||
&self,
|
self.ledger
|
||||||
asset_id: AssetId,
|
.depreciation_runs(asset_id)
|
||||||
period: &str,
|
.iter()
|
||||||
) -> Result<bool, StoreError> {
|
.any(|r| r.period == period)
|
||||||
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<Vec<(EntryId, Decimal, String)>, 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the books carry for an asset's depreciation, entry by entry,
|
/// What the books carry for an asset's depreciation, entry by entry,
|
||||||
/// each in its own month: every run and every reversal of one
|
/// each in its own month: every run and every reversal of one
|
||||||
/// (ledger_core::depreciation_effects), as the catch-up rule reads it.
|
/// (ledger_core::depreciation_effects), as the catch-up rule reads it.
|
||||||
fn depreciation_effects(
|
fn depreciation_effects(&self, asset_id: AssetId) -> Vec<ledger_core::PostedRun> {
|
||||||
&self,
|
self.ledger.depreciation_effects_of(asset_id)
|
||||||
asset_id: AssetId,
|
|
||||||
) -> Result<Vec<ledger_core::PostedRun>, 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<Decimal, StoreError> {
|
|
||||||
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"))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Post one month's depreciation for one asset: what is due through the
|
/// Post one month's depreciation for one asset: what is due through the
|
||||||
@@ -2018,7 +1983,7 @@ impl Store {
|
|||||||
.find(|l| l.period == period)
|
.find(|l| l.period == period)
|
||||||
.map_or(Decimal::ZERO, |l| l.amount))
|
.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 {
|
return Ok(DepreciationRun {
|
||||||
asset_id,
|
asset_id,
|
||||||
period: period.to_string(),
|
period: period.to_string(),
|
||||||
@@ -2033,7 +1998,7 @@ impl Store {
|
|||||||
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
|
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
|
||||||
}
|
}
|
||||||
let due =
|
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() {
|
if due.amount.is_zero() {
|
||||||
let last = ledger_core::life_end(&asset)?
|
let last = ledger_core::life_end(&asset)?
|
||||||
.pred_opt()
|
.pred_opt()
|
||||||
@@ -2066,16 +2031,18 @@ impl Store {
|
|||||||
let description = format!("Depreciation — {} — {period}", asset.description);
|
let description = format!("Depreciation — {} — {period}", asset.description);
|
||||||
let money = Money::new(amount, self.base_currency);
|
let money = Money::new(amount, self.base_currency);
|
||||||
// Every refusal before the first journaled line: the entry on a
|
// Every refusal before the first journaled line: the entry on a
|
||||||
// copy, then the journal feature a period run again needs. A
|
// copy (and that it is the period's run, no more and no less:
|
||||||
// refusal after the entry posted would leave it without the line
|
// Ledger::check_depreciation_run), then the journal feature a
|
||||||
// that makes it the period's run.
|
// 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| {
|
self.rehearse(|l| {
|
||||||
let e = l.try_create_entry(period_end, &description)?;
|
let e = l.try_create_entry(period_end, &description)?;
|
||||||
l.add_line(e, asset.expense_account, Side::Debit, money)?;
|
l.add_line(e, asset.expense_account, Side::Debit, money)?;
|
||||||
l.add_line(e, asset.accum_depr_account, Side::Credit, 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)?;
|
self.ensure_feature(crate::format::MONTH_REPOSTING)?;
|
||||||
}
|
}
|
||||||
let entry_id = self.create_entry(period_end, &description)?;
|
let entry_id = self.create_entry(period_end, &description)?;
|
||||||
@@ -2091,6 +2058,8 @@ impl Store {
|
|||||||
"amount": amount.to_string(),
|
"amount": amount.to_string(),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
self.ledger
|
||||||
|
.record_depreciation_run(asset_id, period, entry_id, amount)?;
|
||||||
apply_line_to_sqlite(&self.conn, &dline)?;
|
apply_line_to_sqlite(&self.conn, &dline)?;
|
||||||
Ok(DepreciationRun {
|
Ok(DepreciationRun {
|
||||||
asset_id,
|
asset_id,
|
||||||
@@ -2202,8 +2171,9 @@ impl Store {
|
|||||||
let month = (date.year(), date.month());
|
let month = (date.year(), date.month());
|
||||||
let month_end = ledger_core::schedules::month_end(date);
|
let month_end = ledger_core::schedules::month_end(date);
|
||||||
let (mut later, mut late) = (Vec::new(), Vec::new());
|
let (mut later, mut late) = (Vec::new(), Vec::new());
|
||||||
for (run, amount, period) in self.depreciation_runs_ever(asset_id)? {
|
for r in self.ledger.depreciation_runs(asset_id) {
|
||||||
if period_month(&period) > month && self.ledger.in_effect(run) {
|
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})"));
|
later.push(format!("{period} (entry {run})"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -2321,19 +2291,27 @@ impl Store {
|
|||||||
}
|
}
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
if date < asset.purchase_date {
|
let acquired = self
|
||||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into());
|
.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();
|
let period = date.format("%Y-%m").to_string();
|
||||||
self.check_nothing_after_disposal(asset_id, date)?;
|
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 =
|
let too_large =
|
||||||
|| StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large"));
|
|| StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large"));
|
||||||
let posted = runs
|
let posted = runs
|
||||||
.iter()
|
.iter()
|
||||||
.try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount))
|
.try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount))
|
||||||
.ok_or_else(too_large)?;
|
.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 (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 in_month = runs.iter().any(|r| r.period == period);
|
||||||
let (final_depreciation, catch_up_depreciation) = if in_month {
|
let (final_depreciation, catch_up_depreciation) = if in_month {
|
||||||
@@ -2449,8 +2427,16 @@ impl Store {
|
|||||||
if asset.status != AssetStatus::Active {
|
if asset.status != AssetStatus::Active {
|
||||||
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
|
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
|
||||||
}
|
}
|
||||||
if date < asset.purchase_date {
|
let acquired = self
|
||||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into());
|
.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;
|
let base = self.base_currency;
|
||||||
|
|
||||||
@@ -2488,14 +2474,24 @@ impl Store {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Several commands make one disposal: rehearse them all together,
|
// 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| {
|
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)?;
|
let e = l.try_create_entry(date, description)?;
|
||||||
for (account, side, amount) in lines {
|
for (account, side, amount) in lines {
|
||||||
l.add_line(e, *account, *side, Money::new(*amount, base))?;
|
l.add_line(e, *account, *side, Money::new(*amount, base))?;
|
||||||
}
|
}
|
||||||
l.post_entry(e)?;
|
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)
|
l.dispose_asset(asset_id, date, proceeds)
|
||||||
})?;
|
})?;
|
||||||
@@ -2518,7 +2514,7 @@ impl Store {
|
|||||||
// depreciation would be in the disposal entry): its line again,
|
// depreciation would be in the disposal entry): its line again,
|
||||||
// which needs a journal feature, asked for (and refused in books
|
// which needs a journal feature, asked for (and refused in books
|
||||||
// that haven't declared it) before the first line is journaled.
|
// 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)?;
|
self.ensure_feature(crate::format::MONTH_REPOSTING)?;
|
||||||
}
|
}
|
||||||
if let Some((description, lines)) = &depreciation {
|
if let Some((description, lines)) = &depreciation {
|
||||||
@@ -2536,6 +2532,12 @@ impl Store {
|
|||||||
"amount": preview.final_depreciation.to_string(),
|
"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)?;
|
apply_line_to_sqlite(&self.conn, &dline)?;
|
||||||
}
|
}
|
||||||
let entry_id = self.create_entry(date, &disposal.0)?;
|
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
|
// The live ledger equals the replay (checked above, in full); the
|
||||||
// SQLite read-back's asset register, classes, and payroll runs
|
// SQLite read-back's asset register, classes, and payroll runs
|
||||||
// must equal it too (the accounting snapshot ignores them).
|
// must equal it too (the accounting snapshot ignores them).
|
||||||
let assets_of = |l: &Ledger| l.registered_assets().cloned().collect::<Vec<_>>();
|
let assets_of = |l: &Ledger| {
|
||||||
|
l.registered_assets()
|
||||||
|
.map(|a| (a.clone(), l.depreciation_runs(a.id).to_vec()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
if assets_of(&replayed) != assets_of(&c) {
|
if assets_of(&replayed) != assets_of(&c) {
|
||||||
return Err(StoreError::VerifyMismatch(
|
return Err(StoreError::VerifyMismatch(
|
||||||
"asset register diverged between journal, live ledger, and sqlite projection"
|
"asset register diverged between journal, live ledger, and sqlite projection"
|
||||||
@@ -5058,7 +5064,7 @@ impl Store {
|
|||||||
};
|
};
|
||||||
let got = ledger.replay_register_asset(NewAsset {
|
let got = ledger.replay_register_asset(NewAsset {
|
||||||
description,
|
description,
|
||||||
purchase_date: parse_date(&purchase_date)?,
|
in_service_date: parse_date(&purchase_date)?,
|
||||||
cost: parse_amt(&cost_amount, &cost_currency)?,
|
cost: parse_amt(&cost_amount, &cost_currency)?,
|
||||||
salvage_value: parse_amt(&salvage_amount, &salvage_currency)?,
|
salvage_value: parse_amt(&salvage_amount, &salvage_currency)?,
|
||||||
useful_life_months: life_months as u32,
|
useful_life_months: life_months as u32,
|
||||||
@@ -5103,20 +5109,56 @@ impl Store {
|
|||||||
let (id, serial) = row?;
|
let (id, serial) = row?;
|
||||||
ledger.set_asset_serial(id as u64, Some(&serial))?;
|
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(
|
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| {
|
let taxes = xstmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
r.get::<_, i64>(0)?,
|
r.get::<_, i64>(0)?,
|
||||||
r.get::<_, String>(1)?,
|
r.get::<_, String>(1)?,
|
||||||
r.get::<_, Option<String>>(2)?,
|
r.get::<_, Option<String>>(2)?,
|
||||||
|
r.get::<_, Option<String>>(3)?,
|
||||||
|
r.get::<_, Option<String>>(4)?,
|
||||||
|
r.get::<_, Option<String>>(5)?,
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
for row in taxes {
|
for row in taxes {
|
||||||
let (id, rule, basis) = row?;
|
let (id, rule, basis, fmv, method, source) = row?;
|
||||||
let tax = tax_basis_from_journal(&json!({"rule": rule, "contributor_basis": basis}))?;
|
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("assets.{what}"));
|
||||||
ledger.replay_set_asset_tax_basis(id as u64, tax)?;
|
let dec = |v: Option<String>, what: &str| {
|
||||||
|
v.map(|s| s.parse::<Decimal>().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(
|
let mut qstmt = self.conn.prepare(
|
||||||
"SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id",
|
"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?;
|
let (id, entry) = row?;
|
||||||
ledger.restore_asset_acquisition(id as u64, entry as u64)?;
|
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::<Decimal>().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
|
// Payroll runs: id order; lines in insertion order. Amounts are
|
||||||
// TEXT in SQLite, so they parse here with exact Decimal math.
|
// TEXT in SQLite, so they parse here with exact Decimal math.
|
||||||
let parse_payroll_amount = |s: String| {
|
let parse_payroll_amount = |s: String| {
|
||||||
@@ -5725,7 +5787,8 @@ pub(crate) fn replay_line_into_ledger(
|
|||||||
let want = get_u64(p, "id")?;
|
let want = get_u64(p, "id")?;
|
||||||
let got = ledger.replay_register_asset(NewAsset {
|
let got = ledger.replay_register_asset(NewAsset {
|
||||||
description: get_str(p, "description")?.to_string(),
|
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")?,
|
cost: parse_money(p, "cost")?,
|
||||||
salvage_value: parse_money(p, "salvage_value")?,
|
salvage_value: parse_money(p, "salvage_value")?,
|
||||||
useful_life_months: get_u64(p, "useful_life_months")? as u32,
|
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")?,
|
cost: parse_money(p, "cost")?,
|
||||||
salvage_value: parse_money(p, "salvage_value")?,
|
salvage_value: parse_money(p, "salvage_value")?,
|
||||||
useful_life_months: get_u64(p, "useful_life_months")? as u32,
|
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" => {
|
"asset.tax_basis_set" => {
|
||||||
let tax = tax_basis_from_journal(p)?;
|
let (rule, contributor_basis) = tax_basis_from_journal(p)?;
|
||||||
ledger.replay_set_asset_tax_basis(get_u64(p, "asset_id")?, tax)?;
|
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" => {
|
"asset.described" => {
|
||||||
ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?;
|
ledger.set_asset_description(get_u64(p, "asset_id")?, get_str(p, "description")?)?;
|
||||||
}
|
}
|
||||||
"asset.acquisition_linked" => {
|
"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" => {
|
"asset.serial_set" => {
|
||||||
let serial =
|
let serial =
|
||||||
@@ -5850,7 +5918,19 @@ pub(crate) fn replay_line_into_ledger(
|
|||||||
}
|
}
|
||||||
"asset.depreciation_posted" => {
|
"asset.depreciation_posted" => {
|
||||||
// The entry itself was journaled as entry.created/entry.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::<Decimal>().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" => {
|
"asset.disposed" => {
|
||||||
let id = get_u64(p, "asset_id")?;
|
let id = get_u64(p, "asset_id")?;
|
||||||
@@ -6143,21 +6223,69 @@ fn apply_document_payment(
|
|||||||
/// written before targets existed).
|
/// written before targets existed).
|
||||||
/// An `asset.tax_basis_set` payload's rule and contributor basis, strictly:
|
/// 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.
|
/// an unknown rule or a basis that isn't a decimal string is corrupt.
|
||||||
fn tax_basis_from_journal(p: &Value) -> Result<ledger_core::TaxBasis, StoreError> {
|
/// 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<Decimal>), StoreError> {
|
||||||
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("asset.tax_basis_set {what}"));
|
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("asset.tax_basis_set {what}"));
|
||||||
let rule =
|
let rule =
|
||||||
ledger_core::TaxBasisRule::parse(get_str(p, "rule")?).ok_or_else(|| corrupt("rule"))?;
|
ledger_core::BasisRule::parse_legacy(get_str(p, "rule")?).ok_or_else(|| corrupt("rule"))?;
|
||||||
let contributor_basis = match p.get("contributor_basis") {
|
Ok((
|
||||||
None | Some(Value::Null) => None,
|
|
||||||
Some(v) => Some(
|
|
||||||
v.as_str()
|
|
||||||
.and_then(|s| s.parse::<Decimal>().ok())
|
|
||||||
.ok_or_else(|| corrupt("contributor_basis"))?,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
Ok(ledger_core::TaxBasis {
|
|
||||||
rule,
|
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<Option<Decimal>, ()> {
|
||||||
|
match p.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => v
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| s.parse::<Decimal>().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<ledger_core::AssetBasis, StoreError> {
|
||||||
|
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" => {
|
"asset.tax_basis_set" => {
|
||||||
|
// A pre-P3 record: its rule by its pre-P3 name, no value at
|
||||||
|
// contribution, method or source.
|
||||||
conn.execute(
|
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![
|
params![
|
||||||
get_str(p, "rule")?,
|
get_str(p, "rule")?,
|
||||||
p.get("contributor_basis").and_then(|v| v.as_str()),
|
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" => {
|
"asset.described" => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE assets SET description = ? WHERE id = ?",
|
"UPDATE assets SET description = ? WHERE id = ?",
|
||||||
@@ -6766,13 +6916,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
"asset.depreciation_posted" => {
|
"asset.depreciation_posted" => {
|
||||||
// A period run again after its run was reversed takes the row
|
// Every run, a period run again after its run was reversed
|
||||||
// (feature month_reposting); a period whose run stands keeps
|
// (feature month_reposting) beside the first: keyed by its
|
||||||
// it, as before.
|
// entry too (schema v13), as the ledger records them and the
|
||||||
|
// projection's read-back restores them.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?) \
|
"INSERT OR IGNORE 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)",
|
|
||||||
params![
|
params![
|
||||||
get_u64(p, "asset_id")? as i64,
|
get_u64(p, "asset_id")? as i64,
|
||||||
get_str(p, "period")?,
|
get_str(p, "period")?,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ fn books(dir: &Path) -> u64 {
|
|||||||
}
|
}
|
||||||
s.register_asset(NewAsset {
|
s.register_asset(NewAsset {
|
||||||
description: "Asset".into(),
|
description: "Asset".into(),
|
||||||
purchase_date: day(2026, 1, 16),
|
in_service_date: day(2026, 1, 16),
|
||||||
cost: usd(300_000),
|
cost: usd(300_000),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 3,
|
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 a = store.ledger().get_asset(asset).unwrap().clone();
|
||||||
let mut active = a.clone();
|
let mut active = a.clone();
|
||||||
active.status = AssetStatus::Active;
|
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.
|
// INVARIANT 3: one run in effect per month.
|
||||||
let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap();
|
let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap();
|
||||||
let mut live: Vec<String> = Vec::new();
|
let mut live: Vec<String> = Vec::new();
|
||||||
@@ -294,7 +294,7 @@ fn check_completion(dir: &Path, asset: u64, ops: &[Op]) {
|
|||||||
for e in ends() {
|
for e in ends() {
|
||||||
let x = balance(&store, EXPENSE, e);
|
let x = balance(&store, EXPENSE, e);
|
||||||
assert!(
|
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}"
|
"{ops:?} then every month: {x} on {e}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
//! SQLite projection agreeing with the journal (`Store::verify`), books
|
//! SQLite projection agreeing with the journal (`Store::verify`), books
|
||||||
//! written before tags existed, refusals journaling nothing, and corrupt
|
//! written before tags existed, refusals journaling nothing, and corrupt
|
||||||
//! `asset.serial_set` lines refused on open; and tax basis (B-25:
|
//! `asset.serial_set` lines refused on open; and tax basis (B-25:
|
||||||
//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the
|
//! `asset.basis_set`, pre-P3 `asset.tax_basis_set` {asset_id, rule,
|
||||||
//! v10 schema, corrupt payloads). A database at an older schema under this
|
//! contributor_basis | null} replayed, the v10 schema and v13's, corrupt
|
||||||
//! version's file name is rebuilt, never migrated (each schema keeps a file
|
//! and newer payloads). A database at an older schema under this
|
||||||
//! of its own: rules.md B-27, tests/schema_files.rs).
|
//! 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`,
|
//! Expectations come from the doc comments on `Store::set_asset_tag`,
|
||||||
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
|
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
|
||||||
@@ -90,7 +92,7 @@ fn laptop(store: &mut Store, a: &Accts, name: &str) -> AssetId {
|
|||||||
store
|
store
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: name.to_string(),
|
description: name.to_string(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: usd("1200.00"),
|
cost: usd("1200.00"),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -571,98 +573,123 @@ fn a_description_change_replays_and_verifies() {
|
|||||||
|
|
||||||
// ------------------------------------------------- tax basis (rules.md B-25)
|
// ------------------------------------------------- tax basis (rules.md B-25)
|
||||||
//
|
//
|
||||||
// Expectations from `Store::set_asset_tax_basis` (journaled as
|
// Expectations from `Store::set_asset_basis` (journaled as
|
||||||
// `asset.tax_basis_set`), `Ledger::set_asset_tax_basis` INVARIANTS and
|
// `asset.basis_set`, the feature `asset_basis` declared first),
|
||||||
// SCENARIOS, and the schema v10 migration comment (tax_rule and
|
// `Ledger::set_asset_basis` INVARIANTS and SCENARIOS, the replay of
|
||||||
// contributor_basis as TEXT, exact decimal).
|
// 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 {
|
fn dec(s: &str) -> rust_decimal::Decimal {
|
||||||
ledger_core::TaxBasis {
|
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,
|
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<ledger_core::TaxBasis> {
|
fn basis_of(s: &Store, id: AssetId) -> Option<ledger_core::AssetBasis> {
|
||||||
s.ledger().get_asset(id).unwrap().tax
|
s.ledger().get_asset(id).unwrap().basis.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Journaled as `asset.tax_basis_set` {asset_id, rule, contributor_basis}:
|
/// Journaled as `asset.basis_set` {asset_id, rule, contributor_adjusted_basis,
|
||||||
/// the basis a decimal string, or null for a purchase.
|
/// 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]
|
#[test]
|
||||||
fn tax_basis_journals_asset_tax_basis_set() {
|
fn a_basis_journals_asset_basis_set() {
|
||||||
use ledger_core::TaxBasisRule::*;
|
use ledger_core::BasisRule::*;
|
||||||
let (dir, mut s, _, x, y) = fixture("journal-tax");
|
let (dir, mut s, _, x, y) = fixture("journal-tax");
|
||||||
s.set_asset_tax_basis(x, tax(Cost, None)).unwrap();
|
s.set_asset_basis(x, ledger_core::AssetBasis::purchase())
|
||||||
s.set_asset_tax_basis(y, tax(PersonalUseConversion, Some("1500.25")))
|
|
||||||
.unwrap();
|
|
||||||
s.set_asset_tax_basis(x, tax(Carryover, Some("800.00")))
|
|
||||||
.unwrap();
|
.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 j = journal(&dir);
|
||||||
|
let with_feature = |p: &Value| {
|
||||||
|
p["features"]
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|f| f.contains(&json!("asset_basis")))
|
||||||
|
};
|
||||||
|
let declared: Vec<usize> = 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
|
let set: Vec<&Value> = j
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(t, _)| t == "asset.tax_basis_set")
|
.filter(|(t, _)| t == "asset.basis_set")
|
||||||
.map(|(_, p)| p)
|
.map(|(_, p)| p)
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(set.len(), 3);
|
assert_eq!(set.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
set[0],
|
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!(
|
assert_eq!(
|
||||||
b.parse::<rust_decimal::Decimal>().unwrap(),
|
set[1],
|
||||||
"1500.25".parse().unwrap()
|
&json!({"asset_id": y, "rule": "personal_use_conversion",
|
||||||
);
|
"contributor_adjusted_basis": "1500.25", "fmv_at_contribution": "1200.00",
|
||||||
assert_eq!(set[2]["rule"], "carryover");
|
"sec704c_method": "traditional", "source": null,
|
||||||
let b = set[2]["contributor_basis"]
|
"tax_basis_723": "1500.25", "depreciable_basis": "1200.00",
|
||||||
.as_str()
|
"built_in_gain_704c": "-300.25"})
|
||||||
.expect("a decimal string");
|
|
||||||
assert_eq!(
|
|
||||||
b.parse::<rust_decimal::Decimal>().unwrap(),
|
|
||||||
"800.00".parse().unwrap()
|
|
||||||
);
|
|
||||||
// Set again replaces (the journal keeps both).
|
|
||||||
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("800.00"))));
|
|
||||||
assert_eq!(
|
|
||||||
s.ledger().get_asset(y).unwrap().tax_basis(),
|
|
||||||
Some("1200.00".parse().unwrap()),
|
|
||||||
"lesser of the 1200.00 book cost and the 1500.25 basis"
|
|
||||||
);
|
);
|
||||||
|
assert_eq!(basis_of(&s, y), Some(b));
|
||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A refused tax basis (Ledger::set_asset_tax_basis INVARIANT 3) journals
|
/// A refused basis (Ledger::set_asset_basis INVARIANT 3) journals nothing
|
||||||
/// nothing and changes nothing, now or on reopen.
|
/// (not even the feature declaration) and changes nothing, now or on
|
||||||
|
/// reopen. A contribution's value is required.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_refused_tax_basis_journals_nothing() {
|
fn a_refused_basis_journals_nothing() {
|
||||||
use ledger_core::TaxBasisRule::*;
|
use ledger_core::BasisRule::*;
|
||||||
let (dir, mut s, _, x, _) = fixture("refused-tax");
|
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 lines = journal(&dir).len();
|
||||||
let before = s.ledger().clone();
|
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 [
|
for bad in [
|
||||||
tax(Cost, Some("100.00")),
|
purchase_with_basis,
|
||||||
tax(Carryover, None),
|
no_fmv,
|
||||||
tax(PersonalUseConversion, None),
|
legacy(PersonalUseConversion, None),
|
||||||
tax(Carryover, Some("-0.01")),
|
contribution(Carryover723, "-0.01", "1.00"),
|
||||||
tax(PersonalUseConversion, Some("10.001")),
|
contribution(PersonalUseConversion, "10.001", "1.00"),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
s.set_asset_tax_basis(x, bad),
|
s.set_asset_basis(x, bad.clone()),
|
||||||
Err(StoreError::Ledger(LedgerError::InvalidTaxBasis(_)))
|
Err(StoreError::Ledger(LedgerError::InvalidTaxBasis(_)))
|
||||||
),
|
),
|
||||||
"{bad:?}"
|
"{bad:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(matches!(
|
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(_)))
|
Err(StoreError::Ledger(LedgerError::UnknownAsset(_)))
|
||||||
));
|
));
|
||||||
assert_eq!(journal(&dir).len(), lines, "nothing journaled");
|
assert_eq!(journal(&dir).len(), lines, "nothing journaled");
|
||||||
@@ -674,18 +701,27 @@ fn a_refused_tax_basis_journals_nothing() {
|
|||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tax basis replays on reopen, with the database and rebuilt from the
|
/// A basis replays on reopen, with the database and rebuilt from the
|
||||||
/// journal alone, and verifies each time.
|
/// journal alone, and in the viewer, and verifies each time (its facts and
|
||||||
|
/// source included).
|
||||||
#[test]
|
#[test]
|
||||||
fn tax_basis_replays_on_reopen() {
|
fn asset_basis_set_journals_replays_and_reloads() {
|
||||||
use ledger_core::TaxBasisRule::*;
|
use ledger_core::BasisRule::*;
|
||||||
let (dir, mut s, a, x, y) = fixture("replay-tax");
|
let (dir, mut s, a, x, y) = fixture("replay-tax");
|
||||||
let z = laptop(&mut s, &a, "Laptop C");
|
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();
|
.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();
|
.unwrap();
|
||||||
s.set_asset_tax_basis(y, tax(Cost, None)).unwrap();
|
|
||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
let live = s.ledger().clone();
|
let live = s.ledger().clone();
|
||||||
drop(s);
|
drop(s);
|
||||||
@@ -696,34 +732,223 @@ fn tax_basis_replays_on_reopen() {
|
|||||||
let s = Store::open(&dir).unwrap();
|
let s = Store::open(&dir).unwrap();
|
||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
assert_eq!(s.ledger(), &live, "rebuild={rebuild}");
|
assert_eq!(s.ledger(), &live, "rebuild={rebuild}");
|
||||||
assert_eq!(
|
assert_eq!(basis_of(&s, x), Some(b.clone()));
|
||||||
tax_of(&s, x),
|
assert_eq!(basis_of(&s, y), Some(ledger_core::AssetBasis::purchase()));
|
||||||
Some(tax(PersonalUseConversion, Some("2000.00")))
|
assert_eq!(basis_of(&s, z), None);
|
||||||
);
|
|
||||||
assert_eq!(tax_of(&s, y), Some(tax(Cost, None)));
|
|
||||||
assert_eq!(tax_of(&s, z), None);
|
|
||||||
let ax = s.ledger().get_asset(x).unwrap();
|
let ax = s.ledger().get_asset(x).unwrap();
|
||||||
assert_eq!(ax.tax_basis(), Some("1200.00".parse().unwrap()));
|
assert_eq!(ax.depreciable_basis(), Some(dec("1200.00")));
|
||||||
assert_eq!(ax.built_in_gain(), Some("-800.00".parse().unwrap()));
|
assert_eq!(ax.built_in_gain_704c(), Some(dec("-800.00")));
|
||||||
drop(s);
|
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,
|
/// Downgrade the database to schema v10 (none of v11's attachment links,
|
||||||
/// statements, reconciliations, or payroll_lines.employee_id).
|
/// statements, reconciliations, or payroll_lines.employee_id).
|
||||||
fn downgrade_db_to_v10(dir: &Path) {
|
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();
|
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"DROP TABLE attachment_links;
|
"DROP TABLE attachment_links;
|
||||||
DROP TABLE statements;
|
DROP TABLE statements;
|
||||||
DROP TABLE reconciliations;
|
DROP TABLE reconciliations;
|
||||||
DROP TABLE reconciliation_lines;
|
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;
|
ALTER TABLE payroll_lines DROP COLUMN employee_id;
|
||||||
UPDATE meta SET value = '10' WHERE key = 'schema_version';",
|
UPDATE meta SET value = '10' WHERE key = 'schema_version';",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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
|
/// 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.
|
/// from the journal as the books open (as the v8 one above), and verifies.
|
||||||
#[test]
|
#[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
|
/// 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]
|
#[test]
|
||||||
fn a_v9_database_migrates_and_verifies() {
|
fn a_v9_database_migrates_and_verifies() {
|
||||||
use ledger_core::TaxBasisRule::*;
|
use ledger_core::BasisRule::*;
|
||||||
let (dir, mut s, _, x, y) = fixture("v9");
|
let (dir, mut s, _, x, y) = fixture("v9");
|
||||||
s.set_asset_tag(x, "FL-0001").unwrap();
|
s.set_asset_tag(x, "FL-0001").unwrap();
|
||||||
drop(s);
|
drop(s);
|
||||||
@@ -764,21 +989,24 @@ fn a_v9_database_migrates_and_verifies() {
|
|||||||
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
|
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
|
||||||
assert_eq!(tax_of(&s, x), None);
|
assert_eq!(basis_of(&s, x), None);
|
||||||
s.set_asset_tax_basis(y, tax(Carryover, Some("640.00")))
|
s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.00"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
drop(s);
|
drop(s);
|
||||||
let s = Store::open(&dir).unwrap();
|
let s = Store::open(&dir).unwrap();
|
||||||
s.verify().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
|
/// A v9 database behind a journal with a tax basis line is rebuilt from
|
||||||
/// the whole journal, that line with it, and verifies.
|
/// the whole journal, that line with it, and verifies.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
|
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");
|
let (dir, s, _, x, _) = fixture("v9-behind");
|
||||||
drop(s);
|
drop(s);
|
||||||
downgrade_db_to_v9(&dir);
|
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");
|
let s = Store::open(&dir).expect("opens and catches up");
|
||||||
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
assert!(kept_aside(&dir), "rebuilt, not migrated");
|
||||||
s.verify().unwrap();
|
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
|
/// 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),
|
cost: usd(cost),
|
||||||
salvage_value: usd("0.00"),
|
salvage_value: usd("0.00"),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
};
|
};
|
||||||
let before = journal(&dir).len();
|
let before = journal(&dir).len();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -903,3 +1131,85 @@ fn reversing_a_linked_acquisition_still_opens_and_verifies() {
|
|||||||
drop(s);
|
drop(s);
|
||||||
Store::open(&dir).unwrap().verify().unwrap();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ fn van(store: &mut Store, dollars: u64, cost_acct: u64, accum_acct: u64, expense
|
|||||||
store
|
store
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Sprinter van".to_string(),
|
description: "Sprinter van".to_string(),
|
||||||
purchase_date: date(2026, 1, 15),
|
in_service_date: date(2026, 1, 15),
|
||||||
cost: usd(&dollars.to_string()),
|
cost: usd(&dollars.to_string()),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 60,
|
useful_life_months: 60,
|
||||||
@@ -230,7 +230,7 @@ fn rejected_asset_registration_journals_nothing() {
|
|||||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||||
let bad = NewAsset {
|
let bad = NewAsset {
|
||||||
description: "Bad".to_string(),
|
description: "Bad".to_string(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: usd("1000"),
|
cost: usd("1000"),
|
||||||
salvage_value: usd("1000"), // salvage >= cost: invalid
|
salvage_value: usd("1000"), // salvage >= cost: invalid
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -255,7 +255,7 @@ fn asset_x(store: &mut Store, cost: u64, accum: u64, expense: u64) -> u64 {
|
|||||||
store
|
store
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Asset X".to_string(),
|
description: "Asset X".to_string(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: usd("12000.00"),
|
cost: usd("12000.00"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 12,
|
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.
|
// The books' accumulated equals the disposed schedule's.
|
||||||
let a = store.ledger().get_asset(x).unwrap();
|
let a = store.ledger().get_asset(x).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
|
||||||
dec("11322.58")
|
dec("11322.58")
|
||||||
);
|
);
|
||||||
store.verify().unwrap();
|
store.verify().unwrap();
|
||||||
@@ -552,7 +552,7 @@ fn run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing() {
|
|||||||
let tiny = store
|
let tiny = store
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Cable".to_string(),
|
description: "Cable".to_string(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: usd("0.01"),
|
cost: usd("0.01"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 3,
|
useful_life_months: 3,
|
||||||
@@ -587,7 +587,7 @@ fn jpy_depreciation_runs_post_whole_yen() {
|
|||||||
let id = store
|
let id = store
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Lathe".to_string(),
|
description: "Lathe".to_string(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: yen("100000"),
|
cost: yen("100000"),
|
||||||
salvage_value: yen("0"),
|
salvage_value: yen("0"),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec<Subject> {
|
|||||||
let asset = s
|
let asset = s
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: date(2026, 1, 5),
|
in_service_date: date(2026, 1, 5),
|
||||||
cost: usd("3000.00"),
|
cost: usd("3000.00"),
|
||||||
salvage_value: usd("0"),
|
salvage_value: usd("0"),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ fn disposal_proceeds_larger_than_a_line_are_refused() {
|
|||||||
let id = s
|
let id = s
|
||||||
.register_asset(ledger_core::NewAsset {
|
.register_asset(ledger_core::NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: d(2026, 1, 1),
|
in_service_date: d(2026, 1, 1),
|
||||||
cost: usd(Decimal::new(240000, 2)),
|
cost: usd(Decimal::new(240000, 2)),
|
||||||
salvage_value: usd(Decimal::ZERO),
|
salvage_value: usd(Decimal::ZERO),
|
||||||
useful_life_months: 24,
|
useful_life_months: 24,
|
||||||
|
|||||||
@@ -1280,7 +1280,7 @@ fn a_write_waiting_for_the_upgrade_journals_nothing() {
|
|||||||
let lathe = |s: &mut Store, what: &str| {
|
let lathe = |s: &mut Store, what: &str| {
|
||||||
s.register_asset(ledger_core::NewAsset {
|
s.register_asset(ledger_core::NewAsset {
|
||||||
description: what.into(),
|
description: what.into(),
|
||||||
purchase_date: day(2026, 1, 1),
|
in_service_date: day(2026, 1, 1),
|
||||||
cost: usd(1_200_000),
|
cost: usd(1_200_000),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ fn inv5_refused_disposal_writes_nothing() {
|
|||||||
.store
|
.store
|
||||||
.register_asset(NewAsset {
|
.register_asset(NewAsset {
|
||||||
description: "Laptop".into(),
|
description: "Laptop".into(),
|
||||||
purchase_date: date(2026, 1, 1),
|
in_service_date: date(2026, 1, 1),
|
||||||
cost: usd(3_600),
|
cost: usd(3_600),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 36,
|
useful_life_months: 36,
|
||||||
|
|||||||
@@ -1365,7 +1365,7 @@ mod refusals {
|
|||||||
Op::RegisterAsset(c, d, x, cost, m) => store
|
Op::RegisterAsset(c, d, x, cost, m) => store
|
||||||
.register_asset(ledger_core::NewAsset {
|
.register_asset(ledger_core::NewAsset {
|
||||||
description: "van".into(),
|
description: "van".into(),
|
||||||
purchase_date: day(0),
|
in_service_date: day(0),
|
||||||
cost: usd(cost),
|
cost: usd(cost),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: m,
|
useful_life_months: m,
|
||||||
@@ -1448,7 +1448,7 @@ mod refusals {
|
|||||||
Op::GoodAsset(c, m) => store
|
Op::GoodAsset(c, m) => store
|
||||||
.register_asset(ledger_core::NewAsset {
|
.register_asset(ledger_core::NewAsset {
|
||||||
description: "machine".into(),
|
description: "machine".into(),
|
||||||
purchase_date: day(0),
|
in_service_date: day(0),
|
||||||
cost: usd(c),
|
cost: usd(c),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: m,
|
useful_life_months: m,
|
||||||
@@ -1939,7 +1939,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
|||||||
let dates: Vec<NaiveDate> = (1..l.next_entry_id())
|
let dates: Vec<NaiveDate> = (1..l.next_entry_id())
|
||||||
.filter_map(|id| l.get_entry(id))
|
.filter_map(|id| l.get_entry(id))
|
||||||
.map(|e| e.date())
|
.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.list_periods().iter().flat_map(|p| [p.start, p.end]))
|
||||||
.chain(l.schedules().map(|s| s.start))
|
.chain(l.schedules().map(|s| s.start))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1995,7 +1995,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
|||||||
}
|
}
|
||||||
for a in l.registered_assets() {
|
for a in l.registered_assets() {
|
||||||
tally(ledger_core::depreciation_schedule(a, d).map(|_| ()));
|
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(
|
tally(
|
||||||
ledger_core::disposal_amounts(
|
ledger_core::disposal_amounts(
|
||||||
a,
|
a,
|
||||||
@@ -2253,7 +2253,7 @@ fn scrapping_an_asset_for_nothing_disposes_it() {
|
|||||||
let asset = store
|
let asset = store
|
||||||
.register_asset(ledger_core::NewAsset {
|
.register_asset(ledger_core::NewAsset {
|
||||||
description: "laptop".into(),
|
description: "laptop".into(),
|
||||||
purchase_date: day(1, 1),
|
in_service_date: day(1, 1),
|
||||||
cost: usd(1200),
|
cost: usd(1200),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
@@ -2442,7 +2442,7 @@ fn laptop(dir: &std::path::Path) -> (Store, u64, u64, u64) {
|
|||||||
let asset = store
|
let asset = store
|
||||||
.register_asset(ledger_core::NewAsset {
|
.register_asset(ledger_core::NewAsset {
|
||||||
description: "laptop".into(),
|
description: "laptop".into(),
|
||||||
purchase_date: day(1, 1),
|
in_service_date: day(1, 1),
|
||||||
cost: usd(1200),
|
cost: usd(1200),
|
||||||
salvage_value: usd(0),
|
salvage_value: usd(0),
|
||||||
useful_life_months: 12,
|
useful_life_months: 12,
|
||||||
|
|||||||
@@ -30,3 +30,35 @@ definitions exactly.
|
|||||||
into retained earnings so the new period starts clean. (Future: R-FUTURE-2.)
|
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
|
- **Current earnings** — Net income to date on an unclosed book. We fold it
|
||||||
into equity so the accounting equation holds before close.
|
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).
|
||||||
+99
-18
@@ -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
|
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.
|
life's last day is 9999-12-31 at the latest.
|
||||||
|
|
||||||
- `books.register_asset { description, purchase_date, cost, salvage_value?,
|
- `books.register_asset { description, in_service_date, cost,
|
||||||
useful_life_months, cost_account, accum_depr_account, expense_account,
|
salvage_value?, useful_life_months, cost_account, accum_depr_account,
|
||||||
tag?, serial? }` → `{ asset_id, tag }`. Accounts resolved by id or code.
|
expense_account, tag?, serial?, acquisition_entry?, basis_rule?,
|
||||||
Without `tag`, the next free `PREFIX-0001` (`[books] asset_tag_prefix`,
|
contributor_adjusted_basis?, fmv_at_contribution?, sec704c_method?,
|
||||||
else the company name's initials without LLC/Inc; "FA" when none).
|
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,
|
- `books.label_asset { asset, tag?, serial?, clear_serial? }` → `{ asset_id,
|
||||||
tag, serial }`: set or change the tag (unique ignoring case, never reused
|
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
|
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
|
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
|
version's asset can't compute is a tool error saying why, never a
|
||||||
panic.
|
panic.
|
||||||
- `books.list_assets {}` → assets with status, accumulated depreciation
|
- `books.list_assets { as_of? }` → `{ as_of, assets }` (default today, the
|
||||||
by schedule and book value as of today (a disposed asset's through its
|
company's time zone): per asset `in_service_date`, `acquired_date`,
|
||||||
disposal day); `accumulated` and `book_value` are null, with
|
`cost`, `status`, `held` (acquired by `as_of` and not disposed),
|
||||||
`schedule_error` saying why, for an asset whose schedule can't be
|
`accumulated_posted` (what the books carry: its depreciation runs, less
|
||||||
computed (one bad asset never fails the list).
|
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? }`
|
- `books.run_depreciation { period: "YYYY-MM", asset_id? }`
|
||||||
→ `runs: [{ asset_id, description, period, amount, caught_up, entry_id,
|
→ `runs: [{ asset_id, description, period, amount, caught_up, entry_id,
|
||||||
already_posted }]` and `skipped: [{ asset_id, description, reason }]`.
|
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
|
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.
|
computed. A run before the in-service date is refused.
|
||||||
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
|
- `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,
|
without confirmation: `{ status: "preview", accumulated, book_value,
|
||||||
proceeds, gain_loss, final_depreciation, excess_depreciation,
|
proceeds, gain_loss, final_depreciation, excess_depreciation,
|
||||||
catch_up_depreciation, lines, receipt_required }` and posts nothing;
|
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
|
posts (`party_required`); a retirement with no proceeds and no buyer is
|
||||||
marked as having no counterparty.
|
marked as having no counterparty.
|
||||||
- `books.export { report: "fixed_asset_register", format: json | csv,
|
- `books.export { report: "fixed_asset_register", format: json | csv,
|
||||||
as_of? }`: every asset registered by the date (tag, serial, cost,
|
as_of? }`: every asset acquired by the date (tag, serial, acquired and
|
||||||
depreciation by schedule, book value, disposal), totals, and the
|
in-service dates, cost, accumulated depreciation scheduled and posted,
|
||||||
general ledger's balances of the assets' cost and accumulated
|
book value as cost less posted, disposal, the basis facts and figures:
|
||||||
depreciation accounts, with `unposted_depreciation` and
|
rule, contributor's adjusted basis, value at contribution and whether
|
||||||
`unregistered_cost` (zero when register and books agree;
|
it was recorded, §723 basis, depreciable basis, §704(c) built-in gain,
|
||||||
`ledger_core::fixed_asset_register`).
|
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"`
|
- `books.show { asset }` opens an asset in the viewer; `page: "assets"`
|
||||||
and `report: "fixed_asset_register"` open the register.
|
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
|
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
|
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 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-<year>.csv`, `as_of`'s calendar
|
**1099 worksheet** (`1099-worksheet-<year>.csv`, `as_of`'s calendar
|
||||||
year) is `books.vendor_1099`'s CSV (above) for the package's cash and
|
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
|
financing accounts: the payments from January 1 through `as_of` (the
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
| 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` |
|
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
||||||
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
| 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) |
|
| 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 |
|
| 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 |
|
| Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
|
||||||
|
|||||||
+181
-44
@@ -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
|
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
|
corrects, B-9: a run of March reversed on Jan 31 would leave January
|
||||||
and February with negative depreciation; commands only, journals
|
and February with negative depreciation; commands only, journals
|
||||||
replay as written). The viewer and the store read every run the
|
replay as written). The viewer and the store read every run the books
|
||||||
journal holds, not a month's latest (a run re-instated after its month
|
recorded (`Ledger::depreciation_runs`: each `asset.depreciation_posted`,
|
||||||
was run again and that run reversed is the month's run).
|
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`,
|
**Enforced:** `assets::depreciation_schedule`, `assets::run_amount`,
|
||||||
`assets::catch_up_room`, `assets::depreciation_effects`,
|
`assets::catch_up_room`, `assets::depreciation_effects`,
|
||||||
`kernel::prorated_through`, `kernel::catch_up`, `kernel::true_up`,
|
`kernel::prorated_through`, `kernel::catch_up`, `kernel::true_up`,
|
||||||
`calendar::month_parts`, `Store::run_depreciation`,
|
`calendar::month_parts`, `Store::run_depreciation`,
|
||||||
`Store::dispose_preview` (`check_nothing_after_disposal`),
|
`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`,
|
`schedule_sums_exactly_to_depreciable_amount`,
|
||||||
`salvage_value_is_never_depreciated`, `prop_depreciation_schedule_exact`,
|
`salvage_value_is_never_depreciated`, `prop_depreciation_schedule_exact`,
|
||||||
`defect22_depreciation_through_mid_final_month_is_prorated_not_plugged`,
|
`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`,
|
Lean `Prorate.kernel_parts_total`, `Prorate.kernel_parts_nonneg`,
|
||||||
`Prorate.part_near`, `Prorate.cum_near`, `Prorate.chargeMid_eq_cum`,
|
`Prorate.part_near`, `Prorate.cum_near`, `Prorate.chargeMid_eq_cum`,
|
||||||
`Prorate.chargeLast_eq_cum`, `Prorate.chargeLast_start`,
|
`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
|
## 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
|
`- _ . /`), and the manufacturer's serial number. Tags are unique ignoring
|
||||||
case and are never reused by another asset, even after a retag or a
|
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
|
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
|
journaled. The fixed asset register lists every asset acquired by a date
|
||||||
reconciles to the general ledger's cost and accumulated depreciation
|
(B-15) and reconciles to the general ledger's cost and accumulated
|
||||||
accounts.
|
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`,
|
**Enforced:** `Ledger::set_asset_tag` / `check_asset_tag_for`,
|
||||||
`assets::check_asset_tag`, `assets::check_serial`. **Checked:**
|
`assets::check_asset_tag`, `assets::check_serial`. **Checked:**
|
||||||
`ledger-core` `tests/spec_asset_tags.rs`; `ledger-store`
|
`ledger-core` `tests/spec_asset_tags.rs`; `ledger-store`
|
||||||
`tests/asset_tags.rs`; `ledger-mcp` `tests/spec_assets.rs`.
|
`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
|
**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.
|
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
|
Reg. §1.704-1(b)(2)(iv)(d)(1), cited; Treas. Reg. §1.704-3(a)(3)(i):
|
||||||
rule that applies: property the company buys has a cost basis ("The basis
|
"book value is equal to fair market value at the time of contribution").
|
||||||
of property shall be the cost of such property", IRC §1012(a)); property a
|
Three tax figures, each named for what it is, follow from the rule and
|
||||||
member contributes keeps "the adjusted basis of such property to the
|
the facts recorded (`asset.basis_set`: the rule, the contributing
|
||||||
contributing partner at the time of the contribution" (IRC §723,
|
member's adjusted basis, the fair market value at contribution, the
|
||||||
carryover); property a member converts from personal use depreciates from
|
§704(c) method if chosen, and who gave them, B-29):
|
||||||
"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`).
|
|
||||||
|
|
||||||
**Enforced:** `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`
|
- **Purchase** (`purchase`): "The basis of property shall be the cost of
|
||||||
(cost takes no contributor basis, a contribution requires one,
|
such property" (IRC §1012(a)); depreciable basis is the cost. Not
|
||||||
non-negative, in cents), `Asset::tax_basis`, `Asset::built_in_gain`,
|
§704(c) property: no §723 basis and no built-in gain.
|
||||||
`reports::fixed_asset_register` (per-row tax fields, `total_tax_basis`,
|
- **§723 basis** of contributed property: "the adjusted basis of such
|
||||||
`missing_tax_basis`). **Checked:** `ledger-core` `tests/spec_tax_basis.rs`;
|
property to the contributing partner at the time of the contribution"
|
||||||
`ledger-store` `tests/asset_tags.rs` (`asset.tax_basis_set` journaling,
|
(IRC §723; Pub. 541: "the partnership's basis for determining
|
||||||
replay, v9 migration, corrupt payloads); `ledger-mcp` `tests/spec_assets.rs`.
|
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
|
## 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),
|
effect, and `vendor.w9_flag_set`: defects 18 and 23, D-40, B-19),
|
||||||
`schedule_convention` (a daily schedule's `schedule.registered` carries
|
`schedule_convention` (a daily schedule's `schedule.registered` carries
|
||||||
`convention: daily`; an older version would read it as equal months,
|
`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
|
posted again after a reversal un-posted it; an older version would refuse
|
||||||
the second `schedule.posted`, and its projection ignore the second
|
the second `schedule.posted`, and its projection ignore the second
|
||||||
`asset.depreciation_posted`, B-15). A command asks for the feature it
|
`asset.depreciation_posted`, B-15) and `asset_basis` (an
|
||||||
needs (`Store::ensure_feature`) before its first journaled line, and only
|
`asset.basis_set`, a new event type: an asset's basis facts with the
|
||||||
when the line it is about to write uses it, so a write refused for
|
value at contribution and the §704(c) method, B-25). A command asks for
|
||||||
waiting on the upgrade journals nothing, never an entry without the line
|
the feature it needs (`Store::ensure_feature`) before its first journaled
|
||||||
that says what it is (`a_write_waiting_for_the_upgrade_journals_nothing`),
|
line, and only when the line it is about to write uses it, so a write
|
||||||
and a write that needs nothing new is never refused. A tool journaling
|
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
|
several commands as one asks before the first of them, never between
|
||||||
them (a refusal there would leave the tool's work half done):
|
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
|
`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,
|
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
|
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
|
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`,
|
`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
|
`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
|
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
|
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`,
|
`schedules::validate_schedule`, `validate_recurring`,
|
||||||
`periods::validate_period_range`, `arap::validate_lines`,
|
`periods::validate_period_range`, `arap::validate_lines`,
|
||||||
`validate_due_date`, `payroll::check_payroll_ranges`, `payout_plan`,
|
`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, `replay_add_line`, `replay_add_line_fx`,
|
||||||
`replay_register_asset`, `replay_correct_asset`,
|
`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_set_entry_parties`, `replay_register_recurring`,
|
||||||
`replay_register_schedule` (`validate_schedule_shape`),
|
`replay_register_schedule` (`validate_schedule_shape`),
|
||||||
`replay_create_period` (`validate_period_order`), `replay_invoice`,
|
`replay_create_period` (`validate_period_order`), `replay_invoice`,
|
||||||
`replay_bill`, `replay_invoice_payment`, `replay_bill_payment`,
|
`replay_bill`, `replay_invoice_payment`, `replay_bill_payment`,
|
||||||
`replay_payroll_run`, `validate_new_asset_journaled`,
|
`replay_payroll_run`, `validate_new_asset_journaled`,
|
||||||
`validate_recurring_journaled`, `check_shares_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`,
|
`Bill::total`/`balance`, `AgingRow::total`, `ar_aging`, `ap_aging`,
|
||||||
`PayrollLine::net_pay`, `payroll_entry_plan`, `parties::entry_flow`,
|
`PayrollLine::net_pay`, `payroll_entry_plan`, `parties::entry_flow`,
|
||||||
`shares::share_target`, `shares::resolve`, `fixed_asset_register`,
|
`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`,
|
`reverseTarget_involutive`, `capitalTarget_odd`, `partyTarget_odd`,
|
||||||
`sumOver_movement_reversal`; `ledger-store` `tests/party_creditors.rs`;
|
`sumOver_movement_reversal`; `ledger-store` `tests/party_creditors.rs`;
|
||||||
`ledger-mcp` `tests/spec_party_totals.rs`; `ledger-gui` `tests/parties.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`).
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Tax basis of fixed assets: cost, contributed property, and personal-use conversion
|
# 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
|
## 26 U.S.C. §1012(a): basis is cost
|
||||||
Source: <https://www.law.cornell.edu/uscode/text/26/1012>
|
Source: <https://www.law.cornell.edu/uscode/text/26/1012>
|
||||||
@@ -38,3 +38,67 @@ Retrieved: 2026-10-15
|
|||||||
SHA-256 of the retrieved file (`ecfr168i4.html`): `284efb83eeb11442ab86e050f6d630bd468fd75463e27e3ec81dbc8de455e9be`
|
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.
|
> 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: <https://www.law.cornell.edu/uscode/text/26/1016>
|
||||||
|
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: <https://www.ecfr.gov/api/renderer/v1/content/enhanced/current/title-26?part=1§ion=1.704-3>
|
||||||
|
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: <https://www.irs.gov/publications/p541>
|
||||||
|
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: <https://www.irs.gov/publications/p946>
|
||||||
|
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.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Partnerships: shares of income and capital accounts
|
# 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)
|
## 26 U.S.C. §704(a)-(b)
|
||||||
Source: <https://www.law.cornell.edu/uscode/text/26/704>
|
Source: <https://www.law.cornell.edu/uscode/text/26/704>
|
||||||
@@ -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)
|
**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.
|
> 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: <https://www.irs.gov/instructions/i1065>
|
||||||
|
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: <https://www.irs.gov/pub/irs-prior/i1065--2020.pdf>
|
||||||
|
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.
|
||||||
+87
-1
@@ -25,7 +25,7 @@ promise.
|
|||||||
| Differential: GnuCash | another implementation, adjudicated (3) | `tests/oracle_gnucash.rs`, `oracle/gnucash/` | `just oracle` | ~2 min | weekly |
|
| 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 |
|
| 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 <target> <secs>` | 10 min each | 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 <target> <secs>` | 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 |
|
| 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) |
|
| 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 |
|
| 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`,
|
`upgrade`, `book_line`, `upgraded_lines`, `upgrade_explained`,
|
||||||
`ASKED_UPGRADES`). None run.
|
`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 `"<member>.<line>"`, 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)
|
## Benchmarks (baseline 2026-10-13)
|
||||||
|
|
||||||
No budget, by decision (the owner, 2026-10-13: the books do little work
|
No budget, by decision (the owner, 2026-10-13: the books do little work
|
||||||
|
|||||||
@@ -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
|
1-to-3-month span of two calendar years, and the bridge and the exact
|
||||||
unit tests check the rest.
|
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
|
## What the proofs found
|
||||||
|
|
||||||
The engine already had 190+ tests, property tests, mutation testing,
|
The engine already had 190+ tests, property tests, mutation testing,
|
||||||
|
|||||||
+3
-3
@@ -519,7 +519,7 @@ Report results also carry `as_of_status {drafts_open_on_or_before, unreconciled,
|
|||||||
- `cash_flow`: `reconciles`, `noncash_disclosures`, `operating_reconciliation`.
|
- `cash_flow`: `reconciles`, `noncash_disclosures`, `operating_reconciliation`.
|
||||||
- `vendor_1099`: instrument-aware; `include_tins` and `save_csv_to` dropped.
|
- `vendor_1099`: instrument-aware; `include_tins` and `save_csv_to` dropped.
|
||||||
- `set_vendor_1099` takes an explicit `not_reportable` plus a reason.
|
- `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_assets`: `accumulated_posted` and `accumulated_scheduled_through`.
|
||||||
- `list_schedules`: start, end, convention, next, remaining.
|
- `list_schedules`: start, end, convention, next, remaining.
|
||||||
- `parties`: last four from `party.tin_set`.
|
- `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` |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `schedule.registered` + `convention` (absent = `monthly_even`) | P3 | schedules |
|
||||||
| `vendor.1099_set` value `not_reportable{reason}`; legacy `null` replays as `undetermined` | P3 | vendors |
|
| `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 |
|
| `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));
|
- `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;
|
- `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`.
|
- `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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user