diff --git a/Cargo.lock b/Cargo.lock index 1a04694..8f82173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3867,10 +3867,12 @@ dependencies = [ "chrono", "criterion", "jiff", + "ledger-store", "proptest", "rust_decimal", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "toml 1.1.8+spec-1.1.0", ] diff --git a/bookspec/README.md b/bookspec/README.md index 70b2ea6..c36cca5 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -39,7 +39,7 @@ code = "1000" name = "Cash" type = "asset" # asset | liability | equity | revenue | expense -[[events]] # in order; event N is entry #N +[[events]] # in order, counting from 1 date = "2026-01-10" description = "Property contributed" lines = [ @@ -50,8 +50,9 @@ lines = [ [[events]] date = "2026-01-10" description = "Reverse entry 1" -reverses = 1 # the event (1-based) this one reverses: - # its lines, sides flipped; no `lines` here +reverses = 1 # the event (1-based) whose entry this + # one reverses: its lines, sides flipped; + # no `lines` here [[expect]] query = "cash_flow" # what is asked of the books (see below) @@ -62,14 +63,29 @@ to = "2026-12-31" # inclusive (a query as of a day: as_of) operating = "0.00" ``` -Events count from 1, as entries do: the first `[[events]]` is event 1 -(entry #1), and `reverses = N` reverses the Nth event, one before it; -`reverses = 0` names no event and is refused. An event is an entry -(`lines`), a reversal (`reverses`), a close (`close`, below) or a bill -(`bill`, below), exactly one of them, and posts entry #N. A change that -posts no entry (a rename, a policy) is a `[[changes]]` entry instead -(below), so the numbering holds. A key the shape doesn't have is refused -too, never ignored. +Events count from 1: the first `[[events]]` is event 1. An event is an +entry (`lines`), a reversal (`reverses`), a close (`close`, below) or a +bill (`bill`, below), each posting one entry; or one of the books' +commands that post what is due (`run_depreciation`, `dispose_asset`, +`run_schedules`, below), which post as many entries as are due, none +included; or a schedule's registration (`register_schedule`), which +posts none: exactly one of them. A key that names an event (`reverses = +N`, `entry = N`, a cash flow's ``) names the entry event N posted, one +before it: an event that posted none, or several, can't be named that +way, and `reverses = 0` names no event and is refused. While every event +posts one entry, event N's is entry #N. A change that posts no entry (a +rename, a policy) is a `[[changes]]` entry instead (below). A key the +shape doesn't have is refused too, never ignored. + +An entry, a reversal, a close, a bill and a disposal are dated (`date`); +the other commands date their own entries (a run its month's end) and +take no date. An entry, a reversal, a close and a bill are described +(`description`); a command describes its own entries and takes none. + +The books are the engine's own (`ledger_store::Store`, in a directory of +their own): each event is the command the books take it by, journaled as +they journal it, and after every expectation is checked the books must +replay to what they kept (`Store::verify`). `rounding` names the rule the example's figures are rounded by where one rounds: `half-even` or `half-away-from-zero` to the currency's minor unit, @@ -113,6 +129,34 @@ the side gives the direction. expense_account }`: accounts that hold property and equipment (their cost and accumulated depreciation are investing; ASC 230-10-45-12(c), 45-13(c)). +- `[[setup.assets]]`: fixed assets registered before the first event, + each named by its `id` in the events and queries that ask about it + (rules.md B-15): + + ```toml + [[setup.assets]] + id = "X" # its name in this example + description = "Asset X" + cost = "12000.00" + salvage = "0.00" + life_months = 12 + in_service = "2026-01-01" # depreciation starts here + method = "straight_line" # the one method the books have + cost_account = "1500" + accumulated_account = "1510" + expense_account = "6200" + ``` + + Registering an asset posts nothing: the entry that buys it is an event + of its own. +- `[[setup.schedules]]`, and `register_schedule = { ... }` as an event: a + prepaid expense or deferred revenue recognized over its months (rules.md + B-16), named by its `id`: + `{ id, kind, description, total, start, months, convention, + balance_account, pl_account }`, `kind` `"prepaid_expense"` or + `"deferred_revenue"`, `convention` `"daily"` (by the day, the last month + taking what the others leave) or `"monthly_even"` (equal months from + the 1st). Registering one posts nothing. - `[[setup.parties]]` `{ name, roles, creditor_of?, tin_last4?, w9_tin_last4?, tin_purged? }`: the company's directory (B-26). `roles` are role keys (`member`, `vendor`, `customer`, `contractor`, @@ -153,8 +197,8 @@ the side gives the direction. liability is a card account (B-35 item 5): while none is recorded, a liability whose name says "card", not "gift card", is. - `payee` on an event: the payee (by name, one of `setup.payees`) the - entry paid. Not on a reversal, which takes its original's, nor on a - close. + entry paid. Only on an entry (`lines`): a reversal takes its + original's, a bill names its vendor, a close and a command have none. - `parties = [{ party, amount? }, { party, percent? }, ...]` on an event: the parties (by name, in `setup.parties`) the entry is with, each with its share: an `amount` (an exact decimal string), a `percent`, or @@ -164,7 +208,7 @@ the side gives the direction. `payee` as well, the payee's party is one of the parties. - `instrument` on an event: the payment method (a `setup.instruments` label) the entry was paid with. Neither `parties` nor `instrument` on a - reversal, which takes its original's. + reversal, which takes its original's, nor on a command. - `close = { start, to, rounding? }` as an event: closes the period from `start` through the event's `date` (rules.md B-11, B-13), `to` `"members"` (split by share, in the order listed) or @@ -173,13 +217,28 @@ the side gives the direction. have closed less what earlier closes gave them; `rounding = "per-close"` splits that close's net on its own instead (books from an engine that rounded each close by itself). A close must have something to close: it - posts entry #N like any other event. + posts one entry like any other event. - `bill = { vendor, amount }` as an event: a bill from `vendor` (a name; a payee's record if one has it) for `amount`, dated and due on the - event's date. It posts entry #N: General Expenses (6999) debited and + event's date. It posts one entry: General Expenses (6999) debited and Accounts Payable (2000) credited, each account created with that fixed name when first needed, as the books create a control role's account (B-32). +- `run_depreciation = { asset, period }` as an event: the asset's run for + `period` (`"YYYY-MM"`), which posts what the schedule says is due + through the month's end and the books don't carry, dated then: a + skipped month caught up, nothing when nothing is due (rules.md B-15). +- `dispose_asset = { asset, proceeds, proceeds_account, gain_loss_account + }` as an event, dated: the asset's disposal for `proceeds` (an exact + decimal, zero allowed), received into `proceeds_account`, its gain or + loss to `gain_loss_account` (B-15). It posts the depreciation still due + through its day in an entry of its own when the month has no run, then + the disposal entry. +- `run_schedules = { through }` as an event: every schedule's months due + through `through` posted (B-16). +- `refused = ""` on a reversal or a command: the books refuse it, + for that reason (below); it changes nothing, posts nothing, and the + example goes on without it. - `[[changes]]` `{ after, rename?, require_tax_category? }`: a change that posts no entry, exactly one of `rename = { code, name }` (rename the account `code`, B-31) and `require_tax_category = "warn"` or @@ -213,7 +272,7 @@ answers is refused, never skipped. | `cash_flow` | `from`, `to` | figures (below) | `ledger-core` `tests/bookspec.rs` | | `trial_balance` | `as_of` | each account's balance by code, debits less credits (a credit balance negative), every account listed | `ledger-core` `tests/bookspec.rs` | | `profit_and_loss` | `from`, `to` | each revenue and expense account's activity by code, on its normal side, closing entries left out, every one listed; `net_income`, revenue less expenses | `ledger-core` `tests/bookspec.rs` | -| `entry` | `entry = N`, no dates | entry N's lines, `".debit"` and `".credit"` each the sum of that account's lines on that side, every line of the entry named | `ledger-core` `tests/bookspec.rs` | +| `entry` | `entry = N`, no dates | the lines of event N's entry, `".debit"` and `".credit"` each the sum of that account's lines on that side, every line of the entry named | `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): `".beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `k1` | `from`, `to` | the K-1 worksheet (B-13; Schedule K-1 Item L): `".beginning"`, `.contributions_cash`, `.contributions_property`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | @@ -227,6 +286,10 @@ answers is refused, never skipped. | `calendar_year` | none | the year a calendar-year count (1099s, party totals) asked without one counts: `year` | `ledger-core` `tests/bookspec.rs` | | `account_names` | none | each account's name by code, as the chart holds it and every report prints it, past periods included (B-31); every account listed, those the books created included | `ledger-core` `tests/bookspec.rs` | | `uncategorized_accounts` | none | a list: every account that needs a tax category (revenue or expense, not a header) and has none, with activity or not, `" "`, by code (B-32) | `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` | +| `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` | +| `schedule_plan` | `schedule`, no dates | the schedule's plan (B-16): what it recognizes at each month end named (`"YYYY-MM-DD"`), `"0.00"` at one it doesn't post; `total`, all it recognizes; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `iif_lines` | `as_of` | formats appendix: the file's lines in order, each its fields joined by tabs, without the CR LF that ends each | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | | `iif_changes` | `as_of` | formats appendix: each text the file writes differently from the books, in file order, as `[what, books' text, file's text]` (the example's header gives `what`) | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | @@ -255,7 +318,15 @@ name, ignoring capitals and surrounding space), `holds_a_tin` (a run of nine digits), `not_one_line` (a line break, tab or other control character, or past 200 characters), `code_taken`, and `tax_category_required` (an account that needs a category left without -one under `refuse`). +one under `refuse`). A refused event's: `depreciation_past_disposal` (a +disposal dated before depreciation a run carries: a later month's run +stands, or a run's change dated after the disposal's month isn't undone), +`entry_irreversible` (an asset's entry that can't be reversed: its +disposal, a disposed asset's depreciation, a run re-instated twice or past +its schedule, or a reversal dated before its run), `already_disposed`, +`invalid_depreciation_period` (a month that ends before the asset is in +service) and `invalid_schedule` (an equal-months schedule not starting on +the 1st, among others). `cash_flow` (rules.md B-14): the statement of cash flows for `[from, to]`. @@ -265,10 +336,10 @@ one under `refuse`). | `net_change`, `cash_beginning`, `cash_ending` | the cash accounts' change and balances | | `difference` | sections less the change in cash: always `"0.00"` | | `net_income` | revenue less expenses, closing entries excluded | -| `
.` | entry N's cash in that section (`financing.4`) | +| `
.` | event N's entry's cash in that section (`financing.4`) | | `
..on_behalf` | of it, what a member or a lender paid or received for the company, no cash account moving (constructive receipt and disbursement) | | `
.count` | how many entries the section lists | -| `noncash.` | entry N's noncash investing and financing disclosure | +| `noncash.` | event N's entry's noncash investing and financing disclosure | | `noncash.count` | how many entries are disclosed | | `reconciliation.depreciation` | depreciation added back | | `reconciliation.investing_gain_loss` | gains and losses whose cash is investing, taken out | @@ -284,8 +355,9 @@ reconciliation key not given must be zero or absent. Every example, in any appendix, is loaded by `crates/ledger-core/tests/bookspec.rs` (`every_bookspec_example_holds`): its shape (a key the shape doesn't have is refused), its setup, and every -event posted. Each query is then checked by the crate that answers it (the -table above), so the shape never rots either. +event taken or refused as it says, on the books' own store. Each query is +then checked by the crate that answers it (the table above), so the shape +never rots either. ## Licence and sources diff --git a/bookspec/examples/depreciation-catch-up-month.toml b/bookspec/examples/depreciation-catch-up-month.toml new file mode 100644 index 0000000..e61ffc8 --- /dev/null +++ b/bookspec/examples/depreciation-catch-up-month.toml @@ -0,0 +1,112 @@ +id = "depreciation-catch-up-month" +title = "A month of depreciation skipped is caught up in the month the run is made" +rule = "A depreciation run for a month posts everything the schedule says is due through that month's end that the books don't carry, so a skipped month is caught up in it, and a later run for the skipped month posts nothing." +status = "CHOICE" +sources = ["ASC 360-10-35-4", "DECISION: plan/served.md P3, Settled in design (2026-10-20)"] +tags = ["depreciation", "catch-up", "skipped-month", "p3"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.accounts]] +code = "7100" +name = "Gain or Loss on Disposal" +type = "revenue" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +# February is skipped; March is run. +[[events]] +run_depreciation = { asset = "X", period = "2026-03" } + +# February run afterwards: nothing is due (March caught it up). +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +# Hand check (worked without the code): the schedule through Mar 31 is +# 3 x 1,000.00 = 3,000.00; January's run carried 1,000.00, so March's run +# posts 2,000.00 (its own 1,000.00 and February's, caught up). February's +# run then posts 0.00: the books already carry the schedule through +# February's end and beyond. +[[expect]] +query = "run" +event = 4 + +[expect.values] +amount = "2000.00" +caught_up = "1000.00" + +[[expect]] +query = "run" +event = 5 + +[expect.values] +amount = "0.00" +caught_up = "0.00" + +[[expect]] +query = "balances" +as_of = "2026-03-31" + +[expect.values] +"1510" = "-3000.00" +"6200" = "3000.00" diff --git a/bookspec/examples/depreciation-disposal-after-a-late-change-undone.toml b/bookspec/examples/depreciation-disposal-after-a-late-change-undone.toml new file mode 100644 index 0000000..2931fa5 --- /dev/null +++ b/bookspec/examples/depreciation-disposal-after-a-late-change-undone.toml @@ -0,0 +1,158 @@ +id = "depreciation-disposal-after-a-late-change-undone" +title = "A disposal is taken once a late change to an earlier run is undone, on any later day" +rule = "A disposal clears what the asset's runs carry now, so it waits until each run carries at the end of the disposal's month what it carries now. A reversal of an earlier run dated after that month is undone by reversing it, on any open day: the disposal month and the end are then right, and a later month end keeps the change and its correction as they were made." +status = "CONVENTION" +sources = ["ASC 360-10-35-4", "Pacioli 1494, ch. 31"] +tags = ["depreciation", "disposal", "refusal", "reversal", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.accounts]] +code = "7100" +name = "Gain or Loss on Disposal" +type = "revenue" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-03" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-04" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-05" } + +# May's run reversed on Aug 1 (doubted). Its month, and July's, still +# carried it at their ends. +[[events]] +date = "2026-08-01" +description = "Reverse event 7" +reverses = 7 + +# Disposed of 2026-06-15 for 7,000.00: refused. The disposal clears what +# the runs carry now (January to April, 4,000.00) and would charge May +# again at Jun 15, so June would end carrying 6,500.00 against a +# schedule of 5,500.00. The refusal names May's run and the reversal. +[[events]] +date = "2026-06-15" +dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } +refused = "depreciation_past_disposal" + +# May was right: its reversal reversed on Sep 2 (August may be closed by +# then; any open day undoes it). +[[events]] +date = "2026-09-02" +description = "Reverse event 8" +reverses = 8 + +[[events]] +date = "2026-06-15" +dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } + +# Hand check (worked without the code): the runs carry 5,000.00 at Jun 30 +# and now. Held through Jun 15: 5,000.00 + 1,000.00 x 15/30 = 5,500.00, so +# the final depreciation is 500.00 and the disposal clears 5,500.00. Book +# value 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. August's end carries +# May's reversal (accumulated depreciation a 1,000.00 debit, the expense +# 4,500.00) and September's its correction, as they would without the +# disposal. +[[expect]] +query = "disposal" +asset = "X" + +[expect.values] +final_depreciation = "500.00" +catch_up_depreciation = "0.00" +excess_depreciation = "0.00" +accumulated = "5500.00" +book_value = "6500.00" +gain_loss = "500.00" + +[[expect]] +query = "balances" +as_of = "2026-06-30" + +[expect.values] +"1510" = "0.00" +"6200" = "5500.00" +"7100" = "500.00" + +[[expect]] +query = "balances" +as_of = "2026-08-31" + +[expect.values] +"1510" = "1000.00" +"6200" = "4500.00" + +[[expect]] +query = "balances" +as_of = "2026-09-30" + +[expect.values] +"1510" = "0.00" +"6200" = "5500.00" diff --git a/bookspec/examples/depreciation-disposal-before-a-later-run.toml b/bookspec/examples/depreciation-disposal-before-a-later-run.toml new file mode 100644 index 0000000..f309041 --- /dev/null +++ b/bookspec/examples/depreciation-disposal-before-a-later-run.toml @@ -0,0 +1,146 @@ +id = "depreciation-disposal-before-a-later-run" +title = "A disposal dated before a later month's run is refused until that run is reversed" +rule = "A disposal is refused while a run of a month after the disposal's stands: crediting it back at the disposal day would leave the disposal month's accumulated depreciation a debit and its expense short at the month's end. Once that run is reversed, the disposal takes the depreciation of the days held." +status = "CONVENTION" +sources = ["ASC 360-10-35-4"] +tags = ["depreciation", "disposal", "refusal", "later-run", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.accounts]] +code = "7100" +name = "Gain or Loss on Disposal" +type = "revenue" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-03" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-04" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-05" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-06" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-07" } + +# Disposed of 2026-06-15 for 7,000.00, after July's run: refused, naming +# July's run. +[[events]] +date = "2026-06-15" +dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } +refused = "depreciation_past_disposal" + +# July's run reversed, dated in July. +[[events]] +date = "2026-07-31" +description = "Reverse event 9" +reverses = 9 + +[[events]] +date = "2026-06-15" +dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } + +# Hand check (worked without the code): January through June carry +# 6,000.00 once July's run is reversed. Held through Jun 15: 5,000.00 + +# 1,000.00 x 15/30 = 5,500.00, so June's run posted 500.00 past the +# disposal day. Book value 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. +# Had the disposal taken July's run back at Jun 15 (1,500.00 credited), +# June would end with accumulated depreciation a 1,000.00 debit and +# 4,500.00 of expense. +[[expect]] +query = "disposal" +asset = "X" + +[expect.values] +final_depreciation = "0.00" +catch_up_depreciation = "0.00" +excess_depreciation = "500.00" +accumulated = "6000.00" +book_value = "6500.00" +gain_loss = "500.00" + +[[expect]] +query = "balances" +as_of = "2026-06-30" + +[expect.values] +"1510" = "0.00" +"6200" = "5500.00" +"7100" = "500.00" + +[[expect]] +query = "balances" +as_of = "2026-07-31" + +[expect.values] +"1510" = "0.00" +"6200" = "5500.00" diff --git a/bookspec/examples/depreciation-disposal-in-final-month.toml b/bookspec/examples/depreciation-disposal-in-final-month.toml new file mode 100644 index 0000000..be7d3ee --- /dev/null +++ b/bookspec/examples/depreciation-disposal-in-final-month.toml @@ -0,0 +1,136 @@ +id = "depreciation-disposal-in-final-month" +title = "A disposal in the life's final month depreciates that month by its days held" +rule = "A disposal dated inside an asset's final month takes depreciation for the days held in that month, not the month's whole charge." +status = "CONVENTION" +sources = ["ASC 360-10-35-4"] +tags = ["depreciation", "disposal", "final-month", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.accounts]] +code = "7100" +name = "Gain or Loss on Disposal" +type = "revenue" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-03" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-04" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-05" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-06" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-07" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-08" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-09" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-10" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-11" } + +# Disposed of 2026-12-10 for 1,000.00. +[[events]] +date = "2026-12-10" +dispose_asset = { asset = "X", proceeds = "1000.00", proceeds_account = "1000", gain_loss_account = "7100" } + +# Hand check (worked without the code): January..November run, 11,000.00. +# December held through the 10th: 1,000.00 x 10/31 = 322.580... -> 322.58. +# Accumulated 11,322.58; book value 677.42; gain 1,000.00 - 677.42 = +# 322.58. (Before P3: 1,000.00 of depreciation and a 1,000.00 gain.) +[[expect]] +query = "disposal" +asset = "X" + +[expect.values] +final_depreciation = "322.58" +catch_up_depreciation = "0.00" +excess_depreciation = "0.00" +accumulated = "11322.58" +book_value = "677.42" +gain_loss = "322.58" + +[[expect]] +query = "balances" +as_of = "2026-12-31" + +[expect.values] +"1000" = "1000.00" +"1510" = "0.00" +"6200" = "11322.58" +"7100" = "322.58" diff --git a/bookspec/examples/depreciation-disposal-inside-a-posted-month.toml b/bookspec/examples/depreciation-disposal-inside-a-posted-month.toml new file mode 100644 index 0000000..5145d75 --- /dev/null +++ b/bookspec/examples/depreciation-disposal-inside-a-posted-month.toml @@ -0,0 +1,122 @@ +id = "depreciation-disposal-inside-a-posted-month" +title = "A disposal inside a month already run takes back the days after it" +rule = "When a month's depreciation was posted through its end and the asset is disposed of inside that month, the disposal credits back the depreciation for the days after the disposal day, so book value and gain are of the days held." +status = "CONVENTION" +sources = ["ASC 360-10-35-4"] +tags = ["depreciation", "disposal", "mid-month", "excess-depreciation", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.accounts]] +code = "7100" +name = "Gain or Loss on Disposal" +type = "revenue" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-03" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-04" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-05" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-06" } + +# Disposed of 2026-06-15 for 7,000.00, after June's run (dated Jun 30). +[[events]] +date = "2026-06-15" +dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } + +# Hand check (worked without the code): the runs carried 6,000.00. Held +# through Jun 15: 5,000.00 + 1,000.00 x 15/30 = 5,500.00, so 500.00 was +# posted past the disposal day. The disposal entry: Dr accumulated +# depreciation 6,000.00, Dr bank 7,000.00 / Cr equipment 12,000.00, Cr +# depreciation expense 500.00, Cr gain 500.00 (13,000.00 a side). Book +# value 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. +[[expect]] +query = "disposal" +asset = "X" + +[expect.values] +final_depreciation = "0.00" +catch_up_depreciation = "0.00" +excess_depreciation = "500.00" +accumulated = "6000.00" +book_value = "6500.00" +gain_loss = "500.00" + +[[expect]] +query = "balances" +as_of = "2026-06-30" + +[expect.values] +"1510" = "0.00" +"6200" = "5500.00" +"7100" = "500.00" diff --git a/bookspec/examples/depreciation-disposal-mid-month.toml b/bookspec/examples/depreciation-disposal-mid-month.toml new file mode 100644 index 0000000..d581198 --- /dev/null +++ b/bookspec/examples/depreciation-disposal-mid-month.toml @@ -0,0 +1,134 @@ +id = "depreciation-disposal-mid-month" +title = "A disposed asset depreciates through its disposal day, and its schedule stops there" +rule = "An asset disposed of mid-month is depreciated through the disposal day, held, and its schedule, accumulated depreciation and book value afterwards are what the disposal took." +status = "CONVENTION" +sources = ["ASC 360-10-35-4"] +tags = ["depreciation", "disposal", "mid-month", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.accounts]] +code = "7100" +name = "Gain or Loss on Disposal" +type = "revenue" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-03" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-04" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-05" } + +# Disposed of 2026-06-15 for 7,000.00. +[[events]] +date = "2026-06-15" +dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } + +# Hand check (worked without the code): January..May run, 5 x 1,000.00 = +# 5,000.00. June held through the 15th: 1,000.00 x 15/30 = 500.00, the +# final depreciation. Accumulated 5,500.00; book value 12,000.00 - +# 5,500.00 = 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. +[[expect]] +query = "disposal" +asset = "X" + +[expect.values] +final_depreciation = "500.00" +catch_up_depreciation = "0.00" +excess_depreciation = "0.00" +accumulated = "5500.00" +book_value = "6500.00" +gain_loss = "500.00" + +# The disposed asset's schedule ends on the disposal day, whatever date it +# is asked through. +[[expect]] +query = "depreciation_schedule" +asset = "X" +as_of = "2030-12-31" + +[expect.values] +"2026-06" = "500.00" +accumulated = "5500.00" +book_value = "6500.00" + +# The books: depreciation expense 5,500.00 (debit), the gain 500.00 +# (credit), accumulated depreciation and equipment cleared, the bank +# 7,000.00 (debit). +[[expect]] +query = "balances" +as_of = "2026-06-30" + +[expect.values] +"1000" = "7000.00" +"1500" = "0.00" +"1510" = "0.00" +"6200" = "5500.00" +"7100" = "500.00" diff --git a/bookspec/examples/depreciation-final-month-by-days.toml b/bookspec/examples/depreciation-final-month-by-days.toml new file mode 100644 index 0000000..3c3a54f --- /dev/null +++ b/bookspec/examples/depreciation-final-month-by-days.toml @@ -0,0 +1,93 @@ +id = "depreciation-final-month-by-days" +title = "Straight-line depreciation by the day through the life's final month" +rule = "Depreciation through a day of an asset's final month is the depreciation before that month plus the final month's share for the days held, never the whole remaining cost." +status = "CONVENTION" +sources = ["ASC 360-10-35-4"] +tags = ["depreciation", "straight-line", "final-month", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01. Its life is +# 2026-01-01 through 2026-12-31; December is its final month. +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +# Hand check (worked without the code): 12,000.00 / 12 = 1,000.00 a month. +# Through Dec 15: January..November, 11 x 1,000.00 = 11,000.00, plus +# December's 1,000.00 x 15/31 = 483.870... -> 483.87 (half away from zero). +# Accumulated 11,483.87; book value 12,000.00 - 11,483.87 = 516.13. +[[expect]] +query = "depreciation_schedule" +asset = "X" +as_of = "2026-12-15" + +[expect.values] +"2026-11" = "1000.00" +"2026-12" = "483.87" +accumulated = "11483.87" +book_value = "516.13" + +# Hand check: through the life's last day, the whole of it. +[[expect]] +query = "depreciation_schedule" +asset = "X" +as_of = "2026-12-31" + +[expect.values] +"2026-12" = "1000.00" +accumulated = "12000.00" +book_value = "0.00" diff --git a/bookspec/examples/depreciation-final-month-drift.toml b/bookspec/examples/depreciation-final-month-drift.toml new file mode 100644 index 0000000..e871319 --- /dev/null +++ b/bookspec/examples/depreciation-final-month-drift.toml @@ -0,0 +1,98 @@ +id = "depreciation-final-month-drift" +title = "The final month of a mid-month life takes what the months before it leave, evenly over its days" +rule = "When a life does not start on the 1st, its final month is charged the depreciable amount less what the months before it took, spread evenly over the final month's days." +status = "CONVENTION" +sources = ["ASC 360-10-35-4"] +tags = ["depreciation", "straight-line", "final-month", "month-length-drift", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# Cost 1,030.00, salvage 100.00 (930.00 to depreciate), 3 months from +# 2026-01-15: the life is 2026-01-15 through 2026-04-14. +[[setup.assets]] +id = "D" +description = "Asset D" +cost = "1030.00" +salvage = "100.00" +life_months = 3 +in_service = "2026-01-15" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-15" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "1030.00" }, + { account = "3000", side = "credit", amount = "1030.00" }, +] + +[[events]] +date = "2026-01-15" +description = "Bought asset D" +lines = [ + { account = "1500", side = "debit", amount = "1030.00" }, + { account = "1000", side = "credit", amount = "1030.00" }, +] + +# Hand check (worked without the code): 930.00 / 3 = 310.00 a month. +# January holds 17 of its 31 days: 310.00 x 17/31 = 170.00. February and +# March are whole: 310.00 each. Before April: 790.00. April holds 14 days +# (1-14) and is charged what is left, 930.00 - 790.00 = 140.00, evenly: +# 10.00 a day (January's own daily rate, 310.00/31). Through Apr 13: +# 130.00; accumulated 920.00; book value 1,030.00 - 920.00 = 110.00. +[[expect]] +query = "depreciation_schedule" +asset = "D" +as_of = "2026-04-13" + +[expect.values] +"2026-01" = "170.00" +"2026-02" = "310.00" +"2026-03" = "310.00" +"2026-04" = "130.00" +accumulated = "920.00" +book_value = "110.00" + +# Through Apr 14, the life's last day: April 140.00, accumulated 930.00, +# book value 100.00 (the salvage, never depreciated). +[[expect]] +query = "depreciation_schedule" +asset = "D" +as_of = "2026-04-14" + +[expect.values] +"2026-04" = "140.00" +accumulated = "930.00" +book_value = "100.00" diff --git a/bookspec/examples/depreciation-reversal-before-its-run-is-refused.toml b/bookspec/examples/depreciation-reversal-before-its-run-is-refused.toml new file mode 100644 index 0000000..7097a2d --- /dev/null +++ b/bookspec/examples/depreciation-reversal-before-its-run-is-refused.toml @@ -0,0 +1,114 @@ +id = "depreciation-reversal-before-its-run-is-refused" +title = "A depreciation run can't be reversed on a day before its own" +rule = "A correction comes after what it corrects: a depreciation run, or a reversal of one, can't be reversed on a day before its own. A run of March reversed on Jan 31 would leave January and February with negative depreciation." +status = "AUTHORITY" +sources = ["Pacioli 1494, ch. 31"] +tags = ["depreciation", "reversal", "refusal", "p3-defect-22"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +[[setup.accounts]] +code = "7100" +name = "Gain or Loss on Disposal" +type = "revenue" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-03" } + +# March's run (dated Mar 31) reversed on Feb 10: refused, naming its date. +[[events]] +date = "2026-02-10" +description = "Reverse event 5" +reverses = 5 +refused = "entry_irreversible" + +# On its own day it is taken. +[[events]] +date = "2026-03-31" +description = "Reverse event 5" +reverses = 5 + +# Hand check (worked without the code): January and February carry +# 2,000.00 at February's end, never 1,000.00 (with March's 1,000.00 taken +# away on Feb 10); March's end carries 2,000.00 (its run and its reversal +# both dated Mar 31). +[[expect]] +query = "balances" +as_of = "2026-02-28" + +[expect.values] +"1510" = "-2000.00" +"6200" = "2000.00" + +[[expect]] +query = "balances" +as_of = "2026-03-31" + +[expect.values] +"1510" = "-2000.00" +"6200" = "2000.00" diff --git a/bookspec/examples/depreciation-run-reversed-after-its-month.toml b/bookspec/examples/depreciation-run-reversed-after-its-month.toml new file mode 100644 index 0000000..905404d --- /dev/null +++ b/bookspec/examples/depreciation-run-reversed-after-its-month.toml @@ -0,0 +1,121 @@ +id = "depreciation-run-reversed-after-its-month" +title = "A run reversed after its month ended is caught up in the reversal's month, never posted twice" +rule = "What the books carry is read entry by entry, each in its own month: a run reversed after its month ended still carried that month at its end, so running the month again posts nothing, and the run of the reversal's month catches the month up." +status = "CHOICE" +sources = ["ASC 360-10-35-4", "DECISION: plan/served.md P3, Settled in design (2026-10-20)", "docs/spec/rules.md B-9"] +tags = ["depreciation", "catch-up", "reversal", "p3"] + +[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 = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6200" +name = "Depreciation Expense" +type = "expense" + +# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a +# month; December the final month). +[[setup.assets]] +id = "X" +description = "Asset X" +cost = "12000.00" +salvage = "0.00" +life_months = 12 +in_service = "2026-01-01" +method = "straight_line" +cost_account = "1500" +accumulated_account = "1510" +expense_account = "6200" + +[[events]] +date = "2026-01-01" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "12000.00" }, + { account = "3000", side = "credit", amount = "12000.00" }, +] + +[[events]] +date = "2026-01-01" +description = "Bought asset X" +lines = [ + { account = "1500", side = "debit", amount = "12000.00" }, + { account = "1000", side = "credit", amount = "12000.00" }, +] + +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +# January's run reversed on 2026-02-02, after January ended. +[[events]] +date = "2026-02-02" +description = "Reverse event 3" +reverses = 3 + +# January run again: nothing is due (Jan 31 carried January). +[[events]] +run_depreciation = { asset = "X", period = "2026-01" } + +[[events]] +run_depreciation = { asset = "X", period = "2026-02" } + +# Hand check (worked without the code): January's run (1,000.00, dated Jan +# 31) stood on Jan 31, so the books carried the schedule there, 1,000.00; +# running January again posts 0.00 (were it to post, Jan 31 would carry +# 2,000.00). The reversal took 1,000.00 away on Feb 2, so February's run +# posts the schedule through Feb 28, 2,000.00, less the 0.00 then carried: +# 2,000.00, of which 1,000.00 is January caught up. +[[expect]] +query = "run" +event = 5 + +[expect.values] +amount = "0.00" +caught_up = "0.00" + +[[expect]] +query = "run" +event = 6 + +[expect.values] +amount = "2000.00" +caught_up = "1000.00" + +[[expect]] +query = "balances" +as_of = "2026-01-31" + +[expect.values] +"1510" = "-1000.00" +"6200" = "1000.00" + +[[expect]] +query = "balances" +as_of = "2026-02-28" + +[expect.values] +"1510" = "-2000.00" +"6200" = "2000.00" diff --git a/bookspec/examples/schedule-daily-deferred-revenue.toml b/bookspec/examples/schedule-daily-deferred-revenue.toml new file mode 100644 index 0000000..d1cdb0c --- /dev/null +++ b/bookspec/examples/schedule-daily-deferred-revenue.toml @@ -0,0 +1,75 @@ +id = "schedule-daily-deferred-revenue" +title = "Deferred revenue is earned by the day, to the cent" +rule = "Revenue collected in advance for a service over time is recognized as each day of the service passes, one posting per calendar month, the last month taking what the others leave." +status = "CHOICE" +sources = ["ASC 606-10-25-23", "DECISION: plan/served.md D-11, D-37 (owner, 2026-10-20)"] +tags = ["schedules", "deferred-revenue", "daily", "p3-defect-9"] + +[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 = "2400" +name = "Deferred Revenue" +type = "liability" + +[[setup.accounts]] +code = "4000" +name = "Subscription Revenue" +type = "revenue" + +# 1,000.00 collected for service 2026-01-05 through 2026-04-04. +[[setup.schedules]] +id = "SUB" +kind = "deferred_revenue" +description = "Quarterly plan, a customer" +total = "1000.00" +start = "2026-01-05" +months = 3 +convention = "daily" +balance_account = "2400" +pl_account = "4000" + +[[events]] +date = "2026-01-05" +description = "Collected a quarter up front" +lines = [ + { account = "1000", side = "debit", amount = "1000.00" }, + { account = "2400", side = "credit", amount = "1000.00" }, +] + +[[events]] +run_schedules = { through = "2026-04-30" } + +# Hand check (worked without the code): January holds 27 of its 31 days +# (5-31); February and March are whole; April holds days 1-4 and takes the +# rest. Cumulative at each month end: 1,000 x (27/31)/3 = 290.3226 -> +# 290.32; 1,000 x (27/31 + 1)/3 = 623.6559 -> 623.66; 1,000 x (27/31 + +# 2)/3 = 956.9892 -> 956.99; then 1,000.00. Months: 290.32, 333.34, +# 333.33, 43.01. +[[expect]] +query = "schedule_plan" +schedule = "SUB" + +[expect.values] +"2026-01-31" = "290.32" +"2026-02-28" = "333.34" +"2026-03-31" = "333.33" +"2026-04-30" = "43.01" + +# Earned through March: 956.99; still owed for April 1-4: 43.01. +[[expect]] +query = "balances" +as_of = "2026-03-31" + +[expect.values] +"2400" = "43.01" +"4000" = "956.99" diff --git a/bookspec/examples/schedule-daily-small-first-and-last-month.toml b/bookspec/examples/schedule-daily-small-first-and-last-month.toml new file mode 100644 index 0000000..a786a38 --- /dev/null +++ b/bookspec/examples/schedule-daily-small-first-and-last-month.toml @@ -0,0 +1,107 @@ +id = "schedule-daily-small-first-and-last-month" +title = "A prepaid expense spread by the day: a small first and last month around full ones" +rule = "A prepaid expense covering whole months from a mid-month start is expensed by the day: each full month an equal share, the first month its days' share, and the last month what the others leave, one posting per calendar month touched." +status = "CHOICE" +sources = ["ASC 340-10-05-4", "ASC 210-10-45-1(g)", "DECISION: plan/served.md D-11, D-37 (owner, 2026-10-20)"] +tags = ["schedules", "prepaid-expense", "daily", "p3-defect-9"] + +[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 = "1300" +name = "Prepaid Expenses" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital" +type = "equity" + +[[setup.accounts]] +code = "6300" +name = "Insurance" +type = "expense" + +# 1,200.00 of insurance covering 2026-03-15 through 2027-03-14. +[[setup.schedules]] +id = "INS" +kind = "prepaid_expense" +description = "Liability insurance" +total = "1200.00" +start = "2026-03-15" +months = 12 +convention = "daily" +balance_account = "1300" +pl_account = "6300" + +[[events]] +date = "2026-03-15" +description = "Capital" +lines = [ + { account = "1000", side = "debit", amount = "1200.00" }, + { account = "3000", side = "credit", amount = "1200.00" }, +] + +[[events]] +date = "2026-03-15" +description = "Paid a year of insurance" +lines = [ + { account = "1300", side = "debit", amount = "1200.00" }, + { account = "1000", side = "credit", amount = "1200.00" }, +] + +[[events]] +run_schedules = { through = "2027-03-31" } + +# Hand check (worked without the code): 1,200.00 / 12 = 100.00 a month. +# March 2026 holds 17 of its 31 days (15-31): 100.00 x 17/31 = 54.838... +# -> 54.84. April 2026 through February 2027 are whole: 100.00 each, 11 of +# them (cumulatives 154.84 ... 1,154.84). March 2027 holds days 1-14 and +# takes what is left: 1,200.00 - 1,154.84 = 45.16 (= 100.00 x 14/31 to the +# cent). Thirteen month ends for a twelve-month policy. +[[expect]] +query = "schedule_plan" +schedule = "INS" + +[expect.values] +"2026-03-31" = "54.84" +"2026-04-30" = "100.00" +"2026-05-31" = "100.00" +"2026-06-30" = "100.00" +"2026-07-31" = "100.00" +"2026-08-31" = "100.00" +"2026-09-30" = "100.00" +"2026-10-31" = "100.00" +"2026-11-30" = "100.00" +"2026-12-31" = "100.00" +"2027-01-31" = "100.00" +"2027-02-28" = "100.00" +"2027-03-31" = "45.16" + +# Through June 2026 the books have expensed 54.84 + 3 x 100.00 = 354.84, +# and 845.16 is still prepaid. +[[expect]] +query = "balances" +as_of = "2026-06-30" + +[expect.values] +"1300" = "845.16" +"6300" = "354.84" + +# Through March 2027, all of it. +[[expect]] +query = "balances" +as_of = "2027-03-31" + +[expect.values] +"1300" = "0.00" +"6300" = "1200.00" diff --git a/bookspec/examples/schedule-equal-months-start-on-the-first.toml b/bookspec/examples/schedule-equal-months-start-on-the-first.toml new file mode 100644 index 0000000..c4a8843 --- /dev/null +++ b/bookspec/examples/schedule-equal-months-start-on-the-first.toml @@ -0,0 +1,63 @@ +id = "schedule-equal-months-start-on-the-first" +title = "An equal-months schedule starts on the 1st of a month" +rule = "A schedule that spreads its total in equal months must start on the 1st of a month, for prepaid expenses and deferred revenue alike, so it never recognizes days it does not cover." +status = "CHOICE" +sources = ["ASC 606-10-25-23", "DECISION: plan/served.md D-37 (owner, 2026-10-20)"] +tags = ["schedules", "monthly-even", "refusal", "p3-defect-9"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +# Equal months keep the rounding schedules had before P3 (each month the +# total over the months to the cent, ties to even; the last month takes +# what the others leave): a posted month's replay re-derives its plan. +rounding = "half-even" + +[[setup.accounts]] +code = "1300" +name = "Prepaid Expenses" +type = "asset" + +[[setup.accounts]] +code = "2400" +name = "Deferred Revenue" +type = "liability" + +[[setup.accounts]] +code = "4000" +name = "Subscription Revenue" +type = "revenue" + +[[setup.accounts]] +code = "6300" +name = "Insurance" +type = "expense" + +# Equal months from March 15: refused (it would recognize all of March's +# 100.00, 45.16 of it, March 1 to 14 at 100.00 × 14/31, before the service +# began). The refusal names the 1st. +[[events]] +register_schedule = { id = "MID", kind = "deferred_revenue", description = "Annual plan", total = "1200.00", start = "2026-03-15", months = 12, convention = "monthly_even", balance_account = "2400", pl_account = "4000" } +refused = "invalid_schedule" + +# The same for a prepaid expense: one rule for both. +[[events]] +register_schedule = { id = "MIDP", kind = "prepaid_expense", description = "Insurance", total = "1200.00", start = "2026-03-15", months = 12, convention = "monthly_even", balance_account = "1300", pl_account = "6300" } +refused = "invalid_schedule" + +# From the 1st it is taken: twelve equal months. +[[events]] +register_schedule = { id = "FIRST", kind = "deferred_revenue", description = "Annual plan", total = "1200.00", start = "2026-03-01", months = 12, convention = "monthly_even", balance_account = "2400", pl_account = "4000" } + +# Hand check (worked without the code): 1,200.00 / 12 = 100.00, every +# month, the last absorbing no rounding. +[[expect]] +query = "schedule_plan" +schedule = "FIRST" + +[expect.values] +"2026-03-31" = "100.00" +"2026-09-30" = "100.00" +"2027-02-28" = "100.00" +total = "1200.00" diff --git a/crates/ledger-config/tests/codec_props.rs b/crates/ledger-config/tests/codec_props.rs index ba63bcd..398fe61 100644 --- a/crates/ledger-config/tests/codec_props.rs +++ b/crates/ledger-config/tests/codec_props.rs @@ -62,6 +62,8 @@ fn candidates() -> Vec<(&'static str, Value)> { ("books.close_into", json!("3000")), ("books.close_sweep", json!(["3100"])), ("books.asset_tag_prefix", json!("FL")), + ("books.schedule_convention", json!("monthly_even")), + ("books.schedule_convention", json!("daily")), ("policy.evidence.threshold", json!("0.00")), ("policy.evidence.threshold", json!("25.5")), ("policy.evidence.required_accounts", json!(["6200"])), diff --git a/crates/ledger-core/Cargo.toml b/crates/ledger-core/Cargo.toml index 938e153..adacdf2 100644 --- a/crates/ledger-core/Cargo.toml +++ b/crates/ledger-core/Cargo.toml @@ -22,8 +22,12 @@ workspace = true [dev-dependencies] criterion = "0.8.2" proptest = "1" -# bookspec examples are TOML (tests/bookspec.rs). +# bookspec examples are TOML (tests/bookspec.rs), run on the engine's own +# store (a dev-dependency only: the store depends on this crate), each in +# a directory of its own. toml = "1" +ledger-store = { path = "../ledger-store" } +tempfile = "3" [[bench]] name = "books" diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs index 2b5188e..4caadd8 100644 --- a/crates/ledger-core/src/assets.rs +++ b/crates/ledger-core/src/assets.rs @@ -5,13 +5,17 @@ //! separate set of books with its own rules and stays with the CPA. This //! module never pretends to do tax. //! -//! The register is master data owned by [`Ledger`](crate::Ledger): assets are journaled +//! The register is master data owned by [`Ledger`]: assets are journaled //! like accounts (see `ledger-store`), and ids are allocator-deterministic //! for replay. Depreciation itself is pure math — [`depreciation_schedule`] //! — and postings go through the normal entry machinery, so every //! accounting invariant holds. -use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money}; +use crate::money::Steps; +use crate::{ + AccountId, AccountType, ChartOfAccounts, EntryId, EntryStatus, Ledger, LedgerError, Money, + kernel, +}; use chrono::{Datelike, Months, NaiveDate}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -24,8 +28,11 @@ pub type AssetId = u64; /// room for declining-balance later. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum DepreciationMethod { - /// Even allocation over the useful life: (cost − salvage) / months, - /// prorated by days for partial first and last months. + /// Even allocation over the useful life by the day: (cost − salvage) / + /// months a month, prorated by days for a partial first month and any + /// month a schedule stops in; the life's final month takes what the + /// others leave, evenly over its days, so the life ends at exactly + /// cost − salvage ([`depreciation_schedule`]). StraightLine, } @@ -322,150 +329,501 @@ pub struct ScheduleLine { pub book_value: Decimal, } -/// The days in a month of a year chrono holds, without stepping to the -/// next month, so chrono's last month (December 262142, whose next month -/// chrono can't hold) has its 31 days too (rules.md B-28: a schedule an -/// earlier version accepted is computed, never a panic). -fn days_in_month(year: i32, month: u32) -> u32 { - match month { - 2 if NaiveDate::from_ymd_opt(year, 2, 29).is_some() => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - } -} - -/// The first day of the month after `month_start`'s, or `None` past -/// chrono's last day (the month containing NaiveDate::MAX). -fn next_month_start(month_start: NaiveDate) -> Option { - month_start.checked_add_months(Months::new(1)) -} - -/// The last day depreciation accrues: purchase + life, or disposal, whichever -/// is earlier. The accrual window is [purchase_date, depreciation_end). -fn depreciation_end(asset: &Asset) -> NaiveDate { - let natural = asset +/// The end of an asset's life, exclusive: its in-service date plus its +/// life in months (chrono clamps a day past a month's end, so Jan 31 plus +/// one month is Feb 28). The life is `[purchase_date, life_end)`. +/// Refused (DateOutOfRange), never a panic, past chrono's last day: replay +/// validation keeps every journaled asset's end a date, so this is for +/// figures asked of a value no command took. +pub fn life_end(asset: &Asset) -> Result { + asset .purchase_date .checked_add_months(Months::new(asset.useful_life_months)) - .expect("useful life does not overflow dates"); - match &asset.status { - AssetStatus::Active => natural, - AssetStatus::Disposed { date, .. } => (*date).min(natural), - } + .ok_or(LedgerError::DateOutOfRange(asset.purchase_date)) +} + +/// One month of a schedule, with its month as numbers (the line names it +/// "YYYY-MM"). +struct MonthLine { + year: i32, + month: u32, + line: ScheduleLine, } /// Straight-line depreciation schedule from the purchase month through -/// `through` (clamped to the depreciation end): one line per month, the -/// last prorated by days when `through` falls mid-month. +/// `through`, inclusive, and through the disposal day of a disposed asset: +/// one line per month, the last prorated by days when the schedule stops +/// mid-month. /// /// ```text /// GOAL: how much of an asset's cost has been used up, month by month. /// SOURCE: docs/spec/rules.md B-15 (straight line; salvage never -/// depreciated; partial months by days); DECISION (owner, -/// 2026-10-13): a schedule through a mid-month date prorates that -/// month by days, as partial first and last months do. +/// depreciated; partial months by days; ASC 360-10-35-4: cost less +/// salvage over the useful life "in a systematic and rational +/// manner", as equitably as possible to the periods served); +/// DECISION (owner, 2026-10-13): a schedule through a mid-month +/// date prorates that month by days, as partial first and last +/// months do. /// INVARIANTS: -/// 1. A full schedule MUST sum to exactly cost - salvage, to the cent. -/// 2. Accumulated depreciation NEVER falls; book value NEVER rises and -/// NEVER drops below salvage. +/// 1. The life is [purchase, purchase + life months), cut into calendar +/// months (calendar::month_parts). Through day k of a month but the +/// last, accumulated is (cost − salvage) / life × (the months held +/// before it, each as days held over days in the month, + k / days +/// in this month): the monthly rate by days. +/// 2. The last month of the life is charged what the months before it +/// leave, evenly over its days (kernel::prorated_through): through +/// its last day accumulated is exactly cost − salvage, and through an +/// earlier day it is prorated by days, never plugged to the whole. +/// 3. Each accumulated figure is rounded half away from zero to the +/// currency's minor unit (cents; whole yen; a thousandth of a dinar); +/// a month's amount is the difference of consecutive accumulated +/// figures. +/// 4. It stops at through, or at the disposal day of a disposed asset, +/// whichever is earlier, both inclusive (the disposal day is held: +/// disposal_amounts), and never runs past the life. +/// 5. Accumulated depreciation NEVER falls and NEVER exceeds cost − +/// salvage; book value NEVER rises and NEVER drops below salvage. +/// 6. Through every month end it equals the per-month running-total +/// method books posted before P3, except that a half-cent tie is +/// rounded exactly here (Decimal's 28-digit quotients could land on +/// either side of one). +/// 7. NEVER panics: a figure that can't be computed (a life past +/// chrono's last day, amounts past exact arithmetic) is refused +/// (rules.md B-28). /// SCENARIOS: /// 1. IF $12,000 over 12 months from Jan 1, through Jun 15: /// - SHOULD end with June at 500.00 (15 of 30 days), accumulated /// 5,500.00 /// BECAUSE depreciation accrues by the day within a month. +/// 2. IF the same asset through Dec 15, its final month: +/// - SHOULD end with December at 1,000 × 15/31 = 483.87, accumulated +/// 11,483.87 +/// - NEVER plug December to 1,000.00 before its last day +/// BECAUSE the final month is held by the day like any other. +/// 3. IF $930 depreciable over 3 months from Jan 15 (the life ends +/// Apr 15; Jan 17/31, Feb, Mar leave 140.00 for Apr 1-14), through +/// Apr 13: +/// - SHOULD be April 130.00 (13 of its 14 days), accumulated 920.00 +/// BECAUSE the month-length drift is spread over the final days. +/// 4. IF the first asset is disposed of on Jun 15: +/// - SHOULD stop there: June 500.00, accumulated 5,500.00, what the +/// disposal posted +/// - NEVER show full depreciation for a disposed asset. /// ``` -/// -/// Partial first and last months are prorated by days held. Each month is -/// rounded to cents via the running-total method, and the final month -/// trues up: partial months in different-length calendar months don't -/// complement exactly (17/31 + 14/30 ≠ 1), so the last month absorbs the -/// drift and the lifetime total is exactly (cost − salvage) — never a -/// penny off. -pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec { - if through < asset.purchase_date { - return Vec::new(); - } - let depreciable = asset.cost.amount - asset.salvage_value.amount; - if depreciable <= Decimal::ZERO { - return Vec::new(); - } - let end = depreciation_end(asset); - // Accrual is inclusive of `through`: work with an exclusive end. - let accrual_end = through.succ_opt().unwrap_or(through).min(end); - if accrual_end <= asset.purchase_date { - return Vec::new(); - } - let monthly_rate = depreciable / Decimal::from(asset.useful_life_months); +pub fn depreciation_schedule( + asset: &Asset, + through: NaiveDate, +) -> Result, LedgerError> { + Ok(schedule_months(asset, through)? + .into_iter() + .map(|m| m.line) + .collect()) +} +/// [`depreciation_schedule`], each line with its year and month. +fn schedule_months(asset: &Asset, through: NaiveDate) -> Result, LedgerError> { + let stop = match &asset.status { + AssetStatus::Active => through, + AssetStatus::Disposed { date, .. } => through.min(*date), + }; + if stop < asset.purchase_date { + return Ok(Vec::new()); + } + let too_large = + || LedgerError::TooLargeToCompute(format!("asset {}'s depreciation schedule", asset.id)); + let depreciable = asset + .cost + .amount + .checked_sub(asset.salvage_value.amount) + .ok_or_else(too_large)?; + if depreciable <= Decimal::ZERO { + return Ok(Vec::new()); + } + let end = life_end(asset)?; + let steps = Steps::of(depreciable, asset.cost.currency).ok_or_else(too_large)?; let mut out = Vec::new(); - let mut cumulative_exact = Decimal::ZERO; - let mut accumulated_rounded = Decimal::ZERO; - // Walk calendar months from the purchase month. - let (mut year, mut month) = (asset.purchase_date.year(), asset.purchase_date.month()); - // Past chrono's last month there is no month to walk, and nothing left - // to accrue: accrual_end is at most NaiveDate::MAX. - while let Some(month_start) = NaiveDate::from_ymd_opt(year, month, 1) { - if month_start >= accrual_end { + let (mut before, mut charged) = (0i128, 0i128); + for p in crate::calendar::month_parts(asset.purchase_date, end) { + if p.first > stop { break; } - let dim = days_in_month(year, month); - // Days held this month: overlap of [purchase_date, accrual_end) with - // the month. In chrono's last month there is no next month to end - // at, and accrual_end (at most NaiveDate::MAX) is the bound. - let held_start = asset.purchase_date.max(month_start); - let held_end = next_month_start(month_start).map_or(accrual_end, |n| accrual_end.min(n)); - let days_held = (held_end - held_start).num_days().max(0) as u32; - if days_held > 0 { - let exact = monthly_rate * Decimal::from(days_held) / Decimal::from(dim); - cumulative_exact += exact; - let rounded_cumulative = cumulative_exact - .round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero); - let amount = rounded_cumulative - accumulated_rounded; - accumulated_rounded = rounded_cumulative; - out.push(ScheduleLine { - period: format!("{year:04}-{month:02}"), - amount, - accumulated: accumulated_rounded, - book_value: asset.cost.amount - accumulated_rounded, + // Days of this month held through the stop day, inclusive. + let held = (stop - p.first).num_days().saturating_add(1); + let k = u32::try_from(held).map_or(p.part.days, |h| h.min(p.part.days)); + let through_k = kernel::prorated_through( + steps.units, + asset.useful_life_months, + before, + p.part, + k, + p.last, + steps.unit, + ) + .ok_or_else(too_large)?; + let accumulated = steps.decimal(through_k).ok_or_else(too_large)?; + out.push(MonthLine { + year: p.year, + month: p.month, + line: ScheduleLine { + period: format!("{:04}-{:02}", p.year, p.month), + amount: steps.decimal(through_k - charged).ok_or_else(too_large)?, + accumulated, + book_value: asset + .cost + .amount + .checked_sub(accumulated) + .ok_or_else(too_large)?, + }, + }); + charged = through_k; + if k < p.part.days { + break; + } + before = before + .checked_add(kernel::part_weight(p.part).ok_or_else(too_large)?) + .ok_or_else(too_large)?; + } + Ok(out) +} + +/// Accumulated depreciation through `as_of` (inclusive), exact to the +/// minor unit ([`depreciation_schedule`]). +pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Result { + Ok(depreciation_schedule(asset, as_of)? + .last() + .map(|l| l.accumulated) + .unwrap_or(Decimal::ZERO)) +} + +/// Book value (cost − accumulated depreciation) as of a date. +pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Result { + asset + .cost + .amount + .checked_sub(accumulated_depreciation(asset, as_of)?) + .ok_or_else(|| LedgerError::TooLargeToCompute(format!("asset {}'s book value", asset.id))) +} + +/// A change to what the books carry for an asset's depreciation, in the +/// month of the entry that made it, as the catch-up rule reads it +/// ([`run_amount`], [`depreciation_effects`]): a run adds its amount in its +/// month; a reversal of the run takes it away (a negative amount) in the +/// reversal's month, and a reversal of that reversal adds it back in its +/// own. Summed through a month's end, they are what the books carry for +/// the asset's runs then. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PostedRun { + /// The entry's year. + pub year: i32, + /// The entry's month, 1 to 12. + pub month: u32, + /// What it adds to what the books carry (negative for a reversal). + pub amount: Decimal, +} + +/// What an asset's depreciation runs did to what the books carry, entry by +/// entry, each in its own month: for every run (`runs`: each run's entry +/// and amount, reversed or not, its disposal's final depreciation +/// included), the run itself and every entry of its reversal chain +/// ([`Ledger::reversal_chain`]), alternately adding and taking away its +/// amount. +/// +/// ```text +/// GOAL: the catch-up rule reads the books as they stood at each month +/// end: a run reversed after its month ended still carried that +/// month, so running the month again would carry it twice. +/// SOURCE: docs/spec/rules.md B-9 (a mistake is corrected by an opposite +/// entry, dated when it is made), B-10 (reversing the reversal +/// re-instates), B-15 (the catch-up rule). +/// INVARIANTS: +/// 1. Through any month's end, the effects sum to what the runs' entries +/// and their reversals dated on or before it carry in the asset's +/// accumulated depreciation. +/// 2. A run in effect sums to its amount; one reversed, to zero. +/// ``` +pub fn depreciation_effects(ledger: &Ledger, runs: &[(EntryId, Decimal)]) -> Vec { + let mut out = Vec::new(); + for &(run, amount) in runs { + let chain = ledger.reversal_chain(run); + let from = chain.iter().position(|&e| e == run).unwrap_or(0); + for (i, e) in chain[from..].iter().enumerate() { + let Some(entry) = ledger + .get_entry(*e) + .filter(|x| x.status() == EntryStatus::Posted) + else { + continue; + }; + let d = entry.date(); + out.push(PostedRun { + year: d.year(), + month: d.month(), + amount: if i % 2 == 0 { amount } else { -amount }, }); } - if month == 12 { - year += 1; - month = 1; - } else { - month += 1; - } - } - // True-up: if the schedule reaches the final month of the asset's life, - // absorb any month-length drift so the lifetime total is exactly - // (cost − salvage). The drift is bounded by a fraction of one month's - // charge, so the final month stays positive. - // The final month named as the lines name theirs: chrono's "%Y" signs - // a year past 9999 ("+10004"), and the lines don't, so books an - // earlier version wrote with such a life true up too (rules.md B-28). - if let Some(last) = out.last_mut() - && let Some(day_before_end) = end.pred_opt() - && last.period == format!("{:04}-{:02}", day_before_end.year(), day_before_end.month()) - { - let plug = depreciable - accumulated_rounded; - last.amount += plug; - last.accumulated = depreciable; - last.book_value = asset.cost.amount - depreciable; } out } -/// Accumulated depreciation through `as_of` (inclusive), exact cents. -pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Decimal { - depreciation_schedule(asset, as_of) - .last() - .map(|l| l.accumulated) - .unwrap_or(Decimal::ZERO) +/// What a depreciation run for a month posts ([`run_amount`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RunAmount { + /// What the run posts: zero when nothing is due. + pub amount: Decimal, + /// The month's own scheduled amount ([`depreciation_schedule`]; zero + /// for a month after the life). + pub scheduled: Decimal, + /// What the books carry beyond the schedule through the month's end: + /// zero unless something carried them past it (a run an earlier + /// version posted on a half-cent tie, a correction by hand), which no + /// run takes back: a human corrects it. + pub over: Decimal, } -/// Book value (cost − accumulated depreciation) as of a date. -pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Decimal { - asset.cost.amount - accumulated_depreciation(asset, as_of) +/// What a depreciation run would post for every month from the asset's +/// first through `through` (year, month), in one pass: [`run_amount`] of +/// each, as a viewer asks it of every month of a life (a 10,000-year life +/// included). It stops at the later of the life's last month and the last +/// month an entry in `posted` ([`depreciation_effects`]) falls in, if +/// `through` is past both: every month after them would say the same as +/// the first one after. +/// +/// ```text +/// INVARIANTS: +/// 1. Each month's amount and over are run_amount's for it +/// (kernel::catch_ups is catch_up of every suffix). +/// 2. Empty when `through` is before the asset's first month. +/// ``` +pub fn run_amounts( + asset: &Asset, + through: (i32, u32), + posted: &[PostedRun], +) -> Result, LedgerError> { + let first = (asset.purchase_date.year(), asset.purchase_date.month()); + if through < first { + return Ok(Vec::new()); + } + let last_run = posted.iter().map(|r| (r.year, r.month)).max(); + let life_last = life_end(asset)? + .pred_opt() + .map_or(first, |d| (d.year(), d.month())); + // One month past the life and the runs says what every later one does + // (and past chrono's last month there is none). + let after = life_last.max(last_run.unwrap_or(first)); + let settled = Some(next_month(after)) + .filter(|&(y, m)| NaiveDate::from_ymd_opt(y, m, 1).is_some()) + .unwrap_or(after); + let through = through.min(settled); + let latest = last_run.map_or(through, |r| r.max(through)); + let end = NaiveDate::from_ymd_opt(latest.0, latest.1, 1) + .map(crate::schedules::month_end) + .ok_or_else(|| { + LedgerError::InvalidDepreciationPeriod(format!("{:04}-{:02}", latest.0, latest.1)) + })?; + let lines = schedule_months(asset, end)?; + let c = cumulatives(asset, first, latest, &lines, posted)?; + let mut due = vec![Decimal::ZERO; c.months.len()]; + kernel::catch_ups(&c.scheduled, &c.carried, &mut due).ok_or_else(|| { + LedgerError::TooLargeToCompute(format!("asset {}'s depreciation runs", asset.id)) + })?; + let mut li = 0; + Ok(c.months + .into_iter() + .zip(due) + .take_while(|((y, m), _)| (*y, *m) <= through) + .zip(c.scheduled.iter().zip(&c.carried)) + .map(|(((y, m), amount), (&s, &g))| { + while lines.get(li).is_some_and(|l| (l.year, l.month) < (y, m)) { + li += 1; + } + let own = lines + .get(li) + .filter(|l| (l.year, l.month) == (y, m)) + .map_or(Decimal::ZERO, |l| l.line.amount); + ( + y, + m, + RunAmount { + amount, + scheduled: own, + over: g + .checked_sub(s) + .map_or(Decimal::ZERO, |o| o.max(Decimal::ZERO)), + }, + ) + }) + .collect()) +} + +/// The month after (year, month). +fn next_month((y, m): (i32, u32)) -> (i32, u32) { + if m == 12 { (y + 1, 1) } else { (y, m + 1) } +} + +/// The schedule and what the books carry, each cumulative, at successive +/// month ends. +struct Cumulatives { + /// The months, (year, month). + months: Vec<(i32, u32)>, + /// The schedule through each month's end. + scheduled: Vec, + /// What the books carry through each month's end: the effects + /// ([`PostedRun`]) of months on or before it. + carried: Vec, +} + +/// The months from `first` through `latest`, with the schedule and what +/// the books carry, each cumulative, at every month end: one pass over the +/// months, the lines and the effects. The schedule through a month's end +/// is its line, or the last line before it (after the life, the whole of +/// it). +fn cumulatives( + asset: &Asset, + first: (i32, u32), + latest: (i32, u32), + lines: &[MonthLine], + posted: &[PostedRun], +) -> Result { + let too_large = + || LedgerError::TooLargeToCompute(format!("asset {}'s depreciation run", asset.id)); + let mut runs: Vec<&PostedRun> = posted.iter().collect(); + runs.sort_by_key(|r| (r.year, r.month)); + let (mut months, mut scheduled, mut carried) = (Vec::new(), Vec::new(), Vec::new()); + let (mut through, mut carry) = (Decimal::ZERO, Decimal::ZERO); + let (mut li, mut ri) = (0, 0); + let (mut y, mut m) = first; + loop { + while let Some(l) = lines.get(li).filter(|l| (l.year, l.month) <= (y, m)) { + through = l.line.accumulated; + li += 1; + } + while let Some(r) = runs.get(ri).filter(|r| (r.year, r.month) <= (y, m)) { + carry = carry.checked_add(r.amount).ok_or_else(too_large)?; + ri += 1; + } + months.push((y, m)); + scheduled.push(through); + carried.push(carry); + if (y, m) >= latest { + break; + } + (y, m) = next_month((y, m)); + } + Ok(Cumulatives { + months, + scheduled, + carried, + }) +} + +/// What a depreciation run for `year`-`month` posts, dated at that month's +/// end, given what the books carry for the asset (`posted`: every run and +/// every reversal of one, each in its own month, [`depreciation_effects`]). +/// +/// ```text +/// GOAL: the books carry the schedule through every month end a run +/// reaches; a month skipped is caught up in the month the run is +/// made, and a run never posts more than the schedule says. +/// SOURCE: plan/served.md P3 "Settled in design" (2026-10-20): a month of +/// depreciation skipped is caught up in the month the run is made +/// (an immaterial miss; a material one is restated, ASC 250, a +/// correction for a human and the CPA); docs/spec/rules.md B-15. +/// INVARIANTS: +/// 1. amount = catch_up_room for the month: kernel::catch_up over the +/// month ends M from this month's through the latest month an effect +/// falls in, the schedule through M against what the books carry +/// through M. So amount >= 0, and the books never carry more than the +/// schedule at any of those month ends after this run (when they +/// didn't before). +/// 2. With nothing after this month, amount is everything due through +/// its end: earlier months skipped, or a run reversed, are caught up +/// here. +/// 3. A month a later run already caught up posts nothing; so does a +/// month whose run was reversed after the month ended (the books +/// carried it then: the run for the reversal's month catches it up), +/// so a month is never carried twice at its end. +/// SCENARIOS: +/// 1. IF January ran, February was skipped, and March runs: +/// - SHOULD post February's and March's depreciation in March +/// BECAUSE a skipped month is caught up in the month the run is made. +/// 2. IF February is run after that: SHOULD post nothing. +/// 3. IF January's run is reversed on Feb 2 and January is run again: +/// - SHOULD post nothing, and a February run 2,000.00 +/// - NEVER carry January twice on Jan 31 +/// BECAUSE the reversal is February's correction, caught up there. +/// 4. IF the month is after the life and everything is posted: SHOULD +/// post nothing; IF something is still due (a final month skipped, +/// or in a closed period): SHOULD post it. +/// 5. IF the month ends before the asset was placed in service: SHOULD +/// refuse (InvalidDepreciationPeriod). +/// ``` +pub fn run_amount( + asset: &Asset, + year: i32, + month: u32, + posted: &[PostedRun], +) -> Result { + let period = format!("{year:04}-{month:02}"); + if month_end_of(year, month)? < asset.purchase_date { + return Err(LedgerError::InvalidDepreciationPeriod(format!( + "{period} ends before asset {} was placed in service on {}", + asset.id, asset.purchase_date + ))); + } + catch_up_room(asset, year, month, posted) +} + +/// The last day of `year`-`month`, refused for a month chrono can't hold. +fn month_end_of(year: i32, month: u32) -> Result { + NaiveDate::from_ymd_opt(year, month, 1) + .map(crate::schedules::month_end) + .ok_or_else(|| LedgerError::InvalidDepreciationPeriod(format!("{year:04}-{month:02}"))) +} + +/// The most an entry dated in `year`-`month` can add to what the books +/// carry for the asset (`posted`, [`depreciation_effects`]) without their +/// carrying more than its schedule at that month's end or any later one an +/// effect reaches: what a run of the month posts ([`run_amount`]), and the +/// most a reversal that re-instates a run may add back +/// (`Store::check_reverse_entry`). A month before the asset's first counts +/// its schedule as nothing. +/// +/// ```text +/// INVARIANTS: +/// 1. amount = kernel::catch_up(scheduled[M..], carried[M..]) over the +/// month ends from this month's through the latest an effect falls in; +/// over = what the books carry beyond the schedule through this +/// month's end (zero normally). +/// 2. NEVER panics: a month chrono can't hold is refused. +/// ``` +pub fn catch_up_room( + asset: &Asset, + year: i32, + month: u32, + posted: &[PostedRun], +) -> Result { + month_end_of(year, month)?; + let latest = posted + .iter() + .map(|r| (r.year, r.month)) + .chain([(year, month)]) + .max() + .expect("this month is in the list"); + let lines = schedule_months(asset, month_end_of(latest.0, latest.1)?)?; + let too_large = + || LedgerError::TooLargeToCompute(format!("asset {}'s depreciation run", asset.id)); + let c = cumulatives(asset, (year, month), latest, &lines, posted)?; + let over = c.carried[0] + .checked_sub(c.scheduled[0]) + .ok_or_else(too_large)? + .max(Decimal::ZERO); + Ok(RunAmount { + amount: kernel::catch_up(&c.scheduled, &c.carried).ok_or_else(too_large)?, + scheduled: lines + .iter() + .find(|l| (l.year, l.month) == (year, month)) + .map_or(Decimal::ZERO, |l| l.line.amount), + over, + }) } /// The amounts for a disposal entry: what to debit/credit where. @@ -480,17 +838,15 @@ pub struct DisposalAmounts { pub gain_loss: Decimal, } -/// Compute disposal amounts. Pure math; posting is the caller's job. -/// A disposal may fall on the purchase date (one day held, accrual being -/// inclusive, B-15 in docs/spec/rules.md); one before it is an error. -pub fn disposal_amounts( - asset: &Asset, - date: NaiveDate, - proceeds: Money, -) -> Result { +/// Whether an asset can be disposed of on `date` for `proceeds`: it is +/// active, the proceeds aren't negative, and the date isn't before it was +/// placed in service (a disposal on that date holds it one day, accrual +/// being inclusive, B-15). The checks only: no depreciation is computed, +/// so replaying a journaled disposal never depends on a figure (rules.md +/// B-28). +pub fn check_disposal(asset: &Asset, date: NaiveDate, proceeds: Money) -> Result<(), LedgerError> { if asset.status != AssetStatus::Active { - let id = asset.id; - return Err(LedgerError::AssetAlreadyDisposed(id)); + return Err(LedgerError::AssetAlreadyDisposed(asset.id)); } if proceeds.amount < Decimal::ZERO { return Err(LedgerError::NegativeProceeds(proceeds.amount)); @@ -498,12 +854,32 @@ pub fn disposal_amounts( if date < asset.purchase_date { return Err(LedgerError::DisposalBeforePurchase(date.to_string())); } - let accumulated = accumulated_depreciation(asset, date); - let book = asset.cost.amount - accumulated; + Ok(()) +} + +/// Compute disposal amounts. Pure math; posting is the caller's job. +/// A disposal may fall on the purchase date (one day held, accrual being +/// inclusive, B-15 in docs/spec/rules.md); one before it is an error +/// ([`check_disposal`]). Accumulated depreciation is the schedule through +/// the disposal day, inclusive: what the schedule of the asset, once +/// disposed, shows ([`depreciation_schedule`] INVARIANT 4). +pub fn disposal_amounts( + asset: &Asset, + date: NaiveDate, + proceeds: Money, +) -> Result { + check_disposal(asset, date, proceeds)?; + let accumulated = accumulated_depreciation(asset, date)?; + let too_large = || LedgerError::TooLargeToCompute(format!("asset {}'s disposal", asset.id)); + let book = asset + .cost + .amount + .checked_sub(accumulated) + .ok_or_else(too_large)?; Ok(DisposalAmounts { book_value: book, accumulated, - gain_loss: proceeds.amount - book, + gain_loss: proceeds.amount.checked_sub(book).ok_or_else(too_large)?, }) } diff --git a/crates/ledger-core/src/calendar.rs b/crates/ledger-core/src/calendar.rs index 1c1c0be..e285fff 100644 --- a/crates/ledger-core/src/calendar.rs +++ b/crates/ledger-core/src/calendar.rs @@ -18,8 +18,9 @@ //! ``` use crate::LedgerError; +use crate::kernel::MonthPart; use crate::settings::Company; -use chrono::{Datelike, NaiveDate}; +use chrono::{Datelike, Months, NaiveDate}; /// The first day the books keep. pub const FIRST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(1, 1, 1) { @@ -248,10 +249,146 @@ impl PeriodDefaults { } } +/// The days in a month of a year chrono holds, without stepping to the +/// next month, so chrono's last month (December 262142, whose next month +/// chrono can't hold) has its 31 days too (rules.md B-28: a schedule an +/// earlier version accepted is computed, never a panic). +pub fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 2 if NaiveDate::from_ymd_opt(year, 2, 29).is_some() => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +/// The first day of the month after `month_start`'s, or `None` past +/// chrono's last day (the month containing NaiveDate::MAX). +pub fn next_month_start(month_start: NaiveDate) -> Option { + month_start.checked_add_months(Months::new(1)) +} + +/// One calendar month's share of a span of days ([`month_parts`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpanPart { + /// The month's year. + pub year: i32, + /// The month, 1 to 12. + pub month: u32, + /// The span's first day in the month. + pub first: NaiveDate, + /// Its days in the month, of the month's days. + pub part: MonthPart, + /// True for the span's last month (the one holding its last day). + pub last: bool, +} + +/// The calendar months of the span `[start, end)`, in order, each with the +/// span's days in it: lazily, so a walk through a date reads only the +/// months up to it. +/// +/// ```text +/// GOAL: cut a span of days (an asset's life, a schedule's coverage) into +/// the calendar months it touches, for proration by days. +/// SOURCE: docs/spec/rules.md B-15, B-16; kernel::prorate_by_days. +/// INVARIANTS: +/// 1. The parts' days sum to the span's days; every part has 1 to its +/// month's days (a valid kernel::MonthPart), and only the last is +/// marked last. +/// 2. NEVER steps past chrono's last day: December 262142, whose next +/// month chrono can't hold, still has its 31 days, and its part ends +/// at `end`. +/// 3. An empty span (end <= start) has no parts. +/// ``` +pub fn month_parts(start: NaiveDate, end: NaiveDate) -> impl Iterator { + let mut at = (start < end).then_some(start); + std::iter::from_fn(move || { + let first = at?; + let month_start = first.with_day(1)?; + let next = next_month_start(month_start); + // The span ends in this month when its end is no later than the + // next month's first day (or chrono has no next month). + let last = next.is_none_or(|n| end <= n); + let part_end = if last { end } else { next? }; + let days = u32::try_from((part_end - first).num_days()).ok()?; + at = if last { None } else { next }; + Some(SpanPart { + year: first.year(), + month: first.month(), + first, + part: MonthPart { + days, + month_days: days_in_month(first.year(), first.month()), + }, + last, + }) + }) +} + #[cfg(test)] mod tests { use super::*; + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + /// INVARIANT 1, on a span from mid-month with a short final month. + #[test] + fn a_span_cuts_into_its_calendar_months() { + let parts: Vec<_> = month_parts(d(2026, 1, 15), d(2026, 4, 15)).collect(); + let got: Vec<_> = parts + .iter() + .map(|p| (p.year, p.month, p.part.days, p.part.month_days, p.last)) + .collect(); + assert_eq!( + got, + [ + (2026, 1, 17, 31, false), + (2026, 2, 28, 28, false), + (2026, 3, 31, 31, false), + (2026, 4, 14, 30, true), + ] + ); + assert_eq!(parts[0].first, d(2026, 1, 15)); + assert_eq!(parts[3].first, d(2026, 4, 1)); + // A span from the 1st: whole months, the last one marked. + let whole: Vec<_> = month_parts(d(2028, 1, 1), d(2028, 3, 1)) + .map(|p| (p.part.days, p.part.month_days, p.last)) + .collect(); + assert_eq!(whole, [(31, 31, false), (29, 29, true)]); + // INVARIANT 3. + assert_eq!(month_parts(d(2026, 1, 1), d(2026, 1, 1)).count(), 0); + assert_eq!(month_parts(d(2026, 1, 2), d(2026, 1, 1)).count(), 0); + } + + /// INVARIANT 2: chrono's last month is walked to the span's end. + #[test] + fn the_walk_reaches_chronos_last_month() { + let y = NaiveDate::MAX.year(); + let parts: Vec<_> = month_parts(d(y, 11, 20), d(y, 12, 15)).collect(); + assert_eq!(parts.len(), 2); + assert_eq!( + parts[1].part, + MonthPart { + days: 14, + month_days: 31 + } + ); + assert!(parts[1].last); + let to_max: Vec<_> = month_parts(d(y, 12, 1), NaiveDate::MAX).collect(); + assert_eq!(to_max.len(), 1); + assert_eq!( + to_max[0].part, + MonthPart { + days: 30, + month_days: 31 + } + ); + assert_eq!(days_in_month(y, 12), 31); + assert_eq!(next_month_start(d(y, 12, 1)), None); + } + #[test] fn the_calendar_is_four_digit_years() { assert!(in_calendar(FIRST_DAY)); @@ -282,10 +419,6 @@ mod tests { } } - fn d(y: i32, m: u32, day: u32) -> NaiveDate { - NaiveDate::from_ymd_opt(y, m, day).unwrap() - } - fn company(md: (u32, u32)) -> Company { Company { fiscal_year_start: md, diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index eacfbe9..f640747 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -419,10 +419,28 @@ pub enum LedgerError { #[error("asset account links must be distinct; account id {0} is linked twice")] DuplicateAssetAccount(u64), - /// purchase_date + useful life overflows the date range. + /// purchase_date + useful life overflows the date range: a new asset's + /// last day of depreciation is 9999-12-31 at the latest. #[error("purchase date plus useful life overflows the calendar")] AssetDateOverflow, + /// A depreciation run for a month that can't be run: not a month, or + /// one ending before the asset was placed in service. + #[error("invalid depreciation period: {0}")] + InvalidDepreciationPeriod(String), + + /// An entry of a fixed asset's that can't be reversed (its disposal, + /// or depreciation the disposal took as final), with why. + #[error("entry {0} can't be reversed: {1}")] + AssetEntryIrreversible(u64, String), + + /// A disposal refused because a depreciation run of the asset carries + /// at the end of the disposal's month other than what it carries now + /// (a run of a later month that stands; a reversal or re-instatement + /// dated after the month, not undone), with what to do. + #[error("the disposal is dated before depreciation the books carry: {0}")] + DepreciationPastDisposal(String), + /// An amortization schedule or recurring entry is invalid, unknown, or /// already posted for that date. #[error("invalid schedule: {0}")] diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 68c0598..600705d 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -42,7 +42,13 @@ //! zero, and mirrored by a reversal (B-14 rule 11); //! * a party's part of an entry is signed as a line on the party's own //! account would be, a reversal's is its original's negated, and what of -//! it counts as money or as property never exceeds it (B-35). +//! it counts as money or as property never exceeds it (B-35); +//! * a span prorated by days (depreciation, B-15; daily schedules, B-16) +//! splits into parts that sum to its total, each but the last its days +//! times the monthly rate within one unit, the last the remainder; +//! * a catch-up run brings the books to the schedule without ever carrying +//! more than it says at a month end already posted (B-15), and a +//! disposal trues what the books carry up or down to the schedule. use crate::{AccountType, Side}; use rust_decimal::Decimal; @@ -1022,6 +1028,380 @@ pub fn reverse_target(t: PartyTarget) -> Option> { }) } +// --------------------------------------------------------------------------- +// Proration by days +// --------------------------------------------------------------------------- + +/// Exact integer arithmetic the proration by days needs: money as a whole +/// number of its smallest step (`i128` in the engine; `i64` and, for the +/// harnesses whose properties relate a product to a quotient, `i32` under +/// Kani). Kept apart from [`Amount`]: proration multiplies and divides, +/// which no journal line ever does. +pub trait Units: Copy + Ord { + /// Zero. + const ZERO: Self; + /// One. + const ONE: Self; + /// `n`, exactly, or `None` when it doesn't fit. + fn from_u32(n: u32) -> Option; + /// `self + rhs`, or `None` on overflow. + fn checked_add(self, rhs: Self) -> Option; + /// `self - rhs`, or `None` on overflow. + fn checked_sub(self, rhs: Self) -> Option; + /// `self * rhs`, or `None` on overflow. + fn checked_mul(self, rhs: Self) -> Option; + /// `(self / rhs, self % rhs)` for `self >= 0` and `rhs > 0`; `None` + /// otherwise. + fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)>; +} + +impl Units for i32 { + const ZERO: Self = 0; + const ONE: Self = 1; + fn from_u32(n: u32) -> Option { + i32::try_from(n).ok() + } + fn checked_add(self, rhs: Self) -> Option { + i32::checked_add(self, rhs) + } + fn checked_sub(self, rhs: Self) -> Option { + i32::checked_sub(self, rhs) + } + fn checked_mul(self, rhs: Self) -> Option { + i32::checked_mul(self, rhs) + } + fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)> { + if self < 0 || rhs <= 0 { + return None; + } + Some((self / rhs, self % rhs)) + } +} + +impl Units for i64 { + const ZERO: Self = 0; + const ONE: Self = 1; + fn from_u32(n: u32) -> Option { + Some(i64::from(n)) + } + fn checked_add(self, rhs: Self) -> Option { + i64::checked_add(self, rhs) + } + fn checked_sub(self, rhs: Self) -> Option { + i64::checked_sub(self, rhs) + } + fn checked_mul(self, rhs: Self) -> Option { + i64::checked_mul(self, rhs) + } + fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)> { + if self < 0 || rhs <= 0 { + return None; + } + Some((self / rhs, self % rhs)) + } +} + +impl Units for i128 { + const ZERO: Self = 0; + const ONE: Self = 1; + fn from_u32(n: u32) -> Option { + Some(i128::from(n)) + } + fn checked_add(self, rhs: Self) -> Option { + i128::checked_add(self, rhs) + } + fn checked_sub(self, rhs: Self) -> Option { + i128::checked_sub(self, rhs) + } + fn checked_mul(self, rhs: Self) -> Option { + i128::checked_mul(self, rhs) + } + fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)> { + if self < 0 || rhs <= 0 { + return None; + } + Some((self / rhs, self % rhs)) + } +} + +/// lcm(28, 29, 30, 31): every month's length divides it, so a day of any +/// month is a whole number of `MONTH_L`ths of a month, and proration needs +/// no fractions. +pub const MONTH_L: u32 = 377_580; + +/// One calendar month's share of a span of days: `days` of the month's +/// `month_days`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MonthPart { + /// Days of the span in the month, 1 to `month_days`. + pub days: u32, + /// Days in the month, 28 to 31. + pub month_days: u32, +} + +impl MonthPart { + /// Whether it is a share a calendar month can have: 28 to 31 days in + /// the month, 1 to all of them in the span. + pub fn is_valid(self) -> bool { + (28..=31).contains(&self.month_days) && (1..=self.month_days).contains(&self.days) + } +} + +/// A part's weight in `MONTH_L`ths of a month: `days × (MONTH_L / +/// month_days)`, exactly. `None` for a part no month has. +pub fn part_weight(p: MonthPart) -> Option { + if !p.is_valid() { + return None; + } + T::from_u32(p.days)?.checked_mul(T::from_u32(MONTH_L / p.month_days)?) +} + +/// `num / den` rounded half away from zero to a multiple of `unit` (`num` +/// not negative; `den` and `unit` positive). +fn round_to_unit(num: T, den: T, unit: T) -> Option { + let step = den.checked_mul(unit)?; + let (q, r) = num.checked_div_rem(step)?; + // Up when the remainder is half a step or more: compared as r >= + // step - r, so nothing is doubled and nothing can overflow. + let q = if r >= step.checked_sub(r)? { + q.checked_add(T::ONE)? + } else { + q + }; + q.checked_mul(unit) +} + +/// The exact cumulative charge of `total` (in units) spread over `months` +/// months by day, through `k` days of `part`, after earlier parts weighing +/// `before` `MONTH_L`ths; `last` marks the span's last part. Rounded half +/// away from zero (half up: nothing here is negative) to a multiple of +/// `unit`, or `total` itself when that rounds above it (a total that isn't +/// a whole number of units); and the last part's last day is `total`. +/// +/// ```text +/// GOAL: how much of a total a span of days has used up by a given day, +/// each day of a month bearing the same share, the whole total +/// reached on the span's last day and never before. +/// SOURCE: docs/spec/rules.md B-15 (straight line, partial months by days; +/// ASC 360-10-35-4: "systematic and rational", as equitably as +/// possible to the periods served), B-16 (daily schedules, D-11), +/// B-21 (exact totals). +/// INVARIANTS (W = months × MONTH_L, m = part.month_days, d = part.days): +/// 1. Not the last part: round(total × (before + k × MONTH_L / m) / W): +/// the monthly rate total/months times the days held over the days +/// in each month. +/// 2. The last part: round(total × (before × d + (W − before) × k) / +/// (W × d)): what the earlier parts leave of the total, C = total × +/// before / W, plus the remainder R = total − C spread evenly over +/// its d days. It is never "plugged": through an earlier day of the +/// last month it is prorated by days. +/// 3. It is non-decreasing in (part, k), never above total, continuous +/// at part boundaries ((i, days_i) equals (i + 1, 0)), and exactly +/// total at (last, d). +/// SCENARIOS: +/// 1. IF total is negative, months is 0, unit isn't positive, the part +/// is no month's (see MonthPart::is_valid), k > d, before is negative +/// or above W, a part but the last is charged through a day past W +/// (before + k × MONTH_L / m > W; prorate_by_days charges every part +/// but the last through its last day, so it refuses a part ending past +/// W), or any product overflows: +/// - SHOULD return None +/// - NEVER panic +/// BECAUSE a figure the books can't compute is refused (rules.md +/// B-28), and symbolic parts are not a calendar. +/// 2. IF 12,000.00 over 12 months from Jan 1, through Dec 15 (the last +/// part, 31 days, before = 11 months): +/// - SHOULD be 11,000.00 + 1,000.00 × 15/31 = 11,483.87 +/// - NEVER 12,000.00 (the old plug) +/// BECAUSE the final month is charged by its days like any other. +/// ``` +pub fn prorated_through( + total: T, + months: u32, + before: T, + part: MonthPart, + k: u32, + last: bool, + unit: T, +) -> Option { + if total < T::ZERO + || months == 0 + || unit <= T::ZERO + || !part.is_valid() + || k > part.days + || before < T::ZERO + { + return None; + } + let whole = T::from_u32(months)?.checked_mul(T::from_u32(MONTH_L)?)?; + if before > whole { + return None; + } + let charged = if last { + if k == part.days { + // Exactly the total, even one that isn't a whole number of + // units (an asset an earlier version took in tenths of a cent). + return Some(total); + } + let d = T::from_u32(part.days)?; + let left = whole.checked_sub(before)?; + let elapsed = before + .checked_mul(d)? + .checked_add(left.checked_mul(T::from_u32(k)?)?)?; + round_to_unit(total.checked_mul(elapsed)?, whole.checked_mul(d)?, unit)? + } else { + let rate = T::from_u32(MONTH_L / part.month_days)?; + let elapsed = before.checked_add(T::from_u32(k)?.checked_mul(rate)?)?; + if elapsed > whole { + return None; + } + round_to_unit(total.checked_mul(elapsed)?, whole, unit)? + }; + // A total that isn't a whole number of units can round above itself + // on the day before the last; the cap keeps every part non-negative. + Some(charged.min(total)) +} + +/// Split `total` (in units) over a span of `months` months cut into +/// calendar-month `parts`: `out[i]` is the cumulative charge through the +/// end of part `i` ([`prorated_through`]) less the one before it, and the +/// last part is `total` less all the others. `out` must be as long as +/// `parts`; on `None` its contents mean nothing. +/// +/// ```text +/// GOAL: one month's posting of a span spread by day (depreciation, +/// daily prepaid and deferred-revenue schedules). +/// SOURCE: docs/spec/rules.md B-15, B-16 (D-11), B-21; plan/served.md P3 +/// defect 9 (one shared prorate_by_days, Kani: parts sum to the +/// total, each is days × rate within one unit, the last absorbs +/// the remainder). +/// INVARIANTS: +/// 1. The parts sum to total exactly. +/// 2. Every part is >= 0. +/// 3. Each part but the last is within one unit of total × days_i / +/// (months × month_days_i). +/// 4. The last part is total − Σ others. +/// SCENARIOS: +/// 1. IF there are no parts, out isn't as long as parts, or prorated_ +/// through refuses a part (the weights of all but the last above W, +/// among others): SHOULD return None, never panic. +/// 2. IF 1,200.00 over 12 months from Mar 15 (parts 17/31, eleven full +/// months, then 14 days): +/// - SHOULD be 54.84, eleven × 100.00, then 45.16 +/// BECAUSE each day bears the same share, and the last part takes +/// what the others leave. +/// ``` +/// +/// Allocation-free and O(n): a loop over fixed slices, so the model +/// checker can prove it outright. +pub fn prorate_by_days( + total: T, + months: u32, + parts: &[MonthPart], + unit: T, + out: &mut [T], +) -> Option<()> { + if out.len() != parts.len() { + return None; + } + let (&last, rest) = parts.split_last()?; + let mut before = T::ZERO; + let mut charged = T::ZERO; + for (o, &p) in out.iter_mut().zip(rest) { + let through = prorated_through(total, months, before, p, p.days, false, unit)?; + *o = through.checked_sub(charged)?; + charged = through; + before = before.checked_add(part_weight(p)?)?; + } + // The last part is checked too (its weight before it within the span), + // and its whole is the total, so it takes the remainder. + let through = prorated_through(total, months, before, last, last.days, true, unit)?; + *out.last_mut()? = through.checked_sub(charged)?; + Some(()) +} + +// --------------------------------------------------------------------------- +// Catching up and truing up +// --------------------------------------------------------------------------- + +/// What a run catches up: the largest amount, at least zero, that can be +/// added to what the books carry at every month end from the run's on +/// without carrying more than the schedule says there: +/// `max(0, min_i(scheduled[i] − posted[i]))`, where `scheduled[i]` and +/// `posted[i]` are cumulative at successive month ends starting with the +/// run's own. `None` when the slices differ in length or are empty, or on +/// overflow. +/// +/// ```text +/// GOAL: a skipped month is caught up in the month the run is made, and a +/// run never overtakes the schedule at a month end already posted. +/// SOURCE: plan/served.md P3 "Settled in design" (2026-10-20): a month of +/// depreciation skipped is caught up in the month the run is made +/// (an immaterial miss; a material one is restated, ASC 250); +/// docs/spec/rules.md B-15. +/// INVARIANTS: +/// 1. The result is >= 0. +/// 2. Whenever every posted[i] <= scheduled[i], posted[i] + result <= +/// scheduled[i] for every i: the run never carries the books past the +/// schedule at any month end it reaches. +/// 3. The result is 0 or some scheduled[i] − posted[i]: with one month +/// (no run after this one), it is everything the schedule says is due +/// through it. +/// ``` +pub fn catch_up(scheduled: &[T], posted: &[T]) -> Option { + if scheduled.len() != posted.len() { + return None; + } + let mut least: Option = None; + for (&s, &p) in scheduled.iter().zip(posted) { + let gap = s.checked_sub(p)?; + least = Some(match least { + Some(l) if l <= gap => l, + _ => gap, + }); + } + let least = least?; + Some(if least > T::ZERO { least } else { T::ZERO }) +} + +/// [`catch_up`] of every suffix at once: `out[i]` is `catch_up(&scheduled[i..], +/// &posted[i..])`, what a run at each month end posts. One pass from the +/// end, O(n), so a viewer can say of every month of a long life whether +/// anything is due for it. `None` when the slices differ in length, or on +/// overflow. +pub fn catch_ups(scheduled: &[T], posted: &[T], out: &mut [T]) -> Option<()> { + if scheduled.len() != posted.len() || out.len() != scheduled.len() { + return None; + } + let mut least: Option = None; + for ((&s, &p), o) in scheduled.iter().zip(posted).zip(out.iter_mut()).rev() { + let gap = s.checked_sub(p)?; + let l = match least { + Some(l) if l <= gap => l, + _ => gap, + }; + least = Some(l); + *o = if l > T::ZERO { l } else { T::ZERO }; + } + Some(()) +} + +/// Bring what the books carry (`posted`) to `target`: `(post, take_back)`, +/// both >= 0, one of them zero, with `posted + post − take_back = target`. +/// This is [`columns`] of `target − posted`: raising the books by `post` +/// and lowering them by `take_back` are a debit and a credit of the gap. +/// +/// ```text +/// GOAL: a disposal clears exactly what the schedule says was used up +/// through its day, whatever the runs posted. +/// SOURCE: docs/spec/rules.md B-15 (a disposed asset depreciates through +/// its disposal day); Ledger::dispose_asset INVARIANT 1 (gain = +/// proceeds − book value at the disposal date). +/// ``` +pub fn true_up(target: T, posted: T) -> Option<(T, T)> { + columns(target.checked_sub(posted)?) +} + #[cfg(test)] mod tests { use super::*; @@ -1140,6 +1520,236 @@ mod tests { assert_eq!(Received.mirror(), Paid); } + fn part(days: u32, month_days: u32) -> MonthPart { + MonthPart { days, month_days } + } + + /// Every month length divides MONTH_L, and it is the least such. + #[test] + fn month_l_is_the_lcm_of_month_lengths() { + for m in 28..=31 { + assert_eq!(MONTH_L % m, 0, "{m}"); + } + for smaller in 1..MONTH_L { + assert!((28..=31).any(|m| smaller % m != 0), "{smaller}"); + } + assert_eq!(part_weight::(part(31, 31)), Some(i64::from(MONTH_L))); + assert_eq!(part_weight::(part(1, 28)), Some(13_485)); + assert_eq!(part_weight::(part(0, 30)), None); + assert_eq!(part_weight::(part(32, 31)), None); + assert_eq!(part_weight::(part(27, 27)), None); + } + + /// Spec scenario 2 of prorated_through, and the brief's 1,200.00 from + /// March 15 (prorate_by_days scenario 2), in cents. + #[test] + fn the_final_month_is_prorated_and_a_mid_month_span_splits_by_day() { + let l = i128::from(MONTH_L); + let dec15 = prorated_through(1_200_000i128, 12, 11 * l, part(31, 31), 15, true, 1); + assert_eq!(dec15, Some(1_148_387)); + assert_eq!( + prorated_through(1_200_000i128, 12, 11 * l, part(31, 31), 31, true, 1), + Some(1_200_000) + ); + let mut parts = vec![part(17, 31)]; + parts.extend([30, 31, 30, 31, 31, 30, 31, 30, 31, 31, 28].map(|m| part(m, m))); + parts.push(part(14, 31)); + let mut out = vec![0i128; parts.len()]; + prorate_by_days(120_000i128, 12, &parts, 1, &mut out).unwrap(); + let mut want = vec![5_484i128]; + want.extend([10_000; 11]); + want.push(4_516); + assert_eq!(out, want); + } + + /// Half a unit rounds away from zero (up, for the non-negative amounts + /// here): 0.01 over 2 months from the 1st gives 0.005 after one month. + #[test] + fn half_a_unit_rounds_up() { + let mut out = [0i64; 2]; + prorate_by_days(1, 2, &[part(31, 31), part(28, 28)], 1, &mut out).unwrap(); + assert_eq!(out, [1, 0]); + // Just under half a unit stays down: 0.01 over 3 months. + let mut out = [0i64; 3]; + prorate_by_days( + 1, + 3, + &[part(31, 31), part(28, 28), part(31, 31)], + 1, + &mut out, + ) + .unwrap(); + assert_eq!(out, [0, 1, 0]); + } + + /// R4(a): a total that isn't a whole number of units (100.006 in + /// thousandths, rounded to cents) never makes the last part negative. + #[test] + fn a_total_finer_than_its_unit_never_gives_a_negative_part() { + let l = i64::from(MONTH_L); + let mut out = [0i64; 2]; + prorate_by_days(100_006, 2, &[part(31, 31), part(28, 28)], 10, &mut out).unwrap(); + assert_eq!(out, [50_000, 50_006]); + // 30 of 31 days of a one-month span: 100.006 × 30/31 = 96.780 + // exactly, already whole cents. + assert_eq!( + prorated_through(100_006i64, 1, 0, part(31, 31), 30, true, 10), + Some(96_780) + ); + // One day short of a 4,000-month span: 100.006 less 0.0008 is + // 100.0052, which rounds to 100.01, past the total: capped there, + // so the day left gets 0, never -0.004. + let w = 4_000 * l; + assert_eq!( + prorated_through(100_006i64, 4_000, w - l, part(30, 31), 30, false, 10), + Some(100_006) + ); + } + + /// Spec scenario 1 of prorated_through (R4(b), R4(c)): malformed input + /// is refused, never a panic. + #[test] + fn malformed_proration_is_refused() { + let l = i64::from(MONTH_L); + let p = part(10, 30); + assert_eq!(prorated_through(-1i64, 1, 0, p, 1, false, 1), None); + assert_eq!(prorated_through(1i64, 0, 0, p, 1, false, 1), None); + assert_eq!(prorated_through(1i64, 1, 0, p, 1, false, 0), None); + assert_eq!(prorated_through(1i64, 1, 0, p, 11, false, 1), None); + assert_eq!(prorated_through(1i64, 1, -1, p, 1, false, 1), None); + assert_eq!(prorated_through(1i64, 1, l + 1, p, 1, true, 1), None); + assert_eq!(prorated_through(1i64, 1, 0, part(0, 30), 0, false, 1), None); + assert_eq!( + prorated_through(1i64, 1, 0, part(31, 30), 1, false, 1), + None + ); + // A part but the last that ends past the span. + assert_eq!(prorated_through(1i64, 1, l, part(1, 31), 1, false, 1), None); + let mut out = [0i64; 3]; + assert_eq!( + prorate_by_days( + 100, + 1, + &[part(31, 31), part(31, 31), part(5, 30)], + 1, + &mut out + ), + None + ); + assert_eq!(prorate_by_days(100i64, 1, &[], 1, &mut []), None); + assert_eq!( + prorate_by_days(100i64, 1, &[part(31, 31)], 1, &mut []), + None + ); + assert_eq!( + prorated_through(i64::MAX, 600, 0, part(31, 31), 31, false, 1), + None + ); + } + + /// prorated_through over every span of 1 to 3 months starting on any + /// day of a common year and a leap year, walked day by day, for a + /// spread of totals and both units: it never falls (within a month and + /// across a month's end: the charge through a month's last day is the + /// next month's through none of its days), never exceeds the total, and + /// is the total on the last day. Exhaustive over those spans; the + /// relation between two divisions is too costly for Kani's solver + /// (docs/verification.md), and Lean proves the law for the model. + /// Not under Miri, which looks for undefined behavior: this is safe + /// integer arithmetic the other kernel tests already take through it, + /// and walked exhaustively under the interpreter it held `just miri` + /// for over ten minutes. + #[test] + #[cfg_attr( + miri, + ignore = "exhaustive arithmetic: the other kernel tests cover it under Miri" + )] + fn the_charge_never_falls_and_is_continuous_over_real_calendars() { + use chrono::NaiveDate; + let mut start = NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(); + let stop = NaiveDate::from_ymd_opt(2029, 1, 1).unwrap(); + let mut spans = 0; + while start < stop { + for months in 1..=3u32 { + let end = start + .checked_add_months(chrono::Months::new(months)) + .unwrap(); + let parts: Vec<_> = crate::calendar::month_parts(start, end).collect(); + for total in [0i64, 1, 3, 7, 10, 999, 100_005, 65_536] { + for unit in [1i64, 10] { + let mut before = 0i64; + let mut last_charge = 0i64; + for p in &parts { + for k in 0..=p.part.days { + let c = prorated_through( + total, months, before, p.part, k, p.last, unit, + ) + .unwrap(); + assert!( + c >= last_charge && c <= total, + "{start} + {months}: {c} after {last_charge}" + ); + last_charge = c; + } + before += part_weight::(p.part).unwrap(); + } + assert_eq!(last_charge, total, "{start} + {months}"); + } + } + spans += 1; + } + start = start.succ_opt().unwrap(); + } + assert_eq!(spans, 731 * 3); + } + + /// catch_up: everything due through the run's month when no run comes + /// after it; never past a later month end already posted. + #[test] + fn a_catch_up_never_overtakes_a_posted_month_end() { + // Jan run, Feb skipped, a Feb run with nothing after it: Feb's. + assert_eq!(catch_up(&[200i64], &[100]), Some(100)); + // March already caught February up: a Feb run posts nothing. + assert_eq!(catch_up(&[200i64, 300], &[100, 300]), Some(0)); + // A run carried past the schedule (an old tie cent): nothing. + assert_eq!(catch_up(&[200i64], &[201]), Some(0)); + // January reversed after March ran: January posts it back. + assert_eq!(catch_up(&[100i64, 200, 300], &[0, 100, 200]), Some(100)); + assert_eq!(catch_up::(&[], &[]), None); + assert_eq!(catch_up(&[1i64], &[1, 2]), None); + } + + /// catch_ups is catch_up of every suffix: positive and distinct ones + /// (a catch_ups that wrote zeros would fail), and a month carried past + /// the schedule (zero from it back). + #[test] + fn catch_ups_is_every_suffixs_catch_up() { + let (s, p) = ([100i64, 200, 300, 400], [0i64, 100, 150, 250]); + let mut out = [0i64; 4]; + catch_ups(&s, &p, &mut out).unwrap(); + for i in 0..4 { + assert_eq!(Some(out[i]), catch_up(&s[i..], &p[i..]), "{i}"); + } + assert_eq!(out, [100, 100, 150, 150]); + let (s, p) = ([100i64, 200, 300, 400], [0i64, 300, 200, 400]); + catch_ups(&s, &p, &mut out).unwrap(); + for i in 0..4 { + assert_eq!(Some(out[i]), catch_up(&s[i..], &p[i..]), "{i}"); + } + assert_eq!(out, [0, 0, 0, 0]); + let mut none: [i64; 0] = []; + assert_eq!(catch_ups::(&[], &[], &mut none), Some(())); + assert_eq!(catch_ups(&s, &p, &mut [0i64; 3]), None); + } + + /// true_up is the gap's columns. + #[test] + fn a_true_up_reaches_the_target() { + assert_eq!(true_up(5_500i64, 5_000), Some((500, 0))); + assert_eq!(true_up(5_500i64, 6_000), Some((0, 500))); + assert_eq!(true_up(5_500i64, 5_500), Some((0, 0))); + } + #[test] fn max_line_amount_is_one_quadrillion() { assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64)); diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 5778ad5..99b5e24 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -345,8 +345,9 @@ impl Ledger { } /// [`Self::register_schedule`] without the total's range and minor-unit - /// checks added on 2026-10-13, for replaying journals written before - /// them. Never for new work. + /// checks added on 2026-10-13, or the calendar bound and the + /// equal-months start on the 1st (D-37) added since, for replaying + /// journals written before them. Never for new work. pub fn replay_register_schedule( &mut self, new: NewSchedule, @@ -366,11 +367,73 @@ impl Ledger { balance_account: new.balance_account, pl_account: new.pl_account, posted: Default::default(), + convention: new.convention, + postings: Default::default(), }, ); Ok(id) } + /// Whether a schedule's month was ever posted (and so, if it isn't + /// posted now, a reversal un-posted it): posting it again needs a + /// journal an older version refuses (store feature `month_reposting`). + pub fn schedule_month_posted_before(&self, id: ScheduleId, month_end: NaiveDate) -> bool { + self.schedules + .get(&id) + .and_then(|s| s.postings.get(&month_end)) + .is_some_and(|p| !p.is_empty()) + } + + /// The latest date after a schedule month's end that one of its + /// postings was reversed (or re-instated) on, if any: posting the month + /// again on its own date would have the books carry it twice from then + /// until that date, so it is left for a human + /// ([`Self::preview_schedule_month`], [`Self::schedule_month_hold`]). + /// + /// ```text + /// GOAL: a schedule month is never recognized twice at a month end. + /// SOURCE: docs/spec/rules.md B-9 (a correction is dated when it is + /// made), B-16; P3 depreciation review R3 (a reversed month is + /// due again: when the reversal is the month's own). + /// ``` + pub fn schedule_month_reversed_after( + &self, + id: ScheduleId, + month_end: NaiveDate, + ) -> Option { + self.schedules + .get(&id)? + .postings + .get(&month_end)? + .iter() + .flat_map(|&e| { + self.reversal_chain(e) + .into_iter() + .skip_while(move |&c| c != e) + .skip(1) + }) + .filter_map(|c| self.entries.get(&c).map(JournalEntry::date)) + .filter(|d| *d > month_end) + .max() + } + + /// Why a run of schedules leaves a due month for a human, if it must: + /// it is dated in a closed period (which takes no new entries), or its + /// posting was reversed after the month ended + /// ([`Self::schedule_month_reversed_after`]). + pub fn schedule_month_hold(&self, id: ScheduleId, month_end: NaiveDate) -> Option { + if let Some(p) = self.closed_period_containing(month_end) { + return Some(format!( + "{month_end} is in closed period {p}: book it in an open one by hand, or not at all" + )); + } + self.schedule_month_reversed_after(id, month_end).map(|r| { + format!( + "its posting was reversed on {r}, after the month ended: posting it again on {month_end} would recognize the month twice until then; book it by hand in an open period, or not at all" + ) + }) + } + /// The id the next schedule will receive (for journaling stores). pub fn next_schedule_id(&self) -> ScheduleId { self.next_schedule_id @@ -409,6 +472,11 @@ impl Ledger { "schedule {id} already posted {date}" ))); } + if let Some(r) = self.schedule_month_reversed_after(id, date) { + return Err(LedgerError::InvalidSchedule(format!( + "schedule {id}'s {date} posting was reversed on {r}, after its month ended: posting it again on {date} would recognize the month twice until then (book it by hand in an open period, or not at all)" + ))); + } let money = Money::new(amount, s.total.currency); let (debit, credit) = s.accounts(); let what = match s.kind { @@ -448,11 +516,9 @@ impl Ledger { self.add_line(entry, account, side, money)?; } self.post_entry(entry)?; - self.schedules - .get_mut(&id) - .expect("previewed") - .posted - .insert(date); + let s = self.schedules.get_mut(&id).expect("previewed"); + s.posted.insert(date); + s.postings.entry(date).or_default().push(entry); Ok(entry) } @@ -1615,6 +1681,14 @@ impl Ledger { /// 4. IF `date` falls in a closed period: /// - SHOULD refuse with `ClosedPeriod` /// BECAUSE closed periods take no new postings. + /// 5. IF the entry reverses a schedule month's posting (so reversing + /// it re-instates that posting) and the month has been posted + /// again since: + /// - SHOULD refuse (InvalidSchedule), naming the entry that posts + /// it now + /// - NEVER recognize one month of a schedule twice + /// BECAUSE a reversed month is due again and may have been run + /// again (P3 depreciation review R3). /// ``` pub fn check_reversal(&self, entry: EntryId, date: NaiveDate) -> Result<(), LedgerError> { let original = self @@ -1636,9 +1710,74 @@ impl Ledger { if let Some(pid) = self.closed_period_containing(date) { return Err(LedgerError::ClosedPeriod(pid, date.to_string())); } + // SCENARIO 5. `entry` ends its chain (it isn't reversed), so + // reversing it flips whether the chain's original stands. + if let Some(top) = self.schedule_chain_top(entry) + && !self.in_effect(top) + && let Some((s, month)) = self.schedule_month_of(top) + && let Some(now) = s.postings[&month] + .iter() + .find(|&&e| e != top && self.in_effect(e)) + { + return Err(LedgerError::InvalidSchedule(format!( + "reversing entry {entry} would post schedule {}'s {month} a second time: entry {now} posts it now (reverse that one instead)", + s.id + ))); + } Ok(()) } + /// The original an entry's reversal chain starts from (the entry itself + /// when it reverses nothing), when some schedule month was ever posted: + /// books without one skip the chain walk. + fn schedule_chain_top(&self, entry: EntryId) -> Option { + if self.schedules.values().all(|s| s.postings.is_empty()) { + return None; + } + Some(self.reversal_chain(entry).first().copied().unwrap_or(entry)) + } + + /// Whether a posted entry's effect stands: nothing reverses it, or what + /// reverses it is itself reversed, and so on (an even number of + /// reversals after it in its chain). A draft or an unknown id stands + /// for nothing. + /// + /// ```text + /// SOURCE: docs/spec/rules.md B-9 (a mistake is corrected by an + /// opposite entry), B-10 (reversed at most once; reversing the + /// reversal re-instates it). + /// ``` + pub fn in_effect(&self, entry: EntryId) -> bool { + if self + .entries + .get(&entry) + .is_none_or(|e| e.status() != EntryStatus::Posted) + { + return false; + } + let chain = self.reversal_chain(entry); + chain + .iter() + .position(|&e| e == entry) + .is_some_and(|i| (chain.len() - 1 - i).is_multiple_of(2)) + } + + /// The schedule and month an entry posted, if it posted one (a + /// schedule month's posting; not its reversal). + pub fn schedule_posting(&self, entry: EntryId) -> Option<(ScheduleId, NaiveDate)> { + self.schedule_month_of(entry).map(|(s, m)| (s.id, m)) + } + + /// The schedule and month an entry posted, if it posted one. + fn schedule_month_of(&self, entry: EntryId) -> Option<(&AmortizationSchedule, NaiveDate)> { + self.schedules.values().find_map(|s| { + s.postings + .iter() + .find(|(_, es)| es.contains(&entry)) + .map(|(m, _)| (s, *m)) + }) + } + /// Post a reversing entry: same lines with sides flipped, linked back to /// the original via `reversal_of`. The books never forget; they correct. /// Refused (see [`Self::check_reversal`]) before anything changes. @@ -1695,6 +1834,21 @@ impl Ledger { if let Some(why) = self.entry_no_party.get(&entry).cloned() { self.entry_no_party.insert(id, why); } + // A reversed schedule month is un-posted, so it is due again; a + // reversed reversal posts it back (P3 depreciation review R3). + if let Some(top) = self.schedule_chain_top(entry) + && let Some((sid, month)) = self.schedule_month_of(top).map(|(s, m)| (s.id, m)) + { + let posted = self.schedules[&sid].postings[&month] + .iter() + .any(|&e| self.in_effect(e)); + let s = self.schedules.get_mut(&sid).expect("found"); + if posted { + s.posted.insert(month); + } else { + s.posted.remove(&month); + } + } Ok(id) } @@ -1709,19 +1863,23 @@ impl Ledger { /// Pure pre-validation for [`Self::register_asset`]: every check that /// does not mutate, so journaling stores can validate before journaling. - /// [`Self::validate_new_asset_journaled`], and both ends of the life - /// inside the calendar the books keep (crate::calendar): a life that - /// runs past 9999-12-31 is refused like one past chrono's last day (P0 - /// review round 3, S1). + /// [`Self::validate_new_asset_journaled`]; the in-service date and the + /// life's last day of depreciation inside the calendar the books keep + /// (crate::calendar): a life whose last day is after 9999-12-31 is + /// refused like one past chrono's last day (P0 review round 3, S1; P3: + /// the bound is the last day held, so a life ending on 9999-12-31 is + /// taken); and the cost and salvage in the currency's minor unit + /// (rules.md B-3: every depreciation and disposal line derives from + /// them, and a line finer than a cent could never post). pub fn validate_new_asset(&self, new: &NewAsset) -> Result<(), LedgerError> { self.check_new_asset(new, true) } /// The checks every journaled `asset.registered` and `asset.corrected` /// passed when it was written (the rule through 2026-10-16): - /// [`Self::validate_new_asset`] without the calendar bound, the end of - /// the life only representable. Replay applies exactly these - /// (rules.md B-28). + /// [`Self::validate_new_asset`] without the calendar bound or the minor + /// unit, the end of the life only representable. Replay applies + /// exactly these (rules.md B-28). pub fn validate_new_asset_journaled(&self, new: &NewAsset) -> Result<(), LedgerError> { self.check_new_asset(new, false) } @@ -1758,17 +1916,25 @@ impl Ledger { if new.useful_life_months < 1 { return Err(LedgerError::InvalidUsefulLife(new.useful_life_months)); } + if strict { + check_minor_units(new.cost)?; + check_minor_units(new.salvage_value)?; + } // The schedule walks every month of the asset's life; a purchase // date near the end of the calendar would overflow date arithmetic // (and hang or panic the schedule). Reject it up front. New ones - // keep both ends inside the calendar the books keep. + // keep the in-service date and the life's last day (the day before + // its exclusive end) inside the calendar the books keep, so every + // month of the life is a month the books can post. if strict { crate::calendar::check(new.purchase_date)?; } let end = new .purchase_date .checked_add_months(Months::new(new.useful_life_months)); - if !end.is_some_and(|end| !strict || crate::calendar::in_calendar(end)) { + if !end + .is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar)) + { return Err(LedgerError::AssetDateOverflow); } assets::validate_asset_accounts( @@ -2273,8 +2439,10 @@ impl Ledger { }); } let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; - // Pure math validates activeness, proceeds sign, and date order. - crate::disposal_amounts(asset, date, proceeds)?; + // Activeness, proceeds sign, and date order; no depreciation is + // computed, so a journaled disposal replays even where the figure + // can't be (rules.md B-28). + assets::check_disposal(asset, date, proceeds)?; if strict { // Proceeds are a line of the disposal entry, and its date one // the books keep (P0 review round 3, S1). diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 466461e..68c0793 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -52,8 +52,9 @@ pub use arap::{ }; pub use assets::{ Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, - ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value, - depreciation_schedule, disposal_amounts, + PostedRun, RunAmount, ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, + book_value, catch_up_room, check_disposal, depreciation_effects, depreciation_schedule, + disposal_amounts, life_end, run_amount, run_amounts, }; pub use cashflow::{ CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection, @@ -91,7 +92,7 @@ pub use reports::{ }; pub use schedules::{ AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId, - ScheduleId, ScheduleKind, TemplateLine, + ScheduleConvention, ScheduleId, ScheduleKind, TemplateLine, }; pub use shares::{PartyMember, PartyShare, Share}; pub use tax::{ diff --git a/crates/ledger-core/src/money.rs b/crates/ledger-core/src/money.rs index b1b2bf8..dba648a 100644 --- a/crates/ledger-core/src/money.rs +++ b/crates/ledger-core/src/money.rs @@ -148,6 +148,70 @@ impl fmt::Debug for Money { } } +/// A non-negative amount as a whole number of steps, for exact proration +/// by days (kernel::prorate_by_days): `units` steps of 10^-`scale` each, +/// where `scale` is the amount's own places or its currency's minor unit, +/// whichever is more, and `unit` is one minor unit in steps. +/// +/// ```text +/// GOAL: depreciation and daily schedules round to the currency's own +/// smallest unit (cents, whole yen, thousandths of a dinar), never a +/// fixed two places. +/// SOURCE: docs/spec/rules.md B-3 (no fraction of the minor unit; ISO 4217, +/// sources/iso-4217.md). +/// INVARIANTS: +/// 1. amount = units × 10^-scale exactly; unit = 10^(scale − minor). +/// 2. A currency with no minor unit (gold, SDRs) counts as two places. +/// 3. NEVER rounds: an amount that doesn't fit is None. +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Steps { + /// The amount, in steps. + pub units: i128, + /// One minor unit, in steps. + pub unit: i128, + /// Decimal places of one step. + pub scale: u32, + /// The currency's minor unit (two when it has none). + pub minor: u32, +} + +impl Steps { + /// `amount` (not negative) in `currency`'s steps. + pub fn of(amount: Decimal, currency: Currency) -> Option { + if amount.is_sign_negative() && !amount.is_zero() { + return None; + } + let minor = currency.minor_units().unwrap_or(2); + let n = amount.normalize(); + let scale = n.scale().max(minor); + let units = n + .mantissa() + .checked_mul(10i128.checked_pow(scale - n.scale())?)?; + Some(Steps { + units, + unit: 10i128.checked_pow(scale - minor)?, + scale, + minor, + }) + } + + /// `n` steps as a decimal, with the currency's minor places at least + /// (12,000 dollars prints 12000.00, 8,333 yen 8333). `None` past + /// Decimal's range. + pub fn decimal(&self, n: i128) -> Option { + let d = Decimal::try_from_i128_with_scale(n, self.scale).ok()?; + if self.scale == self.minor { + return Some(d); + } + let mut d = d.normalize(); + if d.scale() < self.minor { + d.rescale(self.minor); + } + Some(d) + } +} + impl fmt::Display for Money { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.amount, self.currency) @@ -214,6 +278,33 @@ mod tests { assert!(!usd(d(-1)).is_zero()); } + /// Steps INVARIANTS 1-3: cents for dollars, whole yen, thousandths of + /// a dinar, two places for gold; an amount finer than its currency + /// keeps its own places, with the unit a minor unit. + #[test] + fn steps_are_the_currencys_minor_unit() { + let s = + |a: &str, c: &str| Steps::of(a.parse().unwrap(), Currency::new(c).unwrap()).unwrap(); + let usd = s("12000", "USD"); + assert_eq!((usd.units, usd.unit, usd.scale), (1_200_000, 1, 2)); + assert_eq!(usd.decimal(1_148_387).unwrap().to_string(), "11483.87"); + assert_eq!(usd.decimal(1_200_000).unwrap().to_string(), "12000.00"); + let jpy = s("100000", "JPY"); + assert_eq!((jpy.units, jpy.unit, jpy.scale), (100_000, 1, 0)); + assert_eq!(jpy.decimal(8_333).unwrap().to_string(), "8333"); + let kwd = s("1000.000", "KWD"); + assert_eq!((kwd.units, kwd.unit, kwd.scale), (1_000_000, 1, 3)); + let gold = s("5", "XAU"); + assert_eq!((gold.units, gold.unit, gold.scale), (500, 1, 2)); + let fine = s("100.006", "USD"); + assert_eq!((fine.units, fine.unit, fine.scale), (100_006, 10, 3)); + assert_eq!(fine.decimal(100_010).unwrap().to_string(), "100.01"); + assert_eq!(fine.decimal(100_006).unwrap().to_string(), "100.006"); + assert_eq!(fine.decimal(0).unwrap().to_string(), "0.00"); + assert_eq!(Steps::of(d(-1), Currency::USD), None); + assert_eq!(usd.decimal(i128::MAX), None); + } + #[test] fn checked_arithmetic_rejects_currency_mismatch() { let eur = Money::new(d(100), Currency::EUR); diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index c2fa050..7717f11 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -4,17 +4,22 @@ //! harness: CBMC explores *every* input within the stated bounds, not a //! sample, and fails if any one of them breaks an assertion, panics, or //! overflows. The code under proof is the engine's own [`crate::kernel`], -//! instantiated at `i64` (exact integer money, in the smallest unit); the -//! engine runs the same functions on `Decimal`, and the `kernel_bridge` -//! property test checks the two instantiations agree. +//! instantiated at `i64` (exact integer money, in the smallest unit; the +//! four charge harnesses of the proration by days at `i32`); the engine +//! runs the same functions on `Decimal` (proration on `i128`), and the +//! `kernel_bridge` property tests check the instantiations agree. //! //! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and //! amounts up to 2^40 in magnitude; the statement of cash flows' entry -//! harnesses, up to [`NF`] lines, each in any of the four places. The Lean -//! model in `verify/lean` proves the same laws for any number of lines and -//! accounts. +//! harnesses, up to [`NF`] lines, each in any of the four places; +//! proration over up to [`PR`] month parts, 1 to 3 months, totals up to +//! [`PR_TOTAL`] units (the charges: 1 or 2 months, totals to 2,048 units, +//! 64 in a span's last month). The Lean model in `verify/lean` proves the +//! same laws for any number of lines and accounts, and the proration's +//! for any number of parts and any sizes (docs/verification.md says +//! which, and how it ties to the kernel's formula). -use crate::kernel::{self, Amount, LineError}; +use crate::kernel::{self, Amount, LineError, MONTH_L, MonthPart}; use crate::{AccountType, Side}; /// Most lines per entry the harnesses explore. @@ -1354,6 +1359,322 @@ fn reverse_target_is_an_involution() { kani::cover!(t.amount == i64::MIN, "the range's edge"); } +// --------------------------------------------------------------------------- +// Proration by days (depreciation, B-15; daily schedules, B-16) +// --------------------------------------------------------------------------- + +/// Most month parts the proration harnesses explore. +const PR: usize = 3; + +/// Any share a calendar month can have: 28 to 31 days in the month, 1 to +/// all of them. +fn any_part() -> MonthPart { + let month_days: u32 = kani::any(); + kani::assume((28..=31).contains(&month_days)); + let days: u32 = kani::any(); + kani::assume(days >= 1 && days <= month_days); + MonthPart { days, month_days } +} + +/// The largest total the proration harnesses explore, in units: 2^16 +/// (655.36 in cents). The kernel divides a symbolic 64-bit product by a +/// symbolic divisor, which the SAT solver finds costly; narrowed from 2^20 +/// first, as the P3 brief says, before anything else (docs/verification.md +/// says what no bound made tractable, and what proves it instead). +const PR_TOTAL: i64 = 1 << 16; + +/// A total up to `PR_TOTAL` units, 1 to 3 months, 1 to `PR` parts, a unit +/// of 1 or 10 (cents, or cents of an amount in tenths of a cent). +fn any_proration() -> (i64, u32, [MonthPart; PR], usize, i64) { + let total: i64 = kani::any(); + kani::assume(total >= 0 && total <= PR_TOTAL); + let months: u32 = kani::any(); + kani::assume(months >= 1 && months <= 3); + let parts: [MonthPart; PR] = core::array::from_fn(|_| any_part()); + let n: usize = kani::any(); + kani::assume(n >= 1 && n <= PR); + let unit = if kani::any() { 1 } else { 10 }; + (total, months, parts, n, unit) +} + +/// The weight of parts, in MONTH_L-ths of a month. +fn weight(parts: &[MonthPart]) -> i64 { + parts + .iter() + .map(|p| i64::from(p.days) * i64::from(MONTH_L / p.month_days)) + .sum() +} + +/// The parts of a total prorated by days sum to it exactly, and none is +/// negative; and every split whose parts but the last fit in the span is +/// computed (only symbolic parts that are no calendar's are refused). +#[kani::proof] +#[kani::unwind(5)] +fn prorate_parts_sum_to_total() { + let (total, months, parts, n, unit) = any_proration(); + let mut out = [0i64; PR]; + let got = kernel::prorate_by_days(total, months, &parts[..n], unit, &mut out[..n]); + let fits = weight(&parts[..n - 1]) <= i64::from(months) * i64::from(MONTH_L); + assert_eq!(got.is_some(), fits); + if got.is_some() { + let mut sum = 0i64; + for &o in &out[..n] { + assert!(o >= 0); + sum += o; + } + assert_eq!(sum, total); + kani::cover!(n == PR && total > 0, "three parts of a positive total"); + } +} + +/// The last part is the total less all the others: it absorbs the +/// remainder, the final month's drift included, and is never negative. +/// A corollary of [`prorate_parts_sum_to_total`] (the parts sum to the +/// total and none is negative), kept as its own harness because the plan +/// names the law (plan/served.md P3 defect 9). +#[kani::proof] +#[kani::unwind(5)] +fn prorate_last_part_absorbs_the_remainder() { + let (total, months, parts, n, unit) = any_proration(); + let mut out = [0i64; PR]; + if kernel::prorate_by_days(total, months, &parts[..n], unit, &mut out[..n]).is_none() { + return; + } + let mut others = 0i64; + for &o in &out[..n - 1] { + others += o; + } + assert_eq!(out[n - 1], total - others); + assert!(out[n - 1] >= 0); + kani::cover!( + n == PR && parts[0].days < parts[0].month_days, + "a partial first month, then the last absorbing the drift" + ); +} + +/// A cumulative charge is its exact share rounded half up to a multiple of +/// the unit (or the total itself, when that rounds above it): a multiple of +/// the unit or the total, with −unit × den < 2 × (charge × den − num) <= +/// unit × den (a tie goes up, never down), where num/den is the exact +/// share of the total the kernel rounds (a month but the last: total × +/// (before + k × MONTH_L / days in month) / W; the last month: total × +/// (before × d + (W − before) × k) / (W × d)); never negative, never above +/// the total; and the total exactly on the last month's last day. Each +/// part but the last, a difference of two such charges over W, is then +/// its days times the monthly rate within one unit (plan/served.md P3 +/// defect 9: "each part equals days × rate within 0.01", for any minor +/// unit): Lean `Prorate.part_within_a_unit` does that arithmetic, for any +/// parts. +/// +/// At `i32` (the kernel is generic over [`kernel::Units`]), 1 or 2 months, +/// totals to `max_total`, a unit fixed per harness: a property relating a +/// product to a quotient is costly for the solver, and at `i64` these ran +/// past twenty minutes (docs/verification.md). The bridge checks the `i32` +/// kernel against the engine's `i128` one. Returns (charge, total, num, +/// den) for the callers' covers; `None` on the last month's last day. +fn charge_is_within_half_a_unit( + last: bool, + unit: i32, + max_total: i32, +) -> Option<(i64, i64, i64, i64)> { + let total: i32 = kani::any(); + kani::assume(total >= 0 && total <= max_total); + let months: u32 = kani::any(); + kani::assume(months >= 1 && months <= 2); + let part = any_part(); + let whole = months as i32 * MONTH_L as i32; + let before: i32 = kani::any(); + kani::assume(before >= 0 && before <= whole); + let k: u32 = kani::any(); + kani::assume(k <= part.days); + let rate = (MONTH_L / part.month_days) as i32; + // A month but the last ends inside the span. + kani::assume(last || before + k as i32 * rate <= whole); + let c = kernel::prorated_through(total, months, before, part, k, last, unit) + .expect("well formed, in the span, and no overflow at these sizes"); + assert!(0 <= c && c <= total); + if last && k == part.days { + assert_eq!(c, total); + return None; + } + assert!(c % unit == 0 || c == total); + let (total, whole, before, k, unit) = ( + i64::from(total), + i64::from(whole), + i64::from(before), + i64::from(k), + i64::from(unit), + ); + let (num, den) = if last { + let d = i64::from(part.days); + (total * (before * d + (whole - before) * k), whole * d) + } else { + (total * (before + k * i64::from(rate)), whole) + }; + let c = i64::from(c); + let e = 2 * (c * den - num); + assert!(e <= unit * den && e > -(unit * den)); + kani::cover!(c > 0 && c < total, "a charge strictly inside the total"); + kani::cover!(e == unit * den, "a half-unit tie rounds up"); + Some((c, total, num, den)) +} + +/// The cap reached: a total that isn't a whole number of units, whose +/// exact share rounds above it before the last day, is charged itself. +fn cover_the_cap(got: Option<(i64, i64, i64, i64)>) { + kani::cover!( + got.is_some_and(|(c, total, num, den)| c == total && total % 10 != 0 && num < total * den), + "the charge capped at a total finer than its unit" + ); +} + +/// [`charge_is_within_half_a_unit`] for a month but the last, in cents +/// (totals to 2,048 units). +#[kani::proof] +fn a_months_charge_is_within_half_a_cent() { + charge_is_within_half_a_unit(false, 1, 2_048); +} + +/// [`charge_is_within_half_a_unit`] for a month but the last, in cents of +/// an amount kept in tenths of a cent (an asset an earlier version took). +#[kani::proof] +fn a_months_charge_in_tenths_is_within_half_a_cent() { + cover_the_cap(charge_is_within_half_a_unit(false, 10, 2_048)); +} + +/// [`charge_is_within_half_a_unit`] for the span's last month, in cents +/// (totals to 64 units: its numerator is the total times up to 31 months' +/// worth of MONTH_L-ths). +#[kani::proof] +fn the_last_months_charge_is_within_half_a_cent() { + charge_is_within_half_a_unit(true, 1, 64); +} + +/// [`charge_is_within_half_a_unit`] for the span's last month, in cents of +/// an amount kept in tenths of a cent. +#[kani::proof] +fn the_last_months_charge_in_tenths_is_within_half_a_cent() { + cover_the_cap(charge_is_within_half_a_unit(true, 10, 64)); +} + +/// Malformed input is refused, never a panic: a negative total, no months, +/// a unit that isn't positive, a part no month has, a day past the part, +/// a weight before it outside the span, or a part but the last ending past +/// it. +#[kani::proof] +fn prorated_through_refuses_malformed_input() { + let total: i64 = kani::any(); + kani::assume(total.unsigned_abs() <= 1 << 20); + let months: u32 = kani::any(); + kani::assume(months <= 3); + let before: i64 = kani::any(); + kani::assume(before.unsigned_abs() <= 4 * u64::from(MONTH_L)); + let part = MonthPart { + days: kani::any(), + month_days: kani::any(), + }; + kani::assume(part.days <= 40 && part.month_days <= 40); + let k: u32 = kani::any(); + kani::assume(k <= 40); + let last: bool = kani::any(); + let unit: i64 = kani::any(); + kani::assume(unit.unsigned_abs() <= 10); + let got = kernel::prorated_through(total, months, before, part, k, last, unit); + let whole = i64::from(months) * i64::from(MONTH_L); + let malformed = total < 0 + || months == 0 + || unit <= 0 + || !(28..=31).contains(&part.month_days) + || part.days == 0 + || part.days > part.month_days + || k > part.days + || before < 0 + || before > whole; + kani::cover!(malformed, "malformed input"); + kani::cover!( + !malformed && got.is_none(), + "a part but the last past the span" + ); + if malformed { + assert!(got.is_none()); + } + if !malformed { + // At these sizes nothing overflows: only a part but the last that + // ends past the span is refused. + let ends_past = + !last && before + i64::from(k) * i64::from(MONTH_L / part.month_days) > whole; + assert_eq!(got.is_none(), ends_past); + } + kani::cover!(got.is_some(), "well-formed input is computed"); +} + +// --------------------------------------------------------------------------- +// Catching up and truing up (B-15) +// --------------------------------------------------------------------------- + +/// A catch-up run posts max(0, min gap), never negative, and never carries +/// the books past the schedule at a month end already posted. +#[kani::proof] +#[kani::unwind(5)] +fn catch_up_never_overtakes_the_schedule() { + let n = any_len(); + kani::assume(n >= 1 && n <= PR); + let scheduled: [i64; PR] = core::array::from_fn(|_| any_amount()); + let posted: [i64; PR] = core::array::from_fn(|_| any_amount()); + let got = kernel::catch_up(&scheduled[..n], &posted[..n]).expect("in range"); + assert!(got >= 0); + let mut least = scheduled[0] - posted[0]; + for i in 0..n { + least = least.min(scheduled[i] - posted[i]); + } + assert_eq!(got, least.max(0)); + // When the books carry no more than the schedule anywhere, the run + // keeps it so at every month end. + if least >= 0 { + for i in 0..n { + assert!(posted[i] + got <= scheduled[i]); + } + } + kani::cover!(n == PR && got > 0, "a catch-up across three month ends"); +} + +/// What a run of every month posts at once (a viewer's one pass) is the +/// catch-up of every suffix, month by month. +#[kani::proof] +#[kani::unwind(5)] +fn catch_ups_is_every_suffixs_catch_up() { + let n = any_len(); + kani::assume(n >= 1 && n <= PR); + let scheduled: [i64; PR] = core::array::from_fn(|_| any_amount()); + let posted: [i64; PR] = core::array::from_fn(|_| any_amount()); + let mut out = [0i64; PR]; + kernel::catch_ups(&scheduled[..n], &posted[..n], &mut out[..n]).expect("in range"); + for i in 0..n { + assert_eq!( + Some(out[i]), + kernel::catch_up(&scheduled[i..n], &posted[i..n]) + ); + } + kani::cover!( + n == PR && out[0] > 0 && out[0] < out[1], + "distinct positive suffixes" + ); +} + +/// A disposal's true-up reaches the target from what the books carry: up +/// or down, never both, both non-negative. +#[kani::proof] +fn true_up_reaches_the_target() { + let target = any_amount(); + let posted = any_amount(); + let (up, down) = kernel::true_up(target, posted).expect("in range"); + assert!(up >= 0 && down >= 0); + assert!(up == 0 || down == 0); + assert_eq!(posted + up - down, target); + kani::cover!(up > 0, "depreciation still due"); + kani::cover!(down > 0, "depreciation past the day, credited back"); +} + // --------------------------------------------------------------------------- // The checker itself // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index d6151ac..af6ec43 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -1261,8 +1261,9 @@ impl AssetRegister { /// unposted_depreciation and unregistered_cost are zero exactly when /// register and books agree. /// 6. NEVER panics: a contributor basis is as journaled, of any size -/// (rules.md B-28), and a total past exact arithmetic is refused -/// (TooLargeToCompute). +/// (rules.md B-28), and a total past exact arithmetic, or a schedule +/// that can't be computed, is refused (TooLargeToCompute, +/// DateOutOfRange). /// ``` pub fn fixed_asset_register( ledger: &Ledger, @@ -1290,7 +1291,7 @@ pub fn fixed_asset_register( let acc = if disposed.is_some() { Decimal::ZERO } else { - crate::assets::accumulated_depreciation(a, as_of) + crate::assets::accumulated_depreciation(a, as_of)? }; if disposed.is_none() { cost = cost diff --git a/crates/ledger-core/src/schedules.rs b/crates/ledger-core/src/schedules.rs index facea2f..468a5b9 100644 --- a/crates/ledger-core/src/schedules.rs +++ b/crates/ledger-core/src/schedules.rs @@ -3,21 +3,27 @@ //! Two kinds, both master data owned by [`crate::Ledger`] and journaled: //! //! - **Amortization schedules** spread a prepaid expense or deferred revenue -//! evenly over the months it covers: a year of insurance paid up front is -//! an asset expensed 1/12 a month; a year of subscription revenue -//! collected up front is a liability earned 1/12 a month. Like -//! depreciation, the schedule is the evidence for its postings, so they -//! need no receipts. Each month posts once (idempotent runs). +//! over the days it covers: a year of insurance paid up front is an asset +//! expensed as each day passes; a year of subscription revenue collected +//! up front is a liability earned as each day passes. New schedules +//! spread by day ([`ScheduleConvention::Daily`], D-11, D-37: a policy +//! from March 15 posts a small March, full months, and a small final +//! March); schedules an earlier version registered keep equal months +//! ([`ScheduleConvention::MonthlyEven`]). Like depreciation, the +//! schedule is the evidence for its postings, so they need no receipts. +//! Each month posts once (idempotent runs); a reversed month is due +//! again. //! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts* //! on their dates, never posted entries: the agent attaches the actual //! receipt and posts through the normal preview, so the receipt policy is //! never bypassed. -use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side}; +use crate::money::Steps; +use crate::{AccountId, AccountType, ChartOfAccounts, EntryId, LedgerError, Money, Side, kernel}; use chrono::{Datelike, Days, Months, NaiveDate}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; /// Opaque handle for an amortization schedule. pub type ScheduleId = u64; @@ -38,6 +44,52 @@ pub enum ScheduleKind { DeferredRevenue, } +/// How a schedule spreads its total over the time it covers. +/// +/// ```text +/// SOURCE: docs/spec/rules.md B-16; plan/served.md D-11 (new schedules +/// daily, "matching depreciation"; existing ones stay +/// monthly_even), D-37 (owner, 2026-10-20: confirmed; an +/// equal-months schedule starts on the 1st of a month, for +/// prepaid expenses and deferred revenue alike). +/// ``` +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScheduleConvention { + /// Equal months, the last absorbing rounding ([`amortization_plan`]): + /// every schedule registered before P3, and a new one that asks for it + /// (which then starts on the 1st). Unchanged forever: a posted month's + /// replay re-derives it. + #[default] + MonthlyEven, + /// By the day from the start date, as depreciation is ([`daily_plan`]): + /// full months equal, the first and last partial months by their days. + Daily, +} + +impl ScheduleConvention { + /// Its wire name: `monthly_even` or `daily`. + pub fn key(self) -> &'static str { + match self { + ScheduleConvention::MonthlyEven => "monthly_even", + ScheduleConvention::Daily => "daily", + } + } + + /// The convention named `key`. + pub fn parse(key: &str) -> Option { + [Self::MonthlyEven, Self::Daily] + .into_iter() + .find(|c| c.key() == key) + } + + /// True for [`ScheduleConvention::MonthlyEven`] (which the journal and + /// the serialized ledger leave unsaid, as every schedule before P3 is). + pub fn is_monthly_even(&self) -> bool { + *self == ScheduleConvention::MonthlyEven + } +} + /// An amortization schedule. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AmortizationSchedule { @@ -49,7 +101,9 @@ pub struct AmortizationSchedule { pub kind: ScheduleKind, /// The amount to spread. pub total: Money, - /// Any date in the first month covered. + /// The first day covered (daily); any day of the first month covered + /// for a monthly_even schedule an earlier version took (new ones start + /// on the 1st). pub start: NaiveDate, /// Months covered. pub months: u32, @@ -58,8 +112,18 @@ pub struct AmortizationSchedule { pub balance_account: AccountId, /// The P&L account: expense or revenue. pub pl_account: AccountId, - /// Months already posted, as their month-end dates. + /// Months posted and in effect (not reversed), as their month-end + /// dates. pub posted: BTreeSet, + /// How the total spreads. Left unsaid when monthly_even, so books from + /// before P3 serialize as they did. + #[serde(default, skip_serializing_if = "ScheduleConvention::is_monthly_even")] + pub convention: ScheduleConvention, + /// Every entry that ever posted each month, in order: a reversal of + /// one un-posts its month, and reversing that reversal posts it again + /// (Ledger::reverse_entry). Derived on replay, never serialized. + #[serde(skip)] + pub postings: BTreeMap>, } /// Parameters for a new schedule. @@ -71,7 +135,8 @@ pub struct NewSchedule { pub kind: ScheduleKind, /// The amount to spread (positive). pub total: Money, - /// Any date in the first month covered. + /// The first day covered: the 1st of a month for monthly_even (any day + /// of the first month for one an earlier version journaled). pub start: NaiveDate, /// Months covered (at least 1). pub months: u32, @@ -79,6 +144,9 @@ pub struct NewSchedule { pub balance_account: AccountId, /// Expense or revenue account. pub pl_account: AccountId, + /// How the total spreads. Explicit here; a tool resolves the default + /// (`books.schedule_convention`, else daily). + pub convention: ScheduleConvention, } /// The last day of `d`'s month: in chrono's last month (December 262142), @@ -149,22 +217,134 @@ pub fn amortization_plan( .collect() } +/// The schedule's postings by the day: one per calendar month the span +/// `[start, start + months)` touches, dated at the month's end, the total +/// spread as depreciation spreads an asset's cost +/// (kernel::prorate_by_days, rounded to the currency's minor unit). +/// +/// ```text +/// GOAL: recognize a prepaid expense, or earn deferred revenue, as each +/// day it covers passes. +/// SOURCE: docs/spec/rules.md B-16 (ASC 606-10-25-23: revenue as the +/// obligation is satisfied; ASC 340-10-05-4: a prepaid is used up +/// over the time it covers); plan/served.md D-11, D-37. +/// INVARIANTS: +/// 1. The amounts sum to total exactly; every one is > 0 (a month that +/// rounds to nothing is left out: no entry carries a zero line). +/// 2. Full months are total / months each within one minor unit; the +/// first partial month is its days times that rate within one unit; +/// the last month takes what the others leave. +/// 3. A start on the 1st gives `months` postings; any other start one +/// more, the first and last partial. +/// 4. Unchanged forever: a posted month's replay re-derives the plan +/// (store `schedule.posted`), so this and the kernel are pinned by +/// exact tests, chrono's month-end clamping included. +/// 5. NEVER panics: a span past chrono's last day is DateOutOfRange, a +/// total past exact arithmetic TooLargeToCompute. +/// SCENARIOS: +/// 1. IF 1,200.00 from 2026-03-15 for 12 months: +/// - SHOULD be 54.84 on 03-31 (17 of 31 days), eleven of 100.00, and +/// 45.16 on 2027-03-31: thirteen month ends +/// BECAUSE each day covered bears the same share. +/// ``` +pub fn daily_plan( + total: Money, + start: NaiveDate, + months: u32, +) -> Result, LedgerError> { + let too_large = || { + LedgerError::TooLargeToCompute(format!("a {months}-month daily plan of {}", total.amount)) + }; + let end = start + .checked_add_months(Months::new(months)) + .ok_or(LedgerError::DateOutOfRange(start))?; + let steps = Steps::of(total.amount, total.currency).ok_or_else(too_large)?; + let spans: Vec = crate::calendar::month_parts(start, end).collect(); + let parts: Vec = spans.iter().map(|s| s.part).collect(); + let mut amounts = vec![0i128; parts.len()]; + kernel::prorate_by_days(steps.units, months, &parts, steps.unit, &mut amounts) + .ok_or_else(too_large)?; + spans + .iter() + .zip(amounts) + .filter(|(_, a)| *a != 0) + .map(|(s, a)| Ok((month_end(s.first), steps.decimal(a).ok_or_else(too_large)?))) + .collect() +} + +/// A schedule's postings under `convention` ([`amortization_plan`] or +/// [`daily_plan`]). +pub fn plan_for( + convention: ScheduleConvention, + total: Money, + start: NaiveDate, + months: u32, +) -> Result, LedgerError> { + match convention { + ScheduleConvention::MonthlyEven => amortization_plan(total.amount, start, months), + ScheduleConvention::Daily => daily_plan(total, start, months), + } +} + /// Validate a new schedule against the chart: [`validate_schedule_shape`], -/// the total in range and in cents, and its first and last months inside -/// the calendar the books keep (crate::calendar; P0 review round 3, S1), -/// so every month end of the plan is a date. +/// the total in range and in the currency's minor unit, its first day and +/// last month (monthly_even) or last day (daily) inside the calendar the +/// books keep (crate::calendar; P0 review round 3, S1), so every month end +/// of the plan is a date, an equal-months schedule starting on the 1st +/// (D-37), and every month of the plan a whole number of minor units. +/// +/// ```text +/// SCENARIOS: +/// 1. IF a new monthly_even schedule starts on any day but the 1st: +/// - SHOULD refuse, naming the 1st to start on, or the daily +/// convention +/// - NEVER let a schedule starting March 15 recognize all of March +/// BECAUSE revenue is never recognized before it is earned (ASC +/// 606-10-25-23), and one rule for prepaid expenses and deferred +/// revenue alike is simpler and never early (D-37). Replay keeps +/// schedules an earlier version took mid-month (shape checks only). +/// 2. IF a daily schedule's last day is after 9999-12-31 (one month from +/// 9999-12-15): SHOULD refuse. +/// 3. IF a monthly_even month would be finer than the minor unit (¥1,000 +/// over 3 months is ¥333.33 a month): SHOULD refuse, naming daily +/// BECAUSE such a month could never post. +/// ``` pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> { validate_schedule_shape(new, chart)?; crate::calendar::check(new.start)?; - if !new - .start - .checked_add_months(Months::new(new.months - 1)) - .is_some_and(crate::calendar::in_calendar) - { - return Err(LedgerError::InvalidSchedule(format!( + let past_the_calendar = || { + Err(LedgerError::InvalidSchedule(format!( "{} months from {} run past 9999-12-31, the last day the books keep", new.months, new.start - ))); + ))) + }; + match new.convention { + ScheduleConvention::MonthlyEven => { + if new.start.day() != 1 { + return Err(LedgerError::InvalidSchedule(format!( + "an equal-months (monthly_even) schedule starts on the 1st of a month, so it never recognizes days it doesn't cover (revenue is never earned early: ASC 606-10-25-23; the same rule for prepaid expenses, D-37): start it on {}, or spread it by day (convention daily) from {}", + month_first(new.start), + new.start + ))); + } + if !new + .start + .checked_add_months(Months::new(new.months - 1)) + .is_some_and(crate::calendar::in_calendar) + { + return past_the_calendar(); + } + } + ScheduleConvention::Daily => { + if !new + .start + .checked_add_months(Months::new(new.months)) + .and_then(|end| end.pred_opt()) + .is_some_and(crate::calendar::in_calendar) + { + return past_the_calendar(); + } + } } // Every month's posting derives from the total: one no line could // carry, or finer than a cent, is refused here, not at a month end @@ -172,7 +352,23 @@ pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<( if new.total.amount > crate::kernel::MAX_LINE_AMOUNT { return Err(LedgerError::AmountOutOfRange); } - crate::ledger::check_minor_units(new.total) + crate::ledger::check_minor_units(new.total)?; + let currency = new.total.currency; + if let Some((_, fine)) = plan_for(new.convention, new.total, new.start, new.months)? + .into_iter() + .find(|(_, a)| crate::ledger::check_minor_units(Money::new(*a, currency)).is_err()) + { + return Err(LedgerError::InvalidSchedule(format!( + "{} {currency} in equal months over {} months gives {fine} {currency} a month, finer than the {currency} minor unit, so it could never post: spread it by day (convention daily)", + new.total.amount, new.months + ))); + } + Ok(()) +} + +/// The 1st of `d`'s month. +fn month_first(d: NaiveDate) -> NaiveDate { + d.with_day(1).unwrap_or(d) } /// [`validate_schedule`] without the total's range and minor-unit checks @@ -221,21 +417,58 @@ impl AmortizationSchedule { } } - /// Its monthly postings ([`amortization_plan`]); refused, never a - /// panic, for one that can't be computed. + /// Its monthly postings under its convention ([`amortization_plan`], + /// [`daily_plan`]); refused, never a panic, for one that can't be + /// computed. pub fn plan(&self) -> Result, LedgerError> { - amortization_plan(self.total.amount, self.start, self.months) + plan_for(self.convention, self.total, self.start, self.months) } - /// Months due through `through` and not yet posted. + /// The first day it covers: its start (daily), or the 1st of its start's + /// month (monthly_even, whose months are whole). + pub fn first_day(&self) -> NaiveDate { + match self.convention { + ScheduleConvention::Daily => self.start, + ScheduleConvention::MonthlyEven => month_first(self.start), + } + } + + /// The last day it covers: the day before start + months (daily), or + /// the end of its last month (monthly_even). `None` past chrono's last + /// day (a schedule an earlier version took). + pub fn last_day(&self) -> Option { + match self.convention { + ScheduleConvention::Daily => self + .start + .checked_add_months(Months::new(self.months))? + .pred_opt(), + ScheduleConvention::MonthlyEven => month_first(self.start) + .checked_add_months(Months::new(self.months.checked_sub(1)?)) + .map(month_end), + } + } + + /// Months due through `through` and not yet posted. A month whose + /// amount is zero (an equal-months plan of a total smaller than its + /// months) is never due: no entry carries a zero line. pub fn due(&self, through: NaiveDate) -> Result, LedgerError> { Ok(self .plan()? .into_iter() - .filter(|(d, _)| *d <= through && !self.posted.contains(d)) + .filter(|(d, a)| *d <= through && !a.is_zero() && !self.posted.contains(d)) .collect()) } + /// The next month end to post: the first month of the plan with an + /// amount, not posted. `None` once every one is. + pub fn next(&self) -> Result, LedgerError> { + Ok(self + .plan()? + .into_iter() + .find(|(d, a)| !a.is_zero() && !self.posted.contains(d)) + .map(|(d, _)| d)) + } + /// The amount not yet recognized: the sum of the plan's unposted /// months. pub fn remaining(&self) -> Result { @@ -482,12 +715,123 @@ mod tests { balance_account: 1, pl_account: 2, posted: BTreeSet::new(), + convention: ScheduleConvention::MonthlyEven, + postings: BTreeMap::new(), }; assert!(s.due(NaiveDate::MAX).is_err()); assert!(s.remaining().is_err()); + assert!(s.next().is_err()); + assert_eq!(s.last_day(), None); let s = AmortizationSchedule { months: 2, ..s }; assert_eq!(s.due(NaiveDate::MAX).unwrap().len(), 2); assert_eq!(s.remaining(), Ok(Decimal::new(300, 0))); + assert_eq!(s.last_day(), Some(NaiveDate::MAX)); + assert_eq!(s.first_day(), d(262_142, 11, 1)); + // A daily schedule at chrono's last month: computed where it can + // be (Nov 9-30 is 22 of the month's 30 days, Dec 1-8 the rest), + // refused where it can't. + let daily = AmortizationSchedule { + convention: ScheduleConvention::Daily, + months: 1, + ..s.clone() + }; + assert_eq!( + daily.plan().unwrap(), + [ + (d(262_142, 11, 30), Decimal::new(22_000, 2)), + (NaiveDate::MAX, Decimal::new(8_000, 2)), + ] + ); + let daily = AmortizationSchedule { months: 2, ..daily }; + assert_eq!( + daily.plan(), + Err(LedgerError::DateOutOfRange(d(262_142, 11, 9))) + ); + } + + fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) + } + + fn usd(c: i64) -> Money { + Money::new(cents(c), crate::Currency::USD) + } + + /// daily_plan SCENARIO 1 and INVARIANT 3: 1,200.00 from March 15 is + /// thirteen month ends; from the 1st it is twelve equal ones. + #[test] + fn a_daily_plan_spreads_by_the_day() { + let plan = daily_plan(usd(120_000), d(2026, 3, 15), 12).unwrap(); + assert_eq!(plan.len(), 13); + assert_eq!(plan[0], (d(2026, 3, 31), cents(5_484))); + for (i, (date, amount)) in plan[1..12].iter().enumerate() { + assert_eq!(*amount, cents(10_000), "month {i}"); + assert_eq!(*date, month_end(*date)); + } + assert_eq!(plan[11].0, d(2027, 2, 28)); + assert_eq!(plan[12], (d(2027, 3, 31), cents(4_516))); + let from_first = daily_plan(usd(120_000), d(2026, 3, 1), 12).unwrap(); + assert_eq!(from_first.len(), 12); + assert!(from_first.iter().all(|(_, a)| *a == cents(10_000))); + assert_eq!(from_first[11].0, d(2027, 2, 28)); + } + + /// daily_plan INVARIANT 4 (P3 depreciation review R11(b)): chrono's + /// month-end clamping is pinned. One month from Jan 29, 30 or 31 + /// covers through Feb 27 (2026; Jan 29 + 1 month is Feb 28, Jan 30 + /// and 31 clamp to it); one month from Feb 29 (2028) through Mar 28. + #[test] + fn daily_plans_pin_month_end_clamping() { + let one = |start| daily_plan(usd(31_000), start, 1).unwrap(); + // Jan 29-31 (3/31 of 310.00 = 30.00), Feb 1-27: the rest. + assert_eq!( + one(d(2026, 1, 29)), + [ + (d(2026, 1, 31), cents(3_000)), + (d(2026, 2, 28), cents(28_000)) + ] + ); + // Jan 30-31 (2/31 = 20.00), Feb 1-27 the rest. + assert_eq!( + one(d(2026, 1, 30)), + [ + (d(2026, 1, 31), cents(2_000)), + (d(2026, 2, 28), cents(29_000)) + ] + ); + // Jan 31 (1/31 = 10.00), Feb 1-27 the rest. + assert_eq!( + one(d(2026, 1, 31)), + [ + (d(2026, 1, 31), cents(1_000)), + (d(2026, 2, 28), cents(30_000)) + ] + ); + // Feb 29 2028 (1/29 of 290.00 = 10.00), Mar 1-28 the rest. + assert_eq!( + daily_plan(usd(29_000), d(2028, 2, 29), 1).unwrap(), + [ + (d(2028, 2, 29), cents(1_000)), + (d(2028, 3, 31), cents(28_000)) + ] + ); + } + + /// The conventions' wire names, and what the serialized ledger leaves + /// unsaid. + #[test] + fn conventions_have_wire_names() { + for c in [ScheduleConvention::MonthlyEven, ScheduleConvention::Daily] { + assert_eq!(ScheduleConvention::parse(c.key()), Some(c)); + assert_eq!(serde_json::to_value(c).unwrap(), c.key()); + } + assert_eq!(ScheduleConvention::parse("weekly"), None); + assert_eq!( + ScheduleConvention::default(), + ScheduleConvention::MonthlyEven + ); + assert!(ScheduleConvention::MonthlyEven.is_monthly_even()); + assert!(!ScheduleConvention::Daily.is_monthly_even()); } #[test] diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index a16ab86..bb09145 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -153,6 +153,7 @@ const PARTY_POLICY: &[(&str, &[&str])] = const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])]; const TAX_CATEGORY_POLICY: &[(&str, &[&str])] = &[("warn", &[]), ("refuse", &["required", "require"])]; +const SCHEDULE_CONVENTION: &[(&str, &[&str])] = &[("daily", &[]), ("monthly_even", &[])]; /// A return's key as a setting keeps it. pub fn tax_form_key(f: TaxForm) -> &'static str { @@ -334,6 +335,13 @@ pub static KEYS: &[Key] = &[ sensitivity: Sensitivity::Public, doc: "Prefix of generated asset tags (\"AC\" for AC-0001): one to eight letters or digits. Unset: the company name's initials.", }, + Key { + name: "books.schedule_convention", + toml: ("books", "schedule_convention"), + kind: Kind::Choice(SCHEDULE_CONVENTION), + sensitivity: Sensitivity::Public, + doc: "How a new prepaid-expense or deferred-revenue schedule spreads its total: daily (from its start date: full months equal, the first and last partial months by their days, like depreciation) or monthly_even (equal months; the schedule starts on the 1st of a month). Unset: daily (D-11). Existing schedules keep theirs.", + }, Key { name: "policy.evidence.threshold", toml: ("books", "receipt_threshold"), @@ -1296,6 +1304,14 @@ impl Settings { self.str("books.asset_tag_prefix") } + /// `books.schedule_convention`: how a new schedule spreads its total. + /// Unset: daily (plan/served.md D-11, confirmed in D-37). + pub fn schedule_convention(&self) -> crate::ScheduleConvention { + self.str("books.schedule_convention") + .and_then(crate::ScheduleConvention::parse) + .unwrap_or(crate::ScheduleConvention::Daily) + } + /// `policy.parties`. pub fn parties(&self) -> PartyPolicy { self.str("policy.parties") diff --git a/crates/ledger-core/tests/assets.rs b/crates/ledger-core/tests/assets.rs index a3b0017..c7fb5ef 100644 --- a/crates/ledger-core/tests/assets.rs +++ b/crates/ledger-core/tests/assets.rs @@ -70,7 +70,7 @@ fn schedule_sums_exactly_to_depreciable_amount() { // Odd numbers chosen to not divide evenly: 100000 / 60. let id = van_asset(&mut ledger, 100_000, cost, accum, expense); let asset = ledger.get_asset(id).unwrap(); - let sched = depreciation_schedule(asset, date(2031, 12, 31)); + let sched = depreciation_schedule(asset, date(2031, 12, 31)).unwrap(); // 61 calendar months touched (partial first and last), 60 months' worth. assert_eq!(sched.len(), 61); let total: Decimal = sched.iter().map(|l| l.amount).sum(); @@ -94,7 +94,7 @@ fn partial_months_complement_to_whole() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = van_asset(&mut ledger, 100_000, cost, accum, expense); let asset = ledger.get_asset(id).unwrap(); - let sched = depreciation_schedule(asset, date(2031, 12, 31)); + let sched = depreciation_schedule(asset, date(2031, 12, 31)).unwrap(); // Jan 2026 (17/31) + Jan 2031 (14/31) = exactly one month's charge. let monthly = Decimal::from(100_000) / Decimal::from(60); let monthly_rounded = @@ -122,7 +122,7 @@ fn salvage_value_is_never_depreciated() { }) .unwrap(); let asset = ledger.get_asset(id).unwrap(); - let sched = depreciation_schedule(asset, date(2030, 12, 31)); + let sched = depreciation_schedule(asset, date(2030, 12, 31)).unwrap(); let total: Decimal = sched.iter().map(|l| l.amount).sum(); assert_eq!(total, Decimal::from(45_000)); assert_eq!(sched.last().unwrap().book_value, Decimal::from(5_000)); @@ -133,11 +133,11 @@ fn book_value_tracks_accumulated() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = van_asset(&mut ledger, 60_000, cost, accum, expense); let asset = ledger.get_asset(id).unwrap(); - let acc = accumulated_depreciation(asset, date(2026, 12, 31)); + let acc = accumulated_depreciation(asset, date(2026, 12, 31)).unwrap(); // Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000. assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000)); assert_eq!( - book_value(asset, date(2026, 12, 31)), + book_value(asset, date(2026, 12, 31)).unwrap(), Decimal::from(60_000) - acc ); } @@ -281,9 +281,13 @@ fn schedule_through_purchase_date_covers_one_day() { .unwrap(); let asset = ledger.get_asset(id).unwrap(); // Through the day before purchase: nothing. - assert!(depreciation_schedule(asset, date(2026, 1, 14)).is_empty()); + assert!( + depreciation_schedule(asset, date(2026, 1, 14)) + .unwrap() + .is_empty() + ); // Through the purchase date itself: one day of the first month. - let one_day = depreciation_schedule(asset, date(2026, 1, 15)); + let one_day = depreciation_schedule(asset, date(2026, 1, 15)).unwrap(); assert_eq!(one_day.len(), 1); assert_eq!(one_day[0].period, "2026-01"); // 62000/62 = 1000/month; 1/31 of that = 32.26. @@ -302,7 +306,7 @@ fn van_schedule_is_the_hand_computed_one() { let (mut ledger, cost, accum, expense) = test_ledger(); let id = van_asset(&mut ledger, 60_000, cost, accum, expense); let asset = ledger.get_asset(id).unwrap(); - let sched = depreciation_schedule(asset, date(2032, 1, 1)); + let sched = depreciation_schedule(asset, date(2032, 1, 1)).unwrap(); assert_eq!(sched.len(), 61); let mut accumulated = Decimal::ZERO; for (k, line) in sched.iter().enumerate() { @@ -421,7 +425,7 @@ fn one_month_useful_life_is_valid() { }) .unwrap(); let asset = ledger.get_asset(id).unwrap(); - let sched = depreciation_schedule(asset, date(2026, 12, 31)); + let sched = depreciation_schedule(asset, date(2026, 12, 31)).unwrap(); // A one-month life starting Mar 10 spans two calendar months. assert_eq!(sched.len(), 2); // March: 22/31 of 300 = 212.90. diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index ec64b71..5cceec9 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -5,16 +5,23 @@ //! Each example is a setup, events and expected values worked by hand from //! its cited source (`bookspec/README.md` gives the shape). Every example //! must load, whatever it asks or whichever appendix it is in: its shape, -//! its accounts, fixed assets, parties, members, payees, payees' dated -//! 1099 decisions and payment methods, every event (an entry, a reversal, -//! a close or a bill, posting entry #N as event N, with the parties, -//! payee and payment method it is tagged with) and every change that -//! posts no entry (a rename, a policy). The queries the core answers -//! (`cash_flow`, `trial_balance`, `profit_and_loss`, `vendor_1099_paid`, -//! `vendor_1099`, `entry`, `balances`, `members_equity`, `k1`, -//! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`, -//! `calendar_year`, `account_names`, `uncategorized_accounts`) are then -//! checked value by value, and the commands it tries +//! its accounts, fixed assets, schedules, parties, members, payees, +//! payees' dated 1099 decisions and payment methods, every event (an +//! entry, a reversal, a close, a bill, or a command that posts what is +//! due: a depreciation run, a disposal, a schedule run or registration), +//! each accepted or refused as it says, with the parties, payee and +//! payment method it is tagged with, and every change that posts no entry +//! (a rename, a policy). The books are the engine's own store +//! (`ledger_store::Store`, in a directory of their own), so every event +//! goes through the command the books take it by, journaled, and the +//! books must replay to what was kept (`Store::verify`). The queries the +//! core answers (`cash_flow`, `trial_balance`, `profit_and_loss`, +//! `vendor_1099_paid`, `vendor_1099`, `entry`, `balances`, +//! `members_equity`, `k1`, `party_part`, `party_totals`, +//! `party_tin_last4`, `period`, `day`, `calendar_year`, `account_names`, +//! `uncategorized_accounts`, `run`, `disposal`, `depreciation_schedule`, +//! `schedule_plan`) are then checked value by value, and the commands it +//! tries //! (`rename_account`, `add_account`, `set_tax_category`) accepted or //! refused on a copy of the books, each after the event it names (`after`) //! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are @@ -30,13 +37,15 @@ use ledger_core::calendar::{PeriodDefaults, period_defaults}; use ledger_core::parties::{Basis, MoneyAccounts}; use ledger_core::settings::{Company, Source}; use ledger_core::{ - AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, Dated1099, - Decision1099, DocumentLine, EntryId, ExemptReason, Form1099, InstrumentId, InstrumentKind, - Ledger, LedgerError, MemberCapital, MemberClose, Money, NewAsset, PartyId, PartyMember, - PartyShare, PaymentAccounts, ReconcilingKind, Role, Share, Side, TinFacts, TinType, VendorId, + AccountType, AssetId, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, + Dated1099, Decision1099, DocumentLine, EntryId, EntryStatus, ExemptReason, Form1099, + InstrumentId, InstrumentKind, Ledger, LedgerError, MemberCapital, MemberClose, Money, NewAsset, + NewSchedule, PartyId, PartyMember, PartyShare, PaymentAccounts, ReconcilingKind, Role, + ScheduleConvention, ScheduleId, ScheduleKind, Share, Side, TinFacts, TinType, VendorId, W9Facts, cash_flow, members_equity, profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report, }; +use ledger_store::{DepreciationRun, DisposalPreview, Store, StoreError}; use rust_decimal::Decimal; use serde::Deserialize; @@ -77,6 +86,13 @@ struct Setup { investing_accounts: Vec, #[serde(default)] fixed_assets: Vec, + /// Fixed assets registered, with their figures (rules.md B-15). + #[serde(default)] + assets: Vec, + /// Prepaid-expense and deferred-revenue schedules registered (rules.md + /// B-16). + #[serde(default)] + schedules: Vec, /// The company's directory: parties other than a payee's vendor /// record, and the payees' when the example names their roles. #[serde(default)] @@ -137,6 +153,44 @@ struct FixedAsset { expense_account: String, } +/// A fixed asset registered before the first event, named `id` by the +/// events and queries that ask about it: straight line over its life from +/// its in-service date (rules.md B-15). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AssetSpec { + id: String, + description: String, + cost: String, + salvage: String, + life_months: u32, + in_service: String, + /// `"straight_line"`, the one method the books have. + method: String, + cost_account: String, + accumulated_account: String, + expense_account: String, +} + +/// A prepaid expense or deferred revenue recognized over `months` from +/// `start` (rules.md B-16), named `id`: in `setup.schedules`, or registered +/// by a `register_schedule` event. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ScheduleSpec { + id: String, + /// `"prepaid_expense"` or `"deferred_revenue"`. + kind: String, + description: String, + total: String, + start: String, + months: u32, + /// `"daily"` or `"monthly_even"`. + convention: String, + balance_account: String, + pl_account: String, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct MemberSpec { @@ -234,12 +288,20 @@ struct ShareSpec { } /// One event: an entry (`lines`), a reversal (`reverses`), a close -/// (`close`) or a bill (`bill`), exactly one of them. +/// (`close`), a bill (`bill`), or a command that posts what is due +/// (`run_depreciation`, `dispose_asset`, `run_schedules`) or registers a +/// schedule (`register_schedule`), exactly one of them. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Event { - date: String, - description: String, + /// The day of an entry, a reversal, a bill or a disposal, the last day + /// of a close; a command that dates its own entries takes none. + #[serde(default)] + date: Option, + /// An entry's, a reversal's, a close's or a bill's; a command writes + /// its own. + #[serde(default)] + description: Option, #[serde(default)] lines: Vec, reverses: Option, @@ -247,6 +309,20 @@ struct Event { close: Option, #[serde(default)] bill: Option, + #[serde(default)] + run_depreciation: Option, + #[serde(default)] + dispose_asset: Option, + #[serde(default)] + run_schedules: Option, + #[serde(default)] + register_schedule: Option, + /// The engine refuses the event, for this reason in the words of + /// [`refusal`]: it changes nothing, and the books go on without it. + /// For a reversal or a command, which the books take whole or not at + /// all. + #[serde(default)] + refused: Option, /// The payee (in `setup.payees`) the entry paid. #[serde(default)] payee: Option, @@ -259,6 +335,34 @@ struct Event { instrument: Option, } +/// A depreciation run of `asset` for `period` ("YYYY-MM"): what is due +/// through the month's end, dated then (rules.md B-15). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RunSpec { + asset: String, + period: String, +} + +/// The disposal of `asset` on the event's date for `proceeds`, received +/// into `proceeds_account`, its gain or loss to `gain_loss_account` +/// (rules.md B-15). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DisposeSpec { + asset: String, + proceeds: String, + proceeds_account: String, + gain_loss_account: String, +} + +/// Every schedule's months due through `through`, posted (rules.md B-16). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RunSchedulesSpec { + through: String, +} + /// A bill from `vendor` for `amount`, dated and due on the event's date: /// Dr General Expenses (6999), Cr Accounts Payable (2000), each created /// with its fixed name when first needed, as the engine creates a control @@ -323,8 +427,15 @@ struct Expect { from: Option, to: Option, as_of: Option, - /// The entry an `entry` or `party_part` query asks about. - entry: Option, + /// The event whose entry an `entry` or `party_part` query asks about. + entry: Option, + /// The `run_depreciation` event a `run` query asks about. + event: Option, + /// The fixed asset (`setup.assets` id) a `disposal` or + /// `depreciation_schedule` query asks about. + asset: Option, + /// The schedule (its id) a `schedule_plan` query asks about. + schedule: Option, /// The party a `party_part`, `party_totals` or `party_tin_last4` /// query asks about (in `setup.parties`). party: Option, @@ -484,11 +595,25 @@ fn refusal(e: &LedgerError) -> String { LedgerError::InvalidAccountName(_) => "not_one_line", LedgerError::DuplicateAccountCode(_) => "code_taken", LedgerError::TaxCategoryRequired { .. } => "tax_category_required", + LedgerError::DepreciationPastDisposal(_) => "depreciation_past_disposal", + LedgerError::AssetEntryIrreversible(..) => "entry_irreversible", + LedgerError::AssetAlreadyDisposed(_) => "already_disposed", + LedgerError::InvalidDepreciationPeriod(_) => "invalid_depreciation_period", + LedgerError::InvalidSchedule(_) => "invalid_schedule", other => return format!("another refusal: {other}"), } .to_string() } +/// [`refusal`] of a command the books refused: the engine's reason, or the +/// store's own (none is in the examples' words). +fn store_refusal(e: &StoreError) -> String { + match e { + StoreError::Ledger(e) => refusal(e), + other => format!("another refusal: {other}"), + } +} + fn examples_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples") } @@ -511,10 +636,14 @@ fn exact(s: &str, what: &str) -> Decimal { .unwrap_or_else(|e| panic!("{what}: {s:?} is not a decimal: {e}")) } -/// An example's books: its setup, then its events as they are applied. +/// An example's books: its setup, then its events as they are applied, +/// kept by the engine's own store (each command journaled as the books +/// journal it), in a directory of their own that goes when they do. struct Books<'a> { ex: &'a Example, - l: Ledger, + store: Store, + /// Where the store keeps them. + _dir: tempfile::TempDir, currency: Currency, /// The members, with their shares (zero when the example gives none). members: Vec, @@ -529,6 +658,24 @@ struct Books<'a> { vendors: BTreeMap, /// The payment methods, by label. instruments: BTreeMap, + /// The fixed assets of `setup.assets`, by id. + assets: BTreeMap, + /// The schedules of `setup.schedules` and `register_schedule` events, + /// by id. + schedules: BTreeMap, + /// The entries each event applied so far posted, in order: event N's + /// at N - 1. + posted: Vec>, + /// Each `run_depreciation` event's run, by event. + runs: BTreeMap, + /// What each disposal took, by asset id, as the engine computed it + /// just before posting it. + disposals: BTreeMap, +} + +/// A store command's result, unwrapped: the example's books take it. +fn took(r: Result, what: &str) -> T { + r.unwrap_or_else(|e| panic!("{what}: {e}")) } /// Who an example's decisions and records are made by. @@ -568,7 +715,7 @@ fn decision(ex: &str, what: &str, kind: Option<&str>, reason: Option<&str>) -> D /// The example's directory (`setup.parties`), by name: each party with /// its roles (a vendor or customer with its record), the liabilities it /// is the creditor of, and its TIN and W-9 facts' last four. -fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap { +fn add_parties(ex: &Example, store: &mut Store) -> BTreeMap { let mut out = BTreeMap::new(); for p in &ex.setup.parties { let what = format!("{} party {}", ex.id, p.name); @@ -586,35 +733,34 @@ fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap { let id = match (record(Role::Vendor), record(Role::Customer)) { (true, true) => panic!("{what}: a vendor or a customer, not both"), (true, false) => { - let v = l - .add_vendor(&p.name) - .unwrap_or_else(|e| panic!("{what}: {e}")); - l.party_of_vendor(v).expect("a vendor's party") + let v = took(store.add_vendor(&p.name), &what); + store.ledger().party_of_vendor(v).expect("a vendor's party") } (false, true) => { - let c = l - .add_customer(&p.name) - .unwrap_or_else(|e| panic!("{what}: {e}")); - l.party_of_customer(c).expect("a customer's party") + let c = took(store.add_customer(&p.name), &what); + store + .ledger() + .party_of_customer(c) + .expect("a customer's party") } - (false, false) => l - .add_party(&p.name, &others) - .unwrap_or_else(|e| panic!("{what}: {e}")), + (false, false) => took(store.add_party(&p.name, &others), &what), }; if record(Role::Vendor) || record(Role::Customer) { for r in &others { - l.set_party_role(id, *r, true) - .unwrap_or_else(|e| panic!("{what}: {e}")); + took(store.set_party_role(id, *r, true), &what); } } for code in &p.creditor_of { - let account = l + let account = store + .ledger() .chart() .find_by_code(code) .unwrap_or_else(|| panic!("{what}: no account {code}")) .id; - l.set_party_creditor(id, account, true) - .unwrap_or_else(|e| panic!("{what}: creditor of {code}: {e}")); + took( + store.set_party_creditor(id, account, true), + &format!("{what}: creditor of {code}"), + ); } assert!( !p.tin_purged || p.tin_last4.is_some(), @@ -627,8 +773,7 @@ fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap { vault_record: (!p.tin_purged).then(|| "0".repeat(64)), purged: p.tin_purged.then(|| "purged: retention ended".into()), }; - l.set_party_tin(id, Some(facts)) - .unwrap_or_else(|e| panic!("{what}: {e}")); + took(store.set_party_tin(id, Some(facts)), &what); } if let Some(last4) = &p.w9_tin_last4 { let facts = W9Facts { @@ -640,22 +785,50 @@ fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap { kept_at: "wanderbooks vault".into(), vault_record: None, }; - l.set_party_w9(id, Some(facts)) - .unwrap_or_else(|e| panic!("{what}: {e}")); + took(store.set_party_w9(id, Some(facts)), &what); } assert!(out.insert(p.name.clone(), id).is_none(), "{what}: twice"); } out } +/// A schedule as the books register it. +fn new_schedule(l: &Ledger, s: &ScheduleSpec, currency: Currency, what: &str) -> NewSchedule { + let account = |code: &str| { + l.chart() + .find_by_code(code) + .unwrap_or_else(|| panic!("{what}: no account {code}")) + .id + }; + NewSchedule { + description: s.description.clone(), + kind: match s.kind.as_str() { + "prepaid_expense" => ScheduleKind::PrepaidExpense, + "deferred_revenue" => ScheduleKind::DeferredRevenue, + other => panic!("{what}: schedule kind {other:?}"), + }, + total: Money::new(exact(&s.total, what), currency), + start: date(&s.start, what), + months: s.months, + balance_account: account(&s.balance_account), + pl_account: account(&s.pl_account), + convention: ScheduleConvention::parse(&s.convention) + .unwrap_or_else(|| panic!("{what}: schedule convention {:?}", s.convention)), + } +} + impl<'a> Books<'a> { /// The books an example sets up, before any event: its shape checked, - /// its accounts, fixed assets, parties, members, payees, their dated - /// decisions, and payment methods. + /// its accounts, fixed assets, schedules, parties, members, payees, + /// their dated decisions, and payment methods. fn new(ex: &'a Example) -> Self { let s = &ex.setup; let currency = Currency::new(&s.currency).unwrap(); - let mut l = Ledger::new(currency); + let dir = tempfile::tempdir().unwrap(); + let mut store = took( + Store::create(&dir.path().join("books"), currency), + &format!("{}: the books", ex.id), + ); assert!( ex.appendix.as_deref().is_none_or(|a| a == "formats"), "{}: appendix {:?}", @@ -700,32 +873,67 @@ impl<'a> Books<'a> { .id }; for a in &s.accounts { + let what = format!("{}: account {}", ex.id, a.code); // A parent is declared before its subaccounts. - let parent = a.parent.as_deref().map(|p| id(&l, p)); - let account = l - .add_account(&a.code, &a.name, a.account_type(&ex.id), parent, a.header) - .unwrap_or_else(|e| panic!("{}: account {}: {e}", ex.id, a.code)); + let parent = a.parent.as_deref().map(|p| id(store.ledger(), p)); + let account = took( + store.add_account(&a.code, &a.name, a.account_type(&ex.id), parent, a.header), + &what, + ); if let Some(c) = &a.tax_category { - l.set_account_tax(account, Some(c), None) - .unwrap_or_else(|e| panic!("{}: account {}: {e}", ex.id, a.code)); + took(store.set_account_tax(account, Some(c), None), &what); } } let first = ex .events - .first() - .map_or(date("2026-01-01", "start"), |e| date(&e.date, "date")); + .iter() + .find_map(|e| e.date.as_deref()) + .map_or(date("2026-01-01", "start"), |d| date(d, "date")); for fa in &s.fixed_assets { - l.register_asset(NewAsset { + let new = NewAsset { description: format!("Fixed asset on {}", fa.cost_account), purchase_date: first, cost: Money::new(Decimal::ONE, currency), salvage_value: Money::new(Decimal::ZERO, currency), useful_life_months: 12, - cost_account: id(&l, &fa.cost_account), - accum_depr_account: id(&l, &fa.accumulated_account), - expense_account: id(&l, &fa.expense_account), - }) - .unwrap_or_else(|e| panic!("{}: fixed asset: {e}", ex.id)); + cost_account: id(store.ledger(), &fa.cost_account), + accum_depr_account: id(store.ledger(), &fa.accumulated_account), + expense_account: id(store.ledger(), &fa.expense_account), + }; + took( + store.register_asset(new), + &format!("{}: fixed asset", ex.id), + ); + } + let mut assets = BTreeMap::new(); + for a in &s.assets { + let what = format!("{}: asset {}", ex.id, a.id); + assert_eq!(a.method, "straight_line", "{what}: method"); + let new = NewAsset { + description: a.description.clone(), + purchase_date: date(&a.in_service, &what), + cost: Money::new(exact(&a.cost, &what), currency), + salvage_value: Money::new(exact(&a.salvage, &what), currency), + useful_life_months: a.life_months, + cost_account: id(store.ledger(), &a.cost_account), + accum_depr_account: id(store.ledger(), &a.accumulated_account), + expense_account: id(store.ledger(), &a.expense_account), + }; + let asset = took(store.register_asset(new), &what); + assert!( + assets.insert(a.id.clone(), asset).is_none(), + "{what}: id used twice" + ); + } + let mut schedules = BTreeMap::new(); + for sc in &s.schedules { + let what = format!("{}: schedule {}", ex.id, sc.id); + let new = new_schedule(store.ledger(), sc, currency, &what); + let schedule = took(store.register_schedule(new), &what); + assert!( + schedules.insert(sc.id.clone(), schedule).is_none(), + "{what}: id used twice" + ); } let shared = s.members.iter().filter(|m| m.share.is_some()).count(); assert!( @@ -738,8 +946,11 @@ impl<'a> Books<'a> { .iter() .map(|m| MemberCapital { name: m.name.clone(), - capital_account: id(&l, &m.capital_account), - distribution_account: m.distributions_account.as_deref().map(|c| id(&l, c)), + capital_account: id(store.ledger(), &m.capital_account), + distribution_account: m + .distributions_account + .as_deref() + .map(|c| id(store.ledger(), c)), share: m.share.as_deref().map_or(Decimal::ZERO, |x| { let share = exact(x, &format!("{} {} share", ex.id, m.name)); assert!(share > Decimal::ZERO, "{}: {}'s share", ex.id, m.name); @@ -751,13 +962,13 @@ impl<'a> Books<'a> { let total: Decimal = members.iter().map(|m| m.share).sum(); assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id); } - let parties = add_parties(ex, &mut l); + let parties = add_parties(ex, &mut store); // A member who is a party (by name, with the member role), as the // settings' members are (settings::Company::party_members). let party_members: Vec = members .iter() .filter_map(|m| { - let p = l.party_by_name(&m.name)?; + let p = store.ledger().party_by_name(&m.name)?; p.roles.contains(&Role::Member).then_some(PartyMember { party: p.id, share: m.share, @@ -769,20 +980,21 @@ impl<'a> Books<'a> { let source = bookspec_source(&ex.id); let mut payees = BTreeMap::new(); for p in &s.payees { + let what = format!("{}: payee {}", ex.id, p.name); let form = Form1099::parse(&p.form) .filter(|_| p.form.starts_with("1099-")) - .unwrap_or_else(|| panic!("{}: payee {}: form {:?}", ex.id, p.name, p.form)); + .unwrap_or_else(|| panic!("{what}: form {:?}", p.form)); // A payee in the directory with the vendor role is that // party's vendor record. - let v = match parties.get(&p.name).and_then(|&id| l.get_party(id)?.vendor) { + let known = parties + .get(&p.name) + .and_then(|&id| store.ledger().get_party(id)?.vendor); + let v = match known { Some(v) => v, - None => l - .add_vendor(&p.name) - .unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)), + None => took(store.add_vendor(&p.name), &what), }; let dated = Dated1099::from_beginning(Decision1099::Files(form), Some(source.clone())); - l.set_vendor_1099(v, dated, false) - .unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)); + took(store.set_vendor_1099(v, &dated, Some(false)), &what); assert!( payees.insert(p.name.clone(), v).is_none(), "{}: payee {} twice", @@ -805,27 +1017,23 @@ impl<'a> Books<'a> { ), source: Some(source.clone()), }; - l.set_vendor_1099(v, dated, false) - .unwrap_or_else(|e| panic!("{what}: {e}")); + took(store.set_vendor_1099(v, &dated, Some(false)), &what); } let mut instruments = BTreeMap::new(); for i in &s.instruments { let what = format!("{} instrument {}", ex.id, i.label); let kind = InstrumentKind::parse(&i.kind) .unwrap_or_else(|| panic!("{what}: kind {:?}", i.kind)); - let account = l - .chart() - .find_by_code(&i.account) - .unwrap_or_else(|| panic!("{what}: no account {}", i.account)) - .id; + let account = id(store.ledger(), &i.account); let owner = i.owner.as_ref().map(|o| { *parties .get(o) .unwrap_or_else(|| panic!("{what}: owner {o:?} not in setup.parties")) }); - let id = l - .add_instrument(kind, &i.label, i.last4.as_deref(), owner, account) - .unwrap_or_else(|e| panic!("{what}: {e}")); + let id = took( + store.add_instrument(kind, &i.label, i.last4.as_deref(), owner, account), + &what, + ); assert!( instruments.insert(i.label.clone(), id).is_none(), "{what}: twice" @@ -833,7 +1041,8 @@ impl<'a> Books<'a> { } Books { ex, - l, + store, + _dir: dir, currency, members, party_members, @@ -841,74 +1050,236 @@ impl<'a> Books<'a> { vendors: payees.clone(), payees, instruments, + assets, + schedules, + posted: Vec::new(), + runs: BTreeMap::new(), + disposals: BTreeMap::new(), } } + /// The books as they stand. + fn l(&self) -> &Ledger { + self.store.ledger() + } + fn id(&self, code: &str) -> ledger_core::AccountId { - self.l + self.l() .chart() .find_by_code(code) .unwrap_or_else(|| panic!("{}: no account {code}", self.ex.id)) .id } - /// Event `n` (counting from 1), which must post entry #n. - fn apply(&mut self, n: EntryId, e: &Event) { - let ex = &self.ex.id; - let when = date(&e.date, &format!("{ex} event {n}")); + /// The fixed asset `setup.assets` names `name`. + fn asset(&self, name: &str, what: &str) -> AssetId { + *self + .assets + .get(name) + .unwrap_or_else(|| panic!("{what}: no asset {name:?} in setup.assets")) + } + + /// The entry event `event` (counting from 1) posted: an event applied + /// already, that posted exactly one. + fn entry_of(&self, event: usize, what: &str) -> EntryId { assert!( - e.payee.is_none() || (e.reverses.is_none() && e.close.is_none() && e.bill.is_none()), - "{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none" + event >= 1 && event <= self.posted.len(), + "{what}: event {event}: events count from 1, to the last one applied ({})", + self.posted.len() + ); + match self.posted[event - 1].as_slice() { + [e] => *e, + other => panic!( + "{what}: event {event} posted {} entries: only an event that posts one entry names it", + other.len() + ), + } + } + + /// Event `n` (counting from 1): accepted, or refused for the reason it + /// gives and changing nothing; the entries it posted kept as event + /// n's, tagged with the parties, payee and payment method it names. + fn apply(&mut self, n: usize, e: &Event) { + let spec = self.ex; + let ex = spec.id.as_str(); + let kinds = [ + ("lines", !e.lines.is_empty()), + ("reverses", e.reverses.is_some()), + ("close", e.close.is_some()), + ("bill", e.bill.is_some()), + ("run_depreciation", e.run_depreciation.is_some()), + ("dispose_asset", e.dispose_asset.is_some()), + ("run_schedules", e.run_schedules.is_some()), + ("register_schedule", e.register_schedule.is_some()), + ]; + let kind = match kinds.iter().filter(|(_, given)| *given).collect::>()[..] { + [(kind, _)] => *kind, + _ => panic!( + "{ex} event {n}: an event is one of lines, reverses, close, bill, run_depreciation, dispose_asset, run_schedules or register_schedule" + ), + }; + // An entry of the example's own words: dated and described. A + // disposal is dated; the other commands date their own entries, + // and every command describes its own. + let own = matches!(kind, "lines" | "reverses" | "close" | "bill"); + assert_eq!( + e.date.is_some(), + own || kind == "dispose_asset", + "{ex} event {n}: {kind} {}", + if own || kind == "dispose_asset" { + "is dated (date)" + } else { + "dates its own entries: no date" + } + ); + assert_eq!( + e.description.is_some(), + own, + "{ex} event {n}: {kind} {}", + if own { + "is described (description)" + } else { + "describes its own entries: no description" + } ); assert!( - (e.parties.is_empty() && e.instrument.is_none()) || e.reverses.is_none(), + e.payee.is_none() || kind == "lines", + "{ex} event {n}: a payee is an entry's: a reversal takes its original's, a bill names its vendor, a close and a command have none" + ); + assert!( + (e.parties.is_empty() && e.instrument.is_none()) || kind != "reverses", "{ex} event {n}: a reversal takes its original's parties and payment method" ); - let posted = match (&e.close, e.reverses, e.lines.is_empty()) { - (None, None, true) if e.bill.is_some() => { - let b = e.bill.as_ref().unwrap(); - let amount = exact(&b.amount, ex); - assert!( - amount > Decimal::ZERO, - "{ex} event {n}: amounts are positive" + assert!( + (e.parties.is_empty() && e.instrument.is_none()) || own, + "{ex} event {n}: {kind} is tagged with no parties or payment method" + ); + assert!( + e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill"), + "{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all" + ); + let what = format!("{ex} event {n}"); + let when = e.date.as_deref().map(|d| date(d, &what)); + let description = e.description.as_deref().unwrap_or_default(); + let head = self.store.head(); + let first = self.l().next_entry_id(); + let result = self.take(n, e, kind, when, description, &what); + match (result, &e.refused) { + (Ok(()), None) => {} + (Err(err), Some(want)) => { + assert_eq!(store_refusal(&err), *want, "{what}: {kind} refused ({err})"); + assert_eq!( + self.store.head(), + head, + "{what}: a refused {kind} changes nothing" ); - let vendor = match self.vendors.get(&b.vendor) { - Some(&v) => v, - None => { - let v = self - .l - .add_vendor(&b.vendor) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); - self.vendors.insert(b.vendor.clone(), v); - v - } - }; - let bill = self - .l - .create_bill( - vendor, - when, - when, - vec![DocumentLine { - description: e.description.clone(), - amount, - }], - ) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); - self.l.get_bill(bill).expect("the bill just made").entry_id } - (_, _, _) if e.bill.is_some() => { - panic!("{ex} event {n}: an event is one of lines, reverses, close or bill") + (Err(err), None) => panic!("{what}: {kind}: {err}"), + (Ok(()), Some(want)) => panic!("{what}: {kind} accepted, expected refused: {want}"), + } + let next = self.l().next_entry_id(); + let posted: Vec = (first..next).collect(); + for &id in &posted { + assert_eq!( + self.l().get_entry(id).map(|x| x.status()), + Some(EntryStatus::Posted), + "{what}: entry {id}, which it made, is posted" + ); + } + if own && e.refused.is_none() { + assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry"); + } + self.posted.push(posted); + if !e.parties.is_empty() { + let entry = self.entry_of(n, &what); + let shares: Vec = e + .parties + .iter() + .map(|sh| { + let party = *self + .parties + .get(&sh.party) + .unwrap_or_else(|| panic!("{what}: party {:?} not in setup", sh.party)); + let share = match (&sh.amount, &sh.percent) { + (None, None) => Share::Derived, + (Some(a), None) => Share::Amount(exact(a, ex)), + (None, Some(p)) => Share::Percent(exact(p, ex)), + _ => panic!("{what}: a share is an amount or a percent"), + }; + PartyShare { party, share } + }) + .collect(); + took(self.store.set_entry_parties(entry, &shares), &what); + } + // The payee after the parties: a payee's party must be among + // several named (the vendor tag follows the parties). + if let Some(p) = &e.payee { + let v = *self + .payees + .get(p) + .unwrap_or_else(|| panic!("{what}: payee {p:?} not in setup")); + let entry = self.entry_of(n, &what); + took(self.store.set_entry_vendor(entry, Some(v)), &what); + } + if let Some(label) = &e.instrument { + let i = *self + .instruments + .get(label) + .unwrap_or_else(|| panic!("{what}: payment method {label:?} not in setup")); + let entry = self.entry_of(n, &what); + took(self.store.set_entry_instrument(entry, Some(i)), &what); + } + } + + /// Event `n`'s command, given to the books (`kind`, as [`Self::apply`] + /// found it): whatever it posts is posted, or the books refuse it. + fn take( + &mut self, + n: usize, + e: &Event, + kind: &str, + when: Option, + description: &str, + what: &str, + ) -> Result<(), StoreError> { + let money = |s: &str| { + let amount = exact(s, what); + assert!(amount > Decimal::ZERO, "{what}: amounts are positive"); + amount + }; + let day = || when.expect("checked: dated"); + match kind { + "lines" => { + let entry = self.store.create_entry(day(), description)?; + for line in &e.lines { + let side = match line.side.as_str() { + "debit" => Side::Debit, + "credit" => Side::Credit, + other => panic!("{what}: side {other:?}"), + }; + let amount = Money::new(money(&line.amount), self.currency); + let account = self.id(&line.account); + self.store.add_line(entry, account, side, amount)?; + } + self.store.post_entry(entry) } - (Some(c), None, true) => { + "reverses" => { + let of = e.reverses.expect("checked: a reversal"); + // Events count from 1, as entries do: `reverses = 1` is + // the first event (bookspec/README.md). + assert!(of >= 1, "{what}: reverses = {of}: events count from 1"); + assert!(of < n, "{what}: reverses a later event"); + let entry = self.entry_of(of, what); + self.store + .reverse_entry(entry, day(), description) + .map(|_| ()) + } + "close" => { + let c = e.close.as_ref().expect("checked: a close"); + let start = date(&c.start, what); let p = self - .l - .create_period( - &format!("event {n}"), - date(&c.start, &format!("{ex} event {n}")), - when, - ) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + .store + .create_period(&format!("event {n}"), start, day())?; let members: Vec = self .members .iter() @@ -921,102 +1292,95 @@ impl<'a> Books<'a> { if c.to == "members" { assert!( !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), - "{ex} event {n}: a close to the members needs their shares" + "{what}: a close to the members needs their shares" ); } let to = match (c.to.as_str(), c.rounding.as_deref()) { ("members", None) => CloseTo::Members(members), ("members", Some("per-close")) => CloseTo::MembersPerClose(members), ("retained_earnings", None) => CloseTo::RetainedEarnings { sweep: vec![] }, - other => panic!("{ex} event {n}: close {other:?}"), + other => panic!("{what}: close {other:?}"), }; - self.l - .close_period_to(p, &to) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")) - .unwrap_or_else(|| panic!("{ex} event {n}: the close has nothing to close")) + let closed = self.store.close_period_to(p, &to)?; + assert!(closed.is_some(), "{what}: the close has nothing to close"); + Ok(()) } - (None, Some(of), true) => { - // Events count from 1, as entries do: `reverses = 1` is - // the first event (bookspec/README.md). - assert!( - of >= 1, - "{ex} event {n}: reverses = {of}: events count from 1" - ); - assert!( - (of as EntryId) < n, - "{ex} event {n}: reverses a later event" - ); - self.l - .reverse_entry(of as EntryId, when, &e.description) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")) + "bill" => { + let b = e.bill.as_ref().expect("checked: a bill"); + let amount = money(&b.amount); + let vendor = match self.vendors.get(&b.vendor) { + Some(&v) => v, + None => { + let v = self.store.add_vendor(&b.vendor)?; + self.vendors.insert(b.vendor.clone(), v); + v + } + }; + let lines = vec![DocumentLine { + description: description.to_string(), + amount, + }]; + self.store + .create_bill(vendor, day(), day(), lines) + .map(|_| ()) } - (None, Some(_), false) => panic!("{ex} event {n}: a reversal has no lines"), - (None, None, false) => { - let entry = self.l.create_entry(when, &e.description); - for line in &e.lines { - let side = match line.side.as_str() { - "debit" => Side::Debit, - "credit" => Side::Credit, - other => panic!("{ex} event {n}: side {other:?}"), - }; - let amount = exact(&line.amount, ex); - assert!( - amount > Decimal::ZERO, - "{ex} event {n}: amounts are positive" - ); - let account = self.id(&line.account); - self.l - .add_line(entry, account, side, Money::new(amount, self.currency)) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + "run_depreciation" => { + let r = e.run_depreciation.as_ref().expect("checked: a run"); + let asset = self.asset(&r.asset, what); + let run = self.store.run_depreciation(asset, &r.period)?; + self.runs.insert(n, run); + Ok(()) + } + "dispose_asset" => { + let d = e.dispose_asset.as_ref().expect("checked: a disposal"); + let asset = self.asset(&d.asset, what); + let proceeds = exact(&d.proceeds, what); + assert!( + proceeds >= Decimal::ZERO, + "{what}: proceeds aren't negative" + ); + let proceeds = Money::new(proceeds, self.currency); + let (into, gain_loss) = + (self.id(&d.proceeds_account), self.id(&d.gain_loss_account)); + // What the disposal takes, as the engine computes it before + // posting; the disposal itself refused whenever its + // computation is. + let preview = self.store.dispose_preview(asset, day(), proceeds); + let disposed = self + .store + .dispose_asset(asset, day(), proceeds, into, gain_loss); + match (preview, disposed) { + (Ok(p), Ok(_)) => { + assert!( + self.disposals.insert(d.asset.clone(), p).is_none(), + "{what}: asset {} disposed of twice", + d.asset + ); + Ok(()) + } + (Err(p), Ok(_)) => { + panic!("{what}: disposed of, though its computation was refused: {p}") + } + (_, Err(err)) => Err(err), } - self.l - .post_entry(entry) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); - entry } - _ => panic!("{ex} event {n}: an event is one of lines, reverses, close or bill"), - }; - assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}"); - if !e.parties.is_empty() { - let shares: Vec = e - .parties - .iter() - .map(|sh| { - let party = *self.parties.get(&sh.party).unwrap_or_else(|| { - panic!("{ex} event {n}: party {:?} not in setup", sh.party) - }); - let share = match (&sh.amount, &sh.percent) { - (None, None) => Share::Derived, - (Some(a), None) => Share::Amount(exact(a, ex)), - (None, Some(p)) => Share::Percent(exact(p, ex)), - _ => panic!("{ex} event {n}: a share is an amount or a percent"), - }; - PartyShare { party, share } - }) - .collect(); - self.l - .set_entry_parties(n, &shares) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); - } - // The payee after the parties: a payee's party must be among - // several named (the vendor tag follows the parties). - if let Some(p) = &e.payee { - let v = *self - .payees - .get(p) - .unwrap_or_else(|| panic!("{ex} event {n}: payee {p:?} not in setup")); - self.l - .set_entry_vendor(n, Some(v)) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); - } - if let Some(label) = &e.instrument { - let i = *self - .instruments - .get(label) - .unwrap_or_else(|| panic!("{ex} event {n}: payment method {label:?} not in setup")); - self.l - .set_entry_instrument(n, Some(i)) - .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + "run_schedules" => { + let r = e.run_schedules.as_ref().expect("checked: a run"); + let through = date(&r.through, what); + self.store.run_schedules(through).map(|_| ()) + } + "register_schedule" => { + let sc = e.register_schedule.as_ref().expect("checked: a schedule"); + let new = new_schedule(self.l(), sc, self.currency, what); + let schedule = self.store.register_schedule(new)?; + assert!( + self.schedules.insert(sc.id.clone(), schedule).is_none(), + "{what}: schedule id {} used twice", + sc.id + ); + Ok(()) + } + other => unreachable!("{other} is no event"), } } } @@ -1026,42 +1390,28 @@ impl Books<'_> { fn change(&mut self, c: &ChangeSpec) { let ex = &self.ex.id; let after = c.after; + let what = format!("{ex} change after {after}"); match (&c.rename, &c.require_tax_category) { (Some(r), None) => { let account = self.id(&r.code); - self.l - .rename_account(account, &r.name) - .unwrap_or_else(|e| panic!("{ex} change after {after}: {e}")); + took(self.store.rename_account(account, &r.name, None), &what); } (None, Some(policy)) => { assert!( matches!(policy.as_str(), "warn" | "refuse"), - "{ex} change after {after}: require_tax_category {policy:?}" + "{what}: require_tax_category {policy:?}" ); - use ledger_core::settings::{Change, Source}; - let source = Source { - decided_by: "bookspec".into(), - decided_on: date("2026-01-01", ex), - citation: None, - note: None, + use ledger_core::settings::Change; + let change = Change { + key: "policy.require_tax_category".into(), + value: Some(serde_json::json!(policy)), }; - let changes = self - .l - .check_settings( - &[Change { - key: "policy.require_tax_category".into(), - value: Some(serde_json::json!(policy)), - }], - &source, - ) - .unwrap_or_else(|e| panic!("{ex} change after {after}: {e}")); - self.l - .apply_settings(&changes, &source, 1) - .unwrap_or_else(|e| panic!("{ex} change after {after}: {e}")); + took( + self.store.change_settings(&[change], &bookspec_source(ex)), + &what, + ); } - _ => panic!( - "{ex} change after {after}: a change is one of rename or require_tax_category" - ), + _ => panic!("{what}: a change is one of rename or require_tax_category"), } } } @@ -1071,7 +1421,7 @@ impl Books<'_> { fn load(ex: &Example) -> Books<'_> { let mut b = Books::new(ex); for (i, e) in ex.events.iter().enumerate() { - b.apply(i as EntryId + 1, e); + b.apply(i + 1, e); for c in ex.changes.iter().filter(|c| c.after == i + 1) { b.change(c); } @@ -1080,8 +1430,13 @@ fn load(ex: &Example) -> Books<'_> { } /// The `cash_flow` query's value for `key` (bookspec/README.md), or None -/// for a key it doesn't know. -fn cash_flow_value(cf: &CashFlowStatement, key: &str) -> Option { +/// for a key it doesn't know. A key's `` is event N, whose entry +/// `entry_of` gives. +fn cash_flow_value( + cf: &CashFlowStatement, + key: &str, + entry_of: &dyn Fn(usize) -> EntryId, +) -> Option { let sections = [ ("operating", CashFlowSection::Operating), ("investing", CashFlowSection::Investing), @@ -1101,7 +1456,7 @@ fn cash_flow_value(cf: &CashFlowStatement, key: &str) -> Option { } let (head, tail) = key.split_once('.')?; if head == "noncash" { - let n: EntryId = tail.parse().ok()?; + let n = entry_of(tail.parse().ok()?); return Some( cf.noncash_disclosures .iter() @@ -1138,14 +1493,15 @@ fn cash_flow_value(cf: &CashFlowStatement, key: &str) -> Option { let entries: BTreeSet = lines.iter().map(|l| l.entry).collect(); return Some(count(entries.len())); } - // `
.`, or `
..on_behalf`: of entry N's - // cash there, what a member or lender handled for the company. + // `
.`, or `
..on_behalf`: of event N's + // entry's cash there, what a member or lender handled for the + // company. let (n, on_behalf) = match tail.split_once('.') { Some((n, "on_behalf")) => (n, true), Some(_) => return None, None => (tail, false), }; - let n: EntryId = n.parse().ok()?; + let n = entry_of(n.parse().ok()?); lines .iter() .filter(|l| l.entry == n) @@ -1171,7 +1527,8 @@ fn reconciliation_key(l: &ledger_core::ReconcilingLine) -> String { } /// Check one `cash_flow` expectation, every value it gives. -fn check_cash_flow(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { +fn check_cash_flow(b: &Books<'_>, x: &Expect) { + let (ex, l, members) = (b.ex, b.l(), &b.members); let (start, end) = x.period(ex); let values = x.figures(&ex.id); let params = CashFlowParams { @@ -1185,7 +1542,8 @@ fn check_cash_flow(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expe let cf = cash_flow(l, ¶ms).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); for (key, want) in values { let want = exact(want, &format!("{} {key}", ex.id)); - let got = cash_flow_value(&cf, key) + let entry_of = |n: usize| b.entry_of(n, &format!("{} cash_flow {key}", ex.id)); + let got = cash_flow_value(&cf, key, &entry_of) .unwrap_or_else(|| panic!("{}: unknown cash_flow key {key:?}", ex.id)); assert_eq!( got, want, @@ -1313,7 +1671,7 @@ fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) { /// of the cash and financing accounts, an entry's shares resolved with /// the members who are parties (rules.md B-19, B-33). fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) { - let (ex, l) = (b.ex, &b.l); + let (ex, l) = (b.ex, b.l()); // A calendar-year count, never the fiscal default (rules.md B-30): // both dates given. assert!( @@ -1442,15 +1800,16 @@ fn expect_party(b: &Books<'_>, x: &Expect) -> PartyId { /// of it moved as money (`cash`) and as property (`in_kind`); every one /// given. fn check_party_part(b: &Books<'_>, x: &Expect) { - let (ex, l) = (b.ex, &b.l); + let (ex, l) = (b.ex, b.l()); assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: party_part asks about one entry, no dates", ex.id ); - let n = x + let event = x .entry - .unwrap_or_else(|| panic!("{}: a party_part query names its entry", ex.id)); + .unwrap_or_else(|| panic!("{}: a party_part query names its event", ex.id)); + let n = b.entry_of(event, &format!("{} party_part", ex.id)); let party = expect_party(b, x); let r = ledger_core::shares::resolve( l, @@ -1485,7 +1844,7 @@ fn check_party_part(b: &Books<'_>, x: &Expect) { /// money; `all`: money and property in kind), each entry's part on its /// root original's side (rules.md B-35 item 4); both given. fn check_party_totals(b: &Books<'_>, x: &Expect) { - let (ex, l) = (b.ex, &b.l); + let (ex, l) = (b.ex, b.l()); assert!( x.from.is_some() && x.to.is_some(), "{}: party_totals gives from and to", @@ -1544,10 +1903,11 @@ fn check_party_tin_last4(b: &Books<'_>, x: &Expect) { ); let party = expect_party(b, x); exactly(ex, x, &["tin_last4".to_string()].into()); - let got = - b.l.get_party(party) - .and_then(|p| p.tin_last4()) - .unwrap_or_default(); + let got = b + .l() + .get_party(party) + .and_then(|p| p.tin_last4()) + .unwrap_or_default(); assert_eq!( got, x.figures(&ex.id)["tin_last4"], @@ -1639,10 +1999,11 @@ fn check_format_shape(ex: &Example, x: &Expect) { } } -/// Check one `entry` expectation: entry N's lines, `".debit"` and +/// Check one `entry` expectation: the lines of event N's entry, `".debit"` and /// `".credit"` each summing that account's lines on that side, /// every line of the entry named. -fn check_entry(ex: &Example, l: &Ledger, x: &Expect) { +fn check_entry(b: &Books<'_>, x: &Expect) { + let (ex, l) = (b.ex, b.l()); assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: entry asks about one entry, no dates", @@ -1650,10 +2011,10 @@ fn check_entry(ex: &Example, l: &Ledger, x: &Expect) { ); let n = x .entry - .unwrap_or_else(|| panic!("{}: an entry query names its entry", ex.id)); + .unwrap_or_else(|| panic!("{}: an entry query names its event", ex.id)); let entry = l - .get_entry(n) - .unwrap_or_else(|| panic!("{}: no entry {n}", ex.id)); + .get_entry(b.entry_of(n, &format!("{} entry {n}", ex.id))) + .expect("an entry an event posted"); let mut got: BTreeMap = BTreeMap::new(); for line in entry.lines() { let code = &l.chart().get(line.account).unwrap().code; @@ -1735,6 +2096,165 @@ fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x: } } +/// No dates: a query about one thing the books did, not about a day. +fn undated(ex: &Example, x: &Expect) { + assert!( + x.from.is_none() && x.to.is_none() && x.as_of.is_none(), + "{}: {} takes no dates", + ex.id, + x.query + ); +} + +/// Check one `run` expectation: what the `run_depreciation` event `event` +/// posted (`amount`: zero when nothing was due, or the month already had +/// its run) and, of it, what it caught up beyond its own month's +/// depreciation (`caught_up`), both listed (rules.md B-15). +fn check_run(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + undated(ex, x); + let n = x + .event + .unwrap_or_else(|| panic!("{}: run names its event", ex.id)); + let run = b.runs.get(&n).unwrap_or_else(|| { + panic!( + "{}: event {n} is no run_depreciation (or not yet run)", + ex.id + ) + }); + let posted = if run.already_posted { + Decimal::ZERO + } else { + run.amount + }; + let caught_up = (posted - run.scheduled).max(Decimal::ZERO); + exactly( + ex, + x, + &["amount".to_string(), "caught_up".to_string()].into(), + ); + for (key, got) in [("amount", posted), ("caught_up", caught_up)] { + let want = exact(&x.figures(&ex.id)[key], &format!("{} {key}", ex.id)); + assert_eq!(got, want, "{} run of event {n}: {key}", ex.id); + } +} + +/// Check one `disposal` expectation: what the disposal of `asset` took, as +/// the engine computed it when posting it (rules.md B-15), every figure +/// listed: `final_depreciation` (posted in its own entry before the +/// disposal), `catch_up_depreciation` and `excess_depreciation` (charged, +/// and credited back, in the disposal entry), `accumulated` (cleared), +/// `book_value` and `gain_loss` (a gain positive). +fn check_disposal(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + undated(ex, x); + let asset = x + .asset + .as_deref() + .unwrap_or_else(|| panic!("{}: disposal names its asset", ex.id)); + let p = b + .disposals + .get(asset) + .unwrap_or_else(|| panic!("{}: asset {asset:?} wasn't disposed of", ex.id)); + let figures = [ + ("final_depreciation", p.final_depreciation), + ("catch_up_depreciation", p.catch_up_depreciation), + ("excess_depreciation", p.excess_depreciation), + ("accumulated", p.accumulated), + ("book_value", p.book_value), + ("gain_loss", p.gain_loss), + ]; + exactly(ex, x, &figures.iter().map(|(k, _)| k.to_string()).collect()); + for (key, got) in figures { + let want = exact(&x.figures(&ex.id)[key], &format!("{} {key}", ex.id)); + assert_eq!(got, want, "{} disposal of {asset}: {key}", ex.id); + } +} + +/// A month key, "YYYY-MM", checked. +fn month_key(key: &str, what: &str) { + assert!( + key.len() == 7 && NaiveDate::parse_from_str(&format!("{key}-01"), "%Y-%m-%d").is_ok(), + "{what}: {key:?} is no month (YYYY-MM)" + ); +} + +/// Check one `depreciation_schedule` expectation: the schedule of `asset` +/// through `as_of` (rules.md B-15), each month named (`"YYYY-MM"`) its +/// depreciation, 0.00 for a month the schedule doesn't reach; +/// `accumulated` and `book_value` through `as_of`; the example names the +/// figures it shows. +fn check_depreciation_schedule(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + let as_of = x.day(ex); + let name = x + .asset + .as_deref() + .unwrap_or_else(|| panic!("{}: depreciation_schedule names its asset", ex.id)); + let what = format!("{} depreciation_schedule of {name} through {as_of}", ex.id); + let asset = b + .l() + .get_asset(b.asset(name, &what)) + .expect("a registered asset"); + let lines = + ledger_core::depreciation_schedule(asset, as_of).unwrap_or_else(|e| panic!("{what}: {e}")); + for (key, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{what}: {key}")); + let got = match key.as_str() { + "accumulated" => ledger_core::accumulated_depreciation(asset, as_of), + "book_value" => ledger_core::book_value(asset, as_of), + month => { + month_key(month, &what); + Ok(lines + .iter() + .find(|l| l.period == month) + .map_or(Decimal::ZERO, |l| l.amount)) + } + } + .unwrap_or_else(|e| panic!("{what}: {key}: {e}")); + assert_eq!(got, want, "{what}: {key}"); + } +} + +/// Check one `schedule_plan` expectation: what the schedule's plan +/// recognizes at each month end named (`"YYYY-MM-DD"`, 0.00 at a month end +/// the plan doesn't post), and `total`, all it recognizes (rules.md B-16); +/// the example names the figures it shows. +fn check_schedule_plan(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + undated(ex, x); + let name = x + .schedule + .as_deref() + .unwrap_or_else(|| panic!("{}: schedule_plan names its schedule", ex.id)); + let what = format!("{} schedule_plan of {name}", ex.id); + let id = *b + .schedules + .get(name) + .unwrap_or_else(|| panic!("{what}: no schedule {name:?}")); + let plan = b + .l() + .get_schedule(id) + .expect("a registered schedule") + .plan() + .unwrap_or_else(|e| panic!("{what}: {e}")); + for (key, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{what}: {key}")); + let got: Decimal = if key == "total" { + plan.iter().map(|(_, a)| *a).sum() + } else { + let d = date(key, &what); + assert_eq!( + d, + ledger_core::schedules::month_end(d), + "{what}: {key} is no month end" + ); + plan.iter().filter(|(m, _)| *m == d).map(|(_, a)| *a).sum() + }; + assert_eq!(got, want, "{what}: {key}"); + } +} + /// The commands an example may try: each on a copy of the books, so it /// changes nothing that is checked after it. const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"]; @@ -1939,7 +2459,7 @@ fn check_uncategorized_accounts(ex: &Example, l: &Ledger, x: &Expect) { /// to the test [`FORMAT_QUERIES`] names (returned); a query no crate /// answers is refused. fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { - let (ex, l) = (b.ex, &b.l); + let (ex, l) = (b.ex, b.l()); let q = x.query.as_str(); let only = |given: bool, key: &str, queries: &[&str]| { assert!( @@ -1950,6 +2470,13 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { ); }; only(x.entry.is_some(), "entry", &["entry", "party_part"]); + only(x.event.is_some(), "event", &["run"]); + only( + x.asset.is_some(), + "asset", + &["disposal", "depreciation_schedule"], + ); + only(x.schedule.is_some(), "schedule", &["schedule_plan"]); only( x.party.is_some(), "party", @@ -1998,12 +2525,16 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { "calendar_year" => check_calendar_year(ex, x), "account_names" => check_account_names(ex, l, x), "uncategorized_accounts" => check_uncategorized_accounts(ex, l, x), - "cash_flow" => check_cash_flow(ex, l, &b.members, x), + "cash_flow" => check_cash_flow(b, x), "trial_balance" => check_trial_balance(ex, l, x), "profit_and_loss" => check_profit_and_loss(ex, l, x), "vendor_1099_paid" => check_vendor_1099_paid(b, x), "vendor_1099" => check_vendor_1099(b, x), - "entry" => check_entry(ex, l, x), + "entry" => check_entry(b, x), + "run" => check_run(b, x), + "disposal" => check_disposal(b, x), + "depreciation_schedule" => check_depreciation_schedule(b, x), + "schedule_plan" => check_schedule_plan(b, x), "balances" => check_balances(ex, l, x), "members_equity" => check_members_equity(ex, l, &b.members, x), "k1" => check_k1(ex, l, &b.members, x), @@ -2043,7 +2574,7 @@ fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { let mut b = Books::new(ex); let mut out = Vec::new(); for (i, e) in ex.events.iter().enumerate() { - b.apply(i as EntryId + 1, e); + b.apply(i + 1, e); for x in ex.expect.iter().filter(|x| x.after == Some(i + 1)) { out.push((x.query.clone(), check(&b, x))); } @@ -2054,6 +2585,11 @@ fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { for x in ex.expect.iter().filter(|x| x.after.is_none()) { out.push((x.query.clone(), check(&b, x))); } + // What the books kept replays to the books (the journal, the live + // books and their projection agree). + b.store + .verify() + .unwrap_or_else(|e| panic!("{}: the books replay as kept: {e}", ex.id)); out } @@ -2096,7 +2632,7 @@ fn every_bookspec_example_holds() { // Each query the examples use ran: the cash flow's six examples at // least, the as-of and 1099 examples', the members closes', the // formats appendix's, and P3's default period, rename, tax category, - // parties and 1099 examples'. + // parties, 1099, depreciation and schedules examples'. for (q, at_least) in [ ("cash_flow", 6), ("trial_balance", 2), @@ -2120,6 +2656,10 @@ fn every_bookspec_example_holds() { ("rename_account", 5), ("add_account", 4), ("set_tax_category", 2), + ("run", 4), + ("disposal", 5), + ("depreciation_schedule", 5), + ("schedule_plan", 3), ] { let n = checked.get(q).copied().unwrap_or(0); assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}"); @@ -2191,7 +2731,7 @@ none = "0" fn reverses_counts_events_from_one() { let ex = reversing(2); let b = load(&ex); - let r = b.l.get_entry(3).unwrap(); + let r = b.l().get_entry(3).unwrap(); assert_eq!(r.reversal_of(), Some(2)); let amounts: Vec = r.lines().iter().map(|x| x.amount.amount).collect(); assert_eq!(amounts, [Decimal::new(25000, 2), Decimal::new(25000, 2)]); @@ -2517,3 +3057,93 @@ fn a_payment_method_not_in_the_setup_is_refused() { ) }); } + +/// An event the example says the engine refuses must be refused: one it +/// takes fails the example (March's run reversed on its own day is taken). +#[test] +#[should_panic(expected = "accepted, expected refused: entry_irreversible")] +fn an_event_the_engine_takes_is_not_refused() { + run_edited( + "depreciation-reversal-before-its-run-is-refused.toml", + |t| t.replacen("date = \"2026-02-10\"", "date = \"2026-03-31\"", 1), + ); +} + +/// A refused event is refused for the reason it gives, in the examples' +/// words, never another. +#[test] +#[should_panic(expected = "reverses refused")] +fn an_event_refused_for_another_reason_is_refused() { + run_edited( + "depreciation-reversal-before-its-run-is-refused.toml", + |t| { + t.replacen( + "refused = \"entry_irreversible\"", + "refused = \"depreciation_past_disposal\"", + 1, + ) + }, + ); +} + +/// A refused event posts nothing, so nothing can name its entry. +#[test] +#[should_panic(expected = "event 6 posted 0 entries")] +fn a_refused_event_has_no_entry_to_reverse() { + run_edited( + "depreciation-reversal-before-its-run-is-refused.toml", + |t| { + t.replacen( + "reverses = 5\n\n# Hand check", + "reverses = 6\n\n# Hand check", + 1, + ) + }, + ); +} + +/// A command dates its own entries: a run given a date is refused, never +/// read as the day it runs. +#[test] +#[should_panic(expected = "dates its own entries: no date")] +fn a_run_given_a_date_is_refused() { + run_edited("depreciation-catch-up-month.toml", |t| { + t.replacen( + "[[events]]\nrun_depreciation", + "[[events]]\ndate = \"2026-01-31\"\nrun_depreciation", + 1, + ) + }); +} + +/// `run` asks about a run_depreciation event, nothing else. +#[test] +#[should_panic(expected = "event 2 is no run_depreciation")] +fn a_run_query_naming_another_event_is_refused() { + run_edited("depreciation-catch-up-month.toml", |t| { + t.replacen("event = 4", "event = 2", 1) + }); +} + +/// A disposal lists every figure it took: one left out is refused. +#[test] +#[should_panic(expected = "lists exactly what it covers")] +fn a_disposal_leaving_a_figure_out_is_refused() { + run_edited("depreciation-disposal-mid-month.toml", |t| { + t.replacen("catch_up_depreciation = \"0.00\"\n", "", 1) + }); +} + +/// The example's figures are the engine's, checked: a schedule month off +/// by a cent fails it. +#[test] +#[should_panic(expected = "schedule_plan of SUB: 2026-02-28")] +fn a_schedule_month_off_by_a_cent_fails() { + run_edited("schedule-daily-deferred-revenue.toml", |t| { + t.replacen( + "\"2026-02-28\" = \"333.34\"", + "\"2026-02-28\" = \"333.33\"", + 1, + ) + }); +} diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index 2f43ecb..4b98d28 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -1257,6 +1257,7 @@ fn id_allocators_predict_the_next_id_and_never_repeat() { months: 12, balance_account: c.equipment, pl_account: c.rent, + convention: ledger_core::ScheduleConvention::MonthlyEven, }; let mut seen = Vec::new(); for n in ["a", "b"] { diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 4942d6b..5ead23e 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -369,4 +369,96 @@ proptest! { let ri = ti.and_then(kernel::reverse_target); prop_assert_eq!(ri.map(as_dec), td.and_then(kernel::reverse_target)); } + + /// The proration Kani proves at i64 is the engine's at i128, part for + /// part, on calendar-shaped parts (each month 28 to 31 days, 1 to all + /// of them) and on parts no calendar has, refusals included. + #[test] + fn prorate_agrees_at_i64_and_i128( + total in 0i64..=(1 << 30), + months in 1u32..=24, + parts in prop::collection::vec((28u32..=31, 0u32..=32), 1..=26), + tenths in any::(), + ) { + let parts: Vec = parts + .into_iter() + .map(|(month_days, days)| kernel::MonthPart { days, month_days }) + .collect(); + let unit = if tenths { 10 } else { 1 }; + let mut oi = vec![0i64; parts.len()]; + let mut ow = vec![0i128; parts.len()]; + let ri = kernel::prorate_by_days(total, months, &parts, unit, &mut oi); + let rw = kernel::prorate_by_days(i128::from(total), months, &parts, i128::from(unit), &mut ow); + prop_assert_eq!(ri.is_some(), rw.is_some()); + if ri.is_some() { + prop_assert_eq!(oi.iter().map(|&o| i128::from(o)).collect::>(), ow); + } + } + + /// The charge Kani proves at i32 (small totals, 1 or 2 months) is the + /// engine's at i128, through every day of a part, refusals included. + #[test] + fn prorated_through_agrees_at_i32_and_i128( + total in 0i32..=2_048, + months in 1u32..=2, + month_days in 28u32..=31, + days in 0u32..=32, + k in 0u32..=32, + before in 0i32..=800_000, + last in any::(), + tenths in any::(), + ) { + let part = kernel::MonthPart { days, month_days }; + let unit = if tenths { 10 } else { 1 }; + let narrow = kernel::prorated_through(total, months, before, part, k, last, unit); + let wide = kernel::prorated_through( + i128::from(total), months, i128::from(before), part, k, last, i128::from(unit), + ); + // At i32 a product past its range is refused; at i128 it isn't. + if let Some(n) = narrow { + prop_assert_eq!(Some(i128::from(n)), wide); + } else if wide.is_some() { + // Refused at i32 only when a product the kernel forms there + // overflows it. + let w = i128::from(months) * i128::from(kernel::MONTH_L); + let (t, b, k, d, u) = ( + i128::from(total), + i128::from(before), + i128::from(k), + i128::from(days), + i128::from(unit), + ); + let formed: Vec = if last { + let elapsed = b * d + (w - b) * k; + vec![b * d, (w - b) * k, elapsed, t * elapsed, w * d, w * d * u] + } else { + let elapsed = b + k * i128::from(kernel::MONTH_L / month_days); + vec![elapsed, t * elapsed, w * u] + }; + prop_assert!( + formed.iter().any(|&p| p > i128::from(i32::MAX)), + "refused at i32 though nothing overflows it: {formed:?}" + ); + } + } + + /// catch_up and true_up agree at i64 and on Decimal (the engine's). + #[test] + fn catch_up_and_true_up_agree( + pairs in prop::collection::vec((arb_cents(), arb_cents()), 0..6), + ) { + let (si, pi): (Vec, Vec) = pairs.iter().copied().unzip(); + let sd: Vec = si.iter().copied().map(dec).collect(); + let pd: Vec = pi.iter().copied().map(dec).collect(); + prop_assert_eq!( + kernel::catch_up(&si, &pi).map(dec), + kernel::catch_up(&sd, &pd) + ); + if let Some(&(t, p)) = pairs.first() { + prop_assert_eq!( + kernel::true_up(t, p).map(|(u, d)| (dec(u), dec(d))), + kernel::true_up(dec(t), dec(p)) + ); + } + } } diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs index f208dd4..82703b4 100644 --- a/crates/ledger-core/tests/properties.rs +++ b/crates/ledger-core/tests/properties.rs @@ -300,7 +300,7 @@ proptest! { }).unwrap(); let asset = ledger.get_asset(id).unwrap(); let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap(); - let sched = depreciation_schedule(asset, far); + let sched = depreciation_schedule(asset, far).unwrap(); // The lifetime total is exactly cost minus salvage: never a penny off. let total: Decimal = sched.iter().map(|l| l.amount).sum(); prop_assert_eq!(total, cost - salvage); @@ -314,8 +314,8 @@ proptest! { } // Book value at any date matches the schedule. if let Some(last) = sched.last() { - prop_assert_eq!(book_value(asset, far), last.book_value); - prop_assert_eq!(accumulated_depreciation(asset, far), last.accumulated); + prop_assert_eq!(book_value(asset, far).unwrap(), last.book_value); + prop_assert_eq!(accumulated_depreciation(asset, far).unwrap(), last.accumulated); } } } @@ -471,3 +471,414 @@ proptest! { prop_assert_eq!(p.calendar_year(), today.year()); } } + +// --------------------------------------------------------------------------- +// P3: depreciation by the day through the final month (defect 22), and +// schedules spread by day (defect 9). Each law is checked against arithmetic +// written here, never read back from the code. +// --------------------------------------------------------------------------- + +use chrono::Datelike; +use ledger_core::schedules::{amortization_plan, daily_plan, month_end}; +use ledger_core::{AssetStatus, disposal_amounts}; + +mod p3 { + use super::*; + use chrono::Months; + + /// A USD asset on a ledger, as a command registers it. + pub fn asset_on( + cost_cents: u64, + salvage_cents: u64, + life: u32, + start: NaiveDate, + ) -> (Ledger, ledger_core::AssetId) { + let mut ledger = Ledger::new(Currency::USD); + let ca = ledger + .add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let aa = ledger + .add_account("1501", "Accum", AccountType::Asset, None, false) + .unwrap(); + let ex = ledger + .add_account("6100", "Depr", AccountType::Expense, None, false) + .unwrap(); + let id = ledger + .register_asset(NewAsset { + description: "prop".to_string(), + purchase_date: start, + cost: Money::new(Decimal::new(cost_cents as i64, 2), Currency::USD), + salvage_value: Money::new(Decimal::new(salvage_cents as i64, 2), Currency::USD), + useful_life_months: life, + cost_account: ca, + accum_depr_account: aa, + expense_account: ex, + }) + .unwrap(); + (ledger, id) + } + + pub fn arb_day() -> impl Strategy { + (2000i32..=2100, 1u32..=12, 1u32..=31).prop_map(|(y, m, d)| { + (1..=d) + .rev() + .find_map(|d| NaiveDate::from_ymd_opt(y, m, d)) + .unwrap() + }) + } + + /// The days in `d`'s month. + pub fn days_in(d: NaiveDate) -> u32 { + month_end(d).day() + } + + /// The last day of `d`'s month. + pub fn month_end_of(d: NaiveDate) -> NaiveDate { + month_end(d) + } + + /// Before P3: the per-month running total in Decimal, rounded half away + /// from zero to the cent, at each month end of the life (the final + /// month's plug left out: it only ever set the life's end to cost − + /// salvage, as P3 does). A copy of assets.rs as it was, the oracle for + /// "no posted month moves". + pub fn legacy_month_ends( + depreciable: Decimal, + life: u32, + start: NaiveDate, + ) -> Vec<(NaiveDate, Decimal)> { + let end = start.checked_add_months(Months::new(life)).unwrap(); + let rate = depreciable / Decimal::from(life); + let mut out = Vec::new(); + let mut exact = Decimal::ZERO; + let mut month = start.with_day(1).unwrap(); + while month < end { + let next = month.checked_add_months(Months::new(1)).unwrap(); + let held = (next.min(end) - start.max(month)).num_days(); + exact += rate * Decimal::from(held) / Decimal::from(days_in(month)); + out.push(( + month_end(month), + exact.round_dp_with_strategy( + 2, + rust_decimal::RoundingStrategy::MidpointAwayFromZero, + ), + )); + month = next; + } + out + } + + /// The exact cumulative charge through the end of the life's `i`th + /// month (not the last), in cents, as a fraction: (numerator, + /// denominator) of D × (months held, each days held over days in the + /// month) / life, in whole MONTH_L-ths. + pub fn exact_through( + depreciable_cents: i128, + life: u32, + start: NaiveDate, + through: NaiveDate, + ) -> (i128, i128) { + const L: i128 = 377_580; + let mut elapsed = 0i128; + let mut month = start.with_day(1).unwrap(); + while month <= through { + let next = month.checked_add_months(Months::new(1)).unwrap(); + let from = start.max(month); + let to = next.min(through.succ_opt().unwrap()); + elapsed += i128::from((to - from).num_days() as i32) * (L / i128::from(days_in(month))); + month = next; + } + (depreciable_cents * elapsed, i128::from(life) * L) + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + + /// B-15 INVARIANT 5 (defect 22): accumulated depreciation never falls, + /// never exceeds cost − salvage, is 0 before the in-service date and + /// cost − salvage from the life's last day on; each schedule's lines + /// sum to its accumulated and none is negative. Sampled through: every + /// day of the first and final months and of two months between, a + /// dozen month ends, and the days around the life (P3 depreciation + /// review R9: stepping every day of 600-month lives is P12's). + #[test] + fn prop_depreciation_is_monotone_and_bounded( + cost_cents in 2u64..=100_000_000_000, + salvage_frac in 0u64..1000, + life in 1u32..=600, + start in p3::arb_day(), + picks in prop::collection::vec(0u32..600, 14), + ) { + let salvage_cents = (cost_cents - 1) * salvage_frac / 1000; + let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start); + let a = ledger.get_asset(id).unwrap(); + let dep = Decimal::new((cost_cents - salvage_cents) as i64, 2); + let end = start.checked_add_months(chrono::Months::new(life)).unwrap(); + let last_day = end.pred_opt().unwrap(); + let month_of = |k: u32| start.with_day(1).unwrap().checked_add_months(chrono::Months::new(k)).unwrap(); + let mut days: Vec = vec![ + start.pred_opt().unwrap(), start, last_day, end, end + chrono::Days::new(400), + ]; + let months = life; + for k in [0, months, picks[0] % (months + 1), picks[1] % (months + 1)] { + let m = month_of(k); + days.extend((0..p3::days_in(m)).map(|i| m + chrono::Days::new(u64::from(i)))); + } + days.extend(picks[2..].iter().map(|&k| p3::month_end_of(month_of(k % (months + 1))))); + days.sort(); + days.dedup(); + let mut prev = Decimal::ZERO; + for day in days { + let s = depreciation_schedule(a, day).unwrap(); + let acc = accumulated_depreciation(a, day).unwrap(); + prop_assert_eq!(s.iter().map(|l| l.amount).sum::(), acc); + prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO)); + prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev); + prop_assert!(acc <= dep); + if day < start { + prop_assert_eq!(acc, Decimal::ZERO); + } + if day >= last_day { + prop_assert_eq!(acc, dep); + } + prev = acc; + } + } + + /// Defect 22, reproduction B as a law: a disposed asset's schedule + /// shows exactly what its disposal computed, for any disposal day in or + /// after the life, and is unchanged through any later date. + #[test] + fn prop_disposed_schedule_equals_what_disposal_computed( + cost_cents in 2u64..=100_000_000_000, + salvage_frac in 0u64..1000, + life in 1u32..=600, + start in p3::arb_day(), + after in 0u64..20_000, + later in 0u64..1_000, + ) { + let salvage_cents = (cost_cents - 1) * salvage_frac / 1000; + let (mut ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start); + let on = start + chrono::Days::new(after); + let proceeds = Money::new(Decimal::ONE, Currency::USD); + let want = disposal_amounts(ledger.get_asset(id).unwrap(), on, proceeds).unwrap(); + if ledger_core::calendar::check(on).is_err() { + return Ok(()); + } + ledger.dispose_asset(id, on, proceeds).unwrap(); + let a = ledger.get_asset(id).unwrap(); + let disposed = matches!(a.status, AssetStatus::Disposed { .. }); + prop_assert!(disposed); + let full = depreciation_schedule(a, NaiveDate::MAX).unwrap(); + prop_assert_eq!(full.last().map_or(Decimal::ZERO, |l| l.accumulated), want.accumulated); + prop_assert_eq!( + accumulated_depreciation(a, on + chrono::Days::new(later)).unwrap(), + want.accumulated + ); + prop_assert_eq!(book_value(a, on).unwrap(), want.book_value); + } + + /// B-15 rule 3 (defect 22), against arithmetic written here: through + /// any day k of the life's final month (its d days), accumulated is C + + /// (D − C) × k / d rounded half up to the cent, C the exact charge of + /// the months before (D × each month's days held over its days, over + /// the life), D cost less salvage; on its last day exactly D. So the + /// final month is prorated by its days, never plugged. + #[test] + fn prop_the_final_month_takes_the_remainder_by_its_days( + cost_cents in 2u64..=100_000_000_000, + salvage_frac in 0u64..1000, + life in 1u32..=600, + start in p3::arb_day(), + ) { + let salvage_cents = (cost_cents - 1) * salvage_frac / 1000; + let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start); + let a = ledger.get_asset(id).unwrap(); + let dep = i128::from(cost_cents - salvage_cents); + let end = start.checked_add_months(chrono::Months::new(life)).unwrap(); + let last_day = end.pred_opt().unwrap(); + let final_first = last_day.with_day(1).unwrap().max(start); + let d = i128::from((end - final_first).num_days() as i32); + let (num, den) = p3::exact_through(dep, life, start, final_first.pred_opt().unwrap()); + for k in 1..=d { + let through = final_first + chrono::Days::new((k - 1) as u64); + // C + (D − C) k / d = (num d + (D den − num) k) / (den d). + let (x, y) = (num * d + (dep * den - num) * k, den * d); + let want = if k == d { + dep + } else { + x / y + i128::from(2 * (x % y) >= y) + }; + prop_assert_eq!( + accumulated_depreciation(a, through).unwrap(), + Decimal::new(want as i64, 2), + "{} of {}", through, last_day + ); + } + } + + /// B-15 INVARIANT 6 (P3 depreciation brief 2.3, R1, R8): through every + /// month end of the life, the schedule is the pre-P3 running total + /// (USD, a two-place currency), except where the exact cumulative is a + /// half-cent tie, which the old 28-digit Decimal quotients could round + /// either way: there the new figure is the exact one, half a cent up. + /// So no posted month moves, but a tie's cent. + #[test] + fn prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties( + cost_cents in 2u64..=100_000_000_000, + salvage_frac in 0u64..1000, + life in 1u32..=600, + start in p3::arb_day(), + ) { + let salvage_cents = (cost_cents - 1) * salvage_frac / 1000; + let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start); + let a = ledger.get_asset(id).unwrap(); + let dep_cents = i128::from(cost_cents - salvage_cents); + let dep = Decimal::new(dep_cents as i64, 2); + let legacy = p3::legacy_month_ends(dep, life, start); + let new = depreciation_schedule(a, NaiveDate::MAX).unwrap(); + prop_assert_eq!(legacy.len(), new.len()); + let n = new.len(); + for (i, ((at, old), line)) in legacy.iter().zip(&new).enumerate() { + if i + 1 == n { + // The final month: cost − salvage, then (the plug) as now. + prop_assert_eq!(line.accumulated, dep); + continue; + } + if line.accumulated != *old { + let (num, den) = p3::exact_through(dep_cents, life, start, *at); + // A half-cent tie: num/den = q + 1/2 exactly. + prop_assert_eq!(2 * (num % den), den, "{}: {} vs {}, not a tie", at, line.accumulated, old); + let up = Decimal::new(((num / den) + 1) as i64, 2); + prop_assert_eq!(line.accumulated, up); + prop_assert_eq!((line.accumulated - *old).abs(), Decimal::new(1, 2)); + } + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + + /// daily_plan INVARIANTS 1-3 (defect 9): the plan sums to the total + /// exactly, every amount is > 0, the dates are distinct month ends + /// (ascending) from the start's month through the last day's; from the + /// 1st with at least a cent a month, there are `months` postings, each + /// but the last within a cent of total/months, and the last takes the + /// rest. + #[test] + fn prop_daily_plan_sums_exactly_and_lands_on_month_ends( + total_cents in 1i64..=100_000_000_000, + start in p3::arb_day(), + months in 1u32..=600, + on_the_first in any::(), + ) { + let start = if on_the_first { start.with_day(1).unwrap() } else { start }; + let total = Money::new(Decimal::new(total_cents, 2), Currency::USD); + let plan = daily_plan(total, start, months).unwrap(); + let last_day = start.checked_add_months(chrono::Months::new(months)).unwrap().pred_opt().unwrap(); + prop_assert_eq!(plan.iter().map(|(_, a)| *a).sum::(), total.amount); + prop_assert!(plan.iter().all(|(_, a)| *a > Decimal::ZERO)); + prop_assert!(plan.windows(2).all(|w| w[0].0 < w[1].0)); + for (d, _) in &plan { + prop_assert_eq!(*d, month_end(*d)); + prop_assert!(*d >= month_end(start) && *d <= month_end(last_day)); + } + if on_the_first && total_cents >= i64::from(months) { + prop_assert_eq!(plan.len() as u32, months); + let even = total.amount / Decimal::from(months); + for (_, a) in &plan[..plan.len() - 1] { + prop_assert!((*a - even).abs() <= Decimal::new(1, 2), "{} vs {}", a, even); + } + let others: Decimal = plan[..plan.len() - 1].iter().map(|(_, a)| *a).sum(); + prop_assert_eq!(plan.last().unwrap().1, total.amount - others); + } + } + + /// The equal-months plan is unchanged forever (a posted month's replay + /// re-derives it): a copy of amortization_plan as it was before P3. + #[test] + fn prop_monthly_even_plan_is_unchanged( + total_cents in 1i64..=100_000_000_000, + start in p3::arb_day(), + months in 1u32..=600, + ) { + let total = Decimal::new(total_cents, 2); + let each = (total / Decimal::from(months)).round_dp(2); + let first = start.with_day(1).unwrap(); + let want: Vec<(NaiveDate, Decimal)> = (0..months) + .map(|i| { + let m = first.checked_add_months(chrono::Months::new(i)).unwrap(); + let a = if i + 1 == months { total - each * Decimal::from(months - 1) } else { each }; + (month_end(m), a) + }) + .collect(); + prop_assert_eq!(amortization_plan(total, start, months).unwrap(), want); + } +} + +/// The half-cent tie the P3 depreciation review (R8) names: 1,000.03 over +/// 36 months from 2026-01-01. Through month 18 the exact figure is +/// 100,003 × 18/36 = 50,001.5 cents, a tie: P3 rounds it up, 500.02, and +/// month 19 (100,003 × 19/36 = 52,779.36 cents) is 527.79. The old +/// running total in 28-digit Decimal lands a hair under the tie and gives +/// 500.01, so that cent moves from month 19 to month 18 (the change-log +/// case); every other month end is the same, and the life ends at +/// 1,000.03 either way. +#[test] +fn a_half_cent_tie_rounds_up_exactly() { + let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let (ledger, id) = p3::asset_on(100_003, 0, 36, start); + let new = depreciation_schedule(ledger.get_asset(id).unwrap(), NaiveDate::MAX).unwrap(); + assert_eq!(new[17].accumulated, Decimal::new(50_002, 2)); + assert_eq!(new[18].accumulated, Decimal::new(52_779, 2)); + assert_eq!(new[35].accumulated, Decimal::new(100_003, 2)); + let old = p3::legacy_month_ends(Decimal::new(100_003, 2), 36, start); + let moved: Vec = (0..35) + .filter(|&i| old[i].1 != new[i].accumulated) + .collect(); + assert_eq!(moved, [17], "only the tie moves"); + assert_eq!(old[17].1, Decimal::new(50_001, 2)); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + + /// assets::run_amounts INVARIANT 1 (P3 review round 2: it had no test + /// but the viewer's): what it says a run of each month posts, and what + /// the books carry past the schedule then, is run_amount's for that + /// month, over any runs and reversals (each in its own month, a + /// reversal negative), through any month. + #[test] + fn prop_run_amounts_are_each_months_run_amount( + cost_cents in 2u64..=10_000_000, + life in 1u32..=24, + start in p3::arb_day(), + effects in prop::collection::vec((0u32..30, -500_000i64..=500_000), 0..8), + through in 0u32..36, + ) { + let (ledger, id) = p3::asset_on(cost_cents, 0, life, start); + let a = ledger.get_asset(id).unwrap(); + let first = start.with_day(1).unwrap(); + let ym = |i: u32| { + let d = first.checked_add_months(chrono::Months::new(i)).unwrap(); + (chrono::Datelike::year(&d), chrono::Datelike::month(&d)) + }; + let posted: Vec = effects + .iter() + .map(|&(i, cents)| { + let (year, month) = ym(i); + ledger_core::PostedRun { year, month, amount: Decimal::new(cents, 2) } + }) + .collect(); + let rows = ledger_core::run_amounts(a, ym(through), &posted).unwrap(); + prop_assert!(!rows.is_empty()); + prop_assert_eq!((rows[0].0, rows[0].1), ym(0)); + for (k, &(y, m, r)) in rows.iter().enumerate() { + prop_assert_eq!((y, m), ym(k as u32), "the months in turn"); + let one = ledger_core::run_amount(a, y, m, &posted).unwrap(); + prop_assert_eq!(r, one, "{}-{:02}", y, m); + } + } +} diff --git a/crates/ledger-core/tests/schedules.rs b/crates/ledger-core/tests/schedules.rs index db5420d..44fc848 100644 --- a/crates/ledger-core/tests/schedules.rs +++ b/crates/ledger-core/tests/schedules.rs @@ -61,17 +61,19 @@ fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) { #[test] fn a_prepaid_expense_is_expensed_month_by_month() { let mut b = books(); - // A year of insurance, paid March 15: an asset, not an expense yet. - post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200"); + // A year of insurance from March 1, paid that day: an asset, not an + // expense yet. Equal months, so it starts on the 1st (D-37). + post(&mut b.l, d(3, 1), b.prepaid, b.cash, "1200"); let id = b.l.register_schedule(NewSchedule { description: "Liability insurance".into(), kind: ScheduleKind::PrepaidExpense, total: usd("1200"), - start: d(3, 15), + start: d(3, 1), months: 12, balance_account: b.prepaid, pl_account: b.insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); // Through May: March, April, May, at month end, 100 each. @@ -113,17 +115,19 @@ fn a_prepaid_expense_is_expensed_month_by_month() { #[test] fn deferred_revenue_is_earned_to_the_cent() { let mut b = books(); - // Three months of a subscription collected up front. - post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000"); + // Three months of a subscription from January 1, collected up front. + // Equal months, so it starts on the 1st (D-37). + post(&mut b.l, d(1, 1), b.cash, b.deferred, "1000"); let id = b.l.register_schedule(NewSchedule { description: "Quarterly plan, Acme".into(), kind: ScheduleKind::DeferredRevenue, total: usd("1000"), - start: d(1, 5), + start: d(1, 1), months: 3, balance_account: b.deferred, pl_account: b.revenue, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); let plan: Vec = @@ -158,6 +162,7 @@ fn schedules_respect_closed_periods_and_account_types() { months: 12, balance_account: b.prepaid, pl_account: b.insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); assert!(matches!( @@ -174,6 +179,7 @@ fn schedules_respect_closed_periods_and_account_types() { months: 12, balance_account: b.deferred, pl_account: b.insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }); assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_)))); } @@ -276,6 +282,7 @@ fn schedule_length_is_one_to_six_hundred_months() { months, balance_account: b.prepaid, pl_account: b.insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }; let chart = b.l.chart(); for ok in [1, 600] { @@ -376,6 +383,7 @@ fn a_schedule_total_no_month_could_post_is_refused() { months: 6, balance_account: b.prepaid, pl_account: b.insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }; let huge = new("46116860184273879.03"); let sub_cent = new("120.005"); @@ -405,6 +413,7 @@ fn a_refused_schedule_month_leaves_nothing_behind() { months: 6, balance_account: b.prepaid, pl_account: b.insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); let before = b.l.clone(); @@ -415,3 +424,390 @@ fn a_refused_schedule_month_leaves_nothing_behind() { b.l.differs_from(&before) ); } + +// ------------------------------------------- defect 9: schedules by the day + +use ledger_core::ScheduleConvention; + +fn new( + b: &Books, + total: Money, + start: NaiveDate, + months: u32, + convention: ScheduleConvention, +) -> NewSchedule { + NewSchedule { + description: "Liability insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total, + start, + months, + balance_account: b.prepaid, + pl_account: b.insurance, + convention, + } +} + +fn ymd(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +/// Defect 9 (plan/served.md D-11, D-37): a new schedule spreads by the +/// day. Hand check: 1,200.00 of insurance from 2026-03-15 for 12 months +/// covers 2026-03-15 through 2027-03-14. March holds 17 of its 31 days: +/// 100.00 × 17/31 = 54.838 → 54.84. April through February are whole +/// months, 100.00 each (eleven). The last March holds 14 days and takes +/// what the others leave: 1,200.00 − 1,154.84 = 45.16 (= 100.00 × 14/31, +/// to the cent). Thirteen month ends. +#[test] +fn defect9_a_new_schedule_spreads_by_the_day() { + let mut b = books(); + post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200"); + let id = + b.l.register_schedule(new( + &b, + usd("1200.00"), + d(3, 15), + 12, + ScheduleConvention::Daily, + )) + .unwrap(); + let s = b.l.get_schedule(id).unwrap(); + assert_eq!(s.convention, ScheduleConvention::Daily); + let plan = s.plan().unwrap(); + let mut want = vec![(d(3, 31), dec("54.84"))]; + for m in 4..=12 { + want.push((ledger_core::schedules::month_end(d(m, 1)), dec("100.00"))); + } + want.push((ymd(2027, 1, 31), dec("100.00"))); + want.push((ymd(2027, 2, 28), dec("100.00"))); + want.push((ymd(2027, 3, 31), dec("45.16"))); + assert_eq!(plan, want); + assert_eq!(s.first_day(), d(3, 15)); + assert_eq!(s.last_day(), Some(ymd(2027, 3, 14))); + assert_eq!(s.next().unwrap(), Some(d(3, 31))); + // Posted through June: the books carry the days used. + for (m, _) in s.due(d(6, 30)).unwrap() { + b.l.post_schedule_month(id, m).unwrap(); + } + let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(6, 30), None).unwrap(); + assert_eq!(pl.total_expenses, dec("354.84")); + let s = b.l.get_schedule(id).unwrap(); + assert_eq!(s.remaining().unwrap(), dec("845.16")); + assert_eq!(s.next().unwrap(), Some(d(7, 31))); +} + +/// Defect 9 for deferred revenue: earned as the days pass, to the cent. +/// Hand check: 1,000.00 from 2026-01-05 for 3 months covers Jan 5 through +/// Apr 4. Weights: January 27/31, February and March whole, April the +/// rest. Cumulative at each month end: 1,000 × (27/31)/3 = 290.3226 → +/// 290.32; × (27/31 + 1)/3 = 623.6559 → 623.66; × (27/31 + 2)/3 = 956.9892 +/// → 956.99; then 1,000.00. Months: 290.32, 333.34, 333.33, 43.01. +#[test] +fn defect9_deferred_revenue_daily_is_earned_to_the_cent() { + let mut b = books(); + post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000"); + let id = + b.l.register_schedule(NewSchedule { + description: "Quarterly plan, a customer".into(), + kind: ScheduleKind::DeferredRevenue, + total: usd("1000"), + start: d(1, 5), + months: 3, + balance_account: b.deferred, + pl_account: b.revenue, + convention: ScheduleConvention::Daily, + }) + .unwrap(); + let plan: Vec = + b.l.get_schedule(id) + .unwrap() + .due(d(12, 31)) + .unwrap() + .into_iter() + .map(|(_, a)| a) + .collect(); + assert_eq!( + plan, + [dec("290.32"), dec("333.34"), dec("333.33"), dec("43.01")] + ); + for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)).unwrap() { + b.l.post_schedule_month(id, m).unwrap(); + } + // Earned through March: 956.99; still owed for April 1-4: 43.01. + let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap(); + assert_eq!(pl.total_revenue, dec("956.99")); + let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap(); + assert_eq!( + bs.liabilities + .iter() + .find(|a| a.code == "2400") + .unwrap() + .amount, + dec("43.01") + ); + let bs = balance_sheet_as_of(&b.l, d(4, 30)).unwrap(); + assert!(bs.liabilities.is_empty(), "fully earned"); + assert_eq!(b.l.get_schedule(id).unwrap().next().unwrap(), None); +} + +/// Defect 9: schedules an earlier version registered (no convention in +/// the journal) stay equal months, mid-month start and all, exactly as +/// amortization_plan has always made them: replay re-derives every posted +/// month from it. +#[test] +fn defect9_legacy_schedules_stay_monthly_even() { + let mut b = books(); + let id = + b.l.replay_register_schedule(new( + &b, + usd("1000"), + d(1, 5), + 3, + ScheduleConvention::MonthlyEven, + )) + .unwrap(); + let s = b.l.get_schedule(id).unwrap(); + assert_eq!( + s.plan().unwrap(), + ledger_core::schedules::amortization_plan(dec("1000"), d(1, 5), 3).unwrap() + ); + assert_eq!( + s.plan().unwrap(), + [ + (d(1, 31), dec("333.33")), + (d(2, 28), dec("333.33")), + (d(3, 31), dec("333.34")) + ] + ); + // It covers whole months: from the 1st of its first. + assert_eq!(s.first_day(), d(1, 1)); + assert_eq!(s.last_day(), Some(d(3, 31))); + // The serialized ledger says nothing of a convention it never had. + let v = serde_json::to_value(s).unwrap(); + assert!(v.get("convention").is_none()); + assert!(v.get("postings").is_none()); +} + +/// D-37 (owner, 2026-10-20): a new equal-months schedule starts on the +/// 1st of a month, for prepaid expenses and deferred revenue alike, so it +/// never recognizes days it doesn't cover (revenue is never earned early, +/// ASC 606-10-25-23). Refused otherwise, naming the 1st and the daily +/// convention; replay keeps the ones an earlier version took. +#[test] +fn an_equal_months_schedule_starts_on_the_first() { + let mut b = books(); + for kind in [ScheduleKind::PrepaidExpense, ScheduleKind::DeferredRevenue] { + let (balance, pl) = match kind { + ScheduleKind::PrepaidExpense => (b.prepaid, b.insurance), + ScheduleKind::DeferredRevenue => (b.deferred, b.revenue), + }; + let mid = NewSchedule { + description: "x".into(), + kind, + total: usd("1200"), + start: d(3, 15), + months: 12, + balance_account: balance, + pl_account: pl, + convention: ScheduleConvention::MonthlyEven, + }; + let before = b.l.clone(); + match b.l.register_schedule(mid.clone()) { + Err(LedgerError::InvalidSchedule(m)) => { + assert!(m.contains("2026-03-01"), "{m}"); + assert!(m.contains("daily"), "{m}"); + assert!(m.contains("ASC 606-10-25-23"), "{m}"); + } + other => panic!("{kind:?}: {other:?}"), + } + assert!(b.l.differs_from(&before).is_empty()); + b.l.register_schedule(NewSchedule { + start: d(3, 1), + ..mid.clone() + }) + .unwrap(); + b.l.replay_register_schedule(mid).unwrap(); + } +} + +/// R1 (P3 depreciation review): a daily schedule rounds to the currency's +/// minor unit, so in yen every month is whole yen. Hand check: ¥1,000 from +/// 2026-01-05 over 3 months: cumulative 290.32 → 290, 623.66 → 624, 956.99 +/// → 957, then 1,000: ¥290, ¥334, ¥333, ¥43. An equal-months yen schedule +/// whose months aren't whole yen (¥333.33) is refused: it could never post. +#[test] +fn a_jpy_daily_schedule_posts_whole_yen() { + let mut l = Ledger::new(Currency::JPY); + let prepaid = l + .add_account("1300", "Prepaid", AccountType::Asset, None, false) + .unwrap(); + let insurance = l + .add_account("6300", "Insurance", AccountType::Expense, None, false) + .unwrap(); + let yen = |a: &str| Money::new(dec(a), Currency::JPY); + let schedule = |start, convention| NewSchedule { + description: "Insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: yen("1000"), + start, + months: 3, + balance_account: prepaid, + pl_account: insurance, + convention, + }; + let id = l + .register_schedule(schedule(d(1, 5), ScheduleConvention::Daily)) + .unwrap(); + let plan: Vec = l + .get_schedule(id) + .unwrap() + .plan() + .unwrap() + .into_iter() + .map(|(_, a)| a) + .collect(); + assert_eq!(plan, [dec("290"), dec("334"), dec("333"), dec("43")]); + for (m, _) in l.get_schedule(id).unwrap().due(d(12, 31)).unwrap() { + l.post_schedule_month(id, m).unwrap(); + } + match l.register_schedule(schedule(d(1, 1), ScheduleConvention::MonthlyEven)) { + Err(LedgerError::InvalidSchedule(m)) => assert!(m.contains("333.33"), "{m}"), + other => panic!("{other:?}"), + } +} + +/// R1 for a three-place currency: a daily schedule of 1,000.000 KWD from +/// 2026-01-05 over 3 months, in thousandths. Hand check: cumulative +/// 290.32258 → 290.323, 623.65591 → 623.656, 956.98925 → 956.989, then +/// 1,000.000: 290.323, 333.333, 333.333, 43.011. +#[test] +fn a_kwd_daily_schedule_rounds_to_the_fils() { + let kwd = |a: &str| Money::new(dec(a), Currency::new("KWD").unwrap()); + let plan: Vec = ledger_core::schedules::daily_plan(kwd("1000.000"), d(1, 5), 3) + .unwrap() + .into_iter() + .map(|(_, a)| a) + .collect(); + assert_eq!( + plan, + [ + dec("290.323"), + dec("333.333"), + dec("333.333"), + dec("43.011") + ] + ); + assert!(plan.iter().all(|a| a.scale() == 3)); +} + +/// R3 (P3 depreciation review): a reversed schedule month is un-posted, so +/// it is due again; reversing the reversal posts it back; and once it has +/// posted again, re-instating the first posting is refused (one month is +/// never recognized twice). +#[test] +fn a_reversed_schedule_month_is_due_again() { + let mut b = books(); + let id = + b.l.register_schedule(new(&b, usd("1200"), d(1, 1), 12, ScheduleConvention::Daily)) + .unwrap(); + let jan = b.l.post_schedule_month(id, d(1, 31)).unwrap(); + let rev = b.l.reverse_entry(jan, d(1, 31), "wrong month").unwrap(); + let s = b.l.get_schedule(id).unwrap(); + assert!(!s.posted.contains(&d(1, 31))); + assert_eq!(s.next().unwrap(), Some(d(1, 31))); + assert_eq!(s.remaining().unwrap(), dec("1200")); + assert!(b.l.schedule_month_posted_before(id, d(1, 31))); + // Reversing the reversal re-instates January. + let back = b.l.reverse_entry(rev, d(1, 31), "it was right").unwrap(); + assert!(b.l.get_schedule(id).unwrap().posted.contains(&d(1, 31))); + assert!(b.l.post_schedule_month(id, d(1, 31)).is_err()); + // Reverse it again, post January again: then re-instating the first + // posting would recognize January twice. + b.l.reverse_entry(back, d(1, 31), "no, wrong").unwrap(); + let again = b.l.post_schedule_month(id, d(1, 31)).unwrap(); + assert!(b.l.in_effect(again)); + let before = b.l.clone(); + let rev_of_back = + b.l.posted_entries() + .find(|e| e.reversal_of() == Some(back)) + .unwrap() + .id(); + match b.l.reverse_entry(rev_of_back, d(1, 31), "re-instate") { + Err(LedgerError::InvalidSchedule(m)) => { + assert!(m.contains(&format!("entry {again}")), "{m}") + } + other => panic!("{other:?}"), + } + assert!(b.l.differs_from(&before).is_empty()); + let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(12, 31), None).unwrap(); + assert_eq!(pl.total_expenses, dec("100")); +} + +/// P3 review: a schedule month whose posting was reversed after the month +/// ended carried the month at its end; posting it again on its own date +/// would recognize it twice there (until the reversal), so it is left for +/// a human, said, never posted twice. Hand check: 1,200.00 daily from Jan +/// 1 over 12 months, January 100.00 (Jan 31), reversed Feb 3: posting +/// January again is refused, the hold names Feb 3; January's expense on +/// Jan 31 stays 100.00, and February posts as ever. +#[test] +fn a_schedule_month_reversed_after_it_ended_is_left_for_a_human() { + let mut b = books(); + let id = + b.l.register_schedule(new(&b, usd("1200"), d(1, 1), 12, ScheduleConvention::Daily)) + .unwrap(); + let jan = b.l.post_schedule_month(id, d(1, 31)).unwrap(); + b.l.reverse_entry(jan, d(2, 3), "wrong policy").unwrap(); + let s = b.l.get_schedule(id).unwrap(); + assert!(!s.posted.contains(&d(1, 31))); + assert_eq!( + b.l.schedule_month_reversed_after(id, d(1, 31)), + Some(d(2, 3)) + ); + let why = b.l.schedule_month_hold(id, d(1, 31)).unwrap(); + assert!(why.contains("2026-02-03"), "{why}"); + let before = b.l.clone(); + match b.l.post_schedule_month(id, d(1, 31)) { + Err(LedgerError::InvalidSchedule(m)) => assert!(m.contains("twice"), "{m}"), + other => panic!("{other:?}"), + } + assert!(b.l.differs_from(&before).is_empty()); + assert_eq!(b.l.schedule_month_hold(id, d(2, 28)), None); + b.l.post_schedule_month(id, d(2, 28)).unwrap(); + let jan_pl = profit_and_loss_for_period(&b.l, d(1, 1), d(1, 31), None).unwrap(); + assert_eq!(jan_pl.total_expenses, dec("100")); +} + +/// P3 review: a month that rounds to nothing is never a posting (no entry +/// carries a zero line, P3 depreciation review R10). Hand check, daily: +/// 0.02 from Jan 1 over 3 months: the cumulatives are 0.0066 → 0.01, +/// 0.0133 → 0.01, then 0.02, so January 0.01, February nothing (left out +/// of the plan), March 0.01. Equal months: 0.01 over 3 months is 0.00, +/// 0.00 and 0.01; only March is ever due, and it is the next. +#[test] +fn a_month_that_rounds_to_nothing_is_never_a_posting() { + let mut b = books(); + let id = + b.l.register_schedule(new(&b, usd("0.02"), d(1, 1), 3, ScheduleConvention::Daily)) + .unwrap(); + let s = b.l.get_schedule(id).unwrap(); + assert_eq!( + s.plan().unwrap(), + [(d(1, 31), dec("0.01")), (d(3, 31), dec("0.01"))] + ); + let id = + b.l.register_schedule(new( + &b, + usd("0.01"), + d(1, 1), + 3, + ScheduleConvention::MonthlyEven, + )) + .unwrap(); + let s = b.l.get_schedule(id).unwrap(); + assert_eq!(s.plan().unwrap().len(), 3); + assert_eq!(s.due(d(3, 31)).unwrap(), [(d(3, 31), dec("0.01"))]); + assert_eq!(s.next().unwrap(), Some(d(3, 31))); + assert_eq!(s.remaining().unwrap(), dec("0.01")); +} diff --git a/crates/ledger-core/tests/spec_asset_tags.rs b/crates/ledger-core/tests/spec_asset_tags.rs index 687a94a..343b6c7 100644 --- a/crates/ledger-core/tests/spec_asset_tags.rs +++ b/crates/ledger-core/tests/spec_asset_tags.rs @@ -853,8 +853,8 @@ fn register_accumulated_mid_month_follows_the_schedule() { let (l, _, laptop, server) = register_fixture(); let on = date(2026, 5, 17); let r = fixed_asset_register(&l, on).unwrap(); - let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on); - let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on); + let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on).unwrap(); + let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on).unwrap(); assert_eq!(r.rows[0].accumulated, la); assert_eq!(r.rows[1].accumulated, sa); assert_eq!(r.rows[0].book_value, d("12000.00") - la); @@ -898,7 +898,7 @@ fn register_leaves_out_assets_not_yet_in_service() { assert_eq!(ids, vec![laptop, server, chair]); assert_eq!( r.rows[2].accumulated, - accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)) + accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)).unwrap() ); // Before anything: empty and zero. let r = fixed_asset_register(&l, date(2025, 12, 31)).unwrap(); @@ -1029,3 +1029,35 @@ fn register_of_no_assets_is_empty() { assert_eq!(r.unposted_depreciation(), Decimal::ZERO); assert_eq!(r.unregistered_cost(), Decimal::ZERO); } + +/// Defect 22 in the register (INVARIANT 2): an asset disposed of later in +/// the same month as `as_of` is held on `as_of`, its accumulated +/// depreciation prorated by days through `as_of`, never plugged to the +/// whole. Hand check: the 12,000.00 laptop (12 months from 2026-01-01), +/// disposed of 2026-06-20; on 2026-06-10, five months of 1,000.00 and +/// June's 10/30: 5,333.33, book value 6,666.67. Before P3 the disposed +/// 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. +#[test] +fn register_as_of_a_day_before_a_same_month_disposal_is_prorated() { + let (mut l, _, laptop, _) = register_fixture(); + l.dispose_asset(laptop, date(2026, 6, 20), usd("5000.00")) + .unwrap(); + let r = fixed_asset_register(&l, date(2026, 6, 10)).unwrap(); + assert_eq!(r.rows[0].disposed, None); + assert_eq!(r.rows[0].accumulated, d("5333.33")); + assert_eq!(r.rows[0].book_value, d("6666.67")); + assert_totals_are_sums(&r); + // On the disposal day it is gone: no figures, in no total. + let r = fixed_asset_register(&l, date(2026, 6, 20)).unwrap(); + assert_eq!(r.rows[0].accumulated, Decimal::ZERO); + assert!(r.rows[0].disposed.is_some()); + + let (l, _, laptop, _) = register_fixture(); + let r = fixed_asset_register(&l, date(2026, 12, 15)).unwrap(); + let row = r.rows.iter().find(|x| x.asset_id == laptop).unwrap(); + assert_eq!( + (row.accumulated, row.book_value), + (d("11483.87"), d("516.13")) + ); +} diff --git a/crates/ledger-core/tests/spec_assets_payroll.rs b/crates/ledger-core/tests/spec_assets_payroll.rs index 647c176..8370413 100644 --- a/crates/ledger-core/tests/spec_assets_payroll.rs +++ b/crates/ledger-core/tests/spec_assets_payroll.rs @@ -279,7 +279,7 @@ const PLAN_CASH: AccountId = 100; #[test] fn full_months_each_take_one_twelfth_b15() { let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); - let s = depreciation_schedule(&a, date(2026, 12, 31)); + let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap(); assert_eq!(s.len(), 12); for (i, line) in s.iter().enumerate() { let n = i as i64 + 1; @@ -296,7 +296,7 @@ fn full_months_each_take_one_twelfth_b15() { #[test] fn schedule_through_a_mid_month_date_prorates_that_month_by_days() { let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); - let s = depreciation_schedule(&a, date(2026, 6, 15)); + let s = depreciation_schedule(&a, date(2026, 6, 15)).unwrap(); assert_eq!(s.len(), 6); assert_eq!(s.last().unwrap().period, "2026-06"); assert_eq!(s.last().unwrap().amount, d("500.00")); @@ -309,7 +309,7 @@ fn schedule_through_a_mid_month_date_prorates_that_month_by_days() { #[test] fn schedule_clamps_to_end_of_life() { let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); - let s = depreciation_schedule(&a, date(2030, 12, 31)); + let s = depreciation_schedule(&a, date(2030, 12, 31)).unwrap(); let total: Decimal = s.iter().map(|l| l.amount).sum(); assert_eq!(total, d("12000.00")); assert_eq!(s.iter().filter(|l| !l.amount.is_zero()).count(), 12); @@ -327,7 +327,7 @@ fn schedule_clamps_to_end_of_life() { #[test] fn partial_first_and_last_months_make_one_full_month_b15() { let a = asset("620.00", "0.00", 2, date(2026, 1, 15)); - let s = depreciation_schedule(&a, date(2027, 12, 31)); + let s = depreciation_schedule(&a, date(2027, 12, 31)).unwrap(); let got: Vec<(&str, Decimal, Decimal, Decimal)> = s .iter() .map(|l| (l.period.as_str(), l.amount, l.accumulated, l.book_value)) @@ -352,7 +352,7 @@ fn partial_first_and_last_months_make_one_full_month_b15() { #[test] fn last_month_trues_up_unequal_month_lengths_b15() { let a = asset("1030.00", "100.00", 3, date(2026, 1, 15)); - let s = depreciation_schedule(&a, date(2027, 12, 31)); + let s = depreciation_schedule(&a, date(2027, 12, 31)).unwrap(); let got: Vec<(&str, Decimal, Decimal, Decimal)> = s .iter() .map(|l| (l.period.as_str(), l.amount, l.accumulated, l.book_value)) @@ -373,13 +373,13 @@ fn last_month_trues_up_unequal_month_lengths_b15() { #[test] fn salvage_value_is_never_depreciated_b15() { let a = asset("5000.00", "1000.00", 4, date(2026, 3, 1)); - let s = depreciation_schedule(&a, date(2026, 6, 30)); + let s = depreciation_schedule(&a, date(2026, 6, 30)).unwrap(); let amounts: Vec = s.iter().map(|l| l.amount).collect(); assert_eq!(amounts, vec![d("1000.00"); 4]); assert_eq!(s.last().unwrap().book_value, d("1000.00")); - assert_eq!(book_value(&a, date(2040, 1, 31)), d("1000.00")); + assert_eq!(book_value(&a, date(2040, 1, 31)).unwrap(), d("1000.00")); assert_eq!( - accumulated_depreciation(&a, date(2040, 1, 31)), + accumulated_depreciation(&a, date(2040, 1, 31)).unwrap(), d("4000.00") ); } @@ -390,7 +390,7 @@ fn salvage_value_is_never_depreciated_b15() { #[test] fn uneven_split_rounds_to_cents_and_sums_exactly() { let a = asset("1000.00", "0.00", 3, date(2026, 1, 1)); - let s = depreciation_schedule(&a, date(2026, 3, 31)); + let s = depreciation_schedule(&a, date(2026, 3, 31)).unwrap(); assert_eq!(s.len(), 3); for l in &s { assert!( @@ -412,9 +412,16 @@ fn uneven_split_rounds_to_cents_and_sums_exactly() { #[test] fn nothing_depreciates_before_purchase() { let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); - assert!(depreciation_schedule(&a, date(2025, 12, 31)).is_empty()); - assert_eq!(accumulated_depreciation(&a, date(2025, 12, 31)), d("0")); - assert_eq!(book_value(&a, date(2025, 12, 31)), d("12000.00")); + assert!( + depreciation_schedule(&a, date(2025, 12, 31)) + .unwrap() + .is_empty() + ); + assert_eq!( + accumulated_depreciation(&a, date(2025, 12, 31)).unwrap(), + d("0") + ); + assert_eq!(book_value(&a, date(2025, 12, 31)).unwrap(), d("12000.00")); } /// Oracle: interface docs "accumulated through as_of" and "book value = @@ -423,10 +430,205 @@ fn nothing_depreciates_before_purchase() { fn accumulated_and_book_value_mid_life() { let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); assert_eq!( - accumulated_depreciation(&a, date(2026, 6, 30)), + accumulated_depreciation(&a, date(2026, 6, 30)).unwrap(), d("6000.00") ); - assert_eq!(book_value(&a, date(2026, 6, 30)), d("6000.00")); + assert_eq!(book_value(&a, date(2026, 6, 30)).unwrap(), d("6000.00")); +} + +// ------------------------------- defect 22: the final month, disposals + +/// Defect 22, reproduction A (plan/served.md P3; P0 review round 4): a +/// schedule through a day of the life's final month is prorated by days, +/// never plugged to the whole. Hand check: 12,000.00 over 12 months from +/// 2026-01-01; the life ends 2027-01-01, so December is its final month. +/// Through Dec 15: the eleven months before, 11,000.00, plus December's +/// 1,000.00 × 15/31 = 483.870 → 483.87; accumulated 11,483.87, book value +/// 516.13. Before P3 December plugged to 1,000.00 (book value 0.00). +#[test] +fn defect22_depreciation_through_mid_final_month_is_prorated_not_plugged() { + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + let s = depreciation_schedule(&a, date(2026, 12, 15)).unwrap(); + let dec = s.last().unwrap(); + assert_eq!(dec.period, "2026-12"); + assert_eq!(dec.amount, d("483.87")); + assert_eq!(dec.accumulated, d("11483.87")); + assert_eq!(dec.book_value, d("516.13")); + assert_eq!(book_value(&a, date(2026, 12, 15)).unwrap(), d("516.13")); + // Through its last day, the whole of it. + let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap(); + assert_eq!( + (s.last().unwrap().amount, s.last().unwrap().accumulated), + (d("1000.00"), d("12000.00")) + ); +} + +/// Defect 22, reproduction D: the month-length drift of a mid-month life +/// is spread over the final month's days, not charged on one. Hand check: +/// 1,030.00 − 100.00 = 930.00 over 3 months from 2026-01-15; the life ends +/// 2026-04-15, so April holds days 1-14. January 17/31 of 310.00 = 170.00, +/// February and March 310.00 each: 790.00 before April, 140.00 left for +/// its 14 days, 10.00 a day (January's daily rate, 310.00/31). Through +/// Apr 13: April 130.00, accumulated 920.00, book value 110.00. Through +/// Apr 14, the life's last day: April 140.00, accumulated 930.00. +#[test] +fn defect22_final_month_drift_is_spread_by_days() { + let a = asset("1030.00", "100.00", 3, date(2026, 1, 15)); + let s = depreciation_schedule(&a, date(2026, 4, 13)).unwrap(); + let apr = s.last().unwrap(); + assert_eq!( + ( + apr.period.as_str(), + apr.amount, + apr.accumulated, + apr.book_value + ), + ("2026-04", d("130.00"), d("920.00"), d("110.00")) + ); + let s = depreciation_schedule(&a, date(2026, 4, 14)).unwrap(); + let apr = s.last().unwrap(); + assert_eq!( + (apr.amount, apr.accumulated, apr.book_value), + (d("140.00"), d("930.00"), d("100.00")) + ); + // Past the life: nothing more. + assert_eq!( + depreciation_schedule(&a, date(2030, 1, 1)).unwrap().len(), + 4 + ); +} + +/// Defect 22, reproduction B: a disposed asset's schedule stops at its +/// disposal day (held, inclusive) and shows what the disposal took. Hand +/// check: 12,000.00 over 12 months from 2026-01-01, disposed of +/// 2026-06-15: five months of 1,000.00, then June's 15 of 30 days, 500.00; +/// accumulated 5,500.00, book value 6,500.00, the gain on 7,000.00 500.00. +/// Before P3 the disposed schedule plugged June to 7,000.00 (accumulated +/// 12,000.00, book value 0.00). +#[test] +fn defect22_a_disposed_assets_schedule_stops_at_its_disposal_day() { + let (mut l, accts) = fixture(); + let id = l.register_asset(new_asset(&accts)).unwrap(); + let before = + disposal_amounts(l.get_asset(id).unwrap(), date(2026, 6, 15), usd("7000.00")).unwrap(); + assert_eq!( + (before.accumulated, before.book_value, before.gain_loss), + (d("5500.00"), d("6500.00"), d("500.00")) + ); + l.dispose_asset(id, date(2026, 6, 15), usd("7000.00")) + .unwrap(); + let a = l.get_asset(id).unwrap(); + let s = depreciation_schedule(a, NaiveDate::MAX).unwrap(); + let jun = s.last().unwrap(); + assert_eq!( + ( + jun.period.as_str(), + jun.amount, + jun.accumulated, + jun.book_value + ), + ("2026-06", d("500.00"), d("5500.00"), d("6500.00")) + ); + assert_eq!(jun.accumulated, before.accumulated); + assert_eq!( + accumulated_depreciation(a, NaiveDate::MAX).unwrap(), + d("5500.00") + ); + // A through-date before the disposal is unchanged by it. + assert_eq!( + accumulated_depreciation(a, date(2026, 5, 31)).unwrap(), + d("5000.00") + ); + + // Disposed of on its in-service day: one day held, 1,000.00 / 31 = + // 32.258 → 32.26. + let (mut l, accts) = fixture(); + let id = l.register_asset(new_asset(&accts)).unwrap(); + l.dispose_asset(id, date(2026, 1, 1), usd("0.00")).unwrap(); + let s = depreciation_schedule(l.get_asset(id).unwrap(), NaiveDate::MAX).unwrap(); + assert_eq!(s.len(), 1); + assert_eq!((s[0].amount, s[0].accumulated), (d("32.26"), d("32.26"))); +} + +/// R1 (P3 depreciation review): depreciation rounds to the currency's own +/// minor unit. ¥100,000 over 12 months from 2026-01-01: each month's +/// cumulative 100,000 × m / 12, rounded half up to the yen (8,333.33 → +/// 8,333; 16,666.67 → 16,667; 25,000; ...), so months alternate 8,333 and +/// 8,334 and sum to 100,000. Every one is whole yen, so it can post (an +/// entry refuses a fraction of the minor unit, B-3). +#[test] +fn jpy_depreciation_posts_whole_yen() { + let mut a = asset("100000", "0", 12, date(2026, 1, 1)); + a.cost = Money::new(d("100000"), Currency::JPY); + a.salvage_value = Money::new(d("0"), Currency::JPY); + let s = depreciation_schedule(&a, date(2026, 12, 31)).unwrap(); + let got: Vec = s.iter().map(|l| l.amount).collect(); + let want: Vec = [ + 8333, 8334, 8333, 8333, 8334, 8333, 8333, 8334, 8333, 8333, 8334, 8333, + ] + .into_iter() + .map(Decimal::from) + .collect(); + assert_eq!(got, want); + assert!(s.iter().all(|l| l.amount.normalize().scale() == 0)); + assert_eq!(s.last().unwrap().accumulated, d("100000")); + // And the ledger posts each month. + let mut l = Ledger::new(Currency::JPY); + let exp = l + .add_account("6200", "Depreciation", AccountType::Expense, None, false) + .unwrap(); + let acc = l + .add_account("1510", "Accumulated", AccountType::Asset, None, false) + .unwrap(); + for line in &s { + let e = l + .try_create_entry(date(2026, 12, 31), "Depreciation") + .unwrap(); + let yen = Money::new(line.amount, Currency::JPY); + l.add_line(e, exp, Side::Debit, yen).unwrap(); + l.add_line(e, acc, Side::Credit, yen).unwrap(); + l.post_entry(e).unwrap(); + } +} + +/// The run's catch-up rule (plan/served.md P3, settled in design: a month +/// skipped is caught up in the month the run is made). Hand check, the +/// same 12,000.00 asset: January run (1,000.00), February skipped; a March +/// run posts February and March, 2,000.00; a February run after it posts +/// nothing; with January's run reversed later, a January run posts its +/// 1,000.00 back; a run after the life posts what is left. +#[test] +fn a_skipped_month_is_caught_up_in_the_month_run() { + use ledger_core::{PostedRun, run_amount}; + let a = asset("12000.00", "0.00", 12, date(2026, 1, 1)); + let run = |y, m, amount: &str| PostedRun { + year: y, + month: m, + amount: d(amount), + }; + let jan = run(2026, 1, "1000.00"); + let mar = run_amount(&a, 2026, 3, &[jan]).unwrap(); + assert_eq!((mar.amount, mar.scheduled), (d("2000.00"), d("1000.00"))); + let mar = run(2026, 3, "2000.00"); + assert_eq!(run_amount(&a, 2026, 2, &[jan, mar]).unwrap().amount, d("0")); + // January reversed: posted is March's 2,000.00 alone. + assert_eq!( + run_amount(&a, 2026, 1, &[mar]).unwrap().amount, + d("1000.00") + ); + // Nothing posted, a run in 2027: the whole life. + assert_eq!(run_amount(&a, 2027, 6, &[]).unwrap().amount, d("12000.00")); + assert_eq!( + run_amount(&a, 2027, 6, &[run(2026, 12, "12000.00")]) + .unwrap() + .amount, + d("0") + ); + // Before the asset was in service: refused. + assert!(matches!( + run_amount(&a, 2025, 12, &[]), + Err(LedgerError::InvalidDepreciationPeriod(_)) + )); } // ------------------------------------------------------------- disposal @@ -1109,7 +1311,7 @@ proptest! { fn prop_full_schedule_laws(a in arb_asset()) { let cost = a.cost.amount; let salvage = a.salvage_value.amount; - let s = depreciation_schedule(&a, date(2050, 12, 31)); + let s = depreciation_schedule(&a, date(2050, 12, 31)).unwrap(); prop_assert!(!s.is_empty()); let total: Decimal = s.iter().map(|l| l.amount).sum(); prop_assert_eq!(total, cost - salvage); @@ -1139,17 +1341,17 @@ proptest! { /// one, and nothing is depreciated before purchase. #[test] fn prop_point_queries_agree_with_schedule(a in arb_asset()) { - let full = depreciation_schedule(&a, date(2050, 12, 31)); + let full = depreciation_schedule(&a, date(2050, 12, 31)).unwrap(); for (i, l) in full.iter().enumerate() { let end = period_end(&l.period); - prop_assert_eq!(accumulated_depreciation(&a, end), l.accumulated); - prop_assert_eq!(book_value(&a, end), l.book_value); - let prefix = depreciation_schedule(&a, end); + prop_assert_eq!(accumulated_depreciation(&a, end).unwrap(), l.accumulated); + prop_assert_eq!(book_value(&a, end).unwrap(), l.book_value); + let prefix = depreciation_schedule(&a, end).unwrap(); prop_assert_eq!(&prefix[..], &full[..=i]); } let before = a.purchase_date.pred_opt().unwrap(); - prop_assert_eq!(accumulated_depreciation(&a, before), Decimal::ZERO); - prop_assert_eq!(book_value(&a, before), a.cost.amount); + prop_assert_eq!(accumulated_depreciation(&a, before).unwrap(), Decimal::ZERO); + prop_assert_eq!(book_value(&a, before).unwrap(), a.cost.amount); } /// Oracle: B-15 partial months by days + interface "the last month absorbs @@ -1167,7 +1369,7 @@ proptest! { let mut a = asset("1.00", "0.00", life, p); a.salvage_value = Money::new(Decimal::new(salvage, 2), Currency::USD); a.cost = Money::new(Decimal::new(salvage + m * i64::from(life), 2), Currency::USD); - let s = depreciation_schedule(&a, date(2050, 12, 31)); + let s = depreciation_schedule(&a, date(2050, 12, 31)).unwrap(); prop_assert_eq!(s.len(), life as usize + 1); for l in &s[1..s.len() - 1] { prop_assert_eq!(l.amount, monthly); @@ -1187,9 +1389,9 @@ proptest! { let when = month_end(a.purchase_date.checked_add_months(Months::new(offset)).unwrap()); let pr = Decimal::new(proceeds, 2); let got = disposal_amounts(&a, when, Money::new(pr, Currency::USD)).unwrap(); - prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when)); + prop_assert_eq!(got.accumulated, accumulated_depreciation(&a, when).unwrap()); prop_assert_eq!(got.book_value, a.cost.amount - got.accumulated); - prop_assert_eq!(got.book_value, book_value(&a, when)); + prop_assert_eq!(got.book_value, book_value(&a, when).unwrap()); prop_assert_eq!(got.gain_loss, pr - got.book_value); prop_assert!(got.book_value >= a.salvage_value.amount); } @@ -1305,3 +1507,53 @@ proptest! { prop_assert_eq!(balance_of(&l, "1000"), d("10000.00") - cost); } } + +/// P3 review: what the books carry is read entry by entry, each in its own +/// month (depreciation_effects): a run adds its amount in its month, its +/// reversal takes it away in the reversal's month, a reversal of that +/// reversal adds it back in its own. Hand check: January's run (1,000.00, +/// Jan 31) reversed on Feb 2: +1,000.00 in January, -1,000.00 in February, +/// so a January run posts nothing (Jan 31 carried January) and a February +/// one 2,000.00 (the schedule through Feb 28 less the 0.00 then carried). +/// Re-instated on Mar 3: +1,000.00 in March too. +#[test] +fn depreciation_effects_read_each_entry_in_its_month() { + use ledger_core::{PostedRun, depreciation_effects, run_amount}; + let (mut l, a) = fixture(); + let id = l.register_asset(new_asset(&a)).unwrap(); + let e = l + .try_create_entry(date(2026, 1, 31), "Depreciation") + .unwrap(); + l.add_line(e, a.depr_expense, Side::Debit, usd("1000.00")) + .unwrap(); + l.add_line(e, a.accum, Side::Credit, usd("1000.00")) + .unwrap(); + l.post_entry(e).unwrap(); + let rev = l.reverse_entry(e, date(2026, 2, 2), "undo").unwrap(); + let runs = [(e, d("1000.00"))]; + let fx = |y, m, amount: &str| PostedRun { + year: y, + month: m, + amount: d(amount), + }; + let effects = depreciation_effects(&l, &runs); + assert_eq!(effects, [fx(2026, 1, "1000.00"), fx(2026, 2, "-1000.00")]); + let asset = l.get_asset(id).unwrap().clone(); + assert_eq!( + run_amount(&asset, 2026, 1, &effects).unwrap().amount, + d("0") + ); + assert_eq!( + run_amount(&asset, 2026, 2, &effects).unwrap().amount, + d("2000.00") + ); + l.reverse_entry(rev, date(2026, 3, 3), "redo").unwrap(); + assert_eq!( + depreciation_effects(&l, &runs), + [ + fx(2026, 1, "1000.00"), + fx(2026, 2, "-1000.00"), + fx(2026, 3, "1000.00") + ] + ); +} diff --git a/crates/ledger-core/tests/spec_bounds.rs b/crates/ledger-core/tests/spec_bounds.rs index e261c6e..f02b74d 100644 --- a/crates/ledger-core/tests/spec_bounds.rs +++ b/crates/ledger-core/tests/spec_bounds.rs @@ -100,6 +100,7 @@ fn dates_outside_the_calendar_are_refused_everywhere() { months, balance_account: b.prepaid, pl_account: b.expense, + convention: ledger_core::ScheduleConvention::MonthlyEven, }; for (start, months) in [ (d(9999, 12, 15), 2), @@ -175,6 +176,7 @@ fn replayed_dates_outside_the_calendar_are_accepted() { months, balance_account: b.prepaid, pl_account: b.expense, + convention: ledger_core::ScheduleConvention::MonthlyEven, }; for (start, months) in [ (d(9999, 12, 15), 2), @@ -789,3 +791,140 @@ fn the_bound_is_the_line_limit_itself() { LedgerError::AmountOutOfRange ); } + +// ------------------------------------------------- P3: assets, schedules + +/// The equipment accounts an asset needs, on these books. +fn asset_accounts(b: &mut Books) -> (AccountId, AccountId, AccountId) { + let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap(); + ( + add("1500", AccountType::Asset), + add("1501", AccountType::Asset), + add("6100", AccountType::Expense), + ) +} + +fn new_asset( + accounts: (AccountId, AccountId, AccountId), + cost: &str, + salvage: &str, + purchase_date: NaiveDate, + life: u32, +) -> ledger_core::NewAsset { + ledger_core::NewAsset { + description: "Machine".into(), + purchase_date, + cost: usd(cost.parse().unwrap()), + salvage_value: usd(salvage.parse().unwrap()), + useful_life_months: life, + cost_account: accounts.0, + accum_depr_account: accounts.1, + expense_account: accounts.2, + } +} + +/// B-3 (P3): a new asset's cost and salvage are in the currency's minor +/// unit (every depreciation and disposal line derives from them, and a +/// line finer than a cent never posts). Its replay pair: an asset an +/// earlier version took in tenths of a cent opens, and its schedule is +/// computed, never a panic: whole cents until its last day, which is +/// exactly its cost (1,200.005 over 12 months from 2026-01-01: 100.00 a +/// month, the cumulatives 100.0004 × m rounding to whole cents, and +/// December the rest, 100.005). +#[test] +fn an_asset_cost_finer_than_a_cent_is_refused() { + let mut b = books(); + let acc = asset_accounts(&mut b); + for (cost, salvage) in [("1200.005", "0"), ("1200.00", "0.001")] { + let before = b.ledger.clone(); + assert!(matches!( + b.ledger + .register_asset(new_asset(acc, cost, salvage, d(2026, 1, 1), 12)), + Err(LedgerError::FinerThanMinorUnit { .. }) + )); + assert!(b.ledger.differs_from(&before).is_empty()); + } + let id = b + .ledger + .replay_register_asset(new_asset(acc, "1200.005", "0", d(2026, 1, 1), 12)) + .unwrap(); + let s = ledger_core::depreciation_schedule(b.ledger.get_asset(id).unwrap(), NaiveDate::MAX) + .unwrap(); + assert_eq!(s.len(), 12); + for line in &s[..11] { + assert_eq!(line.amount, Decimal::new(10000, 2), "{}", line.period); + } + assert_eq!(s[11].amount, "100.005".parse::().unwrap()); + assert_eq!(s[11].accumulated, "1200.005".parse::().unwrap()); +} + +/// B-28 (P3: lives past 9999, plan/served.md defect 22): a new asset's +/// last day of depreciation is at most 9999-12-31, the calendar's last +/// day, so every month of its life is one the books can post. A life +/// ending on that day is taken (P0 bounded the life's exclusive end, one +/// day too strict); one a day later is refused. Replay takes either. +#[test] +fn a_life_ending_on_9999_12_31_is_accepted() { + let mut b = books(); + let acc = asset_accounts(&mut b); + // 9999-01-01 + 12 months = 10000-01-01, exclusive: last day 9999-12-31. + b.ledger + .register_asset(new_asset(acc, "1200.00", "0", d(9999, 1, 1), 12)) + .unwrap(); + // 9999-12-01 + 1 month = 10000-01-01: last day 9999-12-31. + b.ledger + .register_asset(new_asset(acc, "1200.00", "0", d(9999, 12, 1), 1)) + .unwrap(); +} + +/// The pair of a_life_ending_on_9999_12_31_is_accepted. +#[test] +fn a_life_ending_after_it_is_refused() { + let mut b = books(); + let acc = asset_accounts(&mut b); + for (start, life) in [ + (d(9999, 1, 2), 12), + (d(9999, 12, 2), 1), + (d(9999, 12, 31), 1), + ] { + assert_eq!( + b.ledger + .register_asset(new_asset(acc, "1200.00", "0", start, life)) + .unwrap_err(), + LedgerError::AssetDateOverflow, + "{start} + {life}" + ); + b.ledger + .replay_register_asset(new_asset(acc, "1200.00", "0", start, life)) + .unwrap(); + } +} + +/// B-28 (P3): a daily schedule's last day is in the calendar: one month +/// from 9999-12-15 covers through 10000-01-14, refused; one month from +/// 9999-12-01 ends on 9999-12-31, taken. Replay takes either. +#[test] +fn a_daily_schedule_whose_last_day_passes_9999_is_refused() { + let mut b = books(); + let schedule = |start: NaiveDate| NewSchedule { + description: "Insurance".into(), + kind: ledger_core::schedules::ScheduleKind::PrepaidExpense, + total: usd(Decimal::new(30000, 2)), + start, + months: 1, + balance_account: b.prepaid, + pl_account: b.expense, + convention: ledger_core::ScheduleConvention::Daily, + }; + let (late, ok) = (schedule(d(9999, 12, 15)), schedule(d(9999, 12, 1))); + assert!(matches!( + b.ledger.register_schedule(late.clone()), + Err(LedgerError::InvalidSchedule(m)) if m.contains("9999-12-31") + )); + b.ledger.register_schedule(ok).unwrap(); + let id = b.ledger.replay_register_schedule(late).unwrap(); + // Its plan is computed: December 9999, then January 10000. + let plan = b.ledger.get_schedule(id).unwrap().plan().unwrap(); + assert_eq!(plan.len(), 2); + assert_eq!(plan[1].0, d(10000, 1, 31)); +} diff --git a/crates/ledger-core/tests/spec_replayed_extremes.rs b/crates/ledger-core/tests/spec_replayed_extremes.rs index 44b14ff..a166ed7 100644 --- a/crates/ledger-core/tests/spec_replayed_extremes.rs +++ b/crates/ledger-core/tests/spec_replayed_extremes.rs @@ -56,7 +56,7 @@ fn depreciation_reaches_chronos_last_month() { }) .unwrap(); let a = l.get_asset(id).unwrap(); - let full = ledger_core::depreciation_schedule(a, NaiveDate::MAX); + let full = ledger_core::depreciation_schedule(a, NaiveDate::MAX).unwrap(); assert_eq!(full.len(), 12); assert_eq!(full[0].period, format!("{y}-01")); assert_eq!(full[11].period, format!("{y}-12")); @@ -66,17 +66,30 @@ fn depreciation_reaches_chronos_last_month() { Decimal::new(120000, 2) ); // Through November: eleven months, December not yet. - let nov = ledger_core::depreciation_schedule(a, d(y, 11, 30)); + let nov = ledger_core::depreciation_schedule(a, d(y, 11, 30)).unwrap(); assert_eq!(nov.len(), 11); assert!(nov.last().unwrap().accumulated < Decimal::new(120000, 2)); - // December is walked without stepping past chrono's last day. - let dec = ledger_core::depreciation_schedule(a, d(y, 12, 10)); + // December is walked without stepping past chrono's last day, and, + // the life's final month (its days 1-14), charged by the day (P3, + // defect 22): the life holds Jan 15-31 (17/31 of a month), ten whole + // months, then what they leave of 1,200.00, 1,200 × (14/31)/11, over + // December's 14 days, so 1,200/(31 × 11) a day, January's own rate. + // Through Dec 10: 1,200 × (17/31 + 10 + 10/31)/11 = 404,400/341 = + // 1,185.9237 → 1,185.92. + let dec = ledger_core::depreciation_schedule(a, d(y, 12, 10)).unwrap(); assert_eq!(dec.last().unwrap().period, format!("{y}-12")); + let want = (Decimal::from(404_400) / Decimal::from(341)) + .round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero); + assert_eq!(want, Decimal::new(118592, 2)); + assert_eq!(dec.last().unwrap().accumulated, want); assert_eq!( - ledger_core::accumulated_depreciation(a, NaiveDate::MAX), + ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(), Decimal::new(120000, 2) ); - assert_eq!(ledger_core::book_value(a, NaiveDate::MAX), Decimal::ZERO); + assert_eq!( + ledger_core::book_value(a, NaiveDate::MAX).unwrap(), + Decimal::ZERO + ); } /// An asset whose life runs past 9999 (or starts before year 1) trues up @@ -108,7 +121,7 @@ fn a_life_past_9999_trues_up_to_the_cent() { }) .unwrap(); let a = l.get_asset(id).unwrap(); - let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX); + let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX).unwrap(); assert_eq!( s.last().unwrap().accumulated, Decimal::new(99_999, 2), @@ -170,7 +183,7 @@ fn february_is_prorated_by_chronos_calendar_in_every_year() { .unwrap(); let a = l.get_asset(id).unwrap(); // Through the 14th: 14 of February's days. - let half = ledger_core::depreciation_schedule(a, d(year, 2, 14)); + let half = ledger_core::depreciation_schedule(a, d(year, 2, 14)).unwrap(); let days = if leap { 29 } else { 28 }; let want = (Decimal::from(1450) * Decimal::from(14) / Decimal::from(days)) .round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero); diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index fe52dcf..82ccfe9 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -93,10 +93,28 @@ pub struct EntryParty { #[derive(Clone, Debug, Default, PartialEq)] pub struct AssetTrail { /// Depreciation the books carry, by month ("YYYY-MM"): the entry and - /// its amount, from `asset.depreciation_posted`. + /// its amount, from `asset.depreciation_posted`, each entry in effect + /// (a reversed run is left out: its month is due again, as the store + /// reads it). pub depreciation: BTreeMap, + /// Depreciation runs reversed since (their months un-posted). + pub reversed_runs: Vec, + /// Every run journaled, reversed or not, with its amount. + pub runs: Vec<(EntryId, Decimal)>, + /// Each of [`Self::runs`]' month ("YYYY-MM"), in the same order. + pub periods: Vec, + /// What the books carry for it, entry by entry, each in its own month, + /// as the catch-up rule reads it: every run, and every reversal of one + /// (ledger_core::depreciation_effects of [`Self::runs`], computed once + /// at load). + pub effects: Vec, /// The disposal entry, from `asset.disposed`. pub disposal_entry: Option, + /// Depreciation the disposal entry itself charged (positive: the + /// store's catch_up_depreciation) or credited back (negative: its + /// excess_depreciation), so a disposed asset's depreciation is what + /// the disposal took. + pub disposal_true_up: Decimal, /// The entry that put it on the books, when one can be told: posted, /// debiting its cost account exactly its cost, dated its in-service /// date (else the nearest such entry within 31 days), and not already @@ -108,20 +126,31 @@ pub struct AssetTrail { impl AssetTrail { /// Every entry that touches the asset: purchase, depreciation (by - /// month), disposal. + /// month, then any run reversed), disposal. pub fn entries(&self) -> Vec { let mut out: Vec = self.purchase_entry.into_iter().collect(); out.extend(self.depreciation.values().map(|(e, _)| *e)); + out.extend(&self.reversed_runs); out.extend(self.disposal_entry); out } - /// Depreciation the books carry for it, summed exactly. + /// Depreciation the books carry for it, summed exactly: its runs in + /// effect, and what its disposal trued up (so a disposed asset's is + /// what the disposal took, ledger_store's dispose_preview). pub fn posted(&self) -> Decimal { - self.depreciation.values().map(|(_, a)| *a).sum() + self.depreciation.values().map(|(_, a)| *a).sum::() + self.disposal_true_up } } +/// A "YYYY-MM" period as (year, month), read leniently (an earlier version +/// posted "10000-01"). +pub fn period_month(period: &str) -> Option<(i32, u32)> { + let (y, m) = period.split_once('-')?; + let (y, m) = (y.parse().ok()?, m.parse().ok()?); + (1..=12).contains(&m).then_some((y, m)) +} + /// One party's dealings with the company: its entries, documents, and /// this year's totals, computed once per refresh. /// @@ -201,8 +230,11 @@ pub struct PartyInfo { /// 2. amount is fixed_asset_register(ledger, through) /// .unposted_depreciation(), exactly. /// 3. periods: the months ("YYYY-MM", ascending, distinct) that some -/// asset held on `through` has in its schedule through `through` -/// and no depreciation posted for. +/// asset held on `through` has in its schedule through `through`, +/// no run in effect for, and something due for (ledger_core:: +/// run_amount): a month a later run caught up, or that rounds to +/// nothing, is never due (P3: a month skipped is caught up in the +/// month the run is made). /// ``` #[derive(Clone, Debug, PartialEq)] pub struct DepreciationDue { @@ -1210,9 +1242,22 @@ impl Books { if matches!(a.status, AssetStatus::Disposed { date, .. } if date <= through) { continue; } - let posted = self.asset_trails.get(&a.id); - for l in ledger_core::depreciation_schedule(a, through) { - if !posted.is_some_and(|t| t.depreciation.contains_key(&l.period)) { + let trail = self.asset_trails.get(&a.id).cloned().unwrap_or_default(); + // Due when a run for the month would post something: not when a + // later run caught it up, or it rounds to nothing (one pass). + let due: HashMap<(i32, u32), Decimal> = + ledger_core::run_amounts(a, (through.year(), through.month()), &trail.effects)? + .into_iter() + .map(|(y, m, r)| ((y, m), r.amount)) + .collect(); + for l in ledger_core::depreciation_schedule(a, through)? { + if trail.depreciation.contains_key(&l.period) { + continue; + } + if period_month(&l.period) + .and_then(|ym| due.get(&ym)) + .is_some_and(|a| !a.is_zero()) + { periods.insert(l.period); } } @@ -2030,6 +2075,7 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap> = HashMap::new(); for (i, line) in journal.iter().enumerate() { let p = &line.payload; let asset = match line.line_type.as_str() { @@ -2050,13 +2096,46 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap().ok()), ) { - t.depreciation.insert(period.to_string(), (entry, amount)); + 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 { + gain_loss_accounts.insert(a, u(p, "gain_loss_account")); } } - "asset.disposed" => t.disposal_entry = u(p, "entry_id"), _ => {} } } + // The runs in effect, by month; a run reversed (and not re-instated) + // no longer counts. Every run is read, not a month's latest: one + // re-instated after its month was run again and that run reversed + // counts (P3 review round 2, as the store reads it). + for t in out.values_mut() { + for (&(e, amount), period) in t.runs.iter().zip(&t.periods) { + if ledger.in_effect(e) { + t.depreciation.insert(period.clone(), (e, amount)); + } else { + t.reversed_runs.push(e); + } + } + } + for t in out.values_mut() { + t.effects = ledger_core::depreciation_effects(ledger, &t.runs); + } + // What each disposal trued up, from its entry. + for (id, t) in out.iter_mut() { + if let (Some(a), Some(e)) = ( + ledger.get_asset(*id), + t.disposal_entry.and_then(|e| ledger.get_entry(e)), + ) { + let runs: Decimal = t.depreciation.values().map(|(_, a)| *a).sum(); + let gain_loss = gain_loss_accounts.get(id).copied().flatten(); + t.disposal_true_up = disposal_true_up(a, e, gain_loss, runs); + } + } // The purchase: an entry that debits the cost account exactly the cost, // on the in-service date or as near it as can be (within 31 days). // A linked acquisition is the purchase; otherwise match it. @@ -2108,6 +2187,32 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap, + runs: Decimal, +) -> Decimal { + if gain_loss_account.is_some_and(|g| g != asset.expense_account) { + return entry + .lines() + .iter() + .filter(|l| l.account == asset.expense_account) + .map(|l| match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }) + .sum(); + } + ledger_core::accumulated_depreciation(asset, NaiveDate::MAX).map_or(Decimal::ZERO, |t| t - runs) +} + /// Entry ids a journal line refers to. pub fn entry_refs(line: &JournalLine) -> Vec { let p = &line.payload; diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index 66d55db..fece384 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -539,6 +539,9 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { months: 12, balance_account: prepaid, pl_account: insurance, + // A new schedule spreads by the day (D-11, D-37): a small first + // and last month around eleven full ones. + convention: ledger_core::ScheduleConvention::Daily, })?; let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new(); diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs index d67e7dc..7593a4b 100644 --- a/crates/ledger-gui/src/pages/assets.rs +++ b/crates/ledger-gui/src/pages/assets.rs @@ -15,7 +15,7 @@ use crate::fmt; use crate::nav::{ReportKind, Route}; use crate::theme::{self, pal}; use crate::widgets; -use chrono::NaiveDate; +use chrono::{Datelike, NaiveDate}; use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2}; use egui_phosphor::regular as icon; use ledger_core::{Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, TaxBasisRule}; @@ -774,8 +774,14 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<' pub enum MonthStatus { /// Posted, by this entry. Posted(ledger_core::EntryId), - /// The month has ended and nothing is posted for it. + /// The month has ended, nothing is posted for it, and a run for it + /// would post something. Due, + /// The month has ended with no run of its own, and nothing is due for + /// it: a later run caught it up, its run was reversed after it ended + /// (the reversal's month catches it up), it rounds to nothing, or the + /// asset's disposal took it. + Covered, /// The month under way. ThisMonth, /// A month still to come. @@ -788,29 +794,91 @@ pub enum MonthStatus { /// ```text /// INVARIANTS: /// 1. The lines are ledger_core::depreciation_schedule(asset, the end of -/// time), unchanged and in order. -/// 2. A month with depreciation posted (asset.depreciation_posted) is -/// Posted(its entry); otherwise Due when it ended before today, -/// ThisMonth when today is in it, Scheduled after. +/// time), unchanged and in order: for a disposed asset, through its +/// disposal day. +/// 2. A month with a run in effect (asset.depreciation_posted, not +/// reversed) is Posted(its entry); otherwise, once it has ended, Due +/// when a run for it would post something (ledger_core::run_amount, +/// over what the books carry, every run and reversal in its own +/// month) and Covered when not (a later run caught it up, its run was +/// reversed after it ended, or it rounds to nothing); ThisMonth when +/// today is in it, Scheduled after. A disposed asset's months are +/// never Due: its disposal took the schedule through its day. +/// 3. A schedule the books can't compute (an asset an earlier version +/// took) is refused, never a panic: the page says why. /// ``` -pub fn schedule(b: &Books, id: AssetId) -> Vec<(ledger_core::ScheduleLine, MonthStatus)> { +pub fn schedule( + b: &Books, + id: AssetId, +) -> Result, ledger_core::LedgerError> { let Some(a) = b.ledger.get_asset(id) else { - return Vec::new(); + return Ok(Vec::new()); }; let trail = b.asset_trail(id); let this_month = b.today.format("%Y-%m").to_string(); - ledger_core::depreciation_schedule(a, NaiveDate::MAX) + // What a run would post for each month ended before this one, in one + // pass (a long life has many). + let ended = b + .today + .with_day(1) + .and_then(|d| d.pred_opt()) + .map_or((i32::MIN, 1), |d| (d.year(), d.month())); + let disposed = matches!(a.status, AssetStatus::Disposed { .. }); + let due: std::collections::HashMap<(i32, u32), Decimal> = if disposed { + Default::default() + } else { + ledger_core::run_amounts(a, ended, &trail.effects)? + .into_iter() + .map(|(y, m, r)| ((y, m), r.amount)) + .collect() + }; + Ok(ledger_core::depreciation_schedule(a, NaiveDate::MAX)? .into_iter() .map(|l| { let st = match trail.depreciation.get(&l.period) { Some((e, _)) => MonthStatus::Posted(*e), - None if l.period < this_month => MonthStatus::Due, + None if disposed => MonthStatus::Covered, + None if l.period < this_month => { + let owed = crate::books::period_month(&l.period) + .and_then(|ym| due.get(&ym).copied()) + .unwrap_or(l.amount); + if owed.is_zero() { + MonthStatus::Covered + } else { + MonthStatus::Due + } + } None if l.period == this_month => MonthStatus::ThisMonth, None => MonthStatus::Scheduled, }; (l, st) }) - .collect() + .collect()) +} + +/// Depreciation for finished months that the books don't carry yet: what +/// a run of the last month before today's would post +/// (ledger_core::run_amounts), every skipped or reversed month caught up. +/// Zero for a disposed asset (its disposal took the schedule through its +/// day) or one with nothing due. +pub fn unposted(b: &Books, id: AssetId) -> Result { + let Some(a) = b + .ledger + .get_asset(id) + .filter(|a| a.status == AssetStatus::Active) + else { + return Ok(Decimal::ZERO); + }; + let ended = b + .today + .with_day(1) + .and_then(|d| d.pred_opt()) + .map_or((i32::MIN, 1), |d| (d.year(), d.month())); + Ok( + ledger_core::run_amounts(a, ended, &b.asset_trail(id).effects)? + .last() + .map_or(Decimal::ZERO, |(_, _, r)| r.amount), + ) } /// One asset, completely. @@ -1014,12 +1082,20 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { ui.add_space(14.0); // The schedule. - let lines = schedule(b, id); - let due: Decimal = lines - .iter() - .filter(|(_, s)| *s == MonthStatus::Due) - .map(|(l, _)| l.amount) - .sum(); + let (lines, schedule_error) = match schedule(b, id) { + Ok(lines) => (lines, None), + Err(e) => (Vec::new(), Some(e)), + }; + if let Some(e) = &schedule_error { + notice_box( + ui, + &Notice { + problem: true, + text: format!("Its depreciation schedule can't be computed: {e}."), + }, + ); + } + let due = unposted(b, id).unwrap_or(Decimal::ZERO); if due > Decimal::ZERO { notice_box( ui, @@ -1051,10 +1127,12 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { }, |ui| { if lines.is_empty() { - ui.label( - RichText::new("Nothing to depreciate: its salvage value is its cost.") - .color(p.muted), - ); + let why = if schedule_error.is_some() { + "The schedule can't be computed (above)." + } else { + "Nothing to depreciate: its salvage value is its cost." + }; + ui.label(RichText::new(why).color(p.muted)); return; } let amt = widgets::AMOUNT_MIN + 16.0; @@ -1085,6 +1163,13 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { let (status, color) = match st { MonthStatus::Posted(e) => (format!("Posted · entry #{e}"), p.positive), MonthStatus::Due => ("Not posted".to_string(), p.warning), + MonthStatus::Covered if l.amount.is_zero() => { + ("Rounds to nothing".to_string(), p.muted) + } + MonthStatus::Covered if a.status != AssetStatus::Active => { + ("Taken by the disposal".to_string(), p.muted) + } + MonthStatus::Covered => ("Nothing due".to_string(), p.muted), MonthStatus::ThisMonth => ("This month".to_string(), p.muted), MonthStatus::Scheduled => ("Scheduled".to_string(), p.faint), }; @@ -1422,7 +1507,7 @@ mod tests { for i in 0..2 { let (_dir, books, ids) = two_assets(&format!("asset-page-{i}")); let a = books.ledger.get_asset(ids[i]).unwrap().clone(); - let want = ledger_core::book_value(&a, today()); + let want = ledger_core::book_value(&a, today()).unwrap(); let (texts, _) = render(books, Route::Asset(ids[i]), |_| {}); assert_eq!( beside(&texts, "Book value today").text, diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 3883e55..3684bbb 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -11,6 +11,7 @@ use crate::theme::{self, pal}; use crate::widgets; use egui::{CornerRadius, RichText, Sense, Ui, vec2}; use egui_phosphor::regular as icon; +use ledger_core::AssetStatus; use ledger_core::PeriodStatus; use ledger_core::assets::{accumulated_depreciation, book_value}; use rust_decimal::Decimal; @@ -355,22 +356,9 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }, |ui| { for a in &assets { - // What has actually been posted, so this ties to the balance - // sheet; the schedule may run ahead of the books. - let acc: Decimal = b - .journal - .iter() - .filter(|l| { - l.line_type == "asset.depreciation_posted" - && l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id) - }) - .filter_map(|l| { - l.payload - .get("amount") - .and_then(|v| v.as_str()) - .and_then(|s| s.parse::().ok()) - }) - .sum(); + // What the books carry (runs in effect), so this ties to the + // balance sheet; the schedule may run ahead of the books. + let acc: Decimal = b.asset_trail(a.id).posted(); let bv = a.cost.amount - acc; let scheduled = accumulated_depreciation(a, b.today); let _ = book_value; @@ -394,7 +382,11 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { "bought {} · {} months · {}", fmt::date(a.purchase_date), a.useful_life_months, - fmt::words(&format!("{:?}", a.status)).to_lowercase() + match &a.status { + AssetStatus::Active => "active".to_string(), + AssetStatus::Disposed { date, .. } => + format!("disposed {}", fmt::date(*date)), + } )) .font(theme::regular(12.0)) .color(p.muted), @@ -406,11 +398,13 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ); }); }); - let behind = scheduled - acc; - let note = if behind > Decimal::ZERO { - format!(" · {} more scheduled through today", fmt::dollars(behind)) - } else { - String::new() + 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, @@ -489,12 +483,20 @@ fn schedules(ui: &mut Ui, b: &Books) { ui.horizontal_wrapped(|ui| { ui.label(RichText::new(&s.description).font(theme::semibold(13.5))); ui.label( - RichText::new(format!( - "{} · {} months from {}", - fmt::words(&format!("{:?}", s.kind)), - s.months, - fmt::month(s.start) - )) + RichText::new(match s.convention { + ledger_core::ScheduleConvention::Daily => format!( + "{} · {} months from {}, by the day", + fmt::words(&format!("{:?}", s.kind)), + s.months, + fmt::date(s.start) + ), + ledger_core::ScheduleConvention::MonthlyEven => format!( + "{} · {} months from {}", + fmt::words(&format!("{:?}", s.kind)), + s.months, + fmt::month(s.start) + ), + }) .font(theme::regular(12.0)) .color(p.muted), ); @@ -729,6 +731,7 @@ mod tests { months: 12, balance_account: prepaid, pl_account: insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); b.s.run_schedules(d(2026, 3, 31)).unwrap(); diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 93410b7..f18b519 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -856,7 +856,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { ), "schedule.registered" => ev( icon::CALENDAR, - format!("Schedule: {}", s(p, "description")), + format!( + "Schedule: {} ({})", + s(p, "description"), + // Journals before P3 carry no convention: equal months. + match s(p, "convention") { + "daily" => "by the day", + _ => "equal months", + } + ), Some(Route::Company), ), "schedule.posted" => ev( diff --git a/crates/ledger-gui/tests/assets.proptest-regressions b/crates/ledger-gui/tests/assets.proptest-regressions new file mode 100644 index 0000000..9dc71db --- /dev/null +++ b/crates/ledger-gui/tests/assets.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 99b9f006f95ef07a893337dc3c44f78dd1b4dc3a75da413f2e5b9b428ce21797 # shrinks to ops = [Run(6), Dispose(2026-06-01)] diff --git a/crates/ledger-gui/tests/assets.rs b/crates/ledger-gui/tests/assets.rs index 5ad0bef..8b7eb60 100644 --- a/crates/ledger-gui/tests/assets.rs +++ b/crates/ledger-gui/tests/assets.rs @@ -228,7 +228,8 @@ fn depreciation_due_is_the_register_through_the_last_finished_month() { assert_eq!(due.periods, vec!["2026-08", "2026-09"]); // Independently: the laptop's August and September, the monitor's // September, straight from the schedules. - let sched = |id| depreciation_schedule(f.oracle.get_asset(id).unwrap(), d(2026, 9, 30)); + let sched = + |id| depreciation_schedule(f.oracle.get_asset(id).unwrap(), d(2026, 9, 30)).unwrap(); let month = |id, p: &str| { sched(id) .into_iter() @@ -273,9 +274,9 @@ fn depreciation_due_is_the_register_through_the_last_finished_month() { fn an_assets_schedule_and_trail() { let f = fixture("schedule"); let b = &f.books; - let lines = schedule(b, f.laptop); + let lines = schedule(b, f.laptop).unwrap(); let asset = f.oracle.get_asset(f.laptop).unwrap(); - let oracle = depreciation_schedule(asset, NaiveDate::MAX); + let oracle = depreciation_schedule(asset, NaiveDate::MAX).unwrap(); assert_eq!( oracle.len(), 37, @@ -793,7 +794,7 @@ proptest! { // Every period listed is unposted and in some schedule. for p in &due.periods { let found = b.ledger.registered_assets().any(|a| { - depreciation_schedule(a, due.through).iter().any(|l| &l.period == p) + depreciation_schedule(a, due.through).unwrap().iter().any(|l| &l.period == p) && !b.asset_trail(a.id).depreciation.contains_key(p) }); prop_assert!(found, "{} listed but not due", p); @@ -839,3 +840,346 @@ fn every_journal_event_has_plain_language_text() { assert_ne!(e.text, kind, "{kind} has no journal text"); } } + +/// Books holding asset X of the P3 depreciation brief (12,000.00 over 12 +/// months from 2026-01-01, 1,000.00 a month), with the store to post to. +fn asset_x(tag: &str) -> (TempDir, PathBuf, Store, AssetId, (u64, u64)) { + let dir = TempDir::new(tag); + let data = dir.0.join("books-data"); + let mut s = Store::create(&data, Currency::USD).unwrap(); + let mut add = |code: &str, t| s.add_account(code, code, t, None, false).unwrap(); + let cash = add("1000", AccountType::Asset); + let equip = add("1500", AccountType::Asset); + let accum = add("1510", AccountType::Asset); + let dep = add("6500", AccountType::Expense); + let gain = add("7000", AccountType::Revenue); + let x = s + .register_asset(NewAsset { + description: "Asset X".into(), + purchase_date: d(2026, 1, 1), + cost: usd(1_200_000), + salvage_value: usd(0), + useful_life_months: 12, + cost_account: equip, + accum_depr_account: accum, + expense_account: dep, + }) + .unwrap(); + (dir, data, s, x, (cash, gain)) +} + +/// Defect 22 in the viewer: a disposed asset's page shows its schedule +/// through the disposal day, held, as the store posted it. Hand check: +/// January through May run, disposed of 2026-06-15: June is 1,000.00 × +/// 15/30 = 500.00 (the final depreciation, posted), accumulated 5,500.00, +/// book value 6,500.00; every month posted. +#[test] +fn a_disposed_assets_page_shows_what_it_depreciated_through_its_disposal() { + let (dir, _data, mut s, x, (cash, gain)) = asset_x("disposed-page"); + for m in 1..=5 { + s.run_depreciation(x, &format!("2026-{m:02}")).unwrap(); + } + s.dispose_asset(x, d(2026, 6, 15), usd(700_000), cash, gain) + .unwrap(); + drop(s); + let b = load(&dir.0, today()); + let lines = schedule(&b, x).unwrap(); + assert_eq!(lines.len(), 6); + let (jun, st) = lines.last().unwrap(); + assert_eq!(jun.period, "2026-06"); + assert_eq!(jun.amount, Decimal::new(50_000, 2)); + assert_eq!(jun.accumulated, Decimal::new(550_000, 2)); + assert_eq!(jun.book_value, Decimal::new(650_000, 2)); + assert!(matches!(st, MonthStatus::Posted(_))); + assert!( + lines + .iter() + .all(|(_, st)| matches!(st, MonthStatus::Posted(_))) + ); + assert_eq!(b.asset_trail(x).posted(), Decimal::new(550_000, 2)); +} + +/// The catch-up rule in the viewer (plan/served.md P3, settled in design): +/// a month a later run caught up is Covered, never Due forever; a run +/// reversed within its month is no longer the books', so its month (and +/// one it covered) is Due again, and the books carry what is in effect. +/// Hand check: January and March run (March 2,000.00), February skipped: +/// February Covered; March's run reversed on Mar 31: February and March +/// Due, 1,000.00 carried. +#[test] +fn a_month_a_later_run_caught_up_is_covered_and_a_reversed_one_due() { + let (dir, data, mut s, x, _) = asset_x("caught-up-page"); + s.run_depreciation(x, "2026-01").unwrap(); + let mar = s.run_depreciation(x, "2026-03").unwrap(); + assert_eq!(mar.amount, Decimal::new(200_000, 2)); + drop(s); + let b = load(&dir.0, today()); + let st: Vec = schedule(&b, x) + .unwrap() + .into_iter() + .map(|(_, s)| s) + .collect(); + assert!(matches!(st[0], MonthStatus::Posted(_))); + assert_eq!(st[1], MonthStatus::Covered); + assert!(matches!(st[2], MonthStatus::Posted(_))); + assert_eq!(st[3], MonthStatus::Due); + let due = b.depreciation_due(d(2026, 4, 30)).unwrap(); + assert_eq!(due.periods, vec!["2026-04"]); + let mut s = Store::open(&data).unwrap(); + s.reverse_entry(mar.entry_id.unwrap(), d(2026, 3, 31), "reversed") + .unwrap(); + drop(s); + let b = load(&dir.0, today()); + let st: Vec = schedule(&b, x) + .unwrap() + .into_iter() + .map(|(_, s)| s) + .collect(); + assert_eq!((st[1], st[2]), (MonthStatus::Due, MonthStatus::Due)); + assert_eq!(b.asset_trail(x).posted(), Decimal::new(100_000, 2)); + let due = b.depreciation_due(d(2026, 4, 30)).unwrap(); + assert_eq!(due.periods, vec!["2026-02", "2026-03", "2026-04"]); +} + +/// P3 review of the catch-up rule, in the viewer: a run reversed after its +/// month ended carried that month at its end, so the month isn't Due (a +/// run of it would post nothing); the month the reversal is in is, with +/// everything since caught up. Hand check: January and March run (March +/// 2,000.00); March's run reversed on Apr 2: February and March Covered, +/// April Due; through September (today is in October) 9,000.00 is due, +/// less the 1,000.00 carried: 8,000.00 unposted. +#[test] +fn a_run_reversed_after_its_month_leaves_the_reversals_month_due() { + let (dir, data, mut s, x, _) = asset_x("reversed-later-page"); + s.run_depreciation(x, "2026-01").unwrap(); + let mar = s.run_depreciation(x, "2026-03").unwrap(); + s.reverse_entry(mar.entry_id.unwrap(), d(2026, 4, 2), "reversed") + .unwrap(); + drop(s); + let b = load(&dir.0, today()); + let st: Vec = schedule(&b, x) + .unwrap() + .into_iter() + .map(|(_, s)| s) + .collect(); + assert_eq!( + (st[1], st[2], st[3]), + (MonthStatus::Covered, MonthStatus::Covered, MonthStatus::Due) + ); + assert_eq!( + ledger_gui::pages::assets::unposted(&b, x).unwrap(), + Decimal::new(800_000, 2) + ); + let due = b.depreciation_due(d(2026, 4, 30)).unwrap(); + assert_eq!(due.periods, vec!["2026-04"]); + // The store agrees: March again posts nothing, April 3,000.00. + let mut s = Store::open(&data).unwrap(); + assert_eq!(s.run_depreciation(x, "2026-03").unwrap().entry_id, None); + assert_eq!( + s.run_depreciation(x, "2026-04").unwrap().amount, + Decimal::new(300_000, 2) + ); +} + +/// P3 review: a disposed asset's figures in the viewer are what its +/// disposal took. Hand check: January through June run (6,000.00, June's +/// dated Jun 30), disposed of Jun 15 for 7,000.00: the disposal credits +/// June's last 15 days back (500.00), so the books' depreciation for it is +/// 5,500.00 and its book value at disposal 6,500.00, as books.dispose_asset +/// and books.list_assets say. Disposed the same way after March's run was +/// reversed (on Jun 20), the disposal entry itself charges 500.00 (March's +/// 1,000.00, less June's last 15 days already in June's run): again +/// 5,500.00, and March isn't Due, the disposal took it. +#[test] +fn a_disposed_assets_figures_are_what_its_disposal_took() { + let (dir, _data, mut s, x, (cash, gain)) = asset_x("disposed-true-up"); + for m in 1..=6 { + s.run_depreciation(x, &format!("2026-{m:02}")).unwrap(); + } + s.dispose_asset(x, d(2026, 6, 15), usd(700_000), cash, gain) + .unwrap(); + drop(s); + let b = load(&dir.0, today()); + assert_eq!(b.asset_trail(x).posted(), Decimal::new(550_000, 2)); + assert_eq!(b.asset_trail(x).disposal_true_up, Decimal::new(-50_000, 2)); + assert_eq!( + ledger_gui::pages::assets::unposted(&b, x).unwrap(), + Decimal::ZERO + ); + + let (dir, _data, mut s, x, (cash, gain)) = asset_x("disposed-catch-up"); + let mut mar = 0; + for m in 1..=6 { + let r = s.run_depreciation(x, &format!("2026-{m:02}")).unwrap(); + if m == 3 { + mar = r.entry_id.unwrap(); + } + } + s.reverse_entry(mar, d(2026, 6, 20), "reversed").unwrap(); + let p = s.dispose_preview(x, d(2026, 6, 15), usd(700_000)).unwrap(); + assert_eq!(p.catch_up_depreciation, Decimal::new(50_000, 2)); + s.dispose_asset(x, d(2026, 6, 15), usd(700_000), cash, gain) + .unwrap(); + drop(s); + let b = load(&dir.0, today()); + assert_eq!(b.asset_trail(x).posted(), Decimal::new(550_000, 2)); + let st: Vec = schedule(&b, x) + .unwrap() + .into_iter() + .map(|(_, s)| s) + .collect(); + assert_eq!(st[2], MonthStatus::Covered); + assert!(!st.contains(&MonthStatus::Due)); +} + +/// A command for [`prop_the_viewer_reads_an_asset_as_the_store_does`]. +#[derive(Clone, Copy, Debug)] +enum AssetOp { + Run(u32), + Reverse(usize, NaiveDate), + Dispose(NaiveDate), +} + +/// The entries asset commands made, in journal order: runs (the disposal's +/// final depreciation too), reversals, disposals. +fn asset_entries(data: &Path) -> Vec { + let text = std::fs::read_to_string(data.join(ledger_store::JOURNAL_FILE)).unwrap(); + text.lines() + .filter_map(|l| { + let v: serde_json::Value = serde_json::from_str(l).unwrap(); + let p = &v["payload"]; + match v["type"].as_str()? { + "asset.depreciation_posted" | "asset.disposed" => p["entry_id"].as_u64(), + "entry.reversed" => p["id"].as_u64(), + _ => None, + } + }) + .collect() +} + +/// An account's balance (debit positive) at the end of a day. +fn balance_on(store: &Store, account: u64, on: NaiveDate) -> Decimal { + let code = &store.ledger().chart().get(account).unwrap().code; + ledger_core::reports::trial_balance_as_of(store.ledger(), on) + .lines + .iter() + .find(|l| &l.code == code) + .map_or(Decimal::ZERO, |l| l.debit - l.credit) +} + +fn arb_asset_op() -> impl Strategy { + let date = (1u32..=6, 1u32..=31).prop_map(|(m, day)| { + NaiveDate::from_ymd_opt(2026, m, day).unwrap_or_else(|| { + ledger_core::schedules::month_end(NaiveDate::from_ymd_opt(2026, m, 1).unwrap()) + }) + }); + prop_oneof![ + 4 => (1u32..=6).prop_map(AssetOp::Run), + 4 => (0usize..8, date.clone()).prop_map(|(n, on)| AssetOp::Reverse(n, on)), + 1 => date.prop_map(AssetOp::Dispose), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + /// P3 review round 2: whatever runs, reversals, re-runs, + /// re-instatements and disposals the books hold, the viewer reads the + /// asset as the store does. On July 15: what it says the books carry + /// is the accumulated depreciation account (a disposed asset's, what + /// its disposal took: the schedule through its day); each finished + /// month is Posted by the entry a run of it says is already posted, + /// Due exactly when a run of it would post something, and Covered + /// otherwise; and what it says is unposted is what the schedule says + /// through June less what the books carry then. + #[test] + fn prop_the_viewer_reads_an_asset_as_the_store_does( + ops in prop::collection::vec(arb_asset_op(), 1..10) + ) { + let (dir, data, mut s, x, (cash, gain)) = asset_x("viewer-orders"); + for op in &ops { + let _ = match *op { + AssetOp::Run(m) => s.run_depreciation(x, &format!("2026-{m:02}")).map(|_| ()), + AssetOp::Reverse(n, on) => match asset_entries(&data).get(n) { + Some(&e) => s.reverse_entry(e, on, "reversed").map(|_| ()), + None => Ok(()), + }, + AssetOp::Dispose(on) => s.dispose_asset(x, on, usd(100_000), cash, gain).map(|_| ()), + }; + } + let a = s.ledger().get_asset(x).unwrap().clone(); + let accum = a.accum_depr_account; + let expense = a.expense_account; + drop(s); + let today = d(2026, 7, 15); + let b = load(&dir.0, today); + let trail = b.asset_trail(x); + let s = Store::open(&data).unwrap(); + let disposed = a.status != ledger_core::AssetStatus::Active; + if disposed { + prop_assert_eq!( + trail.posted(), + ledger_core::accumulated_depreciation(&a, NaiveDate::MAX).unwrap() + ); + prop_assert_eq!(balance_on(&s, accum, NaiveDate::MAX), Decimal::ZERO); + } else { + prop_assert_eq!(trail.posted(), -balance_on(&s, accum, NaiveDate::MAX)); + let june = d(2026, 6, 30); + let owed = ledger_core::accumulated_depreciation(&a, june).unwrap() + - balance_on(&s, expense, june); + prop_assert_eq!( + ledger_gui::pages::assets::unposted(&b, x).unwrap(), + owed.max(Decimal::ZERO) + ); + } + drop(s); + let lines = schedule(&b, x).unwrap(); + for (line, st) in lines.iter().filter(|(l, _)| l.period.as_str() < "2026-07") { + // A run of the month, on a copy of the books. + let copy = TempDir::new("viewer-orders-copy"); + let to = copy.0.join("books-data"); + std::fs::create_dir_all(&to).unwrap(); + std::fs::copy(data.join(ledger_store::JOURNAL_FILE), to.join(ledger_store::JOURNAL_FILE)).unwrap(); + let mut c = Store::open(&to).unwrap(); + let run = c.run_depreciation(x, &line.period); + let want = match run { + Ok(r) if r.already_posted => MonthStatus::Posted(r.entry_id.unwrap()), + Ok(r) if r.entry_id.is_some() => MonthStatus::Due, + Ok(_) => MonthStatus::Covered, + Err(_) => { + prop_assert!(disposed, "{} refused: {:?}", line.period, run); + MonthStatus::Covered + } + }; + prop_assert_eq!(*st, want, "{} ({:?})", line.period, ops); + } + } +} + +/// P3 review round 2, in the viewer: a run re-instated after its month was +/// run again and that second run reversed is the month's run, as the store +/// reads it. Hand check: January run (A), reversed on Jan 31; run again +/// (A2), reversed on Jan 31; A's reversal reversed on Jan 31: A stands. +/// January is Posted(A), the books carry 1,000.00, and nothing is unposted +/// but February through June (5,000.00). Before this review the viewer read +/// January's latest run (A2, reversed): January Due, 0.00 carried. +#[test] +fn the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed() { + let (dir, _data, mut s, x, _) = asset_x("reinstated-page"); + let jan31 = d(2026, 1, 31); + let a = s.run_depreciation(x, "2026-01").unwrap().entry_id.unwrap(); + let b_rev = s.reverse_entry(a, jan31, "first doubt").unwrap(); + let a2 = s.run_depreciation(x, "2026-01").unwrap().entry_id.unwrap(); + s.reverse_entry(a2, jan31, "second doubt").unwrap(); + s.reverse_entry(b_rev, jan31, "the first was right") + .unwrap(); + drop(s); + let b = load(&dir.0, d(2026, 7, 15)); + assert_eq!(b.asset_trail(x).posted(), Decimal::new(100_000, 2)); + let st = schedule(&b, x).unwrap(); + assert_eq!(st[0].1, MonthStatus::Posted(a)); + assert_eq!( + ledger_gui::pages::assets::unposted(&b, x).unwrap(), + Decimal::new(500_000, 2) + ); +} diff --git a/crates/ledger-gui/tests/edges.rs b/crates/ledger-gui/tests/edges.rs index b0f2c8c..ebcaf7b 100644 --- a/crates/ledger-gui/tests/edges.rs +++ b/crates/ledger-gui/tests/edges.rs @@ -77,6 +77,7 @@ fn fixture(tag: &str) -> Fixture { months: 12, balance_account: prepaid, pl_account: insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); let sam = s.add_party("Sam", &[Role::Contractor]).unwrap(); diff --git a/crates/ledger-mcp/handbook/fixed-assets.md b/crates/ledger-mcp/handbook/fixed-assets.md index 865e18f..0936504 100644 --- a/crates/ledger-mcp/handbook/fixed-assets.md +++ b/crates/ledger-mcp/handbook/fixed-assets.md @@ -2,6 +2,7 @@ - Register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). - Depreciation is straight-line, book basis only; tax depreciation stays with the CPA. +- It accrues by the day: a partial first month, the final month and a disposal month are prorated by days (the disposal day is held), and the lifetime total is exactly cost minus salvage. A month skipped is caught up by the next run (books.run_depreciation says how much under caught_up; a material miss is a restatement for the CPA); a disposal dated inside a month already run credits the extra days back; one dated before a later month's run is refused until that run is reversed. - Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FA-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (give the human the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. - Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. - books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer. diff --git a/crates/ledger-mcp/handbook/prepaid-and-recurring.md b/crates/ledger-mcp/handbook/prepaid-and-recurring.md index 2630259..6f12b08 100644 --- a/crates/ledger-mcp/handbook/prepaid-and-recurring.md +++ b/crates/ledger-mcp/handbook/prepaid-and-recurring.md @@ -1,4 +1,5 @@ # Prepaid expenses, deferred revenue and recurring entries - Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). +- A new schedule spreads by the day from its start (the [books] schedule_convention setting, default daily): a policy starting mid-month posts a small first and last month, so 12 months from March 15 is 13 month ends. Equal months (monthly_even) start on the 1st, so revenue is never earned early. Schedules from before keep their equal months. - Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview. diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 141fbb4..5e32265 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -2679,6 +2679,10 @@ fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> Sched balance_account: code(s.balance_account), pl_account: code(s.pl_account), total: money(s.total.amount), + convention: s.convention.key().to_string(), + start: s.first_day().to_string(), + end: s.last_day().map(|d| d.to_string()), + next: s.next().ok().flatten().map(|d| d.to_string()), remaining: plan.as_ref().ok().and(s.remaining().ok()).map(money), months: plan .as_ref() @@ -3386,6 +3390,11 @@ impl BooksServer { return Err(format!("entry {} is not posted", p.entry_id)); } let date = parse_date(&p.date)?; + // Refused before the preview, as the post would be (an asset's + // disposal, a run its disposal took as final, a month posted twice). + store + .check_reverse_entry(p.entry_id, date) + .map_err(|e| e.to_string())?; // Build the preview from the flipped lines without mutating. let flipped: Vec<(AccountId, Side, Money)> = orig .lines() @@ -4556,7 +4565,7 @@ impl BooksServer { #[tool( name = "books.create_schedule", title = "Create an amortization schedule", - description = "Spread a prepaid expense (asset expensed monthly, e.g. a year of insurance paid up front) or deferred revenue (liability earned monthly, e.g. an annual subscription collected up front) evenly over the months it covers, to the cent (the last month absorbs rounding). Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the monthly plan; post it with books.run_schedules.", + description = "Spread a prepaid expense (an asset used up as time passes, e.g. a year of insurance paid up front) or deferred revenue (a liability earned as time passes, e.g. an annual subscription collected up front) over the time it covers, to the cent. convention: daily (default, by the day from start: a mid-month start posts a small first and last month, one month end more than its months) or monthly_even (equal months; starts on the 1st). Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the plan; post it with books.run_schedules.", annotations( read_only_hint = false, destructive_hint = false, @@ -4578,6 +4587,12 @@ impl BooksServer { )); } }; + // The books' setting, else daily (D-11, D-37). + let convention = match p.convention.as_deref() { + None => store.ledger().settings().schedule_convention(), + Some(c) => ledger_core::ScheduleConvention::parse(c) + .ok_or_else(|| format!("convention {c:?}: expected daily or monthly_even"))?, + }; let currency = store.ledger().base_currency(); let balance_account = resolve_account(&store, &p.balance_account)?; let pl_account = resolve_account(&store, &p.pl_account)?; @@ -4590,6 +4605,7 @@ impl BooksServer { months: p.months, balance_account, pl_account, + convention, }) .map_err(|e| e.to_string())?; Ok(Json(schedule_out( @@ -4601,7 +4617,7 @@ impl BooksServer { #[tool( name = "books.list_schedules", title = "List amortization schedules", - description = "Amortization schedules (prepaid expenses, deferred revenue): each month's amount, what has posted, and what remains.", + description = "Amortization schedules (prepaid expenses, deferred revenue): each one's convention (daily or monthly_even), the first and last day it covers, each month's amount, what has posted, the next month to post, and what remains.", annotations(read_only_hint = true, open_world_hint = false) )] async fn list_schedules(&self) -> Result, String> { @@ -4618,7 +4634,7 @@ impl BooksServer { #[tool( name = "books.run_schedules", title = "Post scheduled amortization", - description = "Post every amortization schedule's months ending on or before `through` (default today) that haven't posted. Idempotent: a month posts once. Without confirmed=true, returns the postings it would make. Exempt from the receipt policy: the schedule is the evidence (the original payment carries the receipt).", + description = "Post every amortization schedule's months ending on or before `through` (default today) that haven't posted. Idempotent: a month posts once; a month reversed in its own month is due again. A month in a closed period, or reversed after it ended, is listed under skipped, not posted. Without confirmed=true, returns the postings it would make. Exempt from the receipt policy: the schedule is the evidence (the original payment carries the receipt).", annotations( read_only_hint = false, destructive_hint = true, @@ -4646,6 +4662,23 @@ impl BooksServer { due.extend(months.into_iter().map(|(d, a)| (d, s.id, a))); } due.sort(); + // A month dated in a closed period can't post, nor one whose + // posting was reversed after the month ended (it would count twice + // until then): left for a human, and said, never a failed run. + let (held, due): (Vec<_>, Vec<_>) = due + .into_iter() + .partition(|(d, id, _)| store.ledger().schedule_month_hold(*id, *d).is_some()); + let skipped: Vec = held + .into_iter() + .map(|(d, id, _)| ScheduleSkippedOut { + schedule_id: id, + date: d.to_string(), + reason: store + .ledger() + .schedule_month_hold(id, d) + .unwrap_or_default(), + }) + .collect(); let describe = |store: &Store, id: u64, date: NaiveDate| -> Result { Ok(store .ledger() @@ -4669,6 +4702,7 @@ impl BooksServer { return Ok(Json(RunSchedulesOut { status: "preview".to_string(), postings, + skipped, })); } let mut postings = Vec::new(); @@ -4688,6 +4722,7 @@ impl BooksServer { Ok(Json(RunSchedulesOut { status: "posted".to_string(), postings, + skipped, })) } @@ -6112,7 +6147,7 @@ impl BooksServer { #[tool( name = "books.asset_schedule", title = "Depreciation schedule", - description = "Month-by-month straight-line depreciation schedule for an asset: period, amount, accumulated, book value. Partial first and last months are prorated by days; the lifetime total is exactly cost minus salvage.", + description = "Month-by-month straight-line depreciation schedule for an asset: period, amount, accumulated, book value. Depreciation accrues by the day: a partial first month, the life's final month, and a month the schedule stops in (through a date, or a disposed asset's disposal day, which is held) are prorated by days; the lifetime total is exactly cost minus salvage.", annotations(read_only_hint = true, open_world_hint = false) )] async fn asset_schedule( @@ -6134,6 +6169,7 @@ impl BooksServer { None => NaiveDate::MAX, }; let schedule = depreciation_schedule(&asset, through) + .map_err(|e| format!("asset {asset_id}'s schedule: {e}"))? .into_iter() .map(|l| ScheduleLineOut { period: l.period, @@ -6159,7 +6195,7 @@ impl BooksServer { #[tool( name = "books.list_assets", title = "List fixed assets", - description = "List all registered fixed assets with accumulated depreciation and book value as of today.", + 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.", annotations(read_only_hint = true, open_world_hint = false) )] async fn list_assets(&self) -> Result, String> { @@ -6168,22 +6204,33 @@ impl BooksServer { let assets = store .ledger() .registered_assets() - .map(|a| AssetSummary { - asset_id: a.id, - tag: a.tag.clone(), - serial: a.serial.clone(), - acquisition_entry: a.acquired_by, - tax: tax_out(a), - description: a.description.clone(), - purchase_date: a.purchase_date.to_string(), - cost: money(a.cost.amount), - useful_life_months: a.useful_life_months, - status: match a.status { - AssetStatus::Active => "active".to_string(), - AssetStatus::Disposed { .. } => "disposed".to_string(), - }, - accumulated: money(accumulated_depreciation(a, today)), - book_value: money(book_value(a, today)), + .map(|a| { + // One asset whose schedule can't be computed (an earlier + // version's) lists with why, never failing the whole list. + let figures = accumulated_depreciation(a, today) + .and_then(|acc| Ok((acc, book_value(a, today)?))); + let (accumulated, book_value, schedule_error) = match figures { + Ok((acc, bv)) => (Some(money(acc)), Some(money(bv)), None), + Err(e) => (None, None, Some(e.to_string())), + }; + AssetSummary { + asset_id: a.id, + tag: a.tag.clone(), + serial: a.serial.clone(), + acquisition_entry: a.acquired_by, + tax: tax_out(a), + description: a.description.clone(), + purchase_date: a.purchase_date.to_string(), + cost: money(a.cost.amount), + useful_life_months: a.useful_life_months, + status: match a.status { + AssetStatus::Active => "active".to_string(), + AssetStatus::Disposed { .. } => "disposed".to_string(), + }, + accumulated, + book_value, + schedule_error, + } }) .collect(); Ok(Json(ListAssetsOut { assets })) @@ -6192,7 +6239,7 @@ impl BooksServer { #[tool( name = "books.run_depreciation", title = "Post depreciation", - description = "Post one month of depreciation (Dr Depreciation Expense / Cr Accumulated Depreciation) for one asset, or for every active asset when asset_id is omitted. Idempotent: re-running a posted period returns the original entry instead of double-posting. Depreciation is an allocation, not money movement, so it is exempt from the receipt policy; the registered asset and its schedule are the evidence.", + description = "Post one month of depreciation (Dr Depreciation Expense / Cr Accumulated Depreciation) for one asset, or for every active asset when asset_id is omitted: what the schedule says is due through the month's end, so a month skipped (or reversed) is caught up in the month run (caught_up says how much; a material miss is a restatement for the CPA, ASC 250). Idempotent: re-running a posted period returns the original entry. Nothing due (rounds to nothing, already caught up, life over) is listed under skipped. Exempt from the receipt policy: the asset and its schedule are the evidence.", annotations( read_only_hint = false, destructive_hint = true, @@ -6226,35 +6273,49 @@ impl BooksServer { let mut runs = Vec::with_capacity(targets.len()); let mut skipped = Vec::new(); for (id, description) in targets { - // Run-all skips assets whose depreciable life doesn't touch the - // period instead of failing the whole batch. + let skip = |reason: String| SkippedDepreciation { + asset_id: id, + description: description.clone(), + reason, + }; + // Run-all skips assets not yet in service, and any whose + // schedule can't be computed (an earlier version's), instead of + // failing the whole batch. if p.asset_id.is_none() && let Some(asset) = store.ledger().get_asset(id) + && asset.purchase_date > period_end { - let touches = depreciation_schedule(asset, period_end) - .iter() - .any(|l| l.period == p.period); - if !touches { - skipped.push(SkippedDepreciation { - asset_id: id, - description, - reason: format!( - "period {} is outside the asset's depreciable life", - p.period - ), - }); + skipped.push(skip(format!( + "placed in service {}, after {}", + asset.purchase_date, p.period + ))); + continue; + } + let r = match store.run_depreciation(id, &p.period) { + Ok(r) => r, + Err(ledger_store::StoreError::Ledger( + e @ (ledger_core::LedgerError::TooLargeToCompute(_) + | ledger_core::LedgerError::DateOutOfRange(_)), + )) if p.asset_id.is_none() => { + skipped.push(skip(format!("its schedule can't be computed: {e}"))); continue; } - } - let r = store - .run_depreciation(id, &p.period) - .map_err(|e| e.to_string())?; + Err(e) => return Err(e.to_string()), + }; + let Some(entry_id) = r.entry_id else { + skipped + .push(skip(r.nothing_due.unwrap_or_else(|| { + format!("nothing is due for {}", p.period) + }))); + continue; + }; runs.push(DepreciationRunOut { asset_id: r.asset_id, description, period: r.period, amount: money(r.amount), - entry_id: r.entry_id, + caught_up: money((r.amount - r.scheduled).max(Decimal::ZERO)), + entry_id, already_posted: r.already_posted, }); } @@ -6264,7 +6325,7 @@ impl BooksServer { #[tool( name = "books.dispose_asset", title = "Dispose of a fixed asset", - description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids. Name the buyer (buyer: a party id or name): it is the entry's party, and [books] parties = \"require\" refuses a sale without one; a retirement with no proceeds and no buyer is marked as having no counterparty.", + description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview (book value, gain/loss, the depreciation through the disposal day, which is held, and the disposal entry's lines) and posts nothing. With confirmed=true posts it after the receipt policy: file the bill of sale with books.file_document and pass its ids as receipt_attachment_ids. Depreciation a run posted past the disposal day is credited back (excess_depreciation); a later month's run is refused (reverse it first). Name the buyer (a party id or name): [books] parties = \"require\" refuses a sale without one.", annotations( read_only_hint = false, destructive_hint = true, @@ -6317,18 +6378,22 @@ impl BooksServer { .map(|a| a.code.clone()) .ok_or_else(|| format!("unknown account {id}")) }; + let adjusts = + !(preview.excess_depreciation.is_zero() && preview.catch_up_depreciation.is_zero()); let codes: Vec = [ asset.accum_depr_account, proceeds_account, asset.cost_account, gain_loss_account, ] - .iter() - .map(|id| code_of(*id)) + .into_iter() + .chain(adjusts.then_some(asset.expense_account)) + .map(code_of) .collect::>()?; let code_refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect(); let debits = preview.accumulated + proceeds.amount + + preview.catch_up_depreciation + if preview.gain_loss < Decimal::ZERO { -preview.gain_loss } else { @@ -6348,6 +6413,8 @@ impl BooksServer { proceeds: money(proceeds.amount), gain_loss: money(preview.gain_loss), final_depreciation: money(preview.final_depreciation), + excess_depreciation: money(preview.excess_depreciation), + catch_up_depreciation: money(preview.catch_up_depreciation), lines: preview_lines_for(&store, &raw)?, receipt_required, }; diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index ef5fdf0..1acdc63 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -115,7 +115,8 @@ pub struct CreateScheduleParams { pub description: String, /// Amount to spread, decimal string. pub total: String, - /// Any date in the first month covered, YYYY-MM-DD. + /// The first day covered, YYYY-MM-DD: any day for daily; the 1st of a + /// month for monthly_even. pub start: String, /// Months covered. pub months: u32, @@ -123,6 +124,13 @@ pub struct CreateScheduleParams { pub balance_account: String, /// Expense or revenue account code. pub pl_account: String, + /// "daily" (by the day from start, like depreciation: a mid-month + /// start posts a small first and last month, one more month end than + /// its months) or "monthly_even" (equal months, starting on the 1st). + /// Default: the books' `[books] schedule_convention` setting, else + /// daily. + #[serde(default)] + pub convention: Option, } /// One month of a schedule. @@ -151,6 +159,17 @@ pub struct ScheduleOut { pub pl_account: String, /// Total, decimal string. pub total: String, + /// "daily" or "monthly_even". + pub convention: String, + /// The first day covered, YYYY-MM-DD (for a monthly_even schedule an + /// earlier version took mid-month, the 1st of its month). + pub start: String, + /// The last day covered, YYYY-MM-DD; null past the last date the books + /// can compute. + pub end: Option, + /// The next month end to post, YYYY-MM-DD; null once every month has + /// posted (or when the plan can't be computed: see `plan_error`). + pub next: Option, /// Not yet recognized, decimal string. Null when the plan can't be /// computed (see `plan_error`). pub remaining: Option, @@ -194,6 +213,17 @@ pub struct SchedulePostingOut { pub entry_id: Option, } +/// A schedule month a run leaves unposted. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ScheduleSkippedOut { + /// Schedule id. + pub schedule_id: u64, + /// Month-end date. + pub date: String, + /// Why it can't post. + pub reason: String, +} + /// Result of `books.run_schedules`. #[derive(Debug, Serialize, JsonSchema)] pub struct RunSchedulesOut { @@ -201,6 +231,11 @@ pub struct RunSchedulesOut { pub status: String, /// Postings due (preview) or made. pub postings: Vec, + /// Months due that a run leaves for a human: dated in a closed period, + /// or posted and then reversed after the month ended (posting it again + /// would recognize it twice); a human books each in an open period by + /// hand, or not at all. + pub skipped: Vec, } /// One line of a recurring entry. @@ -2404,10 +2439,17 @@ pub struct AssetSummary { pub useful_life_months: u32, /// "active" or "disposed". pub status: String, - /// Accumulated depreciation through today, decimal string. - pub accumulated: String, - /// Book value today, decimal string. - pub book_value: String, + /// Accumulated depreciation by its schedule through today (a disposed + /// asset's through its disposal day), decimal string. Null when the + /// schedule can't be computed (see `schedule_error`). + pub accumulated: Option, + /// Book value today (cost less `accumulated`), decimal string. Null + /// when the schedule can't be computed. + pub book_value: Option, + /// Why `accumulated` and `book_value` are null, when they are: an + /// asset an earlier version took whose schedule the books can't + /// compute. It lists, and the rest of the register with it. + pub schedule_error: Option, } /// Result of `books.list_assets`. @@ -2435,8 +2477,13 @@ pub struct DepreciationRunOut { pub description: String, /// Calendar month posted. pub period: String, - /// Amount posted, decimal string. + /// Amount posted, decimal string: the month's own depreciation plus + /// any earlier month caught up. pub amount: String, + /// What the run caught up from earlier months (skipped, or reversed), + /// decimal string: the amount less the month's own depreciation, when + /// more ("0.00" otherwise). + pub caught_up: String, /// The posted entry's id. pub entry_id: u64, /// True when this period was already posted (idempotent no-op). @@ -2448,8 +2495,11 @@ pub struct DepreciationRunOut { pub struct RunDepreciationOut { /// One result per asset run. pub runs: Vec, - /// Assets skipped by a run-all because the period is outside their - /// depreciable life. + /// Assets with nothing posted: a run-all's assets not yet in service, + /// and any asset with nothing due (the month rounds to nothing, a later + /// run caught it up or its run was reversed after it ended, its life is + /// over and fully posted, or the books carry more than its schedule), + /// each with why. pub skipped: Vec, } @@ -2511,9 +2561,18 @@ pub struct DisposeAssetOut { pub proceeds: String, /// Gain (positive) or loss (negative), decimal string. pub gain_loss: String, - /// Depreciation auto-posted for the final partial period, decimal string - /// ("0.00" when the period was already posted). + /// Depreciation auto-posted (its own entry) through the disposal day, + /// decimal string ("0.00" when the runs already reach it). pub final_depreciation: String, + /// Depreciation the runs posted past the disposal day (the disposal + /// month's run is dated its end), taken back by a credit to + /// depreciation expense in the disposal entry, decimal string. + pub excess_depreciation: String, + /// Depreciation still due through the disposal day when the disposal + /// month already has a run (an earlier run reversed and not run + /// again), debited to depreciation expense in the disposal entry, + /// decimal string. + pub catch_up_depreciation: String, /// The disposal entry's lines with resulting balances. pub lines: Vec, /// True when posting requires a receipt (bill of sale) first. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index a389ddb..ca589e2 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -1984,19 +1984,22 @@ fn mcp_schedules_and_recurring_over_stdio() { ); } - // 1,000.00 of insurance over 3 months: 333.33, 333.33, 333.34. + // 1,000.00 of insurance over 3 equal months: 333.33, 333.33, 333.34 + // (an equal-months schedule starts on the 1st, D-37). let s = mcp.call( "books.create_schedule", json!({ "kind": "prepaid_expense", "description": "Liability insurance", "total": "1000.00", - "start": "2026-11-15", + "start": "2026-11-01", "months": 3, "balance_account": "1300", "pl_account": "6300", + "convention": "monthly_even", }), ); + assert_eq!(s["convention"], "monthly_even"); let amounts: Vec<&str> = s["months"] .as_array() .unwrap() @@ -3257,3 +3260,106 @@ fn rename_says_what_the_new_name_moves_defect_15() { ); assert!(!notes(&out).contains("Convention"), "{out}"); } + +/// Defect 9 end to end (plan/served.md D-11, D-37): a schedule created +/// without a convention spreads by the day; books.list_schedules says how, +/// the first and last day covered, the next month to post, and what +/// remains; a run posts by the day; with books.schedule_convention set to +/// monthly_even, new schedules are equal months, and one that doesn't +/// start on the 1st is refused. Hand check: 1,200.00 from 2026-03-15 for +/// 12 months: March 100 × 17/31 = 54.84, then 100.00 a month, the last +/// March 45.16; through April 30, 154.84 posted, 1,045.16 left. +#[test] +fn mcp_daily_schedule_over_stdio() { + let dir = test_dir("daily-schedule"); + let mut mcp = Mcp::spawn(&dir); + init(&mut mcp); + for (code, name, typ) in [ + ("1300", "Prepaid Expenses", "asset"), + ("6300", "Insurance", "expense"), + ] { + mcp.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": typ}), + ); + } + let create = |start: &str| { + json!({ + "kind": "prepaid_expense", + "description": "Liability insurance", + "total": "1200.00", + "start": start, + "months": 12, + "balance_account": "1300", + "pl_account": "6300", + }) + }; + let s = mcp.call("books.create_schedule", create("2026-03-15")); + assert_eq!(s["convention"], "daily"); + assert_eq!(s["start"], "2026-03-15"); + assert_eq!(s["end"], "2027-03-14"); + assert_eq!(s["next"], "2026-03-31"); + assert_eq!(s["remaining"], "1200.00"); + let months = s["months"].as_array().unwrap(); + assert_eq!(months.len(), 13); + assert_eq!(months[0]["amount"], "54.84"); + assert_eq!(months[1]["amount"], "100.00"); + assert_eq!(months[12]["date"], "2027-03-31"); + assert_eq!(months[12]["amount"], "45.16"); + + let preview = mcp.call("books.run_schedules", json!({"through": "2026-04-30"})); + assert_eq!(preview["status"], "preview"); + let amounts: Vec<&str> = preview["postings"] + .as_array() + .unwrap() + .iter() + .map(|p| p["amount"].as_str().unwrap()) + .collect(); + assert_eq!(amounts, ["54.84", "100.00"]); + assert_eq!(preview["skipped"], json!([])); + let run = mcp.call( + "books.run_schedules", + json!({"through": "2026-04-30", "confirmed": true}), + ); + assert_eq!(run["postings"].as_array().unwrap().len(), 2); + let list = mcp.call("books.list_schedules", json!({})); + let one = &list["schedules"][0]; + assert_eq!(one["convention"], "daily"); + assert_eq!(one["next"], "2026-05-31"); + assert_eq!(one["remaining"], "1045.16"); + + // The books take their settings in (there was no file: the defaults), + // then new schedules are set to equal months. + let done = mcp.call( + "books.import_settings", + json!({"toml_text": "", "decided_by": "Owner", "confirmed": true}), + ); + assert_eq!(done["status"], "imported"); + mcp.call( + "books.set_setting", + json!({"changes": [{"key": "books.schedule_convention", "value": "monthly_even"}], + "decided_by": "Owner", "decided_on": "2026-10-20"}), + ); + let err = mcp.call_err("books.create_schedule", create("2026-03-15")); + assert!(err.contains("2026-03-01"), "{err}"); + let even = mcp.call("books.create_schedule", create("2026-03-01")); + assert_eq!(even["convention"], "monthly_even"); + assert_eq!(even["months"].as_array().unwrap().len(), 12); + assert_eq!(even["end"], "2027-02-28"); + // A convention asked for wins over the setting; an unknown one is + // refused, naming the two. + let daily = mcp.call( + "books.create_schedule", + json!({"kind": "prepaid_expense", "description": "x", "total": "10.00", + "start": "2026-05-20", "months": 1, "balance_account": "1300", + "pl_account": "6300", "convention": "daily"}), + ); + assert_eq!(daily["convention"], "daily"); + let err = mcp.call_err( + "books.create_schedule", + json!({"kind": "prepaid_expense", "description": "x", "total": "10.00", + "start": "2026-05-01", "months": 1, "balance_account": "1300", + "pl_account": "6300", "convention": "weekly"}), + ); + assert!(err.contains("daily or monthly_even"), "{err}"); +} diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index 39f7667..01367f0 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -2012,11 +2012,19 @@ fn dispose_asset_previews_equal_the_store_and_gate_receipts_on_debits() { ); } -/// Run-all depreciation skips an asset whose life doesn't touch the period -/// and posts the others by their schedule. Kills: run_depreciation's -/// period match. +/// Run-all depreciation posts each asset what its schedule says is due +/// through the month's end (plan/served.md P3, settled in design: a month +/// skipped is caught up in the month the run is made), and skips an asset +/// not yet in service, or one with nothing due. Hand check: "Short-lived" +/// is 600.00 over 2 months from 2025-01-01 (life Jan-Feb 2025), never run; +/// "Long-lived" 600.00 over 12 months from 2025-05-01, 50.00 a month, May +/// never run. A June run posts the first's whole 600.00 (all caught up) +/// and the second's May and June, 100.00 (50.00 caught up). An April run +/// then skips both: the second isn't in service yet, and the first's life +/// is over with June's run carrying all of it. Kills: run_depreciation's +/// catch-up and its skips. #[test] -fn run_depreciation_skips_assets_outside_their_life() { +fn run_depreciation_catches_up_and_skips_what_has_nothing_due() { let dir = test_dir("depr"); let mut s = new_store(&dir); let reg = |s: &mut Store, desc: &str, date: &str, months: u32| { @@ -2034,23 +2042,37 @@ fn run_depreciation_skips_assets_outside_their_life() { }; let old = reg(&mut s, "Short-lived", "2025-01-01", 2); let new = reg(&mut s, "Long-lived", "2025-05-01", 12); - let sched = depreciation_schedule(s.ledger().get_asset(new).unwrap(), d("2025-06-30")); + let sched = depreciation_schedule(s.ledger().get_asset(new).unwrap(), d("2025-06-30")).unwrap(); let june = sched.iter().find(|l| l.period == "2025-06").unwrap().amount; - assert!( - !depreciation_schedule(s.ledger().get_asset(old).unwrap(), d("2025-06-30")) - .iter() - .any(|l| l.period == "2025-06") - ); + assert_eq!(june, dec("50.00")); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok("books.run_depreciation", json!({"period": "2025-06"})); let runs = out["runs"].as_array().unwrap(); - assert_eq!(runs.len(), 1, "{out}"); - assert_eq!(runs[0]["asset_id"], json!(new)); - assert_eq!(jamt(&runs[0]["amount"]), june); + assert_eq!(runs.len(), 2, "{out}"); + let run = |id: u64| { + runs.iter() + .find(|r| r["asset_id"] == json!(id)) + .unwrap_or_else(|| panic!("{id} in {out}")) + .clone() + }; + assert_eq!(jamt(&run(old)["amount"]), dec("600.00")); + assert_eq!(jamt(&run(old)["caught_up"]), dec("600.00")); + assert_eq!(jamt(&run(new)["amount"]), dec("100.00")); + assert_eq!(jamt(&run(new)["caught_up"]), dec("50.00")); + assert_eq!(out["skipped"], json!([])); + let out = m.ok("books.run_depreciation", json!({"period": "2025-04"})); + assert_eq!(out["runs"], json!([]), "{out}"); let skipped = out["skipped"].as_array().unwrap(); - assert_eq!(skipped.len(), 1); - assert_eq!(skipped[0]["asset_id"], json!(old)); + assert_eq!(skipped.len(), 2, "{out}"); + let why = |id: u64| { + skipped.iter().find(|r| r["asset_id"] == json!(id)).unwrap()["reason"] + .as_str() + .unwrap() + .to_string() + }; + assert!(why(new).contains("placed in service 2025-05-01"), "{out}"); + assert!(why(old).contains("life is over"), "{out}"); } // ------------------------------------------------- payroll diff --git a/crates/ledger-mcp/tests/spec_assets.rs b/crates/ledger-mcp/tests/spec_assets.rs index b73f17e..55a8c8d 100644 --- a/crates/ledger-mcp/tests/spec_assets.rs +++ b/crates/ledger-mcp/tests/spec_assets.rs @@ -1246,3 +1246,190 @@ fn export_fixed_asset_register_carries_tax_basis() { let total = csv_row("TOTAL"); assert_eq!(canon(&total[c_tax]), "3050.25"); } + +// ------------------------------------- P3: defect 22 and runs, over MCP + +/// Books holding asset X of the P3 depreciation brief (12,000.00, no +/// salvage, 12 months from 2026-01-01: 1,000.00 a month), paid for. +fn asset_x(name: &str) -> (Mcp, u64) { + let mut m = books(name); + m.post( + "2026-01-01", + &[ + ("1000", "debit", "20000.00"), + ("3000", "credit", "20000.00"), + ], + ); + m.post( + "2026-01-01", + &[ + ("1500", "debit", "12000.00"), + ("1000", "credit", "12000.00"), + ], + ); + let (x, _) = m.register_ok(json!({"cost": "12000.00"})); + (m, x) +} + +fn dispose_args(x: u64, date: &str, proceeds: &str) -> Value { + json!({"asset_id": x, "date": date, "proceeds": proceeds, + "proceeds_account": "1000", "gain_loss_account": "7100"}) +} + +/// Defect 22, reproduction B over MCP: January through May run, then a +/// disposal on 2026-06-15 for 7,000.00. Hand check: June held 15 of 30 +/// days, 500.00 (the final depreciation); accumulated 5,500.00, book value +/// 6,500.00, gain 500.00. Afterwards books.asset_schedule ends there and +/// books.list_assets says the same. Before P3 the schedule showed June +/// 7,000.00, accumulated 12,000.00, book value 0.00. +#[test] +fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() { + let (mut m, x) = asset_x("disposed-figures"); + for p in ["2026-01", "2026-02", "2026-03", "2026-04", "2026-05"] { + m.ok( + "books.run_depreciation", + json!({"period": p, "asset_id": x}), + ); + } + let preview = m.ok( + "books.dispose_asset", + dispose_args(x, "2026-06-15", "7000.00"), + ); + assert_eq!(the_value(&preview, "final_depreciation"), "500"); + assert_eq!(the_value(&preview, "excess_depreciation"), "0"); + assert_eq!(the_value(&preview, "book_value"), "6500"); + assert_eq!(the_value(&preview, "gain_loss"), "500"); + let doc = m.file_bill_of_sale(); + let mut confirm = dispose_args(x, "2026-06-15", "7000.00"); + confirm["confirmed"] = json!(true); + confirm["receipt_attachment_ids"] = json!([doc]); + m.ok("books.dispose_asset", confirm); + let s = m.read_only("books.asset_schedule", json!({"asset_id": x})); + assert_eq!(s["status"], "disposed"); + let lines = s["schedule"].as_array().unwrap(); + let jun = lines.last().unwrap(); + assert_eq!(jun["period"], "2026-06"); + assert_eq!(canon(jun["amount"].as_str().unwrap()), "500"); + assert_eq!(canon(jun["accumulated"].as_str().unwrap()), "5500"); + assert_eq!(canon(jun["book_value"].as_str().unwrap()), "6500"); + let listed = m.asset(x); + assert_eq!(canon(listed["accumulated"].as_str().unwrap()), "5500"); + assert_eq!(canon(listed["book_value"].as_str().unwrap()), "6500"); + assert_eq!(listed["schedule_error"], Value::Null); +} + +/// P3 depreciation review R2 over MCP: with June already run (dated Jun +/// 30), a disposal on Jun 15 for 7,000.00 credits back June's last 15 +/// days. Hand check: the runs carry 6,000.00; the schedule through Jun 15 +/// is 5,500.00, so 500.00 is excess; book value 6,500.00, gain 500.00. The +/// entry: Dr 1510 6,000.00, Dr 1000 7,000.00 / Cr 1500 12,000.00, Cr 6500 +/// 500.00, Cr 7100 500.00. +#[test] +fn a_disposal_inside_a_posted_month_takes_back_the_extra_days() { + let (mut m, x) = asset_x("disposed-inside"); + for p in 1..=6 { + m.ok( + "books.run_depreciation", + json!({"period": format!("2026-{p:02}"), "asset_id": x}), + ); + } + let preview = m.ok( + "books.dispose_asset", + dispose_args(x, "2026-06-15", "7000.00"), + ); + assert_eq!(the_value(&preview, "final_depreciation"), "0"); + assert_eq!(the_value(&preview, "excess_depreciation"), "500"); + assert_eq!(the_value(&preview, "catch_up_depreciation"), "0"); + assert_eq!(the_value(&preview, "accumulated"), "6000"); + assert_eq!(the_value(&preview, "book_value"), "6500"); + assert_eq!(the_value(&preview, "gain_loss"), "500"); + let line = |code: &str| { + preview["lines"] + .as_array() + .unwrap() + .iter() + .find(|l| l["code"] == code) + .unwrap_or_else(|| panic!("no {code} line in {preview}")) + .clone() + }; + assert_eq!(line("6500")["side"], "credit"); + assert_eq!(canon(line("6500")["amount"].as_str().unwrap()), "500"); + assert_eq!(canon(line("1510")["amount"].as_str().unwrap()), "6000"); +} + +/// P3 depreciation review R10 over MCP: a run-all for a month whose +/// depreciation rounds to 0.00 posts nothing for that asset and lists it +/// under skipped, with why; journaling nothing. Hand check: 0.01 over 3 +/// months from 2026-01-01: January's cumulative a third of a cent, 0.00. +#[test] +fn run_depreciation_skips_a_month_that_rounds_to_nothing() { + let mut m = books("zero-month"); + let (a, _) = m.register_ok(json!({"cost": "0.01", "useful_life_months": 3})); + let before = m.journal(); + let r = m.ok("books.run_depreciation", json!({"period": "2026-01"})); + assert_eq!(r["runs"], json!([])); + let skipped = r["skipped"].as_array().unwrap(); + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0]["asset_id"], json!(a)); + assert!( + skipped[0]["reason"] + .as_str() + .unwrap() + .contains("rounds to nothing"), + "{r}" + ); + assert_eq!(m.journal(), before, "nothing journaled"); + let r = m.ok("books.run_depreciation", json!({"period": "2026-02"})); + assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "0.01"); +} + +/// The catch-up rule over MCP (plan/served.md P3, settled in design): +/// January run, February skipped, a March run posts 2,000.00, of which +/// 1,000.00 is caught up; a run reversed within its month is due again +/// (reversed after it, the next run catches it up: the store's +/// a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month), and +/// a disposal entry can't be reversed. +#[test] +fn a_skipped_month_is_caught_up_and_said() { + let (mut m, x) = asset_x("caught-up"); + m.ok( + "books.run_depreciation", + json!({"period": "2026-01", "asset_id": x}), + ); + let r = m.ok( + "books.run_depreciation", + json!({"period": "2026-03", "asset_id": x}), + ); + assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "2000"); + assert_eq!(canon(r["runs"][0]["caught_up"].as_str().unwrap()), "1000"); + let jan_entry = { + let again = m.ok( + "books.run_depreciation", + json!({"period": "2026-01", "asset_id": x}), + ); + assert_eq!(again["runs"][0]["already_posted"], json!(true)); + again["runs"][0]["entry_id"].as_u64().unwrap() + }; + m.ok( + "books.reverse_entry", + json!({"entry_id": jan_entry, "date": "2026-01-31", "description": "undo", + "confirmed": true}), + ); + let r = m.ok( + "books.run_depreciation", + json!({"period": "2026-01", "asset_id": x}), + ); + assert_eq!(r["runs"][0]["already_posted"], json!(false)); + assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "1000"); + let doc = m.file_bill_of_sale(); + let mut confirm = dispose_args(x, "2026-03-31", "9000.00"); + confirm["confirmed"] = json!(true); + confirm["receipt_attachment_ids"] = json!([doc]); + let disposed = m.ok("books.dispose_asset", confirm); + let entry = disposed["entry_id"].as_u64().unwrap(); + let err = m.refuse( + "books.reverse_entry", + json!({"entry_id": entry, "date": "2026-04-02", "description": "undo"}), + ); + assert!(err.contains("can't be reversed"), "{err}"); +} diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 02c55f8..6392315 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -47,6 +47,8 @@ pub const KNOWN_FEATURES: &[&str] = &[ PARTY_CREDITORS, PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS, + MONTH_REPOSTING, + SCHEDULE_CONVENTION, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -88,6 +90,17 @@ pub const PAYMENT_INSTRUMENTS: &str = "payment_instruments"; /// (`vendor.w9_flag_set`). pub const VENDOR_1099_DECISIONS: &str = "vendor_1099_decisions"; +/// Feature: a schedule month or a depreciation period posted again after a +/// reversal un-posted it. An older version would refuse the second +/// `schedule.posted` as posted twice, and its projection would ignore the +/// second `asset.depreciation_posted` (P3 depreciation review R3). +pub const MONTH_REPOSTING: &str = "month_reposting"; + +/// Feature: `schedule.registered` carries `convention` (`daily` or +/// `monthly_even`). An older version would read a daily schedule as equal +/// months (P3 defect 9, plan/served.md D-11). +pub const SCHEDULE_CONVENTION: &str = "schedule_convention"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -525,6 +538,38 @@ mod tests { } } + /// P3's features (schedule_convention, month_reposting): this version + /// reads them; a version before them (KNOWN_FEATURES empty, as through + /// P2) stops at their declaration with NewerJournal, never misreading a + /// daily schedule as equal months or a second run of a month as none. + #[test] + fn an_older_reader_stops_at_p3s_features() { + for f in [SCHEDULE_CONVENTION, MONTH_REPOSTING] { + assert!(KNOWN_FEATURES.contains(&f)); + let l = line(FORMAT_SET, json!({"format": 2, "features": [f]})); + assert_eq!( + declared(&l).unwrap(), + JournalFormat { + format: 2, + features: vec![f.to_string()] + } + ); + match declared_knowing(&l, &[]) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, format!("journal features {f}")) + } + other => panic!("expected NewerJournal, got {other:?}"), + } + } + // Declared together, sorted. + assert_eq!( + JournalFormat::default() + .declaration(2, &[SCHEDULE_CONVENTION, MONTH_REPOSTING]) + .unwrap(), + json!({"format": 2, "features": ["month_reposting", "schedule_convention"]}) + ); + } + /// A format newer than this version reads is newer whatever shape the /// rest of its declaration takes, and however large its number: a /// newer format may define its features differently, so they are diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index b5fcde8..4fbdedb 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -37,7 +37,7 @@ use crate::journal::{ GENESIS_PREV, JournalLine, JournalWriter, read_and_verify_journal, sha256_hex, }; use crate::schema; -use chrono::{Months, NaiveDate}; +use chrono::{Datelike, Months, NaiveDate}; use ledger_core::{ AccountId, AccountType, AssetId, AssetStatus, BillId, Class, ClassId, CloseTo, Currency, CustomerId, DocumentLine, EntryId, EntryStatus, InvoiceId, Ledger, LedgerError, Money, @@ -122,19 +122,30 @@ pub struct EventRow { pub created_at: String, } -/// The outcome of posting one month of depreciation for one asset. +/// The outcome of one month's depreciation run for one asset. #[derive(Clone, Debug)] pub struct DepreciationRun { /// Asset id. pub asset_id: u64, /// Calendar month, "YYYY-MM". pub period: String, - /// Amount posted, exact cents. + /// Amount posted, exact to the minor unit: the month's own + /// depreciation and any month before it caught up + /// (ledger_core::run_amount). Zero when nothing was due. pub amount: Decimal, - /// The posted entry's id. - pub entry_id: u64, + /// The month's own scheduled depreciation (zero after the life). + pub scheduled: Decimal, + /// The posted entry's id; `None` when nothing was due, so nothing + /// posted. + pub entry_id: Option, /// True when this period was already posted (idempotent no-op). pub already_posted: bool, + /// Why nothing posted, when nothing did and the period wasn't already + /// posted: the month rounds to nothing, a later run caught it up, its + /// run was reversed after the month ended (the reversal's month catches + /// it up), the life is over and fully depreciated, or the books carry + /// more than the schedule (a correction for a human). + pub nothing_due: Option, } /// The outcome of disposing an asset, computed without posting anything. @@ -147,13 +158,25 @@ pub struct DisposalPreview { pub date: chrono::NaiveDate, /// Proceeds. pub proceeds: Money, - /// Accumulated depreciation that will be cleared: already posted plus - /// the final partial period. + /// Accumulated depreciation the disposal entry clears: every run the + /// books carry for the asset, plus the final depreciation. pub accumulated: Decimal, - /// Depreciation auto-posted for the final partial period. Zero when the - /// disposal month was already posted. + /// Depreciation auto-posted (its own entry) for the months the runs + /// haven't reached, through the disposal day. Zero when the runs + /// already reach it. pub final_depreciation: Decimal, - /// Book value at disposal. + /// Depreciation the runs posted beyond the disposal day (the disposal + /// month's run, dated its end; a run of a later month is refused, to be + /// reversed first), taken back by a credit to depreciation expense in + /// the disposal entry. + pub excess_depreciation: Decimal, + /// Depreciation still due through the disposal day when the disposal + /// month already has a run (an earlier month's run reversed and not + /// posted again), charged by a debit to depreciation expense in the + /// disposal entry: the month's run can't be posted twice. + pub catch_up_depreciation: Decimal, + /// Book value at disposal: cost less the schedule through the disposal + /// day, whatever the runs posted. pub book_value: Decimal, /// proceeds − book_value. Positive is a gain. pub gain_loss: Decimal, @@ -161,6 +184,18 @@ pub struct DisposalPreview { pub period: String, } +/// A depreciation run the books carry for an asset: its row in the +/// projection, its entry in effect (not reversed). +#[derive(Clone, Debug, PartialEq, Eq)] +struct LiveRun { + /// Its month, "YYYY-MM" as journaled. + period: String, + /// The entry that posted it. + entry_id: EntryId, + /// What it posted. + amount: Decimal, +} + /// Attachment metadata (bytes live in the blob store). #[derive(Clone, Debug)] pub struct Attachment { @@ -215,6 +250,10 @@ pub struct Store { base_currency: Currency, /// The journal format in force (the format gate's record). format: JournalFormat, + /// Refuse every journal feature not yet in force + /// ([`Self::refuse_new_features`]): a stand-in for a format-upgrade + /// gate that refuses until the owner consents. + refuse_features: bool, } /// One filing of bytes into the blob store and journal. @@ -268,6 +307,7 @@ impl Store { ledger: Ledger::new(base_currency), base_currency, format: JournalFormat::default(), + refuse_features: false, }; let line = store.journal.append( "store.created", @@ -393,6 +433,7 @@ impl Store { ledger, base_currency, format, + refuse_features: false, }; store.catch_up(&lines)?; if let Some(t) = torn { @@ -622,11 +663,26 @@ impl Store { if features.len() == before { return Ok(()); } + if self.refuse_features { + return Err(StoreError::FormatRefused(format!( + "journal features {:?}: these books take no new feature now", + &features[before..] + ))); + } let format = self.format.format.max(crate::meta::FORMAT); let features: Vec<&str> = features.iter().map(String::as_str).collect(); self.declare_journal_format(format, &features) } + /// Refuse (`refuse: true`) or take again every journal feature not yet + /// in force, as a format-upgrade gate does that waits for the owner's + /// consent: what a command does when [`Self::ensure_feature`] refuses + /// (it journals nothing). Not saved: the books open taking features. + #[doc(hidden)] + pub fn refuse_new_features(&mut self, refuse: bool) { + self.refuse_features = refuse; + } + /// Change settings (`settings.set`): all or nothing, each value /// canonical, the result whole (ledger_core::settings). Returns the /// changes made; none (journaling nothing) when every change is the @@ -1113,7 +1169,9 @@ impl Store { Ok(()) } - /// Post a reversing entry for a posted entry. + /// Post a reversing entry for a posted entry. A reversed depreciation + /// run or schedule month is un-posted (due again; P3 depreciation review + /// R3); see [`Self::check_reverse_entry`] for what is refused. pub fn reverse_entry( &mut self, entry: EntryId, @@ -1127,7 +1185,7 @@ impl Store { .clone(); // Every refusal before the journal: a journaled reversal the engine // then refuses could never be replayed, and the books would not open. - self.ledger.check_reversal(entry, date)?; + self.check_reverse_entry(entry, date)?; let new_id = self.ledger.next_entry_id(); let flipped: Vec = orig.lines().iter().map(flip_line_json).collect(); // Reversals journal a single entry.reversed line (no separate @@ -1149,6 +1207,149 @@ impl Store { Ok(live) } + /// Every refusal of [`Self::reverse_entry`]: the ledger's + /// ([`Ledger::check_reversal`]) and those only the store can make, + /// since only it knows which entries a fixed asset's disposal and + /// depreciation runs are. + /// + /// ```text + /// GOAL: a reversal never leaves a fixed asset's figures and its books + /// apart. + /// SOURCE: docs/spec/rules.md B-9, B-15; P3 depreciation review R3. + /// SCENARIOS (the entry, or the original its reversal chain starts + /// from, since reversing a reversal re-instates that original): + /// 1. IF it is an asset's disposal entry: + /// - SHOULD refuse (AssetEntryIrreversible) + /// BECAUSE the register would still say disposed: un-disposing is + /// not a feature (correct with an entry by hand). + /// 2. IF it is a depreciation run of a disposed asset (the disposal's + /// final depreciation included): + /// - SHOULD refuse + /// BECAUSE the disposal cleared what the runs posted, and took them + /// as final; reversing one would leave the asset's accumulated + /// depreciation behind it. + /// 3. IF reversing re-instates a run whose period has been run again: + /// - SHOULD refuse, naming the run that posts it now + /// - NEVER carry one month's depreciation twice. + /// 4. IF reversing re-instates a run that would carry the books past + /// the schedule at a month end from the reversal's month on (a + /// later run caught the month up: ledger_core::catch_up_room): + /// - SHOULD refuse, naming the room left + /// - NEVER let accumulated depreciation pass the schedule, or cost + /// less salvage. + /// 5. IF it is a depreciation run or a schedule month's posting, or a + /// reversal in the chain of one, and `date` is before the entry's + /// own date: + /// - SHOULD refuse + /// - NEVER carry a correction at month ends the entry it corrects + /// never reached (a run of March reversed on Jan 31 would leave + /// January and February with negative depreciation, and a + /// disposal in February clearing what they don't carry) + /// BECAUSE a correction comes after what it corrects (B-9, Pacioli + /// ch. 31: "after you have made this correction"). Commands only: + /// a journal an earlier version wrote replays as it was (B-28). + /// 6. Otherwise: a run of an active asset is un-posted (its month is + /// due again, caught up by the next run). + /// ``` + pub fn check_reverse_entry( + &self, + entry: EntryId, + date: chrono::NaiveDate, + ) -> Result<(), StoreError> { + self.ledger.check_reversal(entry, date)?; + let top = self + .ledger + .reversal_chain(entry) + .first() + .copied() + .unwrap_or(entry); + let refuse = |why: String| Err(LedgerError::AssetEntryIrreversible(entry, why).into()); + // SCENARIO 5: the date of the entry reversed (it ends its chain). + let dated = self + .ledger + .get_entry(entry) + .map(ledger_core::JournalEntry::date); + let before_it = dated.filter(|&d| date < d); + if let (Some(on), Some((sid, month))) = (before_it, self.ledger.schedule_posting(top)) { + return Err(LedgerError::InvalidSchedule(format!( + "entry {entry} (schedule {sid}'s {month}) is dated {on}: a reversal of it can't be dated before it, on {date}" + )) + .into()); + } + let payloads = |t: &str| -> Result, StoreError> { + let mut stmt = self + .conn + .prepare("SELECT payload FROM events WHERE type = ? ORDER BY seq")?; + let rows = stmt.query_map(params![t], |r| r.get::<_, String>(0))?; + rows.map(|r| Ok(serde_json::from_str(&r?)?)).collect() + }; + let what = if top == entry { + "it is" + } else { + "reversing it re-instates" + }; + for p in payloads("asset.disposed")? { + if p.get("entry_id").and_then(Value::as_u64) == Some(top) { + let asset = p.get("asset_id").and_then(Value::as_u64).unwrap_or(0); + return refuse(format!( + "{what} the disposal of asset {asset}, and an asset can't be un-disposed (correct it with an entry by hand)" + )); + } + } + for p in payloads("asset.depreciation_posted")? { + if p.get("entry_id").and_then(Value::as_u64) != Some(top) { + continue; + } + let asset_id = get_u64(&p, "asset_id")?; + let period = get_str(&p, "period")?; + if let Some(on) = before_it { + return refuse(format!( + "it is dated {on}, and a reversal of asset {asset_id}'s depreciation for {period} can't be dated before it, on {date}" + )); + } + if self + .ledger + .get_asset(asset_id) + .is_some_and(|a| a.status != AssetStatus::Active) + { + return refuse(format!( + "{what} depreciation of asset {asset_id}, which is disposed: its disposal cleared what the runs posted (correct it with an entry by hand)" + )); + } + if self.ledger.in_effect(top) { + continue; + } + // Reversing `entry` re-instates the run, adding it back in the + // reversal's month. + if let Some((now, _)) = self.depreciation_posted_entry(asset_id, period)? + && now != top + { + return refuse(format!( + "it would post asset {asset_id}'s depreciation for {period} a second time: entry {now} posts it now (reverse that one instead)" + )); + } + let amount: Decimal = get_str(&p, "amount")? + .parse() + .map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?; + if let Some(asset) = self.ledger.get_asset(asset_id) { + let room = ledger_core::catch_up_room( + asset, + date.year(), + date.month(), + &self.depreciation_effects(asset_id)?, + )? + .amount; + if amount > room { + return refuse(format!( + "re-instating asset {asset_id}'s depreciation for {period} ({amount}) on {date} would carry more than its schedule: the books have room for {room} from {} on (a later run caught the month up; run a month again instead, which posts what is due)", + date.format("%Y-%m") + )); + } + } + } + Ok(()) + } + // ------------------------------------------------------------------ // Fixed assets // ------------------------------------------------------------------ @@ -1482,105 +1683,232 @@ impl Store { Ok(()) } - /// Was depreciation already posted for this asset and period? + /// The depreciation runs the books carry for an asset: every run + /// journaled for it ([`Self::depreciation_runs_ever`]) whose entry is in + /// effect, in journal order. A reversed run is left out, so its month is + /// due again (P3 depreciation review R3); a reversed reversal counts + /// again, even after its month was run again and that run reversed (the + /// projection's row for the month names the latest run only, so it is + /// not read here: P3 review round 2). + fn live_runs(&self, asset_id: AssetId) -> Result, StoreError> { + Ok(self + .depreciation_runs_ever(asset_id)? + .into_iter() + .filter(|(entry_id, _, _)| self.ledger.in_effect(*entry_id)) + .map(|(entry_id, amount, period)| LiveRun { + period, + entry_id, + amount, + }) + .collect()) + } + + /// The run the books carry for this asset and period, if any: (entry, + /// amount). fn depreciation_posted_entry( &self, asset_id: AssetId, period: &str, ) -> Result, StoreError> { - let row: Option<(i64, String)> = self + 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 + /// it again writes a second `asset.depreciation_posted` for it, which an + /// older version would ignore (feature `month_reposting`). + fn depreciation_period_was_run( + &self, + asset_id: AssetId, + period: &str, + ) -> Result { + Ok(self .conn .query_row( - "SELECT entry_id, amount FROM asset_depreciation WHERE asset_id = ? AND period = ?", + "SELECT 1 FROM asset_depreciation WHERE asset_id = ? AND period = ?", params![asset_id as i64, period], - |r| Ok((r.get(0)?, r.get(1)?)), + |_| Ok(()), ) - .optional()?; - row.map(|(eid, amount)| { + .optional()? + .is_some()) + } + + /// Every depreciation run journaled for an asset, reversed or not, in + /// journal order: each `asset.depreciation_posted` (its runs, and its + /// disposal's final depreciation) as (entry, amount, period). The + /// projection keeps a period's latest run only; the journal keeps each. + fn depreciation_runs_ever( + &self, + asset_id: AssetId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT payload FROM events WHERE type = 'asset.depreciation_posted' ORDER BY seq", + )?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for row in rows { + let p: Value = serde_json::from_str(&row?)?; + if get_u64(&p, "asset_id")? != asset_id { + continue; + } + let amount = get_str(&p, "amount")?; let amount = amount .parse() .map_err(|_| StoreError::JournalCorrupt(format!("bad amount {amount}")))?; - Ok((eid as u64, amount)) - }) - .transpose() - } - - /// Sum of depreciation amounts posted for an asset. 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 { - let mut stmt = self - .conn - .prepare("SELECT amount FROM asset_depreciation WHERE asset_id = ?")?; - let amounts = stmt.query_map(params![asset_id as i64], |r| r.get::<_, String>(0))?; - let mut total = Decimal::ZERO; - for a in amounts { - let s: String = a?; - let amount = s - .parse::() - .map_err(|_| StoreError::JournalCorrupt(format!("bad amount {s}")))?; - total = total.checked_add(amount).ok_or_else(|| { - StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large")) - })?; + out.push(( + get_u64(&p, "entry_id")?, + amount, + get_str(&p, "period")?.to_string(), + )); } - Ok(total) + Ok(out) } - /// Post one month of depreciation for one asset. Idempotent: running it - /// twice for the same period returns the original 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 + /// (ledger_core::depreciation_effects), as the catch-up rule reads it. + fn depreciation_effects( + &self, + asset_id: AssetId, + ) -> Result, StoreError> { + let runs: Vec<(EntryId, Decimal)> = self + .depreciation_runs_ever(asset_id)? + .into_iter() + .map(|(e, a, _)| (e, a)) + .collect(); + Ok(ledger_core::depreciation_effects(&self.ledger, &runs)) + } + + /// Sum of the depreciation the books carry for an asset (its runs in + /// effect). Summed in Rust with exact Decimal math: amounts are TEXT in + /// SQLite, so SQLite must not sum them (REAL would lose pennies). + fn asset_depreciation_total(&self, asset_id: AssetId) -> Result { + self.live_runs(asset_id)? + .iter() + .try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount)) + .ok_or_else(|| { + StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large")) + }) + } + + /// Post one month's depreciation for one asset: what is due through the + /// month's end (ledger_core::run_amount), dated then, Dr depreciation + /// expense / Cr accumulated depreciation. /// /// Depreciation is an allocation, not money movement, so it is exempt /// from the receipt policy by construction: the registered asset and its /// schedule are the supporting documentation. + /// + /// ```text + /// GOAL: the books carry the schedule through the month's end. + /// SOURCE: docs/spec/rules.md B-15; plan/served.md P3 "Settled in + /// design" (2026-10-20): a month skipped is caught up in the + /// month the run is made (an immaterial miss; a material one is + /// restated, ASC 250, by a human and the CPA). + /// INVARIANTS: + /// 1. Idempotent: a period the books carry a run for returns that run + /// (already_posted), journaling nothing. + /// 2. What posts is ledger_core::run_amount over what the books carry, + /// every run and reversal in its own month (Self::depreciation_effects): + /// earlier months skipped or reversed are caught up in this one, + /// and the books never carry more than the schedule at a month end + /// a run reached; a month whose run was reversed after it ended + /// isn't run twice (the reversal's month catches it up). + /// 3. Nothing due (the month rounds to nothing, a later run caught it + /// up, the life is over and posted) journals nothing: an entry + /// can't carry a zero line (P3 depreciation review R10). + /// 4. A period run before and reversed is run again only after + /// journal feature month_reposting is declared. + /// SCENARIOS: + /// 1. IF the asset is unknown or disposed, the period malformed, or + /// it ends before the asset was placed in service: SHOULD refuse. + /// 2. IF the period is after the life and something is still due (its + /// final month skipped, or in a closed period): SHOULD post it. + /// ``` pub fn run_depreciation( &mut self, asset_id: AssetId, period: &str, ) -> Result { - if let Some((entry_id, amount)) = self.depreciation_posted_entry(asset_id, period)? { - return Ok(DepreciationRun { - asset_id, - period: period.to_string(), - amount, - entry_id, - already_posted: true, - }); - } + let (year, month) = parse_period(period)?; let asset = self .ledger .get_asset(asset_id) .ok_or(LedgerError::UnknownAsset(asset_id))? .clone(); + let period_end = last_day_of_month(year, month)?; + let own = |a: &ledger_core::Asset| -> Result { + Ok(ledger_core::depreciation_schedule(a, period_end)? + .into_iter() + .find(|l| l.period == period) + .map_or(Decimal::ZERO, |l| l.amount)) + }; + if let Some((entry_id, amount)) = self.depreciation_posted_entry(asset_id, period)? { + return Ok(DepreciationRun { + asset_id, + period: period.to_string(), + amount, + scheduled: own(&asset)?, + entry_id: Some(entry_id), + already_posted: true, + nothing_due: None, + }); + } if asset.status != AssetStatus::Active { return Err(LedgerError::AssetAlreadyDisposed(asset_id).into()); } - let (year, month) = parse_period(period)?; - let period_end = last_day_of_month(year, month)?; - let sched = ledger_core::depreciation_schedule(&asset, period_end); - let line = sched.iter().find(|l| l.period == period).ok_or_else(|| { - StoreError::JournalCorrupt(format!( - "period {period} is outside asset {asset_id}'s depreciable life" - )) + let due = + ledger_core::run_amount(&asset, year, month, &self.depreciation_effects(asset_id)?)?; + if due.amount.is_zero() { + let last = ledger_core::life_end(&asset)? + .pred_opt() + .map(|d| (d.year(), d.month())); + let why = if !due.over.is_zero() { + format!( + "the books carry {} more than the schedule through {period}, which no run takes back (a correction for a human)", + due.over + ) + } else if last.is_some_and(|l| (year, month) > l) { + "the asset's life is over, and the books carry all of its depreciation".to_string() + } else if due.scheduled.is_zero() { + format!("{period}'s depreciation rounds to nothing") + } else { + format!( + "the books already carry the schedule through {period} (a later run caught it up, or its run was reversed after the month ended: the run of the reversal's month catches that up)" + ) + }; + return Ok(DepreciationRun { + asset_id, + period: period.to_string(), + amount: Decimal::ZERO, + scheduled: due.scheduled, + entry_id: None, + already_posted: false, + nothing_due: Some(why), + }); + } + let amount = due.amount; + let description = format!("Depreciation — {} — {period}", asset.description); + let money = Money::new(amount, self.base_currency); + // Every refusal before the first journaled line: the entry on a + // copy, then the journal feature a period run again needs. A + // refusal after the entry posted would leave it without the line + // that makes it the period's run. + self.rehearse(|l| { + let e = l.try_create_entry(period_end, &description)?; + l.add_line(e, asset.expense_account, Side::Debit, money)?; + l.add_line(e, asset.accum_depr_account, Side::Credit, money)?; + l.post_entry(e) })?; - let amount = line.amount; - debug_assert!(amount > Decimal::ZERO); - - let entry_id = self.create_entry( - period_end, - &format!("Depreciation — {} — {period}", asset.description), - )?; - self.add_line( - entry_id, - asset.expense_account, - Side::Debit, - Money::new(amount, self.base_currency), - )?; - self.add_line( - entry_id, - asset.accum_depr_account, - Side::Credit, - Money::new(amount, self.base_currency), - )?; + if self.depreciation_period_was_run(asset_id, period)? { + self.ensure_feature(crate::format::MONTH_REPOSTING)?; + } + let entry_id = self.create_entry(period_end, &description)?; + self.add_line(entry_id, asset.expense_account, Side::Debit, money)?; + self.add_line(entry_id, asset.accum_depr_account, Side::Credit, money)?; self.post_entry_unchecked(entry_id)?; let dline = self.append_journal( "asset.depreciation_posted", @@ -1596,8 +1924,10 @@ impl Store { asset_id, period: period.to_string(), amount, - entry_id, + scheduled: due.scheduled, + entry_id: Some(entry_id), already_posted: false, + nothing_due: None, }) } @@ -1606,6 +1936,18 @@ impl Store { /// for both the preview and the post. Zero lines are left out (an /// engine entry has none): scrapping brings no proceeds, and an asset /// disposed before its first depreciation has none accumulated. + /// + /// ```text + /// INVARIANTS: + /// 1. Dr accumulated depreciation by everything the books carry for + /// the asset (preview.accumulated), Dr proceeds, Cr cost; depreciation + /// the runs posted past the disposal day credited back to the + /// asset's depreciation expense (excess), or depreciation still due + /// debited to it (catch_up); the gain or loss the difference. + /// 2. The lines balance, and the book value they take is cost less + /// the schedule through the disposal day (Ledger::dispose_asset + /// INVARIANT 1). + /// ``` pub fn disposal_lines( asset: &ledger_core::Asset, preview: &DisposalPreview, @@ -1616,7 +1958,17 @@ impl Store { [ (asset.accum_depr_account, Side::Debit, preview.accumulated), (proceeds_account, Side::Debit, proceeds), + ( + asset.expense_account, + Side::Debit, + preview.catch_up_depreciation, + ), (asset.cost_account, Side::Credit, asset.cost.amount), + ( + asset.expense_account, + Side::Credit, + preview.excess_depreciation, + ), (gain_loss_account, Side::Credit, preview.gain_loss), (gain_loss_account, Side::Debit, -preview.gain_loss), ] @@ -1625,8 +1977,150 @@ impl Store { .collect() } - /// Compute a disposal without posting. Pure reads plus the posted-vs- - /// scheduled reconciliation that [`Store::dispose_asset`] performs. + /// Refuse a disposal unless every depreciation run of the asset carries, + /// at the end of the disposal's month, what it carries in the end (in + /// effect, or reversed): the disposal entry clears what the runs carry + /// in effect, so it must be what they carry at that month end. + /// + /// ```text + /// GOAL: a disposal never leaves the books wrong at the end of its + /// month, nor in the end: the expense is the schedule through the + /// disposal day, and accumulated depreciation 0.00. It neither + /// makes nor hides an error at a later month end: what a run + /// carries there and not in the end (a later month's run reversed + /// after its month, a run reversed and re-instated after the + /// disposal's month) is that run's own error and correction, dated + /// when they were made, the same with or without the disposal. + /// SOURCE: docs/spec/rules.md B-15 (a disposed asset depreciates through + /// its disposal day); B-9 (a mistake is corrected by an opposite + /// entry); plan/served.md P3, settled in design (a miss is + /// corrected in the period the correction is made, a material + /// one restated); Ledger::dispose_asset INVARIANT 1. P3 review + /// round 2: round 1 checked only the runs of the disposal's month + /// and before at later month ends, which refused a disposal for + /// ever once the month that could undo a change was closed, and + /// let a later month's run reversed on a day in or before the + /// disposal's month through (its reversal charged the disposal + /// month, the disposal charged the month again). + /// SCENARIOS: + /// 1. IF July ran and the asset is disposed of on Jun 15: + /// - SHOULD refuse, naming July's run: reverse it first (dated + /// when the correction is made; a reversal dated after July + /// leaves July's books as they were, the correction a later + /// month's) + /// - NEVER credit July back at Jun 15 (June's expense 1,000.00 + /// short, its accumulated depreciation a 1,000.00 debit) + /// 2. IF the disposal month's own run (dated its end) stands: SHOULD + /// allow it (no month end falls between; the disposal entry credits + /// the days after the disposal back). + /// 3. IF May's run was reversed on Aug 1 and the asset is disposed of + /// on Jun 15: SHOULD refuse (the disposal would charge May again + /// at Jun 15, and the reversal take it away only in August), until + /// that reversal is itself reversed (May stands again), on any day. + /// 4. IF March's run was reversed on Jan 31 (a reversal dated before + /// its run) and the asset is disposed of on Feb 15: SHOULD refuse + /// (February's end would carry the reversal and the disposal's + /// whole charge through Feb 15). + /// ``` + fn check_nothing_after_disposal( + &self, + asset_id: AssetId, + date: NaiveDate, + ) -> Result<(), StoreError> { + let month = (date.year(), date.month()); + let month_end = ledger_core::schedules::month_end(date); + let (mut later, mut late) = (Vec::new(), Vec::new()); + for (run, amount, period) in self.depreciation_runs_ever(asset_id)? { + if period_month(&period) > month && self.ledger.in_effect(run) { + later.push(format!("{period} (entry {run})")); + continue; + } + // The run's chain, each entry dated: what it carries at the + // disposal month's end must be what it carries in the end. + let dated: Vec<(EntryId, NaiveDate, Decimal)> = self + .ledger + .reversal_chain(run) + .into_iter() + .skip_while(|&e| e != run) + .enumerate() + .filter_map(|(i, e)| { + let d = self.ledger.get_entry(e)?.date(); + Some((e, d, if i % 2 == 0 { amount } else { -amount })) + }) + .collect(); + let at_disposal: Decimal = dated + .iter() + .filter(|(_, d, _)| *d <= month_end) + .map(|(_, _, a)| *a) + .sum(); + let in_the_end: Decimal = dated.iter().map(|(_, _, a)| *a).sum(); + if at_disposal != in_the_end { + // The entries the difference is in: those dated after the + // month (or the run itself, dated after a reversal of it). + let after: Vec = dated + .iter() + .filter(|(_, d, _)| *d > month_end) + .map(|(e, d, _)| format!("entry {e} on {d}")) + .collect(); + late.push(format!( + "{period}'s run (entry {run}), by {}", + after.join(", ") + )); + } + } + if !later.is_empty() { + return Err(LedgerError::DepreciationPastDisposal(format!( + "asset {asset_id} has depreciation runs for months after {}, the disposal's month: {}; reverse them first (books.reverse_entry), then dispose of it", + date.format("%Y-%m"), + later.join(", ") + )) + .into()); + } + if !late.is_empty() { + return Err(LedgerError::DepreciationPastDisposal(format!( + "what asset {asset_id}'s runs carry at {month_end}, the end of the disposal's month, isn't what they carry now: {}; the disposal clears what they carry now, so make the two agree first (reverse the latest of those entries, on any open day)", + late.join("; ") + )) + .into()); + } + Ok(()) + } + + /// Compute a disposal without posting: the schedule through the + /// disposal day against what the books carry ([`Store::dispose_asset`] + /// posts it). + /// + /// ```text + /// GOAL: a disposal takes exactly the depreciation of the days held, + /// whatever the runs posted. + /// SOURCE: docs/spec/rules.md B-15 (the disposal day is held); + /// Ledger::dispose_asset INVARIANTS 1, 2; P3 depreciation + /// review R2. + /// INVARIANTS: + /// 1. target = the schedule through the disposal day; posted = the + /// runs the books carry (reversed ones left out); (up, down) = + /// kernel::true_up(target, posted). + /// 2. book_value = cost − target; gain_loss = proceeds − book_value. + /// 3. up is posted as the final depreciation (its own entry), or, when + /// the disposal month already has a run (which can't be posted + /// twice), as catch_up_depreciation in the disposal entry; down is + /// excess_depreciation, credited back in the disposal entry. + /// 4. accumulated (cleared) = posted + final_depreciation: the asset + /// leaves the accumulated depreciation account at zero. + /// SCENARIOS: + /// 1. IF June ran (1,000.00 dated Jun 30) and the asset is disposed + /// of on Jun 15 for 7,000.00 (cost 12,000.00, 1,000.00 a month): + /// - SHOULD clear 6,000.00, credit back 500.00, book value + /// 6,500.00, gain 500.00 + /// - NEVER keep June's last 15 days of depreciation (a book value + /// of 6,000.00, a gain of 1,000.00) + /// BECAUSE the asset was held through Jun 15, not Jun 30. + /// 2. IF a run carries at the end of the disposal's month other than + /// what it carries now (a run of a later month stands; a reversal + /// or re-instatement dated after the month isn't undone): SHOULD + /// refuse (DepreciationPastDisposal, + /// Self::check_nothing_after_disposal). + /// ``` pub fn dispose_preview( &self, asset_id: AssetId, @@ -1659,23 +2153,29 @@ impl Store { return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into()); } let period = date.format("%Y-%m").to_string(); - let posted = self.asset_depreciation_total(asset_id)?; - let target = ledger_core::accumulated_depreciation(asset, date); - let already = self.depreciation_posted_entry(asset_id, &period)?.is_some(); - let final_depreciation = if already { - Decimal::ZERO - } else { - (target - posted).max(Decimal::ZERO) - }; + self.check_nothing_after_disposal(asset_id, date)?; + let runs = self.live_runs(asset_id)?; let too_large = || StoreError::JournalCorrupt(format!("asset {asset_id}'s depreciation is too large")); + let posted = runs + .iter() + .try_fold(Decimal::ZERO, |t, r| t.checked_add(r.amount)) + .ok_or_else(too_large)?; + let target = ledger_core::accumulated_depreciation(asset, date)?; + let (up, down) = ledger_core::kernel::true_up(target, posted).ok_or_else(too_large)?; + let in_month = runs.iter().any(|r| r.period == period); + let (final_depreciation, catch_up_depreciation) = if in_month { + (Decimal::ZERO, up) + } else { + (up, Decimal::ZERO) + }; let accumulated = posted .checked_add(final_depreciation) .ok_or_else(too_large)?; let book_value = asset .cost .amount - .checked_sub(accumulated) + .checked_sub(target) .ok_or_else(too_large)?; Ok(DisposalPreview { asset_id, @@ -1683,8 +2183,13 @@ impl Store { proceeds, accumulated, final_depreciation, + excess_depreciation: down, + catch_up_depreciation, book_value, - gain_loss: proceeds.amount - book_value, + gain_loss: proceeds + .amount + .checked_sub(book_value) + .ok_or_else(too_large)?, period, }) } @@ -1694,10 +2199,12 @@ impl Store { /// accumulated depreciation, record proceeds and gain/loss) and mark /// the asset disposed. Returns the disposal entry's id. /// - /// The auto-post matters: the disposal entry must clear exactly the - /// accumulated depreciation the books actually carry, not the schedule's - /// theoretical number. If the agent already ran depreciation for the - /// disposal month, that posted amount is the books' truth. + /// The disposal entry clears exactly the accumulated depreciation the + /// books carry for the asset, and takes the book value the schedule + /// gives through the disposal day ([`Store::dispose_preview`]): a run + /// that reached past the disposal day (the disposal month's, dated its + /// end) is taken back by a credit to depreciation expense, so the + /// depreciation and the gain or loss are both of the days held. pub fn dispose_asset( &mut self, asset_id: AssetId, @@ -1835,6 +2342,13 @@ impl Store { }, true, )?; + // The disposal month's run was reversed (else the final + // depreciation would be in the disposal entry): its line again, + // which needs a journal feature, declared (or refused) before the + // first line is journaled. + if depreciation.is_some() && self.depreciation_period_was_run(asset_id, &preview.period)? { + self.ensure_feature(crate::format::MONTH_REPOSTING)?; + } if let Some((description, lines)) = &depreciation { let eid = self.create_entry(date, description)?; for (account, side, amount) in lines { @@ -2800,7 +3314,11 @@ impl Store { Ok((id, sha, dedup)) } - /// Register an amortization schedule. Journaled as `schedule.registered`. + /// Register an amortization schedule. Journaled as `schedule.registered`, + /// with its `convention` (`daily` or `monthly_even`). The first daily + /// schedule declares journal feature `schedule_convention` before its + /// line: an older version reads every schedule as equal months, and + /// would refuse a daily one's postings as wrong (rather than as newer). pub fn register_schedule( &mut self, new: ledger_core::NewSchedule, @@ -2808,6 +3326,9 @@ impl Store { self.rehearse(|l| l.register_schedule(new.clone()))?; ledger_core::schedules::validate_schedule(&new, self.ledger.chart())?; let id = self.ledger.next_schedule_id(); + if new.convention == ledger_core::ScheduleConvention::Daily { + self.ensure_feature(crate::format::SCHEDULE_CONVENTION)?; + } let line = self.append_journal( "schedule.registered", json!({ @@ -2819,6 +3340,7 @@ impl Store { "months": new.months, "balance_account": new.balance_account, "pl_account": new.pl_account, + "convention": new.convention.key(), }), )?; let got = self.ledger.register_schedule(new)?; @@ -2829,7 +3351,9 @@ impl Store { /// Post one month of a schedule. Journaled as `schedule.posted` with the /// whole entry, so a month is never half-posted or posted twice; replay - /// re-derives the entry and checks it line by line. + /// re-derives the entry and checks it line by line. A month a reversal + /// un-posted posts again only after journal feature `month_reposting` is + /// declared: an older version would refuse it as posted twice. pub fn post_schedule_month( &mut self, id: ledger_core::ScheduleId, @@ -2841,6 +3365,9 @@ impl Store { let (date, _, _) = self.ledger.preview_schedule_month(id, month_end)?; let mut next = self.ledger.clone(); let entry = next.post_schedule_month(id, month_end)?; + if self.ledger.schedule_month_posted_before(id, date) { + self.ensure_feature(crate::format::MONTH_REPOSTING)?; + } let line = self.append_journal( "schedule.posted", json!({ @@ -2855,7 +3382,12 @@ impl Store { } /// Post every schedule's due months through `through`, oldest first. - /// Returns (schedule, month, entry) for each. + /// Returns (schedule, month, entry) for each. A month a run must leave + /// for a human (dated in a closed period, which takes no new entries; + /// or its posting reversed after the month ended, so posting it again + /// would recognize it twice: Ledger::schedule_month_hold) is left + /// unposted, as run_recurring leaves its occurrences, and + /// [`Self::schedule_months_held`] lists it. pub fn run_schedules( &mut self, through: NaiveDate, @@ -2870,11 +3402,33 @@ impl Store { due.sort(); let mut out = Vec::with_capacity(due.len()); for (month, id) in due { + if self.ledger.schedule_month_hold(id, month).is_some() { + continue; + } out.push((id, month, self.post_schedule_month(id, month)?)); } Ok(out) } + /// Schedule months due through `through` that a run leaves for a human, + /// as (schedule, month, why): dated in a closed period, or reversed + /// after the month ended (Ledger::schedule_month_hold). A human books + /// each in an open period by hand, or not at all. + pub fn schedule_months_held( + &self, + through: NaiveDate, + ) -> Result, StoreError> { + let mut out = Vec::new(); + for s in self.ledger.schedules() { + for (d, _) in s.due(through)? { + if let Some(why) = self.ledger.schedule_month_hold(s.id, d) { + out.push((s.id, d, why)); + } + } + } + Ok(out) + } + /// Register a recurring entry. Journaled as `recurring.registered`. pub fn register_recurring( &mut self, @@ -4653,6 +5207,17 @@ fn parse_period(period: &str) -> Result<(i32, u32), StoreError> { Ok((year, month)) } +/// A run's period as (year, month), read leniently, since an earlier +/// version posted "10000-01"; a period no month parses as counts before +/// every month. +fn period_month(period: &str) -> (i32, u32) { + period + .split_once('-') + .and_then(|(y, m)| Some((y.parse::().ok()?, m.parse::().ok()?))) + .filter(|(_, m)| (1..=12).contains(m)) + .unwrap_or((i32::MIN, 1)) +} + /// Last calendar day of a month; refused, never a panic, for a month /// chrono can't reach. fn last_day_of_month(year: i32, month: u32) -> Result { @@ -4804,6 +5369,22 @@ pub(crate) fn replay_line_into_ledger( } "schedule.registered" => { let want = get_u64(p, "id")?; + // No convention (every schedule before P3): equal months, as + // they were posted. A convention this version doesn't know is + // newer, never corrupt (rules.md B-27). + let convention = match p.get("convention") { + None | Some(Value::Null) => ledger_core::ScheduleConvention::MonthlyEven, + Some(Value::String(c)) => { + ledger_core::ScheduleConvention::parse(c).ok_or_else(|| { + crate::format::newer_content(line, format!("the schedule convention {c}")) + })? + } + Some(_) => { + return Err(StoreError::JournalCorrupt( + "schedule.registered convention".into(), + )); + } + }; let got = ledger.replay_register_schedule(ledger_core::NewSchedule { description: get_str(p, "description")?.to_string(), kind: serde_json::from_value(p["kind"].clone())?, @@ -4812,6 +5393,7 @@ pub(crate) fn replay_line_into_ledger( months: get_u64(p, "months")? as u32, balance_account: get_u64(p, "balance_account")?, pl_account: get_u64(p, "pl_account")?, + convention, })?; if got != want { return Err(StoreError::ReplayMismatch(format!( @@ -6004,8 +6586,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr )?; } "asset.depreciation_posted" => { + // A period run again after its run was reversed takes the row + // (feature month_reposting); a period whose run stands keeps + // it, as before. conn.execute( - "INSERT OR IGNORE INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?)", + "INSERT INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?) \ + ON CONFLICT(asset_id, period) DO UPDATE SET entry_id = excluded.entry_id, amount = excluded.amount \ + WHERE EXISTS (SELECT 1 FROM entries r WHERE r.reversal_of = asset_depreciation.entry_id)", params![ get_u64(p, "asset_id")? as i64, get_str(p, "period")?, diff --git a/crates/ledger-store/tests/asset_orders.proptest-regressions b/crates/ledger-store/tests/asset_orders.proptest-regressions new file mode 100644 index 0000000..790c2e7 --- /dev/null +++ b/crates/ledger-store/tests/asset_orders.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 48a4bd4129ca2d47a2e5f07eff290d0ad86b9af2e241e6d09b072f766abfb552 # shrinks to ops = [Run(1), Dispose(2026-02-01)] diff --git a/crates/ledger-store/tests/asset_orders.rs b/crates/ledger-store/tests/asset_orders.rs new file mode 100644 index 0000000..8acf5da --- /dev/null +++ b/crates/ledger-store/tests/asset_orders.rs @@ -0,0 +1,405 @@ +//! Every order of depreciation runs, reversals, re-runs, re-instatements, +//! disposals and closed periods, on one asset: exhaustive over short +//! sequences, and random over longer ones (P3 review round 2). +//! +//! ```text +//! GOAL: whatever a human does, and in whatever order, the books never +//! carry an asset's depreciation past its schedule at a month end, +//! never carry a month twice, take exactly cost less salvage over +//! the life, and dispose at proceeds less the book value of the +//! days held. +//! SOURCE: docs/spec/rules.md B-9, B-10, B-15; plan/served.md P3 (settled +//! in design: a skipped month is caught up in the month the run is +//! made); Ledger::dispose_asset INVARIANT 1. +//! INVARIANTS (checked after every command, refused or not): +//! 1. A refused command journals nothing. +//! 2. At every month end before the disposal (all of them, while the +//! asset is active), the depreciation expense is the accumulated +//! depreciation credit, and at most the schedule through that day. +//! 3. No month has two runs in effect. +//! 4. Disposed on D: at the end of D's month and in the end, the +//! expense is the schedule through D, accumulated depreciation 0.00, +//! the cost cleared, and the gain proceeds less (cost less that). +//! 5. Active, then run month by month past the life (a closed month +//! refused): the expense is exactly cost less salvage, and every +//! month end still within the schedule. +//! 6. The books replay to the same figures (verify, at the search's +//! leaves and each random sequence's end). +//! ``` +//! +//! The asset: 3,000.00, no salvage, 3 months from 2026-01-16, so January +//! holds 16 of its 31 days (516.13), February and March 1,000.00 each, and +//! April 1 to 15 the rest (483.87): rounding in the first and last months. + +use chrono::NaiveDate; +use ledger_core::{AccountType, AssetStatus, Currency, Money, NewAsset}; +use ledger_store::{DB_FILE, JOURNAL_FILE, Store}; +use proptest::prelude::*; +use rust_decimal::Decimal; +use std::path::{Path, PathBuf}; + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "wanderbooks-orders-{name}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn usd(cents: i64) -> Money { + Money::new(Decimal::new(cents, 2), Currency::USD) +} + +fn month_end(m: u32) -> NaiveDate { + ledger_core::schedules::month_end(day(2026, m, 1)) +} + +/// The books' accounts, by code. +const COST: &str = "1500"; +const ACCUM: &str = "1501"; +const EXPENSE: &str = "6100"; +const GAIN: &str = "4900"; +const CASH: &str = "1000"; +const PROCEEDS: i64 = 100_000; + +/// One command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Op { + /// Run 2026-`m`. + Run(u32), + /// Reverse the `n`th asset entry made so far (a run, a reversal, the + /// disposal or its final depreciation), on a day. + Reverse(usize, NaiveDate), + /// Dispose of the asset on a day for 1,000.00. + Dispose(NaiveDate), + /// Close 2026-`m`. + Close(u32), +} + +/// Books with the asset, a period per month of 2026, and the asset's id. +fn books(dir: &Path) -> u64 { + let mut s = Store::create(dir, Currency::USD).unwrap(); + let mut add = |code: &str, t| s.add_account(code, code, t, None, false).unwrap(); + let cash = add(CASH, AccountType::Asset); + let cost = add(COST, AccountType::Asset); + let accum = add(ACCUM, AccountType::Asset); + let expense = add(EXPENSE, AccountType::Expense); + add(GAIN, AccountType::Revenue); + add("3000", AccountType::Equity); + let _ = cash; + for m in 1..=12 { + s.create_period(&format!("2026-{m:02}"), day(2026, m, 1), month_end(m)) + .unwrap(); + } + s.register_asset(NewAsset { + description: "Asset".into(), + purchase_date: day(2026, 1, 16), + cost: usd(300_000), + salvage_value: usd(0), + useful_life_months: 3, + cost_account: cost, + accum_depr_account: accum, + expense_account: expense, + }) + .unwrap() +} + +/// The entries the asset's commands made, in order: what a reversal picks +/// from. Read from the journal so a copy of the books knows them. +fn asset_entries(store: &Store) -> Vec { + let mut out = Vec::new(); + let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap(); + for line in text.lines() { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + let p = &v["payload"]; + let id = match v["type"].as_str().unwrap() { + "asset.depreciation_posted" | "asset.disposed" => p["entry_id"].as_u64(), + "entry.reversed" => p["id"].as_u64(), + _ => None, + }; + out.extend(id); + } + out +} + +fn code_id(store: &Store, code: &str) -> u64 { + store.ledger().chart().find_by_code(code).unwrap().id +} + +/// An account's movement (debit positive) through the end of `on`, its +/// closing entries left out: what was charged to it, closed or not. +fn balance(store: &Store, code: &str, on: NaiveDate) -> Decimal { + let l = store.ledger(); + let id = code_id(store, code); + let closing = l.closing_entry_ids(); + l.posted_entries() + .filter(|e| e.date() <= on && !closing.contains(&e.id())) + .flat_map(|e| e.lines().iter()) + .filter(|x| x.account == id) + .map(|x| match x.side { + ledger_core::Side::Debit => x.amount.amount, + ledger_core::Side::Credit => -x.amount.amount, + }) + .sum() +} + +/// Apply one command; Ok(true) when it changed the books. +fn apply(store: &mut Store, asset: u64, op: Op) -> bool { + let head = store.head(); + let done = match op { + Op::Run(m) => store + .run_depreciation(asset, &format!("2026-{m:02}")) + .map(|r| r.entry_id.is_some() && !r.already_posted), + Op::Reverse(n, on) => { + let entries = asset_entries(store); + match entries.get(n) { + Some(&e) => store.reverse_entry(e, on, "reversed").map(|_| true), + None => Ok(false), + } + } + Op::Dispose(on) => { + let (cash, gain) = (code_id(store, CASH), code_id(store, GAIN)); + store + .dispose_asset(asset, on, usd(PROCEEDS), cash, gain) + .map(|_| true) + } + Op::Close(m) => { + let id = store + .ledger() + .list_periods() + .into_iter() + .find(|p| p.name == format!("2026-{m:02}")) + .unwrap() + .id; + store.close_period(id).map(|_| true) + } + }; + match done { + Ok(changed) => { + if !changed { + assert_eq!(store.head(), head, "{op:?} changed nothing yet journaled"); + } + changed + } + Err(_) => { + assert_eq!(store.head(), head, "{op:?} was refused yet journaled"); + false + } + } +} + +/// The month ends checked: from before the asset through after the life. +fn ends() -> Vec { + std::iter::once(day(2025, 12, 31)) + .chain((1..=12).map(month_end)) + .chain([NaiveDate::MAX]) + .collect() +} + +/// Invariants 2 to 4 (and 6, when `verify`). +fn check(store: &Store, asset: u64, ops: &[Op], verify: bool) { + let a = store.ledger().get_asset(asset).unwrap().clone(); + let mut active = a.clone(); + active.status = AssetStatus::Active; + let sched = |on: NaiveDate| ledger_core::accumulated_depreciation(&active, on).unwrap(); + // INVARIANT 3: one run in effect per month. + let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap(); + let mut live: Vec = Vec::new(); + for line in text.lines() { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + if v["type"] == "asset.depreciation_posted" + && store + .ledger() + .in_effect(v["payload"]["entry_id"].as_u64().unwrap()) + { + let p = v["payload"]["period"].as_str().unwrap().to_string(); + assert!(!live.contains(&p), "{ops:?}: two runs in effect for {p}"); + live.push(p); + } + } + let disposed = match a.status { + AssetStatus::Disposed { date, .. } => Some(date), + AssetStatus::Active => None, + }; + for e in ends() { + let x = balance(store, EXPENSE, e); + let acc = balance(store, ACCUM, e); + if disposed.is_none_or(|d| e < d) { + assert_eq!(x, -acc, "{ops:?}: expense and accumulated apart on {e}"); + assert!( + x <= sched(e), + "{ops:?}: {x} carried on {e}, past the schedule's {}", + sched(e) + ); + } + } + if let Some(d) = disposed { + let target = sched(d); + for e in [ledger_core::schedules::month_end(d), NaiveDate::MAX] { + assert_eq!( + balance(store, EXPENSE, e), + target, + "{ops:?}: expense on {e}" + ); + assert_eq!( + balance(store, ACCUM, e), + Decimal::ZERO, + "{ops:?}: accum on {e}" + ); + // No purchase entry here: the disposal credits the whole cost. + assert_eq!( + balance(store, COST, e), + -a.cost.amount, + "{ops:?}: cost on {e}" + ); + let gain = Decimal::new(PROCEEDS, 2) - (a.cost.amount - target); + assert_eq!(balance(store, GAIN, e), -gain, "{ops:?}: gain on {e}"); + } + } + if verify { + store.verify().unwrap(); + } +} + +/// Invariant 5, on a copy: run every month in turn past the life. +fn check_completion(dir: &Path, asset: u64, ops: &[Op]) { + let copy = test_dir("complete"); + copy_books(dir, ©.0); + let mut store = Store::open(©.0).unwrap(); + if store.ledger().get_asset(asset).unwrap().status != AssetStatus::Active { + return; + } + for m in 1..=12 { + apply(&mut store, asset, Op::Run(m)); + } + let a = store.ledger().get_asset(asset).unwrap().clone(); + for e in ends() { + let x = balance(&store, EXPENSE, e); + assert!( + x <= ledger_core::accumulated_depreciation(&a, e).unwrap(), + "{ops:?} then every month: {x} on {e}" + ); + } + let whole = a.cost.amount - a.salvage_value.amount; + assert_eq!( + balance(&store, EXPENSE, NaiveDate::MAX), + whole, + "{ops:?} then every month: the lifetime total" + ); + assert_eq!(balance(&store, ACCUM, NaiveDate::MAX), -whole, "{ops:?}"); +} + +/// Copy the books (journal and projection) to `to`, closed. +fn copy_books(from: &Path, to: &Path) { + for f in [JOURNAL_FILE, DB_FILE] { + let src = from.join(f); + if src.exists() { + std::fs::copy(&src, to.join(f)).unwrap(); + } + } +} + +/// Every command at a node: runs of each month, reversals of each asset +/// entry made so far on three days (before, in and after most entries' +/// months), two disposals (mid-month, and in the life's final month), and +/// closes of January and February. +fn alphabet(entries: usize) -> Vec { + let mut ops: Vec = (1..=5).map(Op::Run).collect(); + for n in 0..entries { + for on in [day(2026, 1, 31), day(2026, 3, 31), day(2026, 5, 2)] { + ops.push(Op::Reverse(n, on)); + } + } + ops.extend([Op::Dispose(day(2026, 2, 15)), Op::Dispose(day(2026, 4, 10))]); + ops.extend([Op::Close(1), Op::Close(2)]); + ops +} + +/// Depth-first over every sequence of commands up to `depth` long, each +/// node a closed copy of its parent's books. +fn explore(dir: &Path, asset: u64, ops: &mut Vec, depth: usize, seen: &mut u64) { + if ops.len() == depth { + return; + } + let entries = asset_entries(&Store::open(dir).unwrap()).len(); + for op in alphabet(entries) { + let child = test_dir("node"); + copy_books(dir, &child.0); + let mut store = Store::open(&child.0).unwrap(); + let changed = apply(&mut store, asset, op); + ops.push(op); + *seen += 1; + if changed { + check(&store, asset, ops, ops.len() == depth); + drop(store); + check_completion(&child.0, asset, ops); + explore(&child.0, asset, ops, depth, seen); + } + ops.pop(); + } +} + +/// Every sequence of up to three commands (about half a minute; deeper +/// with WB_ORDERS_DEPTH, for a review, not every run: four took a minute +/// and five five minutes, 2026-10-21, both clean). +#[test] +fn every_order_of_runs_reversals_disposals_and_closes_keeps_the_books_right() { + let depth = std::env::var("WB_ORDERS_DEPTH") + .ok() + .and_then(|d| d.parse().ok()) + .unwrap_or(3); + let root = test_dir("root"); + let asset = books(&root.0); + let mut seen = 0; + explore(&root.0, asset, &mut Vec::new(), depth, &mut seen); + assert!(seen > 0); +} + +fn arb_op() -> impl Strategy { + let date = (1u32..=6, 1u32..=31) + .prop_map(|(m, d)| NaiveDate::from_ymd_opt(2026, m, d).unwrap_or_else(|| month_end(m))); + prop_oneof![ + 4 => (1u32..=6).prop_map(Op::Run), + 4 => (0usize..8, date.clone()).prop_map(|(n, on)| Op::Reverse(n, on)), + 1 => date.prop_map(Op::Dispose), + 1 => (1u32..=4).prop_map(Op::Close), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Longer sequences at random, any day of the first half of 2026. + #[test] + fn prop_any_sequence_keeps_the_books_right(ops in prop::collection::vec(arb_op(), 1..10)) { + let root = test_dir("prop"); + let asset = books(&root.0); + { + let mut store = Store::open(&root.0).unwrap(); + for (i, &op) in ops.iter().enumerate() { + apply(&mut store, asset, op); + check(&store, asset, &ops[..=i], false); + } + store.verify().unwrap(); + } + check_completion(&root.0, asset, &ops); + } +} diff --git a/crates/ledger-store/tests/assets.rs b/crates/ledger-store/tests/assets.rs index 57e71fb..60d9103 100644 --- a/crates/ledger-store/tests/assets.rs +++ b/crates/ledger-store/tests/assets.rs @@ -111,7 +111,7 @@ fn depreciation_run_is_idempotent() { assert_eq!(run3.amount, Decimal::from(1_000)); // The depreciation entries hit the right accounts. - let entry = store.ledger().get_entry(run3.entry_id).unwrap(); + let entry = store.ledger().get_entry(run3.entry_id.unwrap()).unwrap(); assert!(entry.lines().iter().any(|l| l.account == expense && l.side == Side::Debit && l.amount.amount == Decimal::from(1_000))); @@ -125,13 +125,26 @@ fn depreciation_rejects_period_outside_life() { let id = van(&mut store, 60_000, cost, accum, expense); // Before purchase. assert!(store.run_depreciation(id, "2025-12").is_err()); - // After the 60-month life ends (2031-01). - assert!(store.run_depreciation(id, "2031-06").is_err()); // Malformed period. assert!(store.run_depreciation(id, "2026-13").is_err()); assert!(store.run_depreciation(id, "january").is_err()); // Unknown asset. assert!(store.run_depreciation(999, "2026-01").is_err()); + // After the 60-month life ends (2031-01): the run catches up every + // month skipped, here all of them (plan/served.md P3, settled in + // design: a month skipped is caught up in the month the run is made). + let late = store.run_depreciation(id, "2031-06").unwrap(); + assert_eq!(late.amount, Decimal::from(60_000)); + assert_eq!(late.scheduled, Decimal::ZERO); + assert!(late.entry_id.is_some()); + // Then nothing is left: a run after the life posts nothing, and + // journals nothing. + let head = store.head(); + let after = store.run_depreciation(id, "2031-07").unwrap(); + assert_eq!((after.amount, after.entry_id), (Decimal::ZERO, None)); + assert!(after.nothing_due.unwrap().contains("life is over")); + assert_eq!(store.head(), head); + store.verify().unwrap(); } #[test] @@ -233,3 +246,833 @@ fn rejected_asset_registration_journals_nothing() { let id = van(&mut store, 1_000, cost, accum, expense); assert_eq!(id, 1); } + +// ---------------------------------------------- P3: defect 22, catch-up, R2, R3 + +/// Asset X of the P3 depreciation brief: 12,000.00, no salvage, 12 months +/// from 2026-01-01, so 1,000.00 a month and December its final month. +fn asset_x(store: &mut Store, cost: u64, accum: u64, expense: u64) -> u64 { + store + .register_asset(NewAsset { + description: "Asset X".to_string(), + purchase_date: date(2026, 1, 1), + cost: usd("12000.00"), + salvage_value: usd("0"), + useful_life_months: 12, + cost_account: cost, + accum_depr_account: accum, + expense_account: expense, + }) + .unwrap() +} + +/// An account's balance in the books (debit positive) on a day. +fn balance(store: &Store, account: u64, on: NaiveDate) -> Decimal { + let code = &store.ledger().chart().get(account).unwrap().code; + ledger_core::reports::trial_balance_as_of(store.ledger(), on) + .lines + .iter() + .find(|l| &l.code == code) + .map_or(Decimal::ZERO, |l| l.debit - l.credit) +} + +fn dec(s: &str) -> Decimal { + s.parse().unwrap() +} + +/// Defect 22, reproduction C (wrong posted figures before P3): January +/// through November run (11,000.00), then a disposal on 2026-12-10 for +/// 1,000.00. Hand check: December holds 10 of its 31 days, 1,000.00 × +/// 10/31 = 322.58; accumulated 11,322.58, book value 677.42, gain 1,000.00 +/// − 677.42 = 322.58. Before P3: 1,000.00 of depreciation, book value 0.00, +/// gain 1,000.00 (expense and gain each 677.42 too high). +#[test] +fn defect22_a_disposal_in_the_final_month_posts_depreciation_by_days() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + for m in 1..=11 { + store.run_depreciation(x, &format!("2026-{m:02}")).unwrap(); + } + let p = store + .dispose_preview(x, date(2026, 12, 10), usd("1000.00")) + .unwrap(); + assert_eq!(p.final_depreciation, dec("322.58")); + assert_eq!(p.accumulated, dec("11322.58")); + assert_eq!(p.book_value, dec("677.42")); + assert_eq!(p.gain_loss, dec("322.58")); + assert_eq!(p.excess_depreciation, Decimal::ZERO); + store + .dispose_asset(x, date(2026, 12, 10), usd("1000.00"), bank, gain) + .unwrap(); + let on = date(2026, 12, 31); + assert_eq!(balance(&store, expense, on), dec("11322.58")); + assert_eq!(balance(&store, gain, on), dec("-322.58")); + assert_eq!(balance(&store, accum, on), Decimal::ZERO); + assert_eq!(balance(&store, cost, on), dec("-12000.00")); + // The books' accumulated equals the disposed schedule's. + let a = store.ledger().get_asset(x).unwrap(); + assert_eq!( + ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(), + dec("11322.58") + ); + store.verify().unwrap(); +} + +/// P3 depreciation review R2: a disposal dated inside a month already run +/// takes back the days after it. Hand check: January through June run +/// (June's 1,000.00 dated Jun 30), then a disposal on 2026-06-15 for +/// 7,000.00. Held through Jun 15: 5,500.00 of depreciation, book value +/// 6,500.00, gain 500.00. The entry: Dr accumulated depreciation 6,000.00 +/// (all the runs posted), Dr bank 7,000.00 / Cr equipment 12,000.00, Cr +/// depreciation expense 500.00, Cr gain 500.00: 13,000.00 a side. Before +/// P3: a book value of 6,000.00 and a gain of 1,000.00. +#[test] +fn defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + for m in 1..=6 { + store.run_depreciation(x, &format!("2026-{m:02}")).unwrap(); + } + let p = store + .dispose_preview(x, date(2026, 6, 15), usd("7000.00")) + .unwrap(); + assert_eq!( + ( + p.final_depreciation, + p.excess_depreciation, + p.catch_up_depreciation + ), + (Decimal::ZERO, dec("500.00"), Decimal::ZERO) + ); + assert_eq!(p.accumulated, dec("6000.00")); + assert_eq!(p.book_value, dec("6500.00")); + assert_eq!(p.gain_loss, dec("500.00")); + let a = store.ledger().get_asset(x).unwrap().clone(); + let lines = Store::disposal_lines(&a, &p, dec("7000.00"), bank, gain); + assert_eq!( + lines, + vec![ + (accum, Side::Debit, dec("6000.00")), + (bank, Side::Debit, dec("7000.00")), + (cost, Side::Credit, dec("12000.00")), + (expense, Side::Credit, dec("500.00")), + (gain, Side::Credit, dec("500.00")), + ] + ); + store + .dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain) + .unwrap(); + let on = date(2026, 6, 30); + assert_eq!(balance(&store, expense, on), dec("5500.00")); + assert_eq!(balance(&store, gain, on), dec("-500.00")); + assert_eq!(balance(&store, accum, on), Decimal::ZERO); + store.verify().unwrap(); +} + +/// The catch-up rule (plan/served.md P3, settled in design: a month of +/// depreciation skipped is caught up in the month the run is made): run +/// January, skip February, run March: March posts 2,000.00 (caught up +/// 1,000.00); a February run after it posts nothing and journals nothing. +#[test] +fn a_skipped_month_is_caught_up_in_the_month_run() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, _, _) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + store.run_depreciation(x, "2026-01").unwrap(); + let mar = store.run_depreciation(x, "2026-03").unwrap(); + assert_eq!( + (mar.amount, mar.scheduled), + (dec("2000.00"), dec("1000.00")) + ); + let head = store.head(); + let feb = store.run_depreciation(x, "2026-02").unwrap(); + assert_eq!((feb.amount, feb.entry_id), (Decimal::ZERO, None)); + assert!(feb.nothing_due.unwrap().contains("later run")); + assert_eq!(store.head(), head); + assert_eq!(balance(&store, accum, date(2026, 3, 31)), dec("-3000.00")); + store.verify().unwrap(); +} + +/// P3 depreciation review R3: a run reversed within its month is +/// un-posted, so its month is due again; running it again declares journal +/// feature month_reposting first (an older version would ignore the second +/// line for the month). +#[test] +fn a_reversed_depreciation_month_can_be_run_again() { + let (dir, mut store) = open_store(); + let (cost, accum, expense, _, _) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let jan = store.run_depreciation(x, "2026-01").unwrap(); + let jan_entry = jan.entry_id.unwrap(); + store + .reverse_entry(jan_entry, date(2026, 1, 31), "posted to the wrong asset") + .unwrap(); + assert!(store.journal_format().features.is_empty()); + let again = store.run_depreciation(x, "2026-01").unwrap(); + assert!(!again.already_posted); + assert_eq!(again.amount, dec("1000.00")); + assert_ne!(again.entry_id, Some(jan_entry)); + assert_eq!( + store.journal_format().features, + vec![ledger_store::format::MONTH_REPOSTING.to_string()] + ); + // Idempotent again: the new run is the month's. + let twice = store.run_depreciation(x, "2026-01").unwrap(); + assert!(twice.already_posted); + assert_eq!(twice.entry_id, again.entry_id); + // January's end carries January once: the first run, its reversal and + // the run again are all dated Jan 31. + assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00")); + assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-1000.00")); + store.verify().unwrap(); + // Reopened (the projection rebuilt from the journal, then caught up), + // the same. + drop(store); + std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap(); + let store = Store::open(dir.path()).unwrap(); + store.verify().unwrap(); + let mut store = store; + assert!(store.run_depreciation(x, "2026-01").unwrap().already_posted); +} + +/// R3's example: January through May run, May reversed, then a disposal on +/// Jun 15 for 7,000.00. The runs in effect carry 4,000.00; the schedule +/// through Jun 15 is 5,500.00, so the final depreciation is 1,500.00 (May +/// again, and half of June), and the disposal clears 5,500.00: the +/// accumulated depreciation account ends at 0.00, never a 1,000.00 debit. +#[test] +fn a_reversed_run_before_a_disposal_leaves_nothing_behind() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let mut may = 0; + for m in 1..=5 { + may = store + .run_depreciation(x, &format!("2026-{m:02}")) + .unwrap() + .entry_id + .unwrap(); + } + store + .reverse_entry(may, date(2026, 6, 1), "reversed") + .unwrap(); + let p = store + .dispose_preview(x, date(2026, 6, 15), usd("7000.00")) + .unwrap(); + assert_eq!(p.final_depreciation, dec("1500.00")); + assert_eq!(p.accumulated, dec("5500.00")); + assert_eq!(p.gain_loss, dec("500.00")); + store + .dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain) + .unwrap(); + let on = date(2026, 6, 30); + assert_eq!(balance(&store, accum, on), Decimal::ZERO); + assert_eq!(balance(&store, expense, on), dec("5500.00")); + store.verify().unwrap(); +} + +/// R3: a disposal can't be reversed (the register would still say +/// disposed), nor a run its disposal took as final; nor can a reversal be +/// reversed when that would post a month a second time. The ledger and +/// journal are untouched by each refusal. +#[test] +fn a_disposal_entry_cannot_be_reversed() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let jan = store + .run_depreciation(x, "2026-01") + .unwrap() + .entry_id + .unwrap(); + // Re-instating a run whose month ran again would post it twice. + let rev = store.reverse_entry(jan, date(2026, 1, 31), "oops").unwrap(); + let again = store + .run_depreciation(x, "2026-01") + .unwrap() + .entry_id + .unwrap(); + let head = store.head(); + match store.reverse_entry(rev, date(2026, 2, 2), "re-instate") { + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::AssetEntryIrreversible(e, why), + )) => { + assert_eq!(e, rev); + assert!(why.contains(&format!("entry {again}")), "{why}"); + } + other => panic!("{other:?}"), + } + assert_eq!(store.head(), head); + let disposal = store + .dispose_asset(x, date(2026, 2, 15), usd("11000.00"), bank, gain) + .unwrap(); + let head = store.head(); + for (entry, what) in [(disposal, "disposal"), (again, "disposed")] { + match store.reverse_entry(entry, date(2026, 3, 1), "undo") { + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::AssetEntryIrreversible(_, why), + )) => assert!(why.contains(what), "{why}"), + other => panic!("{entry}: {other:?}"), + } + assert!(store.check_reverse_entry(entry, date(2026, 3, 1)).is_err()); + } + assert_eq!(store.head(), head); + store.verify().unwrap(); +} + +/// P3 depreciation review R10: a month whose depreciation rounds to 0.00 +/// posts nothing and journals nothing (an entry can't carry a zero line); +/// the cent posts in the month it rounds into. Hand check: 0.01 over 2 +/// months from 2026-01-01: half a cent after January rounds up, so +/// January posts 0.01 and February nothing. 0.01 over 3 months: January +/// nothing, February 0.01, March nothing. +#[test] +fn run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, _, _) = asset_accounts(&mut store); + let tiny = store + .register_asset(NewAsset { + description: "Cable".to_string(), + purchase_date: date(2026, 1, 1), + cost: usd("0.01"), + salvage_value: usd("0"), + useful_life_months: 3, + cost_account: cost, + accum_depr_account: accum, + expense_account: expense, + }) + .unwrap(); + let head = store.head(); + let jan = store.run_depreciation(tiny, "2026-01").unwrap(); + assert_eq!((jan.amount, jan.entry_id), (Decimal::ZERO, None)); + assert!(jan.nothing_due.unwrap().contains("rounds to nothing")); + assert_eq!(store.head(), head); + let feb = store.run_depreciation(tiny, "2026-02").unwrap(); + assert_eq!(feb.amount, dec("0.01")); + let mar = store.run_depreciation(tiny, "2026-03").unwrap(); + assert_eq!((mar.amount, mar.entry_id), (Decimal::ZERO, None)); + store.verify().unwrap(); +} + +/// R1: in yen, each run posts whole yen (¥100,000 over 12 months: ¥8,333, +/// ¥8,334, ¥8,333, ...), so every month can post. +#[test] +fn jpy_depreciation_runs_post_whole_yen() { + let dir = tempdir().unwrap(); + let mut store = Store::create(dir.path(), Currency::JPY).unwrap(); + let add = |s: &mut Store, code: &str, t| s.add_account(code, code, t, None, false).unwrap(); + let cost = add(&mut store, "1500", AccountType::Asset); + let accum = add(&mut store, "1501", AccountType::Asset); + let expense = add(&mut store, "6100", AccountType::Expense); + let yen = |a: &str| Money::new(dec(a), Currency::JPY); + let id = store + .register_asset(NewAsset { + description: "Lathe".to_string(), + purchase_date: date(2026, 1, 1), + cost: yen("100000"), + salvage_value: yen("0"), + useful_life_months: 12, + cost_account: cost, + accum_depr_account: accum, + expense_account: expense, + }) + .unwrap(); + let got: Vec = (1..=3) + .map(|m| { + store + .run_depreciation(id, &format!("2026-{m:02}")) + .unwrap() + .amount + }) + .collect(); + assert_eq!(got, [dec("8333"), dec("8334"), dec("8333")]); + store.verify().unwrap(); +} + +/// P3 review of the catch-up rule: a run reversed after its month ended +/// still carried that month at its end, so running the month again would +/// carry it twice there; the run of the reversal's month catches it up +/// instead. Hand check: January run (1,000.00, Jan 31), reversed Feb 2. +/// January again: nothing (Jan 31 carries 1,000.00, the schedule). February: +/// 2,000.00 (the schedule through Feb 28, 2,000.00, less the 0.00 carried +/// after the reversal). Jan 31 carries 1,000.00, Feb 28 2,000.00: each the +/// schedule, never 2,000.00 on Jan 31 (before this review it was). +#[test] +fn a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, _, _) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let jan = store + .run_depreciation(x, "2026-01") + .unwrap() + .entry_id + .unwrap(); + store + .reverse_entry(jan, date(2026, 2, 2), "posted twice by hand") + .unwrap(); + let head = store.head(); + let again = store.run_depreciation(x, "2026-01").unwrap(); + assert_eq!((again.amount, again.entry_id), (Decimal::ZERO, None)); + assert!( + again + .nothing_due + .as_deref() + .unwrap() + .contains("reversed after"), + "{again:?}" + ); + assert_eq!(store.head(), head, "nothing journaled"); + let feb = store.run_depreciation(x, "2026-02").unwrap(); + assert_eq!( + (feb.amount, feb.scheduled), + (dec("2000.00"), dec("1000.00")) + ); + assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00")); + assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-2000.00")); + assert!(store.journal_format().features.is_empty()); + store.verify().unwrap(); +} + +/// P3 review: re-instating a reversed run (reversing its reversal) after a +/// later run caught its month up would carry the books past the schedule, +/// here past cost less salvage. Hand check: January through November run +/// (11,000.00); November reversed on Nov 30; December's run posts 2,000.00 +/// (the whole 12,000.00 less the 10,000.00 carried). Re-instating November +/// on Dec 31 would carry 13,000.00 of a 12,000.00 asset: refused, journaling +/// nothing. A run re-instated with nothing run since (January, reversed and +/// re-instated on Jan 31, room 1,000.00) is taken. +#[test] +fn reinstating_a_run_a_later_run_caught_up_is_refused() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, _, _) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let mut nov = 0; + for m in 1..=11 { + nov = store + .run_depreciation(x, &format!("2026-{m:02}")) + .unwrap() + .entry_id + .unwrap(); + } + let rev = store + .reverse_entry(nov, date(2026, 11, 30), "check the count") + .unwrap(); + let dec_run = store.run_depreciation(x, "2026-12").unwrap(); + assert_eq!(dec_run.amount, dec("2000.00")); + let head = store.head(); + match store.reverse_entry(rev, date(2026, 12, 31), "it was right") { + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::AssetEntryIrreversible(e, why), + )) => { + assert_eq!(e, rev); + assert!(why.contains("more than its schedule"), "{why}"); + } + other => panic!("{other:?}"), + } + assert_eq!(store.head(), head); + assert_eq!(balance(&store, accum, date(2026, 12, 31)), dec("-12000.00")); + store.verify().unwrap(); + + // Before a later run, re-instating is room the schedule has: taken. + let (_dir, mut store) = open_store(); + let (cost, accum, expense, _, _) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let jan = store + .run_depreciation(x, "2026-01") + .unwrap() + .entry_id + .unwrap(); + let rev = store.reverse_entry(jan, date(2026, 1, 31), "oops").unwrap(); + store + .reverse_entry(rev, date(2026, 1, 31), "it was right") + .unwrap(); + assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00")); + assert!(store.run_depreciation(x, "2026-01").unwrap().already_posted); + store.verify().unwrap(); +} + +/// P3 review: a disposal dated before a later month's run would credit +/// that run back at the disposal day, leaving the disposal month's +/// accumulated depreciation a debit and its expense short at its end; it is +/// refused, naming the run to reverse first. Hand check: January through +/// July run (7,000.00); a disposal on Jun 15 is refused. July reversed on +/// Jul 31: the disposal takes 5,500.00 (June's last 15 days, 500.00, +/// credited back), and on Jun 30 the books carry accumulated depreciation +/// 0.00 and expense 5,500.00; before this review June ended with a +/// 1,000.00 debit in accumulated depreciation and 4,500.00 of expense. +#[test] +fn a_disposal_before_a_later_months_run_is_refused_until_it_is_reversed() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let mut jul = 0; + for m in 1..=7 { + jul = store + .run_depreciation(x, &format!("2026-{m:02}")) + .unwrap() + .entry_id + .unwrap(); + } + let head = store.head(); + for refused in [ + store + .dispose_preview(x, date(2026, 6, 15), usd("7000.00")) + .map(|_| ()), + store + .dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain) + .map(|_| ()), + ] { + match refused { + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::DepreciationPastDisposal(why), + )) => assert!(why.contains(&format!("2026-07 (entry {jul})")), "{why}"), + other => panic!("{other:?}"), + } + } + assert_eq!(store.head(), head); + store + .reverse_entry(jul, date(2026, 7, 31), "sold in June") + .unwrap(); + let p = store + .dispose_preview(x, date(2026, 6, 15), usd("7000.00")) + .unwrap(); + assert_eq!( + (p.excess_depreciation, p.book_value, p.gain_loss), + (dec("500.00"), dec("6500.00"), dec("500.00")) + ); + store + .dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain) + .unwrap(); + for on in [date(2026, 6, 30), date(2026, 7, 31)] { + assert_eq!(balance(&store, accum, on), Decimal::ZERO, "{on}"); + assert_eq!(balance(&store, expense, on), dec("5500.00"), "{on}"); + } + store.verify().unwrap(); +} + +/// P3 review: a run of the disposal's month or before reversed after the +/// disposal month's end would be charged again at the disposal and taken +/// away only later: refused until that change is undone within its month. +/// Hand check: January through May run; May reversed on Aug 1; a disposal +/// on Jun 15 would charge 1,500.00 (May again, and half of June) and so +/// carry 6,500.00 on Jun 30. Refused; the reversal reversed on Aug 3 (May +/// stands again, what it carries the same at every month end from June), +/// the disposal charges 500.00 and June ends at 0.00 and 5,500.00. +#[test] +fn a_disposal_before_a_later_reversal_of_an_earlier_run_is_refused() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let mut may = 0; + for m in 1..=5 { + may = store + .run_depreciation(x, &format!("2026-{m:02}")) + .unwrap() + .entry_id + .unwrap(); + } + let rev = store + .reverse_entry(may, date(2026, 8, 1), "doubted") + .unwrap(); + match store.dispose_preview(x, date(2026, 6, 15), usd("7000.00")) { + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::DepreciationPastDisposal(why), + )) => assert!( + why.contains(&format!("by entry {rev} on 2026-08-01")), + "{why}" + ), + other => panic!("{other:?}"), + } + store + .reverse_entry(rev, date(2026, 8, 3), "it was right") + .unwrap(); + let p = store + .dispose_preview(x, date(2026, 6, 15), usd("7000.00")) + .unwrap(); + assert_eq!(p.final_depreciation, dec("500.00")); + store + .dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain) + .unwrap(); + let on = date(2026, 6, 30); + assert_eq!(balance(&store, accum, on), Decimal::ZERO); + assert_eq!(balance(&store, expense, on), dec("5500.00")); + store.verify().unwrap(); +} + +/// P3 review round 2: a run re-instated after its month was run again and +/// that second run reversed still counts. Hand check: January run (A, Jan +/// 31), reversed on Jan 31 (B); January run again (A2), reversed on Jan 31 +/// (B2); then B reversed on Jan 31, re-instating A: the room is the whole +/// 1,000.00 (nothing else carries January). The books carry A, so January +/// is posted (A's run), and a disposal on Feb 15 for 11,000.00 charges +/// only February's 15 of 28 days, 535.71, clearing 1,535.71 (book value +/// 10,464.29, gain 535.71): the accumulated depreciation account ends at +/// 0.00 and the expense at 1,535.71. Before this review the projection's +/// January row still named A2, so A was left out: January ran as nothing +/// due, and the disposal charged January again (1,535.71 final), leaving +/// 1,000.00 in accumulated depreciation. +#[test] +fn a_run_reinstated_after_its_rerun_was_reversed_still_counts() { + let (dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let jan31 = date(2026, 1, 31); + let a = store + .run_depreciation(x, "2026-01") + .unwrap() + .entry_id + .unwrap(); + let b = store.reverse_entry(a, jan31, "first doubt").unwrap(); + let a2 = store + .run_depreciation(x, "2026-01") + .unwrap() + .entry_id + .unwrap(); + store.reverse_entry(a2, jan31, "second doubt").unwrap(); + store + .reverse_entry(b, jan31, "the first was right") + .unwrap(); + assert_eq!(balance(&store, accum, jan31), dec("-1000.00")); + let again = store.run_depreciation(x, "2026-01").unwrap(); + assert!(again.already_posted, "{again:?}"); + assert_eq!((again.entry_id, again.amount), (Some(a), dec("1000.00"))); + let p = store + .dispose_preview(x, date(2026, 2, 15), usd("11000.00")) + .unwrap(); + assert_eq!( + (p.final_depreciation, p.accumulated, p.gain_loss), + (dec("535.71"), dec("1535.71"), dec("535.71")) + ); + store + .dispose_asset(x, date(2026, 2, 15), usd("11000.00"), bank, gain) + .unwrap(); + let on = date(2026, 2, 28); + assert_eq!(balance(&store, accum, on), Decimal::ZERO); + assert_eq!(balance(&store, expense, on), dec("1535.71")); + store.verify().unwrap(); + // The same after the projection is rebuilt from the journal. + drop(store); + std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap(); + Store::open(dir.path()).unwrap().verify().unwrap(); +} + +/// P3 review round 2: a correction comes after what it corrects (B-9). A +/// reversal of a depreciation run dated before the run (March's run, dated +/// Mar 31, reversed on Feb 10) would leave January and February with +/// negative depreciation, and a disposal on Feb 15 clearing what February +/// doesn't carry (before this review it was taken: February ended with +/// accumulated depreciation a 1,000.00 debit and 535.71 of expense, not +/// 1,535.71). So is a re-instatement dated before the reversal it reverses, +/// and a schedule month's reversal dated before its posting. A reversal on +/// the entry's own day is taken. Each refusal journals nothing. +#[test] +fn a_reversal_dated_before_the_entry_it_reverses_is_refused() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, _, _) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let mut mar = 0; + for m in 1..=3 { + mar = store + .run_depreciation(x, &format!("2026-{m:02}")) + .unwrap() + .entry_id + .unwrap(); + } + let head = store.head(); + for on in [date(2026, 2, 10), date(2026, 3, 30)] { + match store.reverse_entry(mar, on, "backdated") { + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::AssetEntryIrreversible(e, why), + )) => { + assert_eq!(e, mar); + assert!(why.contains("can't be dated before it"), "{why}"); + } + other => panic!("{on}: {other:?}"), + } + } + assert_eq!(store.head(), head); + let rev = store + .reverse_entry(mar, date(2026, 3, 31), "doubted") + .unwrap(); + let head = store.head(); + assert!(matches!( + store.reverse_entry(rev, date(2026, 3, 30), "it was right"), + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::AssetEntryIrreversible(..) + )) + )); + assert_eq!(store.head(), head); + store + .reverse_entry(rev, date(2026, 3, 31), "it was right") + .unwrap(); + assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-2000.00")); + assert_eq!(balance(&store, accum, date(2026, 3, 31)), dec("-3000.00")); + + // A schedule month's posting, the same. + let prepaid = store + .add_account("1300", "Prepaid", AccountType::Asset, None, false) + .unwrap(); + let insurance = store + .add_account("6300", "Insurance", AccountType::Expense, None, false) + .unwrap(); + let s = store + .register_schedule(ledger_core::NewSchedule { + description: "Insurance".into(), + kind: ledger_core::ScheduleKind::PrepaidExpense, + total: usd("1200.00"), + start: date(2026, 1, 1), + months: 12, + balance_account: prepaid, + pl_account: insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, + }) + .unwrap(); + let feb = store.post_schedule_month(s, date(2026, 2, 28)).unwrap(); + let head = store.head(); + assert!(matches!( + store.reverse_entry(feb, date(2026, 1, 31), "backdated"), + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::InvalidSchedule(_) + )) + )); + assert_eq!(store.head(), head); + store + .reverse_entry(feb, date(2026, 2, 28), "doubted") + .unwrap(); + store.verify().unwrap(); +} + +/// P3 review round 2: a disposal waits only until what the runs carry at +/// the end of its month is what they carry now, so a late change can be +/// undone on any open day. Hand check: January through May run (5,000.00); +/// May reversed on Aug 1, and August closed. A disposal on Jun 15 for +/// 7,000.00 is refused (it would charge May again: June would end carrying +/// 6,500.00). May re-instated on Sep 2 (August is closed, so it can't be +/// undone in August): the disposal is taken, charging June's 15 days, +/// 500.00, and clearing 5,500.00. June ends at accumulated depreciation +/// 0.00 and expense 5,500.00, gain 500.00; August's end carries the change +/// made in August (May's 1,000.00 taken away, a 1,000.00 debit) and +/// September's end its correction (0.00), as without the disposal. Before +/// this review the disposal was refused for ever: round 1 required what May +/// carries at every month end after June to be the same. +#[test] +fn a_disposal_is_taken_once_a_late_change_is_undone_after_its_month_closed() { + let (_dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let mut may = 0; + for m in 1..=5 { + may = store + .run_depreciation(x, &format!("2026-{m:02}")) + .unwrap() + .entry_id + .unwrap(); + } + let rev = store + .reverse_entry(may, date(2026, 8, 1), "doubted") + .unwrap(); + let aug = store + .create_period("2026-08", date(2026, 8, 1), date(2026, 8, 31)) + .unwrap(); + store.close_period(aug).unwrap(); + assert!(matches!( + store.dispose_preview(x, date(2026, 6, 15), usd("7000.00")), + Err(ledger_store::StoreError::Ledger( + ledger_core::LedgerError::DepreciationPastDisposal(_) + )) + )); + store + .reverse_entry(rev, date(2026, 9, 2), "it was right") + .unwrap(); + let p = store + .dispose_preview(x, date(2026, 6, 15), usd("7000.00")) + .unwrap(); + assert_eq!( + (p.final_depreciation, p.accumulated, p.gain_loss), + (dec("500.00"), dec("5500.00"), dec("500.00")) + ); + store + .dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain) + .unwrap(); + let jun = date(2026, 6, 30); + assert_eq!(balance(&store, accum, jun), Decimal::ZERO); + assert_eq!(balance(&store, expense, jun), dec("5500.00")); + assert_eq!(balance(&store, gain, jun), dec("-500.00")); + assert_eq!(balance(&store, accum, date(2026, 8, 31)), dec("1000.00")); + assert_eq!(balance(&store, accum, date(2026, 9, 30)), Decimal::ZERO); + store.verify().unwrap(); +} + +/// P3 review round 2 (no store test held it): depreciation still due when +/// the disposal's month already has a run is charged in the disposal entry +/// (catch_up_depreciation), never as a second run of that month. Hand +/// check: January through June run (June's 1,000.00 dated Jun 30); March's +/// run reversed on Jun 20 (5,000.00 carried at June's end, and now). A +/// disposal on Jun 15 for 7,000.00 takes 5,500.00 (held through Jun 15): +/// 500.00 still due, debited to depreciation expense in the disposal entry +/// (March's 1,000.00 again, less June's last 15 days already in June's +/// run). The entry: Dr accumulated depreciation 5,000.00, Dr bank +/// 7,000.00, Dr depreciation expense 500.00 / Cr equipment 12,000.00, Cr +/// gain 500.00: 12,500.00 a side. No final depreciation entry, June keeps +/// one run, no journal feature; June ends at 0.00 and 5,500.00. +#[test] +fn a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry() { + let (dir, mut store) = open_store(); + let (cost, accum, expense, bank, gain) = asset_accounts(&mut store); + let x = asset_x(&mut store, cost, accum, expense); + let mut mar = 0; + for m in 1..=6 { + let r = store.run_depreciation(x, &format!("2026-{m:02}")).unwrap(); + if m == 3 { + mar = r.entry_id.unwrap(); + } + } + store + .reverse_entry(mar, date(2026, 6, 20), "doubted") + .unwrap(); + let p = store + .dispose_preview(x, date(2026, 6, 15), usd("7000.00")) + .unwrap(); + assert_eq!( + ( + p.final_depreciation, + p.catch_up_depreciation, + p.excess_depreciation + ), + (Decimal::ZERO, dec("500.00"), Decimal::ZERO) + ); + assert_eq!( + (p.accumulated, p.book_value, p.gain_loss), + (dec("5000.00"), dec("6500.00"), dec("500.00")) + ); + let a = store.ledger().get_asset(x).unwrap().clone(); + assert_eq!( + Store::disposal_lines(&a, &p, dec("7000.00"), bank, gain), + vec![ + (accum, Side::Debit, dec("5000.00")), + (bank, Side::Debit, dec("7000.00")), + (expense, Side::Debit, dec("500.00")), + (cost, Side::Credit, dec("12000.00")), + (gain, Side::Credit, dec("500.00")), + ] + ); + let next = store.ledger().next_entry_id(); + let disposal = store + .dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain) + .unwrap(); + assert_eq!(disposal, next, "the disposal entry alone"); + let journal = std::fs::read_to_string(dir.path().join(ledger_store::JOURNAL_FILE)).unwrap(); + let junes = journal + .lines() + .filter(|l| l.contains("asset.depreciation_posted") && l.contains("\"2026-06\"")) + .count(); + assert_eq!(junes, 1, "June keeps one run"); + assert!(store.journal_format().features.is_empty()); + let on = date(2026, 6, 30); + assert_eq!(balance(&store, accum, on), Decimal::ZERO); + assert_eq!(balance(&store, expense, on), dec("5500.00")); + assert_eq!(balance(&store, gain, on), dec("-500.00")); + store.verify().unwrap(); +} diff --git a/crates/ledger-store/tests/attachments.rs b/crates/ledger-store/tests/attachments.rs index a0d30eb..a693627 100644 --- a/crates/ledger-store/tests/attachments.rs +++ b/crates/ledger-store/tests/attachments.rs @@ -366,6 +366,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec { months: 12, balance_account: prepaid, pl_account: fx.expense, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); clear_jan(s, fx); diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index 958e571..375a4b7 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -989,3 +989,367 @@ fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() { other => panic!("expected NewerJournal, got {:?}", other.map(|_| ())), } } + +/// Books with a prepaid account and an expense to schedule against; +/// returns (prepaid, insurance). +fn schedule_books(dir: &Path) -> (u64, u64) { + books(dir); + let mut store = Store::open(dir).unwrap(); + let prepaid = store + .add_account("1300", "Prepaid", AccountType::Asset, None, false) + .unwrap(); + let insurance = store + .add_account("6300", "Insurance", AccountType::Expense, None, false) + .unwrap(); + (prepaid, insurance) +} + +fn insurance( + accounts: (u64, u64), + start: NaiveDate, + convention: ledger_core::ScheduleConvention, +) -> ledger_core::NewSchedule { + ledger_core::NewSchedule { + description: "Liability insurance".into(), + kind: ledger_core::ScheduleKind::PrepaidExpense, + total: Money::new(Decimal::from(1200), Currency::USD), + start, + months: 12, + balance_account: accounts.0, + pl_account: accounts.1, + convention, + } +} + +fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// The journal's lines, parsed. +fn lines(dir: &Path) -> Vec { + std::fs::read_to_string(dir.join(JOURNAL_FILE)) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() +} + +/// P3 (defect 9; rules.md B-27): the first daily schedule declares journal +/// feature schedule_convention, on the line before its schedule.registered, +/// which carries convention "daily". A version before P3 knew no features, +/// so it stops at that declaration (NewerJournal; format.rs's +/// an_older_reader_stops_at_p3s_features), never reading the schedule as +/// equal months. This version reads it back, posts by the day, and +/// verifies; a second daily schedule declares nothing more. +#[test] +fn the_first_daily_schedule_declares_its_feature_and_an_older_reader_stops_there() { + let dir = test_dir("daily-feature"); + let accounts = schedule_books(&dir); + let mut store = Store::open(&dir).unwrap(); + let id = store + .register_schedule(insurance( + accounts, + day(2026, 3, 15), + ledger_core::ScheduleConvention::Daily, + )) + .unwrap(); + let journal = lines(&dir); + let n = journal.len(); + assert_eq!(journal[n - 1]["type"], "schedule.registered"); + assert_eq!(journal[n - 1]["payload"]["convention"], "daily"); + assert_eq!(journal[n - 2]["type"], FORMAT_SET); + assert_eq!( + journal[n - 2]["payload"], + json!({"format": 2, "features": ["schedule_convention"]}) + ); + store.run_schedules(day(2026, 5, 31)).unwrap(); + store + .register_schedule(insurance( + accounts, + day(2026, 4, 2), + ledger_core::ScheduleConvention::Daily, + )) + .unwrap(); + let declared = lines(&dir) + .iter() + .filter(|l| l["type"] == FORMAT_SET) + .count(); + assert_eq!(declared, 1, "declared once"); + store.verify().unwrap(); + let posted: Vec = store + .ledger() + .get_schedule(id) + .unwrap() + .posted + .iter() + .copied() + .collect(); + assert_eq!( + posted, + [day(2026, 3, 31), day(2026, 4, 30), day(2026, 5, 31)] + ); + drop(store); + // Reopened, rebuilt, and through the viewer: the same plan, posted. + let mut store = Store::open(&dir).unwrap(); + store.rebuild().unwrap(); + store.verify().unwrap(); + let s = store.ledger().get_schedule(id).unwrap(); + assert_eq!(s.convention, ledger_core::ScheduleConvention::Daily); + assert_eq!(s.plan().unwrap()[0].1, Decimal::new(5484, 2)); + let snap = Snapshot::load(&dir).unwrap(); + assert_eq!( + snap.ledger().differs_from(store.ledger()), + Vec::<&str>::new() + ); +} + +/// P3: an equal-months schedule (journals before P3 hold only these) +/// declares no feature: books that never use a daily schedule stay +/// readable by versions before it. Its line names its convention. +#[test] +fn a_monthly_even_schedule_declares_no_feature() { + let dir = test_dir("monthly-even-no-feature"); + let accounts = schedule_books(&dir); + let mut store = Store::open(&dir).unwrap(); + store + .register_schedule(insurance( + accounts, + day(2026, 3, 1), + ledger_core::ScheduleConvention::MonthlyEven, + )) + .unwrap(); + let journal = lines(&dir); + assert!(journal.iter().all(|l| l["type"] != FORMAT_SET)); + assert_eq!( + journal.last().unwrap()["payload"]["convention"], + "monthly_even" + ); + assert_eq!(store.journal_format(), &JournalFormat::default()); + store.verify().unwrap(); +} + +/// P3: a schedule convention this version doesn't know (one a newer +/// version adds) is newer, never corrupt (rules.md B-27); one that isn't a +/// string is damage. +#[test] +fn an_unknown_schedule_convention_is_newer_not_corrupt() { + let dir = test_dir("unknown-convention"); + let accounts = schedule_books(&dir); + let registered = |convention: serde_json::Value| { + json!({ + "id": 1, + "description": "Insurance", + "kind": "prepaid_expense", + "total": {"amount": "1200", "currency": "USD"}, + "start": "2026-03-15", + "months": 12, + "balance_account": accounts.0, + "pl_account": accounts.1, + "convention": convention, + }) + }; + let seq = append( + &dir, + &[("schedule.registered", registered(json!("weekly")))], + )[0]; + std::fs::remove_file(dir.join(DB_FILE)).unwrap(); + for got in [Store::open(&dir).err(), Snapshot::load(&dir).err()] { + match got { + Some(StoreError::NewerJournal { seq: s, need, .. }) => { + assert_eq!(s, seq); + assert_eq!(need, "the schedule convention weekly"); + } + other => panic!("expected NewerJournal, got {other:?}"), + } + } + let dir = test_dir("bad-convention"); + let accounts2 = schedule_books(&dir); + assert_eq!(accounts2, accounts); + append(&dir, &[("schedule.registered", registered(json!(7)))]); + std::fs::remove_file(dir.join(DB_FILE)).unwrap(); + assert_corrupt(Store::open(&dir)); +} + +/// P3 (R3; rules.md B-27): a schedule month reversed within its month is +/// posted again only after journal feature month_reposting is declared +/// (an older version would refuse the second schedule.posted as posted +/// twice), and the books replay to the same ledger. A month reversed after +/// it ended is left by the run, listed with why (it would count twice until +/// the reversal), and declares nothing. Hand check: equal months of 100.00 +/// from 2026-03-01; March posted, reversed on Mar 31, run again: one +/// March, 100.00; April posted, reversed on May 2: April held. +#[test] +fn a_schedule_month_posted_again_declares_month_reposting_and_replays() { + let dir = test_dir("schedule-repost"); + let accounts = schedule_books(&dir); + let mut store = Store::open(&dir).unwrap(); + let id = store + .register_schedule(insurance( + accounts, + day(2026, 3, 1), + ledger_core::ScheduleConvention::MonthlyEven, + )) + .unwrap(); + let mar = store.post_schedule_month(id, day(2026, 3, 31)).unwrap(); + store + .reverse_entry(mar, day(2026, 3, 31), "posted early") + .unwrap(); + assert!(store.journal_format().features.is_empty()); + let posted = store.run_schedules(day(2026, 3, 31)).unwrap(); + assert_eq!(posted.len(), 1); + assert_eq!( + store.journal_format().features, + vec![ledger_store::format::MONTH_REPOSTING.to_string()] + ); + let apr = store.post_schedule_month(id, day(2026, 4, 30)).unwrap(); + store + .reverse_entry(apr, day(2026, 5, 2), "wrong policy") + .unwrap(); + let head = store.head(); + let posted = store.run_schedules(day(2026, 4, 30)).unwrap(); + assert!(posted.is_empty(), "{posted:?}"); + assert_eq!(store.head(), head); + let held = store.schedule_months_held(day(2026, 4, 30)).unwrap(); + assert_eq!(held.len(), 1); + assert_eq!((held[0].0, held[0].1), (id, day(2026, 4, 30))); + assert!(held[0].2.contains("2026-05-02"), "{}", held[0].2); + store.verify().unwrap(); + drop(store); + let mut store = Store::open(&dir).unwrap(); + store.rebuild().unwrap(); + store.verify().unwrap(); + let s = store.ledger().get_schedule(id).unwrap(); + assert!(s.posted.contains(&day(2026, 3, 31))); + assert!(!s.posted.contains(&day(2026, 4, 30))); + let snap = Snapshot::load(&dir).unwrap(); + assert_eq!( + snap.ledger().differs_from(store.ledger()), + Vec::<&str>::new() + ); +} + +/// P3 review round 2 (the format-upgrade gate refuses a feature until the +/// owner consents): every command that needs a journal feature decides it, +/// and is refused, before its first journaled line, so a refused feature +/// journals nothing: never a depreciation entry without the +/// `asset.depreciation_posted` that makes it the period's run, nor a +/// disposal's final depreciation without its line. Each command: a period +/// run again after its run was reversed (month_reposting), a disposal whose +/// month's run was reversed (its final depreciation, month_reposting), a +/// daily schedule (schedule_convention), and a schedule month posted again +/// (month_reposting). Taken again, each journals and the books verify. +#[test] +fn a_refused_journal_feature_journals_nothing() { + let dir = test_dir("feature-refused"); + let accounts = schedule_books(&dir); + let mut store = Store::open(&dir).unwrap(); + let add = |s: &mut Store, code: &str, t| s.add_account(code, code, t, None, false).unwrap(); + let cost = add(&mut store, "1500", AccountType::Asset); + let accum = add(&mut store, "1501", AccountType::Asset); + let expense = add(&mut store, "6100", AccountType::Expense); + let gain = add(&mut store, "4900", AccountType::Revenue); + let cash = store.ledger().chart().find_by_code("1000").unwrap().id; + let usd = |a: i64| Money::new(Decimal::new(a, 2), Currency::USD); + let lathe = |s: &mut Store, what: &str| { + s.register_asset(ledger_core::NewAsset { + description: what.into(), + purchase_date: day(2026, 1, 1), + cost: usd(1_200_000), + salvage_value: usd(0), + useful_life_months: 12, + cost_account: cost, + accum_depr_account: accum, + expense_account: expense, + }) + .unwrap() + }; + // One asset's January run, reversed within January: due again. + let asset = lathe(&mut store, "Lathe"); + let run = store.run_depreciation(asset, "2026-01").unwrap(); + store + .reverse_entry(run.entry_id.unwrap(), day(2026, 1, 31), "doubted") + .unwrap(); + // Another's January to March run, March reversed within March: a + // disposal on Mar 15 posts March's final depreciation again. + let sold = lathe(&mut store, "Old lathe"); + for m in ["2026-01", "2026-02"] { + store.run_depreciation(sold, m).unwrap(); + } + let mar = store.run_depreciation(sold, "2026-03").unwrap(); + store + .reverse_entry(mar.entry_id.unwrap(), day(2026, 3, 31), "sold") + .unwrap(); + let monthly = store + .register_schedule(insurance( + accounts, + day(2026, 1, 1), + ledger_core::ScheduleConvention::MonthlyEven, + )) + .unwrap(); + let jan = store + .post_schedule_month(monthly, day(2026, 1, 31)) + .unwrap(); + store + .reverse_entry(jan, day(2026, 1, 31), "doubted") + .unwrap(); + assert!(store.journal_format().features.is_empty()); + + store.refuse_new_features(true); + let head = store.head(); + let refused = |r: Result<(), StoreError>, what: &str| match r { + Err(StoreError::FormatRefused(why)) => assert!(why.contains("feature"), "{what}: {why}"), + other => panic!("{what}: {other:?}"), + }; + refused( + store.run_depreciation(asset, "2026-01").map(|_| ()), + "a period run again", + ); + refused( + store + .dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain) + .map(|_| ()), + "a disposal's final depreciation", + ); + refused( + store + .register_schedule(insurance( + accounts, + day(2026, 3, 15), + ledger_core::ScheduleConvention::Daily, + )) + .map(|_| ()), + "a daily schedule", + ); + refused( + store + .post_schedule_month(monthly, day(2026, 1, 31)) + .map(|_| ()), + "a schedule month posted again", + ); + assert_eq!(store.head(), head, "nothing journaled"); + assert_eq!(store.ledger().next_entry_id(), { + let snap = Snapshot::load(&dir).unwrap(); + snap.ledger().next_entry_id() + }); + store.verify().unwrap(); + + store.refuse_new_features(false); + assert!( + store + .run_depreciation(asset, "2026-01") + .unwrap() + .entry_id + .is_some() + ); + store + .post_schedule_month(monthly, day(2026, 1, 31)) + .unwrap(); + store + .dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain) + .unwrap(); + assert_eq!( + store.journal_format().features, + vec![ledger_store::format::MONTH_REPOSTING.to_string()] + ); + store.verify().unwrap(); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index fbe5aab..ae3fba0 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -664,6 +664,7 @@ fn schedules_and_recurring_entries_replay_exactly() { months: 3, balance_account: prepaid, pl_account: ins, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); store @@ -1033,7 +1034,7 @@ mod refusals { DisposeAsset(u64, u32, i64, u64, u64), RunPayroll(u32, i64, i64, u64), Remit(u32, u64), - RegisterSchedule(bool, i64, u32, u32, u64, u64), + RegisterSchedule(bool, i64, u32, u32, u64, u64, bool), PostScheduleMonth(u64, u32), RegisterRecurring(u64, u64, i64, u32), RunRecurring(u32), @@ -1045,7 +1046,7 @@ mod refusals { // that need a chain of earlier steps get exercised too. Book(u32, u64, u64, i64), GoodAsset(i64, u32), - GoodSchedule(bool, i64, u32), + GoodSchedule(bool, i64, u32, bool), GoodInvoice(u32, i64), GoodBill(u32, i64), GoodClose(u32), @@ -1055,6 +1056,16 @@ mod refusals { NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n % 120)) } + /// A schedule's convention: by the day, or equal months (refused + /// unless it starts on the 1st, D-37). + fn convention(daily: bool) -> ledger_core::ScheduleConvention { + if daily { + ledger_core::ScheduleConvention::Daily + } else { + ledger_core::ScheduleConvention::MonthlyEven + } + } + /// Cents, biased toward the edges: zero, negative, tiny, huge. fn arb_cents() -> impl Strategy { prop_oneof![ @@ -1135,9 +1146,12 @@ mod refusals { 0u32..120, 0u32..30, 0u64..10, - 0u64..10 + 0u64..10, + any::() ) - .prop_map(|(k, t, st, m, b, p)| Op::RegisterSchedule(k, t, st, m, b, p)), + .prop_map(|(k, t, st, m, b, p, daily)| Op::RegisterSchedule( + k, t, st, m, b, p, daily + )), (0u64..4, 0u32..12).prop_map(|(s, m)| Op::PostScheduleMonth(s, m)), (0u64..10, 0u64..10, arb_cents(), 0u32..120) .prop_map(|(a, b, c, d)| Op::RegisterRecurring(a, b, c, d)), @@ -1159,8 +1173,8 @@ mod refusals { (0u32..120, 1u64..10, 1u64..10, 1i64..500_000) .prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)), (1i64..5_000_000, 1u32..60).prop_map(|(c, m)| Op::GoodAsset(c, m)), - (any::(), 1i64..1_000_000, 1u32..24) - .prop_map(|(k, t, m)| Op::GoodSchedule(k, t, m)), + (any::(), 1i64..1_000_000, 1u32..24, any::()) + .prop_map(|(k, t, m, daily)| Op::GoodSchedule(k, t, m, daily)), (0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodInvoice(d, c)), (0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodBill(d, c)), (1u32..5).prop_map(Op::GoodClose), @@ -1384,7 +1398,7 @@ mod refusals { ) .map(drop), Op::Remit(d, c) => store.remit_payroll_taxes(day(d), c).map(drop), - Op::RegisterSchedule(k, t, st, m, b, pl) => store + Op::RegisterSchedule(k, t, st, m, b, pl, daily) => store .register_schedule(ledger_core::NewSchedule { description: "sched".into(), kind: if k { @@ -1397,6 +1411,7 @@ mod refusals { months: m, balance_account: b, pl_account: pl, + convention: convention(daily), }) .map(drop), Op::PostScheduleMonth(id, m) => { @@ -1440,7 +1455,7 @@ mod refusals { expense_account: 9, }) .map(drop), - Op::GoodSchedule(k, t, m) => store + Op::GoodSchedule(k, t, m, daily) => store .register_schedule(ledger_core::NewSchedule { description: "insurance".into(), kind: if k { @@ -1453,6 +1468,7 @@ mod refusals { months: m, balance_account: if k { 1 } else { 6 }, pl_account: if k { 8 } else { 7 }, + convention: convention(daily), }) .map(drop), Op::GoodInvoice(d, c) => { @@ -1976,8 +1992,8 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) { tally(s.due(d).map(|_| ())); } for a in l.registered_assets() { - let _ = ledger_core::depreciation_schedule(a, d); - let _ = ledger_core::book_value(a, d); + tally(ledger_core::depreciation_schedule(a, d).map(|_| ())); + tally(ledger_core::book_value(a, d).map(|_| ())); tally( ledger_core::disposal_amounts( a, @@ -2863,6 +2879,7 @@ fn replay_refuses_a_schedule_posting_it_does_not_rederive() { months: 12, balance_account: prepaid, pl_account: insurance, + convention: ledger_core::ScheduleConvention::MonthlyEven, }) .unwrap(); store.post_schedule_month(id, day(1, 31)).unwrap(); diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index f564dbb..d4caa5e 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -447,7 +447,14 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. when the shares don't add up to what the entry moved, `parties_error` says why and confirming fails with code `party_shares`. - `books.reverse_entry { entry_id, date, description, confirmed }` → same - preview/confirm pattern as post. + preview/confirm pattern as post. A reversed depreciation run or schedule + month is un-posted (its month is due again); refused, at the preview + already (P3): an asset's disposal entry (it would stay disposed), a + depreciation run of a disposed asset (its disposal took it as final), + and a reversal whose reversal would post a depreciation period or a + schedule month a second time (naming the entry that posts it now), or + re-instate a run a later run caught up (carrying the books past the + schedule). - `books.get_entry { entry_id }` → full entry with lines, receipts, and its `vendor`, `party_id`, and `party` (the party's label, when it names exactly one) tags, `parties` / `parties_error` as in the `post_entry` @@ -556,8 +563,13 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. ## Tools — fixed assets Straight-line **book** depreciation only; tax depreciation stays with the -CPA. Partial first/final months prorated by days; the final month trues up -so the lifetime total is exactly (cost − salvage). +CPA. Depreciation accrues by the day (rules.md B-15): a partial first +month, the life's final month (which takes what the others leave, evenly +over its days), and any month a schedule stops in (through a date, or a +disposed asset's disposal day, held) are prorated by days, never plugged; +the lifetime total is exactly (cost − salvage), rounded to the currency's +minor unit. A new asset's cost and salvage are in the minor unit, and its +life's last day is 9999-12-31 at the latest. - `books.register_asset { description, purchase_date, cost, salvage_value?, useful_life_months, cost_account, accum_depr_account, expense_account, @@ -573,19 +585,50 @@ so the lifetime total is exactly (cost − salvage). tags }`: an Avery 5160/8160 label sheet (PDF): company, tag, QR code of the tag, description, serial. Default: every active tagged asset; `skip` reuses a partly used sheet. -- `books.asset_schedule { asset_id | tag }` - → `{ status, schedule: [{ period, amount, accumulated, book_value }] }`. -- `books.list_assets {}` → assets with status and current book values. +- `books.asset_schedule { asset_id | tag, through? }` + → `{ status, schedule: [{ period, amount, accumulated, book_value }] }`; + a disposed asset's ends on its disposal day. A schedule an earlier + version's asset can't compute is a tool error saying why, never a + panic. +- `books.list_assets {}` → assets with status, accumulated depreciation + by schedule and book value as of today (a disposed asset's through its + disposal day); `accumulated` and `book_value` are null, with + `schedule_error` saying why, for an asset whose schedule can't be + computed (one bad asset never fails the list). - `books.run_depreciation { period: "YYYY-MM", asset_id? }` - → per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for - assets outside their useful life. Idempotent per asset and period. + → `runs: [{ asset_id, description, period, amount, caught_up, entry_id, + already_posted }]` and `skipped: [{ asset_id, description, reason }]`. + A run posts what the schedule says is due through the month's end, so a + month skipped or reversed is caught up in the month run (`caught_up`: + the part beyond the month's own; a material miss is a restatement for + the CPA, ASC 250), and never more than the schedule at a later month + end. What the books carry is read entry by entry, each in its own + month, so a month whose run was reversed after it ended posts nothing + (it carried the month then; the run of the reversal's month catches it + up). Idempotent per asset and period. Nothing due (the + month rounds to nothing, a later run caught it up, the life is over and + posted) posts nothing and is listed under `skipped` with why, as are a + run-all's assets not yet in service and any whose schedule can't be + computed. A run before the in-service date is refused. - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` → - without - confirmation: `{ status: "preview", ... }` and posts nothing; with - `confirmed: true`: auto-posts missing final-period depreciation, then - posts the balanced disposal entry. Subject to receipt policy (bill of - sale); monthly depreciation is exempt as an allocation. `buyer` (a + without confirmation: `{ status: "preview", accumulated, book_value, + proceeds, gain_loss, final_depreciation, excess_depreciation, + catch_up_depreciation, lines, receipt_required }` and posts nothing; + with `confirmed: true`: auto-posts the final depreciation (through the + disposal day, held), then posts the balanced disposal entry. Book value + is cost less the schedule through the disposal day, whatever the runs + posted: depreciation a run posted past it (the disposal month's run is + dated its end) is credited back to depreciation expense in the disposal + entry (`excess_depreciation`); depreciation still due when the disposal + month already has a run is debited there (`catch_up_depreciation`); the + entry clears everything the runs carry. Refused, at the preview already, + unless every run carries at the end of the disposal's month what it + carries now: a run of a later month that stands (reverse it first), or + a reversal (or re-instatement) of an earlier run dated after the + disposal's month that a later one hasn't undone (undo it, on any open + day). Subject to receipt policy (bill + of sale); monthly depreciation is exempt as an allocation. `buyer` (a party) is the disposal entry's party; under `[books] parties = "require"` a sale with proceeds without one is refused before anything posts (`party_required`); a retirement with no proceeds and no buyer is @@ -603,27 +646,47 @@ so the lifetime total is exactly (cost − salvage). ## Tools — schedules and recurring entries Amortization spreads a prepaid expense (asset → expense) or deferred -revenue (liability → revenue) evenly over the months it covers, posting on -each month end, to the cent (the last month absorbs rounding). Like +revenue (liability → revenue) over the time it covers, posting on each +month end, to the minor unit (rules.md B-16). A new schedule's convention +is `books.schedule_convention` (unset: `daily`, D-11/D-37): `daily` +spreads by the day from `start`, the first day covered, as depreciation +does, so a mid-month start posts a small first and last month (a 12-month +policy from March 15 is thirteen month ends: 54.84, eleven of 100.00, +45.16 for 1,200.00); `monthly_even` is equal months, the last absorbing +rounding, and starts on the 1st (D-37: revenue is never earned early, +ASC 606-10-25-23). Schedules registered before P3 stay `monthly_even` +(mid-month starts included), forever. Like depreciation, the schedule is the evidence: its postings are exempt from the receipt policy (the original payment carries the receipt). Recurring entries never post: they generate drafts, so every real receipt still goes through the policy. - `books.create_schedule { kind: "prepaid_expense" | "deferred_revenue", - description, total, start, months, balance_account, pl_account }` → - `{ schedule_id, ..., total, remaining, months: [{ date, amount, posted }], - plan_error }`. Prepaid needs an asset and an expense account; deferred - revenue a liability and a revenue account. `plan_error` is null, except - for a schedule an earlier version accepted whose months run past the - last date the books can compute: then it says why, `remaining` is null - and `months` empty, and `books.run_schedules` refuses until it is dealt - with. + description, total, start, months, balance_account, pl_account, + convention?: "daily" | "monthly_even" }` → `{ schedule_id, ..., + convention, start, end, next, total, remaining, months: [{ date, amount, + posted }], plan_error }`. Prepaid needs an asset and an expense account; + deferred revenue a liability and a revenue account. `start` is the + first day covered (for a monthly_even schedule an earlier version took + mid-month, the 1st of its month), `end` the last day covered, `next` + the next month end to post (null once every month has). Refused: a + convention but those two (naming them); a `monthly_even` schedule not + starting on the 1st (naming the 1st, and `daily`); one whose months + aren't whole minor units (yen); a daily one whose last day is after + 9999-12-31. `plan_error` is null, except for a schedule an earlier + version accepted whose months run past the last date the books can + compute: then it says why, `remaining` and `next` are null and `months` + empty, and `books.run_schedules` refuses until it is dealt with. - `books.list_schedules {}` → `{ schedules: [...] }`, same shape. - `books.run_schedules { through?, confirmed? }` → `{ status: "preview" | "posted", postings: [{ schedule_id, date, description, amount, - entry_id? }] }`: every unposted month ending on or before `through` - (default today). Idempotent; closed periods are refused. + entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every + unposted month ending on or before `through` (default today). + Idempotent; a month reversed within its month is due again; a month + dated in a closed period, or whose posting was reversed after the month + ended (posting it again would recognize it twice until then), is left + and listed under `skipped` with why (book it in an open period by hand, + or not at all), never a failed run. - `books.create_recurring { description, frequency: "weekly" | "monthly" | "quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }` → `{ recurring_id, ..., generated: [] }`. Lines must balance. Monthly and diff --git a/docs/spec/rules.md b/docs/spec/rules.md index e9fca0b..95beca4 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -71,7 +71,12 @@ minor unit), `Ledger::add_line_fx` (base amount = foreign amount × rate, rounded to the minor unit; original kept), `Ledger::post_entry` (every line, whatever made it: payroll, closes, disposals; `FinerThanMinorUnit`, since 2026-10-13 after a payroll run posted $65.27794). Journals from -before replay as booked (`replay_post_entry`). **Checked:** +before replay as booked (`replay_post_entry`). A new asset's cost and +salvage are in the minor unit too (P3: every depreciation and disposal +line derives from them; `validate_new_asset`), and depreciation and daily +schedules round to it (B-15, B-16); assets an earlier version took finer +replay as journaled. **Checked:** +`an_asset_cost_finer_than_a_cent_is_refused`, `a_foreign_line_is_booked_in_whole_base_units`, `no_entry_posts_a_fraction_of_the_minor_unit`; the GnuCash oracle. **Checked:** `foreign_currency_line_is_rejected`, `fx_overflow_is_an_error`. @@ -679,9 +684,127 @@ allocation. Tax conventions (half-year, mid-month, mid-quarter): IRS Pub. 946 (`sources/irs-depreciation.md`), for the CPA. -**Enforced:** `assets::depreciation_schedule`. **Checked:** +The rule, exactly (P3, defect 22; the engineer settled it from ASC +360-10-35-4, 2026-10-20): + +1. The life is `[in-service date, in-service date + life months)` (chrono + clamps a day past a month's end: Jan 31 plus one month is Feb 28), cut + into the calendar months it touches. +2. Through day `k` of a month but the life's last, the depreciation is + (cost − salvage) / life × (the months before, each as days held over + days in the month, + `k` / days in this month): the monthly rate by + days. +3. The life's last month takes what the months before it leave, evenly + over its days: through its last day the depreciation is exactly cost + − salvage, and through an earlier day of it, prorated by days, never + plugged to the whole. (Without clamping its daily rate is the first + month's: the remainder is that month's missing days.) Before P3 any + schedule through a day of the final month, and every disposed asset's, + plugged to the whole: a disposal in the final month posted too much + depreciation and too large a gain. +4. Each cumulative figure is rounded half away from zero to the + currency's minor unit (cents; whole yen; a thousandth of a dinar; + ISO 4217, `sources/iso-4217.md`), in exact integer arithmetic + (`kernel::prorated_through`); a month's amount is the difference of + consecutive ones. Through every month end this is the per-month + running total books posted before P3, except where the exact figure is + a half-cent tie, which the old 28-digit Decimal quotients could round + either way; P3 rounds it up, exactly, moving that cent to the month + before. +5. A disposed asset depreciates through its disposal day, held + (inclusive): its schedule, accumulated depreciation and book value + stop there, and equal what the disposal took. The disposal's book + value is cost less the schedule through that day whatever the runs + posted: depreciation still due posts as the final depreciation; + depreciation the disposal month's run (dated its end) posted past the + day is credited back to depreciation expense in the disposal entry + (`excess_depreciation`); and the disposal clears what the runs carry, + so the asset leaves the accumulated depreciation account at zero. A + disposal is refused unless every run carries at the end of the + disposal's month what it carries now (the disposal clears what they + carry now): while a run of a later month stands (reverse it first: + credited back at the disposal day, it would leave the disposal month's + accumulated depreciation a debit and its expense short), or while a + reversal (or re-instatement) of an earlier run dated after the month + isn't undone (the disposal would charge that run again, and the + reversal take it away only later; undone on any open day, so a closed + month never blocks a disposal for ever). A change a run carries only + at later month ends (a later month's run reversed after its month) is + that run's error and its correction, dated when made, and the disposal + leaves it as it is. +6. A month's run posts what the schedule says is due through the + month's end, less what the books carry then, but never so much that + the books would carry more than the schedule at a later month end + (`kernel::catch_up`). What the books carry is read entry by entry, each + in its own month (`assets::depreciation_effects`): a run adds its + amount in its month, a reversal of it takes it away in the reversal's + month. So a month skipped, or a run reversed, is caught up in the month + the run is made (plan/served.md P3, settled in design, 2026-10-20: an + immaterial miss; a material one is restated under ASC 250, a + correction for a human and the CPA), and a month whose run was + reversed after it ended is never run twice (it carried the month at + its end; the run of the reversal's month catches it up). A month a + later run caught up, or that rounds to nothing, posts nothing (an entry + carries no zero line); a run after the life posts what is still due. + A reversed run is un-posted (its month due again, when reversed within + it); re-instating one (reversing its reversal) is refused when it would + carry the books past the schedule (a later run caught its month up: + `assets::catch_up_room`); a disposal, or a run its disposal took as + final, can't be reversed; and a run, or a reversal of one, can't be + reversed on a day before its own (a correction comes after what it + corrects, B-9: a run of March reversed on Jan 31 would leave January + and February with negative depreciation; commands only, journals + replay as written). The viewer and the store read every run the + journal holds, not a month's latest (a run re-instated after its month + was run again and that run reversed is the month's run). + +**Enforced:** `assets::depreciation_schedule`, `assets::run_amount`, +`assets::catch_up_room`, `assets::depreciation_effects`, +`kernel::prorated_through`, `kernel::catch_up`, `kernel::true_up`, +`calendar::month_parts`, `Store::run_depreciation`, +`Store::dispose_preview` (`check_nothing_after_disposal`), +`Store::check_reverse_entry`. **Checked:** `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_final_month_drift_is_spread_by_days`, +`defect22_a_disposed_assets_schedule_stops_at_its_disposal_day`, +`defect22_a_disposal_in_the_final_month_posts_depreciation_by_days`, +`defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days`, +`a_run_reinstated_after_its_rerun_was_reversed_still_counts`, +`the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed`, +`a_reversal_dated_before_the_entry_it_reverses_is_refused`, +`a_disposal_is_taken_once_a_late_change_is_undone_after_its_month_closed`, +`a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry`, +`every_order_of_runs_reversals_disposals_and_closes_keeps_the_books_right`, +`prop_any_sequence_keeps_the_books_right`, +`prop_the_viewer_reads_an_asset_as_the_store_does`, +`prop_run_amounts_are_each_months_run_amount`, +`jpy_depreciation_posts_whole_yen`, `a_skipped_month_is_caught_up_in_the_month_run`, +`a_reversed_depreciation_month_can_be_run_again`, +`a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`, +`reinstating_a_run_a_later_run_caught_up_is_refused`, +`a_disposal_before_a_later_months_run_is_refused_until_it_is_reversed`, +`a_disposal_before_a_later_reversal_of_an_earlier_run_is_refused`, +`a_disposal_entry_cannot_be_reversed`, +`prop_the_final_month_takes_the_remainder_by_its_days`, +`depreciation_effects_read_each_entry_in_its_month`, +`prop_depreciation_is_monotone_and_bounded`, +`prop_disposed_schedule_equals_what_disposal_computed`, +`prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties`; +`the_charge_never_falls_and_is_continuous_over_real_calendars`; Kani +`prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder`, +`a_months_charge_is_within_half_a_cent`, +`a_months_charge_in_tenths_is_within_half_a_cent`, +`the_last_months_charge_is_within_half_a_cent`, +`the_last_months_charge_in_tenths_is_within_half_a_cent`, +`prorated_through_refuses_malformed_input`, +`catch_up_never_overtakes_the_schedule`, +`catch_ups_is_every_suffixs_catch_up`, `true_up_reaches_the_target`; +Lean `Prorate.kernel_parts_total`, `Prorate.kernel_parts_nonneg`, +`Prorate.part_near`, `Prorate.cum_near`, `Prorate.chargeMid_eq_cum`, +`Prorate.chargeLast_eq_cum`, `Prorate.chargeLast_start`, +`Prorate.chargeLast_mono`, `Prorate.cum_mono` (`docs/verification.md`). ## B-16 Prepaid expenses and deferred revenue are recognized over the period benefited or earned @@ -697,8 +820,51 @@ current within the operating cycle; the ASC prescribes no expensing method, so monthly expensing stays a convention. Tax: the 12-month rule, Treas. Reg. §1.263(a)-4(f) (`sources/irs-capitalization.md`). -**Enforced:** `schedules`. **Checked:** `a_prepaid_expense_is_expensed_month_by_month`, -`deferred_revenue_is_earned_to_the_cent`. +Conventions (P3, defect 9): `daily` or `monthly_even`. A new schedule +spreads by the day from its start (the first day covered), as +depreciation does (B-15 rules 1-4: full months equal, the first and last +partial months by their days, the last taking what the others leave, +rounded to the minor unit): a 12-month policy from March 15 posts +thirteen month ends, a small March and a small final March (DECISION, +the owner, D-11 confirmed in D-37, 2026-10-20). Schedules registered before +P3 keep equal months, the last absorbing rounding, forever: a posted +month's replay re-derives its plan. `books.schedule_convention` sets the +convention new schedules take (unset: daily). An equal-months schedule +starts on the 1st of a month, for prepaid expenses and deferred revenue +alike (DECISION, the owner, D-37, 2026-10-20): revenue is never recognized +before it is earned (ASC 606-10-25-23), and one rule for both is simpler +and never early; schedules an earlier version took mid-month replay as +they were. A reversed month is un-posted, so it is due again; a month in +a closed period, or one whose posting was reversed after the month ended +(posting it again on its own date would recognize it twice until the +reversal; a schedule posts its plan's months, so it can't catch one up +later), is left by a run for a human, listed with why, never a failed run. +A month's posting can't be reversed on a day before its own (a +correction comes after what it corrects, B-9; commands only). Not yet +vendored: ASC 606-10-25-31 (progress toward satisfying an obligation over +time), 606-10-25-32 (one method, applied consistently), 606-10-55-20 (time +elapsed as an input method): to read from the owner's ASC login before +B-16 cites them (P3 depreciation review R7). + +**Enforced:** `schedules` (`daily_plan`, `amortization_plan`, +`validate_schedule`), `kernel::prorate_by_days`, `Ledger::reverse_entry`, +`Ledger::schedule_month_hold`. +**Checked:** `a_prepaid_expense_is_expensed_month_by_month`, +`deferred_revenue_is_earned_to_the_cent`, +`defect9_a_new_schedule_spreads_by_the_day`, +`defect9_deferred_revenue_daily_is_earned_to_the_cent`, +`defect9_legacy_schedules_stay_monthly_even`, +`an_equal_months_schedule_starts_on_the_first`, +`a_jpy_daily_schedule_posts_whole_yen`, `a_kwd_daily_schedule_rounds_to_the_fils`, +`a_reversed_schedule_month_is_due_again`, +`a_reversal_dated_before_the_entry_it_reverses_is_refused`, +`a_schedule_month_reversed_after_it_ended_is_left_for_a_human`, +`a_month_that_rounds_to_nothing_is_never_a_posting`, +`a_schedule_month_posted_again_declares_month_reposting_and_replays`, +`daily_plans_pin_month_end_clamping`, +`prop_daily_plan_sums_exactly_and_lands_on_month_ends`, +`prop_monthly_even_plan_is_unchanged`; `ledger-mcp` +`mcp_daily_schedule_over_stdio`. ## B-17 Platform payouts are recorded gross when the business is the principal @@ -1258,13 +1424,23 @@ count's first close, `"rounding": "cumulative_since"`), `account_renamed` (before the first `settings.set` naming `policy.require_tax_category`, B-32), `party_creditors` (`party.creditor_set`: defect 24, B-35), `payment_instruments` (`instrument.added`, `entry.instrument_set`: -defect 12, B-19) and `vendor_1099_decisions` (`vendor.1099_set` with who +defect 12, B-19), `vendor_1099_decisions` (`vendor.1099_set` with who 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). A value +effect, and `vendor.w9_flag_set`: defects 18 and 23, D-40, B-19), +`schedule_convention` (a daily schedule's `schedule.registered` carries +`convention: daily`; an older version would read it as equal months, +B-16) and `month_reposting` (a schedule month or a depreciation period +posted again after a reversal un-posted it; an older version would refuse +the second `schedule.posted`, and its projection ignore the second +`asset.depreciation_posted`, B-15). A value in a line of a known type that only a newer version writes (a 1099 decision or reason kind, a payment method's kind) is newer, never -corrupt. A later piece asks the owner's go-ahead before the first such -declaration (plan D-39). +corrupt. A command decides whether it needs a feature, and declares it, +before its first journaled line, so a feature refused (a format upgrade +awaiting the owner's consent) journals nothing, never an entry without +the line that says what it is +(`a_refused_journal_feature_journals_nothing`). A later piece asks the +owner's go-ahead before the first such declaration (plan D-39). **Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`, `gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`, @@ -1299,7 +1475,12 @@ bounds are the commands', never replay's) 0001-01-01 through 9999-12-31 (`ledger_core::calendar`): a date given to a tool is exactly YYYY-MM-DD, a real day in that range (no sign, no space, no fifth year digit); a period is a month of it; a new schedule's -last month, a new asset's last day of depreciation, a new recurring +last month (a daily one's last day), a new asset's last day of +depreciation (so a life ending on 9999-12-31 is taken, and one a day +later refused: P3 settled the plan's defect 22 question this way, lives +past 9999 refused at registration, the bound the life's last day held; +ones an earlier version took open, and their months after 9999-12 show +as scheduled and never post), a new recurring entry's start and end, a new period, a new invoice's or bill's date and due date, a disposal's date stay in it. Every amount a command takes, not only a posted line's, is at most a journal line's diff --git a/docs/testing.md b/docs/testing.md index e64b38d..b404585 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1368,6 +1368,88 @@ unknown-code check, the accounts passed) and `update_party`'s feature declaration; the bookspec loader's party, payee, decision and payment method setup. +## P3: depreciation and schedules (defects 22 and 9, 2026-10-20) + +Every expected figure below was worked by hand from the rule (rules.md +B-15, B-16) and is written out in the test's doc comment; the fixtures are +made up (asset X: 12,000.00 over 12 months from 2026-01-01; a 1,200.00 +policy from March 15). Each regression that is an accounting rule is also +a bookspec example (`bookspec/examples/`, P13, D-34). + +| Law | Test | +|---|---| +| Through a day of the life's final month, depreciation is prorated by days, never plugged (repro A: December 483.87, accumulated 11,483.87) | `ledger-core` `spec_assets_payroll.rs` `defect22_depreciation_through_mid_final_month_is_prorated_not_plugged`; `spec_asset_tags.rs` `register_as_of_a_day_before_a_same_month_disposal_is_prorated` | +| A mid-month life's drift is spread over the final month's days (repro D: through Apr 13, 920.00; Apr 14, 930.00) | `defect22_final_month_drift_is_spread_by_days`; `spec_replayed_extremes.rs` `depreciation_reaches_chronos_last_month` (1,185.92 through Dec 10 of year 262142) | +| A disposed asset's schedule stops at its disposal day, held, and equals what the disposal took (repro B) | `defect22_a_disposed_assets_schedule_stops_at_its_disposal_day`, property `prop_disposed_schedule_equals_what_disposal_computed`; `ledger-mcp` `spec_assets.rs` `a_disposed_asset_lists_what_it_depreciated_through_its_disposal` | +| A disposal in the final month posts depreciation by days (repro C: 322.58, gain 322.58) | `ledger-store` `tests/assets.rs` `defect22_a_disposal_in_the_final_month_posts_depreciation_by_days` | +| A disposal inside a month already run takes back the days after it (R2: excess 500.00, book value 6,500.00) | `defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days` (store and `ledger-mcp` `spec_assets.rs`) | +| A month skipped is caught up in the month the run is made; a later run for it posts nothing (settled in design, P3) | core `a_skipped_month_is_caught_up_in_the_month_run`, store `a_skipped_month_is_caught_up_in_the_month_run`, MCP `a_skipped_month_is_caught_up_and_said` | +| A reversed run is un-posted and runs again (feature month_reposting); re-instating a run whose month ran again is refused; a disposal, and a run its disposal took as final, can't be reversed; a reversed run before a disposal leaves nothing behind (R3) | store `a_reversed_depreciation_month_can_be_run_again`, `a_disposal_entry_cannot_be_reversed`, `a_reversed_run_before_a_disposal_leaves_nothing_behind`; core `a_reversed_schedule_month_is_due_again` | +| What the books carry is read entry by entry, each in its own month: a run reversed after its month ended isn't run twice (its month posts nothing; the reversal's month catches it up), for depreciation; a schedule month reversed after it ended is left for a human, listed with why (P3 review) | store `a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`; GUI `a_run_reversed_after_its_month_leaves_the_reversals_month_due`; core `schedules.rs` `a_schedule_month_reversed_after_it_ended_is_left_for_a_human`; store `format.rs` `a_schedule_month_posted_again_declares_month_reposting_and_replays` | +| Re-instating a run (reversing its reversal) never carries the books past the schedule, nor past cost less salvage (P3 review) | store `reinstating_a_run_a_later_run_caught_up_is_refused` | +| What the books carry is each run and each reversal in its own month, alternately adding and taking away (P3 review) | core `spec_assets_payroll.rs` `depreciation_effects_read_each_entry_in_its_month` | +| A schedule month that rounds to nothing is never a posting: left out of a daily plan, never due in an equal-months one (P3 review; no test held it before) | core `schedules.rs` `a_month_that_rounds_to_nothing_is_never_a_posting` | +| A disposal is refused while a later month's run stands, or while an earlier run's reversal is dated after the disposal's month; once undone, the disposal month ends at 0.00 accumulated and its expense whole (P3 review) | store `a_disposal_before_a_later_months_run_is_refused_until_it_is_reversed`, `a_disposal_before_a_later_reversal_of_an_earlier_run_is_refused` | +| A disposed asset's figures in the viewer are what its disposal took (its excess or catch-up from the disposal entry) and none of its months is Due (P3 review) | GUI `a_disposed_assets_figures_are_what_its_disposal_took` | +| The final month is C + (D − C) × k / d, rounded half up, against arithmetic written in the test (P3 review) | property `prop_the_final_month_takes_the_remainder_by_its_days` (500 cases) | +| A month that rounds to nothing posts and journals nothing (R10) | store `run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing`, MCP `run_depreciation_skips_a_month_that_rounds_to_nothing` | +| Depreciation and daily schedules round to the currency's minor unit (R1: yen, dinars) | `jpy_depreciation_posts_whole_yen`, store `jpy_depreciation_runs_post_whole_yen`, `schedules.rs` `a_jpy_daily_schedule_posts_whole_yen`, `a_kwd_daily_schedule_rounds_to_the_fils`; `money.rs` `steps_are_the_currencys_minor_unit` | +| A new schedule spreads by the day (1,200.00 from March 15: 54.84, eleven 100.00, 45.16); deferred revenue is earned by the day (290.32, 333.34, 333.33, 43.01) | `schedules.rs` `defect9_a_new_schedule_spreads_by_the_day`, `defect9_deferred_revenue_daily_is_earned_to_the_cent`; `schedules.rs` unit `a_daily_plan_spreads_by_the_day`; `ledger-mcp` `mcp_stdio.rs` `mcp_daily_schedule_over_stdio` | +| Schedules from before P3 stay equal months, forever | `defect9_legacy_schedules_stay_monthly_even`, property `prop_monthly_even_plan_is_unchanged`; every old-books fixture (`books_an_earlier_version_wrote_open_to_its_figures`) | +| An equal-months schedule starts on the 1st (D-37); a daily one's last day is in the calendar | `an_equal_months_schedule_starts_on_the_first`, `spec_bounds.rs` `a_daily_schedule_whose_last_day_passes_9999_is_refused` | +| chrono's month-end clamping is pinned for daily plans (Jan 29, 30, 31; Feb 29) | `schedules.rs` unit `daily_plans_pin_month_end_clamping` | +| A new asset's cost and salvage are in the minor unit (replay takes finer ones, computed); a life ending 9999-12-31 is taken, a day later refused | `spec_bounds.rs` `an_asset_cost_finer_than_a_cent_is_refused`, `a_life_ending_on_9999_12_31_is_accepted`, `a_life_ending_after_it_is_refused` | +| The first daily schedule declares feature schedule_convention; an older reader stops there; monthly_even declares nothing; an unknown convention is newer, never corrupt | `ledger-store` `tests/format.rs` `the_first_daily_schedule_declares_its_feature_and_an_older_reader_stops_there`, `a_monthly_even_schedule_declares_no_feature`, `an_unknown_schedule_convention_is_newer_not_corrupt`; `format.rs` unit `an_older_reader_stops_at_p3s_features` | +| Accumulated depreciation never falls, never exceeds cost − salvage, is 0 before service and the whole from the last day (sampled days: the first and final months, two between, a dozen month ends; R9) | property `prop_depreciation_is_monotone_and_bounded` (500 cases) | +| No posted month moves but a half-cent tie's cent (old Decimal arithmetic as the oracle) | property `prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties` (500 cases) | +| A daily plan sums exactly, lands on month ends, each amount > 0; from the 1st, `months` postings within a cent of total/months | property `prop_daily_plan_sums_exactly_and_lands_on_month_ends` (2,000 cases) | +| Refused commands never reach the journal, now with daily and monthly_even schedules and zero-month runs | `ledger-store` `tests/store.rs` `refused_commands_never_reach_the_journal` | +| Every order of runs, reversals (any day, before an entry's own included), re-runs, re-instatements, disposals and closes on one asset keeps the books right: refusals journal nothing; at every month end before the disposal the expense is the accumulated depreciation and within the schedule; one run in effect per month; a disposal's month and the end at the schedule through its day, accumulated 0.00, the gain proceeds less book value; an active asset run month by month past its life takes exactly cost less salvage (P3 review round 2) | store `asset_orders.rs` `every_order_of_runs_reversals_disposals_and_closes_keeps_the_books_right` (exhaustive, three commands deep; four and five with `WB_ORDERS_DEPTH`, run clean 2026-10-21), `prop_any_sequence_keeps_the_books_right` (64 cases, up to 9 commands, any day) | +| The viewer reads an asset as the store does, whatever the runs, reversals and disposals: what the books carry, each finished month's status against a run of it, what is unposted (P3 review round 2) | GUI `prop_the_viewer_reads_an_asset_as_the_store_does` (48 cases), `the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed` | +| A run re-instated after its month was run again and that run reversed still counts: in the store, the disposal and the viewer (P3 review round 2; before, the projection's row named the reversed re-run) | store `a_run_reinstated_after_its_rerun_was_reversed_still_counts`; GUI `the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed` | +| A depreciation run's or schedule month's reversal can't be dated before the entry it reverses (P3 review round 2: a run of March reversed on Jan 31, then a disposal on Feb 15, ended February with a 1,000.00 debit in accumulated depreciation) | store `a_reversal_dated_before_the_entry_it_reverses_is_refused` | +| A disposal waits only until what the runs carry at its month's end is what they carry now, so a late change undone after its month closed lets it through (P3 review round 2; round 1 refused it for ever) | store `a_disposal_is_taken_once_a_late_change_is_undone_after_its_month_closed` | +| Depreciation still due when the disposal's month has a run is charged in the disposal entry, never as a second run of the month (P3 review round 2; no store test held it) | store `a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry` | +| A command that needs a journal feature decides and declares it before its first journaled line: a refused feature journals nothing (the format-upgrade gate) | store `format.rs` `a_refused_journal_feature_journals_nothing` | +| What a viewer's one pass says a run of each month posts is run_amount's for that month | property `prop_run_amounts_are_each_months_run_amount` (500 cases); Kani `catch_ups_is_every_suffixs_catch_up` | +| The kernel on i64 and i32 (Kani's) is the kernel on i128 and Decimal (the engine's) | `kernel_bridge.rs` `prorate_agrees_at_i64_and_i128`, `prorated_through_agrees_at_i32_and_i128`, `catch_up_and_true_up_agree` | +| The charge never falls and is continuous at month ends, over every 1-to-3-month span from every day of a common and a leap year | `kernel.rs` unit `the_charge_never_falls_and_is_continuous_over_real_calendars` (exhaustive) | +| Proofs | Kani `prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder`, `a_months_charge_is_within_half_a_cent`, `a_months_charge_in_tenths_is_within_half_a_cent`, `the_last_months_charge_is_within_half_a_cent`, `the_last_months_charge_in_tenths_is_within_half_a_cent`, `prorated_through_refuses_malformed_input`, `catch_up_never_overtakes_the_schedule`, `catch_ups_is_every_suffixs_catch_up`, `true_up_reaches_the_target`; Lean `Prorate.lean` (`cum_mono`, `cum_le`, `cum_full`, `parts_telescope`, `prorate_total`, `prorate_nonneg`, `part_within_a_unit`, `roundUnit_near`, `roundUnit_scale`, `cum_near`, `part_near`, `chargeMid_eq_cum`, `chargeLast_eq_cum`, `chargeLast_start`, `chargeLast_mono`, `kernel_parts_total`, `kernel_parts_nonneg`; `docs/verification.md`) | + +**Mutation targets for P12** (none run in P3, D-33): `kernel.rs` +`prorated_through` (the last/not-last branch, the rounding comparison +`r >= step - r` against `>`, the `k == days && last` shortcut, the cap at +the total, every refusal), `round_to_unit`, `prorate_by_days`, +`part_weight`, `MonthPart::is_valid`, the three `Units` impls, `catch_up`, +`catch_ups`, `true_up`; `calendar.rs` `month_parts`, `days_in_month`, +`next_month_start`; `money.rs` `Steps::of`, `Steps::decimal`; `assets.rs` +`schedule_months` (the stop date's `min` with the disposal day, the +inclusive `+ 1`, the partial-month break), `life_end`, `check_disposal`, +`run_amount`, `catch_up_room`, `depreciation_effects` (the chain's +alternating signs), `run_amounts` (its stop past the life), `cumulatives`; +`ledger.rs` `check_new_asset` (the minor-unit checks, the `pred()` +bound), `check_reversal` SCENARIO 5, `in_effect`, `reverse_entry_steps`' +schedule un-posting, `schedule_month_posted_before`, +`schedule_month_reversed_after`, `schedule_month_hold`; `schedules.rs` +`daily_plan`, `plan_for`, `validate_schedule` (the 1st rule, the daily +bound, the minor-unit plan check), `first_day`, `last_day`, `next`, `due`'s +zero months, `ScheduleConvention::parse`/`key`; `settings.rs` the new key +and `schedule_convention()`'s default; `store.rs` `run_depreciation` +(nothing due, the feature on a re-run), `live_runs`, +`depreciation_runs_ever`, `dispose_preview` (true-up split, `in_month`), +`check_nothing_after_disposal`, `disposal_lines`, `check_reverse_entry` +(the re-instatement room), +`register_schedule` (the feature only for daily), `ensure_feature`, +`post_schedule_month`'s feature, `run_schedules`' held months, the +`schedule.registered` replay arm's convention, the `asset.depreciation_posted` +projection upsert, `period_month`; `format.rs` `KNOWN_FEATURES`, +`declared_knowing`; `server.rs` `create_schedule`'s default resolution, +`schedule_out`'s new fields, `run_depreciation`'s skips, `list_assets`' +`schedule_error`, `dispose_asset`'s debits, `run_schedules`' held months; +`ledger-gui` `books.rs` `disposal_true_up`, `pages/assets.rs` `unposted` +and `schedule`'s disposed months. Equivalent mutants foreseen: +none (`r >= step - r` against `>` is killed by `half_a_unit_rounds_up`). + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/docs/verification.md b/docs/verification.md index 184cdfe..9b8664d 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -6,9 +6,9 @@ ways, each covering what the others can't: | Layer | Tool | What it proves | Scope | |---|---|---|---| -| **Code** | [Kani](https://model-checking.github.io/kani/) (bounded model checking) | The engine's own Rust kernel is correct and never panics or overflows | Every input within the bounds: up to 4 lines, 3 accounts, amounts to 2^40 | +| **Code** | [Kani](https://model-checking.github.io/kani/) (bounded model checking) | The engine's own Rust kernel is correct and never panics or overflows | Every input within the bounds: up to 4 lines, 3 accounts, amounts to 2^40; proration by days up to 3 month parts (bounds below) | | **Laws** | [Lean 4](https://lean-lang.org) (machine-checked proof) | The laws hold for books of any size, after any sequence of commands | Unbounded: any number of entries, accounts, periods, steps | -| **Bridge** | proptest | The kernel on `Decimal` (what the engine runs) agrees with the kernel on `i64` (what Kani proves) | 2,000 random cases per property | +| **Bridge** | proptest | The kernel on `Decimal` (what the engine runs) agrees with the kernel on `i64` (what Kani proves); proration's `i128` (the engine's) with its `i64` and `i32` | 2,000 random cases per property | Run all of it with `just prove` (after `mise install`; see the end). @@ -26,12 +26,14 @@ period closing, `periods::allocate`, and the trial balance. There is no second copy of the arithmetic for proofs to drift from. Kani compiles the same functions at `i64` (exact integer money in the -smallest unit) and explores every input in the bounds. (Grouping by +smallest unit; the four proration charge harnesses at `i32`) and +explores every input in the bounds. (Grouping by account is proved through `movements_into`, the allocation-free core that `movements` wraps: symbolic heap growth is beyond what the model checker explores in reasonable time.) The Lean model in -`verify/lean` restates each function over `Int` and proves the laws with -no bounds at all. +`verify/lean` restates each function over `Int` (proration over `Nat`, +both of `prorated_through`'s branches) and proves the laws with no bounds +at all. ## The laws @@ -306,6 +308,117 @@ their numbers are assigned when P3 lands: `a_reversal_nets_every_bucket_to_zero` (ledger-core `tests/form1099.rs`) +### Proration by days, catching up, truing up (P3, defects 22 and 9) + +Depreciation (rules.md B-15) and daily prepaid and deferred-revenue +schedules (B-16) spread a total over a span of days with one kernel +function, `kernel::prorate_by_days` (each month's share through +`kernel::prorated_through`), in exact integer arithmetic over a common +unit, `MONTH_L` = lcm(28, 29, 30, 31) = 377,580 parts of a month, so a day +of any month is a whole number of them. + +22. **The parts of a span sum to its total exactly, none is negative, and + the last is the total less the others** (plan/served.md P3 defect 9: + "parts sum to the total; the last part absorbs the remainder"). Every + split whose parts but the last fit in the span is computed; only parts + no calendar has are refused. + - Kani `prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder` (a + corollary, kept because the plan names the law) · Lean + `Prorate.kernel_parts_total`, `Prorate.kernel_parts_nonneg` (the + kernel's split, any number of parts and any sizes), `Prorate.cum_dvd` + (a total in whole units is charged, so split, in whole units), from + `Prorate.prorate_total`, `Prorate.prorate_nonneg`, + `Prorate.parts_telescope` · bridge `prorate_agrees_at_i64_and_i128` +23. **Each part but the last is its days times the monthly rate within one + minor unit** (P3 defect 9: "each part equals days × rate within 0.01", + stated for any currency's minor unit). Each cumulative charge is its + exact share rounded half up to a multiple of the unit (a tie goes up; + the total itself when that rounds above it): Kani of the kernel at its + bounds, Lean of both of the kernel's branches at any size + (`roundUnit_near`, `cum_near`; `chargeMid_eq_cum`, `chargeLast_eq_cum` + say the kernel's two formulas are the model's). Two such halves make + the part's one unit (`part_near`, at any size, for any parts). + - Kani `a_months_charge_is_within_half_a_cent`, + `a_months_charge_in_tenths_is_within_half_a_cent`, + `the_last_months_charge_is_within_half_a_cent`, + `the_last_months_charge_in_tenths_is_within_half_a_cent` · Lean + `Prorate.roundUnit_near`, `Prorate.cum_near`, `Prorate.part_near`, + `Prorate.part_within_a_unit` · bridge `prorated_through_agrees_at_i32_and_i128` +24. **The charge through a day never falls, never exceeds the total, is + continuous where one month ends and the next begins, and is the total + on the last day**; malformed input (a negative total, no months, a + unit that isn't positive, a part no month has, a day past the part, a + weight outside the span) is refused, never a panic. + - Lean `Prorate.cum_mono`, `Prorate.cum_le`, `Prorate.cum_full` (the + model, any span), `Prorate.chargeLast_mono` and + `Prorate.chargeLast_start` (the kernel's last month, by days, from + where the month before ends: rounding is unchanged when an amount and + its denominator are scaled together, `Prorate.roundUnit_scale`) · unit + `the_charge_never_falls_and_is_continuous_over_real_calendars` + (the kernel, exhaustively: every span of 1 to 3 months from every day + of a common and a leap year, walked day by day, eight totals, both + units) · Kani's four charge harnesses (bounded: never negative, never + above the total, the total on the last day), + `prorated_through_refuses_malformed_input` · property + `prop_depreciation_is_monotone_and_bounded` (the engine) +25. **A catch-up run never carries the books past the schedule** at a + month end already posted, and posts everything due through its month + when no run comes after it (plan/served.md P3, settled in design: a + month skipped is caught up in the month the run is made); every + month's figure at once (`catch_ups`) is each suffix's catch-up. What + the books carry at each month end is read entry by entry, each run and + each reversal in its own month (`assets::depreciation_effects`), so the + kernel's law is a law of the books' month ends, not of the runs' + periods (a run reversed after its month ended is never run twice; a + re-instated run never carries the books past the schedule). + - Kani `catch_up_never_overtakes_the_schedule`, + `catch_ups_is_every_suffixs_catch_up` · bridge + `catch_up_and_true_up_agree` · unit `catch_ups_is_every_suffixs_catch_up` + · property `prop_run_amounts_are_each_months_run_amount` + · store `a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`, + `reinstating_a_run_a_later_run_caught_up_is_refused` +26. **A disposal's true-up reaches the schedule** through its day from what + the books carry, up or down, never both (`true_up` is `columns` of the + gap). + - Kani `true_up_reaches_the_target` · bridge `catch_up_and_true_up_agree` + +Bounds (Kani): the split harnesses, at `i64`: up to 3 month parts, 1 to +3 months, each month 28 to 31 days with 1 to all of them in the span, +totals to 2^16 units (they also passed at 2^20, 2026-10-20), a unit of +1 or 10. The charge harnesses, at `i32` (the kernel is generic over +`Units`; `i32` so the solver can relate a product to a quotient): 1 or +2 months, any one month part and weight before it, totals to 2,048 units +(a month but the last) or 64 (the last month, whose numerator carries the +month's days), each unit in its own harness; +the malformed-input harness: totals to 2^20 either sign, months 0 to 3, +days, month lengths and k 0 to 40, a weight before of up to 4 × +MONTH_L either sign, units -10 to 10; catch-up (and every suffix's) over +up to 3 month ends, and the true-up, at amounts to 2^40. + +What the solver could not do, and what carries it instead: a harness +relating two symbolic divisions (a part against days × rate directly; a +charge never falling within a month; the charge through a month's last +day against the next month's through none of its days) ran past twenty +minutes each, at 2^20 and again at 2^16, with CaDiCaL and with Kissat, +and was stopped (2026-10-20); so did the one-division charge bound at +`i64` (a checked 64-bit product is a 128-bit multiplier to the solver). +Each such relation is nonlinear in the divisor. So Kani proves the +one-division fact at `i32` (each charge within half a unit of its exact +share: one to seven minutes a harness), the bridge ties the `i32` kernel +to the engine's `i128` one, and Lean proves every one of these relations +at any size, of the kernel's own two formulas: the model's charge is +`cum t unit V w` (the total times `w/V`, rounded half up to a multiple of +`unit`, capped at the total, and the total at `w = V`), and +`chargeMid`/`chargeLast` are `prorated_through`'s two branches written +out, proved equal to `cum` over `W` = months × MONTH_L and over `W` × the +last month's days, with the two meeting where the last month starts +(`roundUnit_scale`). The one hypothesis is the calendar's: every part +but the last ends short of the span (`e < W`), which a walk from any +in-service date keeps (B-15: a first month of at most one, then whole +months); an exhaustive test walks the kernel itself over every +1-to-3-month span of two calendar years, and the bridge and the exact +unit tests check the rest. + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, @@ -363,8 +476,11 @@ can also prove false claims. So: entry, a reversal on one account with several lines, three distinct accounts, a balanced three-account chart, a close to two members, a part-cash part-noncash investing entry, cash settling two sections at - once, a mixed entry reversed, property contributed for capital. `just - prove` fails if any cover is unsatisfiable. + once, a mixed entry reversed, property contributed for capital; a split + of three parts, a half-unit tie rounding up, a charge capped at a total + finer than its unit, a catch-up across three month ends and distinct + ones for every suffix, a true-up each way. `just prove` fails if any + cover is unsatisfiable. ## What is and isn't proved @@ -373,10 +489,12 @@ Be precise about the claims: - **Kani is bounded.** It proves the kernel for every input up to 4 lines over 3 accounts and amounts to 2^40 units (the accounting equation end to end: up to 3 accounts; the cash flow entry harnesses: up to 3 lines, - each tagged any of 4 ways). Lean proves the laws for any - size, but of a *model*. The model is a direct restatement of the kernel, - and the bridge property test ties the `Decimal` instantiation to the - `i64` one Kani checked. + each tagged any of 4 ways), and the proration by days within the bounds + above. Lean proves the laws for any size, but of a *model*. The model + is a direct restatement of the kernel (for proration, its two formulas + written out and proved equal to the model's charge), and the bridge + property tests tie the `Decimal` (and `i128`) instantiation to the + `i64` (and `i32`) one Kani checked. - **`Decimal` itself is trusted.** `rust_decimal` is exact for these sizes: 96-bit integers scaled by a power of ten. With lines capped at one quadrillion and ten decimal places, every sum is exact while the @@ -389,9 +507,10 @@ Be precise about the claims: `tests/properties.rs` (trial balance, balance sheet, reversals, immutability) and the golden files. - **Out of scope:** currency (the ledger is single-currency, enforced - structurally), A/R and A/P documents, payroll, depreciation schedules - (those have their own property tests), and persistence (the journal - store's hash chain is tested in `ledger-store`). + structurally), A/R and A/P documents, payroll, the calendar walk around + the proration kernel (`calendar::month_parts`, `assets::depreciation_schedule`, + `schedules::daily_plan`: property tests, `tests/properties.rs`, P3), and + persistence (the journal store's hash chain is tested in `ledger-store`). - **The cash flow model tags lines per entry with any function** meeting the engine's guarantees (the cash tag follows the account; revenue and expense lines are operating or investing; a reversal takes its diff --git a/plan/STATUS.md b/plan/STATUS.md index 2d18ee1..723ee48 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -88,6 +88,30 @@ folder's `legacy/`. Agents connect with `wanderbooks connect`, the viewer is books data. The before-check found defect 21 again (the IIF ignores `as_of`). +**Open from P3's depreciation and schedules work (defects 22 and 9, +review 2026-10-20), for the P3 lead:** the viewer still recomputes a +long-life asset's whole schedule every frame (plan defect 22's note; R12 +of its brief: slow, never wrong); a schedule month a run leaves for a +human (dated in a closed period, or reversed after it ended) stays +unposted with nothing to mark it booked by hand, so its schedule never +shows complete, and a schedule or an asset registered by mistake has no +way to stop (a reversed month comes due again); both want a design +(a schedule's postings are its plan's, re-derived on replay, so it can't +catch a month up as depreciation does). From review round 2: a reversal +dated before the entry it reverses is refused for depreciation runs and +schedule postings only (B-9: a correction comes after what it corrects); +every other entry still takes one, a general rule for the P3 lead to +settle; the bookspec examples cite ASC 606-10-25-23, which `sources/` +holds only through DART (vendor it with 25-31, 25-32 and 55-20); the +every-order search runs three commands deep in `just check`; four and +five ran clean by hand (`WB_ORDERS_DEPTH`, one and five minutes), six is +P12's. Seen in the same review, not P3's: `ledger-store` +`ops::tests::the_server_makes_the_folder_and_the_viewer_only_reads_it` +failed once in a whole-workspace run (`!viewer_open` after both holders +dropped) and passed alone and in six runs of the crate's tests; likely a +child a concurrent test forks (backup's git) holding the viewer lock's +descriptor until it execs. + **Next:** P3 (engine correctness; designed: one brief per defect group, each attacked by a reviewer, kept outside this public repo since they cite the first company's figures; the owner's questions come one at a time as diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index cf3d1ab..f801482 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -71,3 +71,21 @@ open Wanderbooks #print axioms capitalTarget_odd #print axioms partyTarget_odd #print axioms sumOver_movement_reversal +#print axioms Prorate.cum_mono +#print axioms Prorate.cum_le +#print axioms Prorate.cum_full +#print axioms Prorate.parts_telescope +#print axioms Prorate.prorate_total +#print axioms Prorate.prorate_nonneg +#print axioms Prorate.part_within_a_unit +#print axioms Prorate.roundUnit_near +#print axioms Prorate.roundUnit_scale +#print axioms Prorate.cum_near +#print axioms Prorate.cum_dvd +#print axioms Prorate.part_near +#print axioms Prorate.chargeMid_eq_cum +#print axioms Prorate.chargeLast_eq_cum +#print axioms Prorate.chargeLast_start +#print axioms Prorate.chargeLast_mono +#print axioms Prorate.kernel_parts_total +#print axioms Prorate.kernel_parts_nonneg diff --git a/verify/lean/Wanderbooks.lean b/verify/lean/Wanderbooks.lean index 8ab0c43..15931d5 100644 --- a/verify/lean/Wanderbooks.lean +++ b/verify/lean/Wanderbooks.lean @@ -5,3 +5,4 @@ import Wanderbooks.Closing import Wanderbooks.Ledger import Wanderbooks.Apportion import Wanderbooks.Parties +import Wanderbooks.Prorate diff --git a/verify/lean/Wanderbooks/Prorate.lean b/verify/lean/Wanderbooks/Prorate.lean new file mode 100644 index 0000000..6d69892 --- /dev/null +++ b/verify/lean/Wanderbooks/Prorate.lean @@ -0,0 +1,324 @@ +/-! +# Proration by days: the parts sum to the total, and none is negative, +for any number of parts + +Mirrors `crates/ledger-core/src/kernel.rs` `prorated_through` and +`prorate_by_days` (depreciation, rules.md B-15; daily schedules, B-16). +The kernel computes, through any day of a span, the elapsed share of the +span as a fraction (the months held before, each as days held over days in +the month, plus the days of this one; in the last month, what the others +leave spread over its days). Here that share is `w / V`: `V` the whole +span over any common denominator, `w` the elapsed part of it, which the +walk only ever raises. The charge through it is `cum`: the total times the +share, rounded half up to a multiple of `unit`, capped at the total, and +the total itself once the whole span has elapsed (the kernel's last day, +exact even for a total that isn't a whole number of units). + +Kani proves the kernel's laws for up to three parts at bounded sizes +(`prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder`), +and each charge within half a unit of its exact share +(`a_months_charge_is_within_half_a_cent` and its siblings). Here they +hold for any number of parts and any sizes: `cum` never falls as the +share grows, never exceeds the total, and ends at it, so the parts +(differences of `cum` at each part's end) telescope to the total and are +each at least zero; each charge is within half a unit of its exact share, +so each part is within one unit of its days times the rate (`cum_near`, +`part_near`). The last section writes out the kernel's two formulas and +proves them `cum` (`chargeMid_eq_cum`, `chargeLast_eq_cum`), so these are +laws of the kernel's own arithmetic, not only of the model. +-/ + +namespace Wanderbooks.Prorate + +/-- `num / den` rounded half up to a multiple of `unit`: the kernel's +`round_to_unit` (its `r >= step - r` is `2r >= step`, which is the floor of +`num / step + 1/2`). -/ +def roundUnit (num den unit : Nat) : Nat := + unit * ((2 * num + den * unit) / (2 * den * unit)) + +/-- The charge of total `t` once `w` of the span's `V` has elapsed. -/ +def cum (t unit V w : Nat) : Nat := + if w = V then t else min t (roundUnit (t * w) V unit) + +/-- Rounding never falls as the amount rounded grows. -/ +theorem roundUnit_mono (den unit : Nat) {a b : Nat} (h : a ≤ b) : + roundUnit a den unit ≤ roundUnit b den unit := by + unfold roundUnit + apply Nat.mul_le_mul_left + apply Nat.div_le_div_right + omega + +/-- The charge never exceeds the total. -/ +theorem cum_le (t unit V w : Nat) : cum t unit V w ≤ t := by + unfold cum + split + · exact Nat.le_refl t + · exact Nat.min_le_left _ _ + +/-- Once the whole span has elapsed, the charge is the total exactly. -/ +theorem cum_full (t unit V : Nat) : cum t unit V V = t := by + simp [cum] + +/-- Before anything has elapsed, nothing is charged. -/ +theorem cum_zero (t unit V : Nat) (hV : 0 < V) (hu : 0 < unit) : cum t unit V 0 = 0 := by + have hne : (0 : Nat) ≠ V := by omega + have hlt : V * unit < 2 * V * unit := by + have : 0 < V * unit := Nat.mul_pos hV hu + rw [Nat.mul_assoc] + omega + simp [cum, hne, roundUnit, Nat.div_eq_of_lt hlt] + +/-- **The charge never falls** as the elapsed share grows, up to the +whole span. -/ +theorem cum_mono (t unit V : Nat) {w w' : Nat} (h : w ≤ w') (hw' : w' ≤ V) : + cum t unit V w ≤ cum t unit V w' := by + by_cases e' : w' = V + · subst e' + rw [cum_full] + exact cum_le t unit w' w + · have e : w ≠ V := by omega + simp only [cum, e, e', ite_false] + have r := roundUnit_mono V unit (Nat.mul_le_mul_left t h) + omega + +/-- The parts of a split: at each part's end (`w` its elapsed share), the +charge through it less the charge before it (the kernel's `out[i]`, +`prorate_by_days`). -/ +def parts (t unit V : Nat) : Nat → List Nat → List Int + | _, [] => [] + | prev, w :: ws => ((cum t unit V w : Int) - cum t unit V prev) :: parts t unit V w ws + +/-- **The parts telescope**: they sum to the charge at the last part's end +less the charge where they start (the shape of `runPart_telescopes`). -/ +theorem parts_telescope (t unit V : Nat) (prev : Nat) (ws : List Nat) : + (parts t unit V prev ws).sum = (cum t unit V (ws.getLastD prev) : Int) - cum t unit V prev := by + induction ws generalizing prev with + | nil => simp [parts] + | cons w rest ih => + simp only [parts, List.sum_cons, ih] + cases rest with + | nil => simp + | cons _ _ => simp only [List.getLastD_cons]; omega + +/-- **The parts sum to the total**, for any number of parts: a span +walked from nothing elapsed to all of it. -/ +theorem prorate_total (t unit V : Nat) (hV : 0 < V) (hu : 0 < unit) (ws : List Nat) + (hlast : ws.getLastD 0 = V) : (parts t unit V 0 ws).sum = t := by + rw [parts_telescope, hlast, cum_full, cum_zero t unit V hV hu] + simp + +/-- The parts' ends rise: each at least the one before, none past the +span. -/ +def Rising (V : Nat) : Nat → List Nat → Prop + | _, [] => True + | prev, w :: ws => prev ≤ w ∧ w ≤ V ∧ Rising V w ws + +/-- **No part is negative** when the parts' ends rise, for any number of +parts. -/ +theorem prorate_nonneg (t unit V : Nat) (prev : Nat) (ws : List Nat) (h : Rising V prev ws) : + ∀ x ∈ parts t unit V prev ws, 0 ≤ x := by + induction ws generalizing prev with + | nil => simp [parts] + | cons w rest ih => + obtain ⟨hpw, hwV, hrest⟩ := h + intro x hx + simp only [parts, List.mem_cons] at hx + rcases hx with hx | hx + · subst hx + have := cum_mono t unit V hpw hwV + omega + · exact ih w hrest x hx + +/-- **Each part is within one unit of its exact share** when each charge +is within half a unit of its exact one (Kani's +`a_months_charge_is_within_half_a_cent` and its three siblings prove +that of the kernel): a part is the difference of two charges over the same +denominator `D` (W, the span in MONTH_L-ths of a month), so its error is +at most the two halves. With `x - y` = total × days × MONTH_L / days in +the month and `D` = months × MONTH_L, dividing through by MONTH_L / days in +the month gives the plan's form: |part × months × days in month − total × +days| <= unit × months × days in month. -/ +theorem part_within_a_unit (a b D x y u : Int) + (ha : -(u * D) ≤ 2 * (a * D - x) ∧ 2 * (a * D - x) ≤ u * D) + (hb : -(u * D) ≤ 2 * (b * D - y) ∧ 2 * (b * D - y) ≤ u * D) : + -(u * D) ≤ (a - b) * D - (x - y) ∧ (a - b) * D - (x - y) ≤ u * D := by + rw [Int.sub_mul] + omega + +/-! ## The kernel's own formula + +`prorated_through` charges in two ways. Through a part but the span's +last, the elapsed share is `e / W` (`W` = months × MONTH_L, `e` = before ++ k × MONTH_L / days in the month): `chargeMid`. Through `k` of the `d` +days of the last part, after `b` of `W`, it is `(b·d + (W − b)·k) / (W·d)`, +and the total itself on its last day: `chargeLast`. Both are `cum`, over +`W` and over `W·d` (`chargeMid_eq_cum`, `chargeLast_eq_cum`), and the two +meet where the last month starts (`chargeLast_start`, rounding being +unchanged when the amount and its denominator are scaled together). So +`cum`'s laws are the kernel's, at any size: through the last month the +charge never falls (`chargeLast_mono`), the split of any number of parts +sums to the total with none negative (`kernel_parts_total`, +`kernel_parts_nonneg`), and each part but the last is within one unit of +its days times the rate (`part_near`). A calendar walk keeps every part's +end short of the span's (rules.md B-15: the first month is at most one, +then whole months), which is the hypothesis `e < W`. -/ + +/-- **Rounding is within half a unit**, at any size (a tie goes up): +`2·den·r ≤ 2·num + den·unit` and `2·num < 2·den·r + den·unit`. -/ +theorem roundUnit_near (num den unit : Nat) (hd : 0 < den) (hu : 0 < unit) : + 2 * den * roundUnit num den unit ≤ 2 * num + den * unit ∧ + 2 * num < 2 * den * roundUnit num den unit + den * unit := by + unfold roundUnit + have hs : 0 < 2 * den * unit := Nat.mul_pos (Nat.mul_pos (by omega) hd) hu + have hdm := Nat.div_add_mod (2 * num + den * unit) (2 * den * unit) + have hml := Nat.mod_lt (2 * num + den * unit) hs + generalize (2 * num + den * unit) / (2 * den * unit) = q at * + generalize (2 * num + den * unit) % (2 * den * unit) = m at * + have e : 2 * den * (unit * q) = 2 * den * unit * q := by + rw [Nat.mul_assoc (2 * den) unit q] + have hS : 2 * den * unit = 2 * (den * unit) := Nat.mul_assoc 2 den unit + rw [e] + generalize 2 * den * unit * q = P at * + generalize 2 * den * unit = S at * + generalize den * unit = DU at * + omega + +/-- Rounding is unchanged when the amount and its denominator are scaled +together. -/ +theorem roundUnit_scale (n d u c : Nat) (hc : 0 < c) : + roundUnit (c * n) (c * d) u = roundUnit n d u := by + unfold roundUnit + have h1 : 2 * (c * n) + c * d * u = c * (2 * n + d * u) := by + rw [Nat.mul_add, Nat.mul_left_comm 2 c n, Nat.mul_assoc c d u] + have h2 : 2 * (c * d) * u = c * (2 * d * u) := by + rw [Nat.mul_left_comm 2 c d, Nat.mul_assoc c (2 * d) u] + rw [h1, h2, Nat.mul_div_mul_left _ _ hc] + +/-- **The charge is within half a unit of its exact share** `t·w / V`, at +any size, short of the span's end (where it is the total): the cap at the +total included. -/ +theorem cum_near (t u V w : Nat) (hV : 0 < V) (hu : 0 < u) (hw : w < V) : + 2 * V * cum t u V w ≤ 2 * (t * w) + V * u ∧ + 2 * (t * w) < 2 * V * cum t u V w + V * u := by + have hne : w ≠ V := by omega + simp only [cum, hne, ite_false] + obtain ⟨h1, h2⟩ := roundUnit_near (t * w) V u hV hu + have htw : t * w ≤ t * V := Nat.mul_le_mul_left t (Nat.le_of_lt hw) + have hVu : 0 < V * u := Nat.mul_pos hV hu + by_cases hc : t ≤ roundUnit (t * w) V u + · rw [Nat.min_eq_left hc] + have hm : 2 * V * t ≤ 2 * V * roundUnit (t * w) V u := Nat.mul_le_mul_left _ hc + have e : 2 * V * t = 2 * (t * V) := by rw [Nat.mul_assoc, Nat.mul_comm V t] + constructor <;> omega + · rw [Nat.min_eq_right (by omega)] + exact ⟨h1, h2⟩ + +/-- **Each part is within one unit of its exact share**, at any size: a +part `c' − c` of the charge (its ends `w ≤ w'` short of the span's end), +against its exact share `t·(w' − w) / V`: +`|(c' − c)·V − t·(w' − w)| ≤ u·V`, written without subtraction. Over +the span `V` = months × MONTH_L and `w' − w` = days × MONTH_L / days in +the month, this is the plan's "each part equals days × rate within one +unit" (plan/served.md P3 defect 9). -/ +theorem part_near (t u V w w' : Nat) (hV : 0 < V) (hu : 0 < u) (hw' : w' < V) + (h : w ≤ w') : + cum t u V w' * V + t * w ≤ cum t u V w * V + t * w' + u * V ∧ + cum t u V w * V + t * w' ≤ cum t u V w' * V + t * w + u * V := by + obtain ⟨a1, a2⟩ := cum_near t u V w' hV hu hw' + obtain ⟨b1, b2⟩ := cum_near t u V w hV hu (by omega) + have e1 : 2 * V * cum t u V w' = 2 * (cum t u V w' * V) := by + rw [Nat.mul_assoc, Nat.mul_comm V] + have e2 : 2 * V * cum t u V w = 2 * (cum t u V w * V) := by + rw [Nat.mul_assoc, Nat.mul_comm V] + have e3 : V * u = u * V := Nat.mul_comm V u + constructor <;> omega + +/-- **A total in whole units is charged in whole units**: every charge is +then a multiple of the unit, and so is every part (a difference of two). -/ +theorem cum_dvd (t u V w : Nat) (h : u ∣ t) : u ∣ cum t u V w := by + unfold cum roundUnit + split + · exact h + · rcases Nat.le_total t (u * ((2 * (t * w) + V * u) / (2 * V * u))) with hc | hc + · rw [Nat.min_eq_left hc]; exact h + · rw [Nat.min_eq_right hc]; exact Nat.dvd_mul_right u _ + +/-- The kernel's charge through a part but the span's last +(`prorated_through`, `last = false`): `e` of the span's `W`. -/ +def chargeMid (t u W e : Nat) : Nat := min t (roundUnit (t * e) W u) + +/-- The kernel's charge through `k` of the `d` days of the span's last +part, after `b` of `W` (`prorated_through`, `last = true`). -/ +def chargeLast (t u W b d k : Nat) : Nat := + if k = d then t else min t (roundUnit (t * (b * d + (W - b) * k)) (W * d) u) + +/-- A part but the last, short of the span's end, is charged `cum`. -/ +theorem chargeMid_eq_cum (t u W e : Nat) (h : e < W) : chargeMid t u W e = cum t u W e := by + have : e ≠ W := by omega + simp [chargeMid, cum, this] + +/-- The span's last part is charged `cum` over `W·d`. -/ +theorem chargeLast_eq_cum (t u W b d k : Nat) (hb : b < W) (hk : k ≤ d) : + chargeLast t u W b d k = cum t u (W * d) (b * d + (W - b) * k) := by + by_cases hkd : k = d + · subst hkd + have e : b * k + (W - b) * k = W * k := by + rw [← Nat.add_mul, Nat.add_sub_of_le (Nat.le_of_lt hb)] + simp [chargeLast, cum, e] + · have hlt : (W - b) * k < (W - b) * d := + (Nat.mul_lt_mul_left (by omega : 0 < W - b)).mpr (by omega) + have e : b * d + (W - b) * d = W * d := by + rw [← Nat.add_mul, Nat.add_sub_of_le (Nat.le_of_lt hb)] + have hne : b * d + (W - b) * k ≠ W * d := by omega + simp [chargeLast, cum, hkd, hne] + +/-- **The last month starts where the month before it ends**: its charge +through none of its days is the charge through the end of the part before +it. -/ +theorem chargeLast_start (t u W b d : Nat) (hd : 0 < d) : + chargeLast t u W b d 0 = chargeMid t u W b := by + have hne : (0 : Nat) ≠ d := by omega + have e1 : t * (b * d + (W - b) * 0) = d * (t * b) := by + rw [Nat.mul_zero, Nat.add_zero, ← Nat.mul_assoc, Nat.mul_comm (t * b) d] + have e2 : W * d = d * W := Nat.mul_comm W d + simp only [chargeLast, chargeMid, hne, ite_false, e1, e2, roundUnit_scale _ _ _ _ hd] + +/-- **The charge never falls through the span's last month**, at any size: +for days `k ≤ k'` of it. -/ +theorem chargeLast_mono (t u W b d k k' : Nat) (hb : b < W) (hk : k ≤ k') (hk' : k' ≤ d) : + chargeLast t u W b d k ≤ chargeLast t u W b d k' := by + rw [chargeLast_eq_cum t u W b d k hb (by omega), chargeLast_eq_cum t u W b d k' hb hk'] + apply cum_mono + · exact Nat.add_le_add_left (Nat.mul_le_mul_left _ hk) _ + · have : (W - b) * k' ≤ (W - b) * d := Nat.mul_le_mul_left _ hk' + have e : b * d + (W - b) * d = W * d := by + rw [← Nat.add_mul, Nat.add_sub_of_le (Nat.le_of_lt hb)] + omega + +/-- The kernel's split (`prorate_by_days`): each part but the last the +charge at its end (`es`, each short of the span's end, `chargeMid_eq_cum`) +less the charge before it; the last, the total less the charge at the end +of the part before it (its last day is charged the total). -/ +def kernelParts (t u W : Nat) (es : List Nat) : List Int := + parts t u W 0 es ++ [(t : Int) - cum t u W (es.getLastD 0)] + +/-- **The kernel's parts sum to the total**, for any number of parts. -/ +theorem kernel_parts_total (t u W : Nat) (hW : 0 < W) (hu : 0 < u) (es : List Nat) : + (kernelParts t u W es).sum = t := by + simp only [kernelParts, List.sum_append, parts_telescope, cum_zero t u W hW hu, + List.sum_cons, List.sum_nil] + omega + +/-- **No part of the kernel's split is negative**, for any number of +parts, when their ends rise within the span. -/ +theorem kernel_parts_nonneg (t u W : Nat) (es : List Nat) (h : Rising W 0 es) : + ∀ x ∈ kernelParts t u W es, 0 ≤ x := by + intro x hx + simp only [kernelParts, List.mem_append, List.mem_singleton] at hx + rcases hx with hx | hx + · exact prorate_nonneg t u W 0 es h x hx + · subst hx + have := cum_le t u W (es.getLastD 0) + omega + +end Wanderbooks.Prorate