From a7f3cc09a93b39a96d63118deee6d46384c58c39 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 20 Oct 2026 18:54:12 -0600 Subject: [PATCH] P3: odd cents split the same running way everywhere (defect 17, D-38) Members' shares of earnings are split cumulatively by largest remainder in the closes, the members' equity statement, the K-1 worksheet and the viewer, so a member's capital on a given day never moves by a cent when its month is later closed. Old closes replay as they were; the first P3 close carries any true-up. Feature members_cumulative. Kani and Lean proofs of the bounded drift; bookspec examples. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 1 + .../members-close.odd-cent-across-closes.toml | 129 ++ ...se.statement-before-and-after-a-close.toml | 113 ++ ...mbers-close.thirds-over-twelve-months.toml | 288 +++++ ...rs-close.true-up-of-a-per-close-split.toml | 157 +++ crates/ledger-core/Cargo.toml | 2 + crates/ledger-core/src/k1.rs | 50 +- crates/ledger-core/src/kernel.rs | 104 +- crates/ledger-core/src/ledger.rs | 377 +++++- crates/ledger-core/src/lib.rs | 3 +- crates/ledger-core/src/money.rs | 12 + crates/ledger-core/src/periods.rs | 310 ++++- crates/ledger-core/src/proofs.rs | 170 +++ crates/ledger-core/src/reports.rs | 92 +- crates/ledger-core/tests/bookspec_members.rs | 398 ++++++ .../tests/kernel_bridge.proptest-regressions | 7 + crates/ledger-core/tests/kernel_bridge.rs | 74 ++ .../tests/odd_cent.proptest-regressions | 9 + crates/ledger-core/tests/odd_cent.rs | 1021 +++++++++++++++ crates/ledger-core/tests/statements.rs | 58 +- crates/ledger-gui/src/books.rs | 16 +- crates/ledger-gui/tests/odd_cent.rs | 219 ++++ crates/ledger-mcp/handbook/tracing-figures.md | 1 + crates/ledger-mcp/src/server.rs | 42 +- crates/ledger-mcp/src/types.rs | 47 + crates/ledger-mcp/tests/mcp_stdio.rs | 237 ++++ crates/ledger-store/src/format.rs | 51 +- crates/ledger-store/src/store.rs | 92 +- .../fixtures/old-books-writer/Cargo.toml | 5 + .../old-books-writer/src/bin/members.rs | 83 ++ .../old-books/x_member_closes/expected.json | 1141 +++++++++++++++++ .../old-books/x_member_closes/journal.jsonl | 86 ++ crates/ledger-store/tests/member_closes.rs | 525 ++++++++ crates/ledger-store/tests/store.rs | 89 +- docs/mcp-interface.md | 43 +- docs/requirements.md | 18 +- docs/spec/rules.md | 166 ++- docs/spec/sources/irs-partnerships.md | 46 +- docs/testing.md | 109 +- docs/verification.md | 75 +- plan/served.md | 2 +- verify/lean/Audit.lean | 12 + verify/lean/Wanderbooks/Apportion.lean | 268 ++++ verify/lean/Wanderbooks/Closing.lean | 83 +- 44 files changed, 6636 insertions(+), 195 deletions(-) create mode 100644 bookspec/examples/members-close.odd-cent-across-closes.toml create mode 100644 bookspec/examples/members-close.statement-before-and-after-a-close.toml create mode 100644 bookspec/examples/members-close.thirds-over-twelve-months.toml create mode 100644 bookspec/examples/members-close.true-up-of-a-per-close-split.toml create mode 100644 crates/ledger-core/tests/bookspec_members.rs create mode 100644 crates/ledger-core/tests/kernel_bridge.proptest-regressions create mode 100644 crates/ledger-core/tests/odd_cent.proptest-regressions create mode 100644 crates/ledger-core/tests/odd_cent.rs create mode 100644 crates/ledger-gui/tests/odd_cent.rs create mode 100644 crates/ledger-store/tests/fixtures/old-books-writer/src/bin/members.rs create mode 100644 crates/ledger-store/tests/fixtures/old-books/x_member_closes/expected.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl create mode 100644 crates/ledger-store/tests/member_closes.rs diff --git a/Cargo.lock b/Cargo.lock index 0b66913..a8bfd70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,6 +3866,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", ] [[package]] diff --git a/bookspec/examples/members-close.odd-cent-across-closes.toml b/bookspec/examples/members-close.odd-cent-across-closes.toml new file mode 100644 index 0000000..ca5b66c --- /dev/null +++ b/bookspec/examples/members-close.odd-cent-across-closes.toml @@ -0,0 +1,129 @@ +id = "members-close.odd-cent-across-closes" +title = "An odd cent split cumulatively across closes, a close to retained earnings between" +rule = "Each close to the members MUST give each member their largest-remainder split of everything closed to the same members and shares so far, to the minor unit, less what earlier closes gave them; a close to retained earnings allocates nothing to the members and MUST NOT restart the count." +status = "CHOICE" +sources = ["26 U.S.C. §704(a)", "Treas. Reg. §1.704-1(b)(2)(iv)(b)"] +tags = ["partnership", "close", "rounding", "book", "us"] + +# The regression this example comes from (wanderbooks P3 defect 17, audit +# F52, anonymized): a net loss of 399.23 at 50/50, closed in January and +# again in March, with February closed to retained earnings. Restarting the +# count at March gave A the tied cent twice (A −399.24, B −399.22). +# +# By hand, from the rule: +# January: −399.23 × 0.5 = −199.615 each; rounded toward zero, −199.61 +# each, one cent short; the remainders tie (0.005), so the cent goes to +# the member listed first: A −199.62, B −199.61. +# February: 10.00 to retained earnings; the members' count skips it. +# March: the count's running total is −798.46, −399.23 each exactly; +# less January's −199.62 / −199.61, March gives A −199.61, B −199.62. +# Each member ends at −399.23, their exact share. + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "largest-remainder" # odd cents to the largest remainders, ties + # to the member listed first, cumulatively + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital, A" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital, B" +type = "equity" + +[[setup.accounts]] +code = "3999" +name = "Retained earnings" +type = "equity" + +[[setup.accounts]] +code = "6000" +name = "Fees" +type = "expense" + +[[setup.members]] +name = "A" +capital_account = "3000" +share = "0.5" + +[[setup.members]] +name = "B" +capital_account = "3001" +share = "0.5" + +[[events]] # event 1 +date = "2026-01-15" +description = "January's fees" +lines = [ + { account = "6000", side = "debit", amount = "399.23" }, + { account = "1000", side = "credit", amount = "399.23" }, +] + +[[events]] # event 2: the closing entry +date = "2026-01-31" +description = "Close January to the members" +close = { start = "2026-01-01", to = "members" } + +[[events]] # event 3 +date = "2026-02-15" +description = "February's fees" +lines = [ + { account = "6000", side = "debit", amount = "10.00" }, + { account = "1000", side = "credit", amount = "10.00" }, +] + +[[events]] # event 4 +date = "2026-02-28" +description = "Close February to retained earnings" +close = { start = "2026-02-01", to = "retained_earnings" } + +[[events]] # event 5 +date = "2026-03-15" +description = "March's fees" +lines = [ + { account = "6000", side = "debit", amount = "399.23" }, + { account = "1000", side = "credit", amount = "399.23" }, +] + +[[events]] # event 6 +date = "2026-03-31" +description = "Close March to the members" +close = { start = "2026-03-01", to = "members" } + +[[expect]] +query = "entry" +entry = 2 + +[expect.values] +"6000.credit" = "399.23" +"3000.debit" = "199.62" +"3001.debit" = "199.61" + +[[expect]] +query = "entry" +entry = 6 + +[expect.values] +"6000.credit" = "399.23" +"3000.debit" = "199.61" +"3001.debit" = "199.62" + +[[expect]] +query = "balances" +as_of = "2026-03-31" + +[expect.values] +"3000" = "-399.23" +"3001" = "-399.23" +"3999" = "-10.00" +"1000" = "-808.46" diff --git a/bookspec/examples/members-close.statement-before-and-after-a-close.toml b/bookspec/examples/members-close.statement-before-and-after-a-close.toml new file mode 100644 index 0000000..9c66f37 --- /dev/null +++ b/bookspec/examples/members-close.statement-before-and-after-a-close.toml @@ -0,0 +1,113 @@ +id = "members-close.statement-before-and-after-a-close" +title = "A member's capital on a day is the same before and after its month closes" +rule = "The statement of members' equity MUST split earnings not yet closed the way the next close to the members will (each member's split of everything closed plus those earnings, less their split of what was closed), so a member's capital as of a day MUST NOT change when a period ending on or before it is closed." +status = "CHOICE" +sources = ["Treas. Reg. §1.704-1(b)(2)(iv)(b)"] +tags = ["partnership", "close", "rounding", "statement", "book", "us"] + +# Two members at 50/50; January's cent is closed to them, February's is +# earned but not yet closed (event 3), then closed (event 4). +# +# By hand, from the rule: +# January's close: 0.01 is 0.005 each; the tied cent to A (listed first): +# A 0.01, B 0.00. +# As of Feb 28, February not yet closed: closed 0.01, unclosed 0.01. The +# split of everything, 0.02, is 0.01 / 0.01; of the closed part, 0.01 / +# 0.00; so the unclosed cent is B's: A 0.01 + 0.00, B 0.00 + 0.01. +# (Splitting February's cent on its own would give it to A: A 0.02, +# B 0.00, and the close would then move it to B.) +# February's close gives the running 0.02, 0.01 each, less 0.01 / 0.00: +# B 0.01. As of Feb 28: A 0.01, B 0.01, as before the close. + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "largest-remainder" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital, A" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital, B" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" + +[[setup.members]] +name = "A" +capital_account = "3000" +share = "0.5" + +[[setup.members]] +name = "B" +capital_account = "3001" +share = "0.5" + +[[events]] # event 1 +date = "2026-01-10" +description = "January's sale" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 2 +date = "2026-01-31" +description = "Close January to the members" +close = { start = "2026-01-01", to = "members" } + +[[events]] # event 3 +date = "2026-02-10" +description = "February's sale" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 4 +date = "2026-02-28" +description = "Close February to the members" +close = { start = "2026-02-01", to = "members" } + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-02-28" +after = 3 # the books before February's close + +[expect.values] +"A.ending" = "0.01" +"B.ending" = "0.01" +"A.net_income" = "0.01" +"B.net_income" = "0.01" + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-02-28" # after every event: February closed + +[expect.values] +"A.ending" = "0.01" +"B.ending" = "0.01" +"A.net_income" = "0.01" +"B.net_income" = "0.01" + +[[expect]] +query = "entry" +entry = 4 + +[expect.values] +"4000.debit" = "0.01" +"3001.credit" = "0.01" diff --git a/bookspec/examples/members-close.thirds-over-twelve-months.toml b/bookspec/examples/members-close.thirds-over-twelve-months.toml new file mode 100644 index 0000000..9561956 --- /dev/null +++ b/bookspec/examples/members-close.thirds-over-twelve-months.toml @@ -0,0 +1,288 @@ +id = "members-close.thirds-over-twelve-months" +title = "Three members at a third each, twelve monthly closes" +rule = "Each monthly close to the members MUST give each member their largest-remainder split of everything closed so far, to the minor unit, less what earlier closes gave them, so after every close each member's total is less than one minor unit from their exact share." +status = "CHOICE" +sources = ["26 U.S.C. §704(a)", "Treas. Reg. §1.704-1(b)(2)(iv)(b)"] +tags = ["partnership", "close", "rounding", "book", "us"] + +# A third each can't be written as decimal shares summing to one, so the +# members hold 33.33%, 33.33% and 33.34%. Twelve months, each closed to +# them: 100.00, 0.01, 0.02, 33.33, -0.05, 0.10, 1.00, 0.07, 0.01, 12.34, +# -5.00, 0.02 (141.85 in all). +# +# By hand, from the rule (the running total, its exact shares, the floors +# to the cent, then the left-over cents to the largest remainders): +# January, 100.00: 33.33 / 33.33 / 33.34 exactly. +# February, running 100.01: 33.333333 / 33.333333 / 33.343334; floors +# 33.33 / 33.33 / 33.34 leave a cent for the largest remainder, C's +# .003334: 33.33 / 33.33 / 33.35, so February gives C 0.01. +# March, running 100.03: 33.339999 / 33.339999 / 33.350002; floors 33.33 +# / 33.33 / 33.35 leave two cents for A's and B's .009999: 33.34 / +# 33.34 / 33.35, so March gives A 0.01 and B 0.01. +# November, running 141.83: 47.271939 / 47.271939 / 47.286122; floors +# 47.27 / 47.27 / 47.28 leave a cent for C's .006122: 47.27 / 47.27 / +# 47.29. +# December, running 141.85: 47.278605 / 47.278605 / 47.29279; floors +# 47.27 / 47.27 / 47.29 leave two cents for A's and B's .008605: 47.28 +# / 47.28 / 47.29, so December gives A 0.01 and B 0.01. + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "largest-remainder" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital, A" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital, B" +type = "equity" + +[[setup.accounts]] +code = "3002" +name = "Capital, C" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" + +[[setup.accounts]] +code = "6000" +name = "Fees" +type = "expense" + +[[setup.members]] +name = "A" +capital_account = "3000" +share = "0.3333" + +[[setup.members]] +name = "B" +capital_account = "3001" +share = "0.3333" + +[[setup.members]] +name = "C" +capital_account = "3002" +share = "0.3334" + +[[events]] # event 1 +date = "2026-01-10" +description = "January's sales" +lines = [ + { account = "1000", side = "debit", amount = "100.00" }, + { account = "4000", side = "credit", amount = "100.00" }, +] + +[[events]] # event 2 +date = "2026-01-31" +description = "Close January to the members" +close = { start = "2026-01-01", to = "members" } + +[[events]] # event 3 +date = "2026-02-10" +description = "February's sales" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 4 +date = "2026-02-28" +description = "Close February to the members" +close = { start = "2026-02-01", to = "members" } + +[[events]] # event 5 +date = "2026-03-10" +description = "March's sales" +lines = [ + { account = "1000", side = "debit", amount = "0.02" }, + { account = "4000", side = "credit", amount = "0.02" }, +] + +[[events]] # event 6 +date = "2026-03-31" +description = "Close March to the members" +close = { start = "2026-03-01", to = "members" } + +[[events]] # event 7 +date = "2026-04-10" +description = "April's sales" +lines = [ + { account = "1000", side = "debit", amount = "33.33" }, + { account = "4000", side = "credit", amount = "33.33" }, +] + +[[events]] # event 8 +date = "2026-04-30" +description = "Close April to the members" +close = { start = "2026-04-01", to = "members" } + +[[events]] # event 9 +date = "2026-05-10" +description = "May's fees" +lines = [ + { account = "6000", side = "debit", amount = "0.05" }, + { account = "1000", side = "credit", amount = "0.05" }, +] + +[[events]] # event 10 +date = "2026-05-31" +description = "Close May to the members" +close = { start = "2026-05-01", to = "members" } + +[[events]] # event 11 +date = "2026-06-10" +description = "June's sales" +lines = [ + { account = "1000", side = "debit", amount = "0.10" }, + { account = "4000", side = "credit", amount = "0.10" }, +] + +[[events]] # event 12 +date = "2026-06-30" +description = "Close June to the members" +close = { start = "2026-06-01", to = "members" } + +[[events]] # event 13 +date = "2026-07-10" +description = "July's sales" +lines = [ + { account = "1000", side = "debit", amount = "1.00" }, + { account = "4000", side = "credit", amount = "1.00" }, +] + +[[events]] # event 14 +date = "2026-07-31" +description = "Close July to the members" +close = { start = "2026-07-01", to = "members" } + +[[events]] # event 15 +date = "2026-08-10" +description = "August's sales" +lines = [ + { account = "1000", side = "debit", amount = "0.07" }, + { account = "4000", side = "credit", amount = "0.07" }, +] + +[[events]] # event 16 +date = "2026-08-31" +description = "Close August to the members" +close = { start = "2026-08-01", to = "members" } + +[[events]] # event 17 +date = "2026-09-10" +description = "September's sales" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 18 +date = "2026-09-30" +description = "Close September to the members" +close = { start = "2026-09-01", to = "members" } + +[[events]] # event 19 +date = "2026-10-10" +description = "October's sales" +lines = [ + { account = "1000", side = "debit", amount = "12.34" }, + { account = "4000", side = "credit", amount = "12.34" }, +] + +[[events]] # event 20 +date = "2026-10-31" +description = "Close October to the members" +close = { start = "2026-10-01", to = "members" } + +[[events]] # event 21 +date = "2026-11-10" +description = "November's fees" +lines = [ + { account = "6000", side = "debit", amount = "5.00" }, + { account = "1000", side = "credit", amount = "5.00" }, +] + +[[events]] # event 22 +date = "2026-11-30" +description = "Close November to the members" +close = { start = "2026-11-01", to = "members" } + +[[events]] # event 23 +date = "2026-12-10" +description = "December's sales" +lines = [ + { account = "1000", side = "debit", amount = "0.02" }, + { account = "4000", side = "credit", amount = "0.02" }, +] + +[[events]] # event 24 +date = "2026-12-31" +description = "Close December to the members" +close = { start = "2026-12-01", to = "members" } + +[[expect]] +query = "entry" +entry = 2 + +[expect.values] +"4000.debit" = "100.00" +"3000.credit" = "33.33" +"3001.credit" = "33.33" +"3002.credit" = "33.34" + +[[expect]] +query = "entry" +entry = 4 + +[expect.values] +"4000.debit" = "0.01" +"3002.credit" = "0.01" + +[[expect]] +query = "entry" +entry = 6 + +[expect.values] +"4000.debit" = "0.02" +"3000.credit" = "0.01" +"3001.credit" = "0.01" + +[[expect]] +query = "entry" +entry = 24 + +[expect.values] +"4000.debit" = "0.02" +"3000.credit" = "0.01" +"3001.credit" = "0.01" + +[[expect]] +query = "balances" +as_of = "2026-11-30" + +[expect.values] +"3000" = "47.27" +"3001" = "47.27" +"3002" = "47.29" + +[[expect]] +query = "balances" +as_of = "2026-12-31" + +[expect.values] +"3000" = "47.28" +"3001" = "47.28" +"3002" = "47.29" diff --git a/bookspec/examples/members-close.true-up-of-a-per-close-split.toml b/bookspec/examples/members-close.true-up-of-a-per-close-split.toml new file mode 100644 index 0000000..a91dbbb --- /dev/null +++ b/bookspec/examples/members-close.true-up-of-a-per-close-split.toml @@ -0,0 +1,157 @@ +id = "members-close.true-up-of-a-per-close-split" +title = "Closes rounded one at a time, trued up by the first cumulative close" +rule = "When earlier closes split each close's net on its own, the first cumulative close that posts an entry MUST give each member their split of everything closed to the members, less what the earlier closes gave them, so it trues up the rounding they left; until it posts, the statement of members' equity MUST show the capital those closes posted, not the pending true-up." +status = "CHOICE" +sources = ["Treas. Reg. §1.704-1(b)(2)(iv)(b)"] +tags = ["partnership", "close", "rounding", "statement", "book", "us"] + +# Books migrated from an engine that rounded each close on its own: three +# months of one cent at 50/50, each tie to A. April's cent is closed by the +# cumulative rule, counting from the first close. +# +# By hand, from the rule: +# January to March, each close on its own: 0.005 each, the tie to A, so +# A 0.03, B 0.00 (exact: 0.015 each). +# As of Apr 30, April's cent not yet closed: the split of everything, +# 0.04, is 0.02 / 0.02; of the closed 0.03, 0.02 / 0.01 (the tie to +# A); the unclosed cent is B's (0.02 − 0.01): A 0.03, B 0.01. The +# rounding the earlier closes left (A 0.01 ahead) shows only once +# posted. +# April's close: the running 0.04 is 0.02 / 0.02, less what was given +# (0.03 / 0.00): A −0.01, B +0.02. The entry: Dr 4000 0.01, Dr 3000 +# 0.01, Cr 3001 0.02. As of Apr 30: A 0.02, B 0.02, each exact; as of +# Mar 31, still A 0.03, B 0.00. + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "largest-remainder" + +[[setup.accounts]] +code = "1000" +name = "Cash" +type = "asset" + +[[setup.accounts]] +code = "3000" +name = "Capital, A" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital, B" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Sales" +type = "revenue" + +[[setup.members]] +name = "A" +capital_account = "3000" +share = "0.5" + +[[setup.members]] +name = "B" +capital_account = "3001" +share = "0.5" + +[[events]] # event 1 +date = "2026-01-10" +description = "January's sale" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 2 +date = "2026-01-31" +description = "Close January, rounded on its own" +close = { start = "2026-01-01", to = "members", rounding = "per-close" } + +[[events]] # event 3 +date = "2026-02-10" +description = "February's sale" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 4 +date = "2026-02-28" +description = "Close February, rounded on its own" +close = { start = "2026-02-01", to = "members", rounding = "per-close" } + +[[events]] # event 5 +date = "2026-03-10" +description = "March's sale" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 6 +date = "2026-03-31" +description = "Close March, rounded on its own" +close = { start = "2026-03-01", to = "members", rounding = "per-close" } + +[[events]] # event 7 +date = "2026-04-10" +description = "April's sale" +lines = [ + { account = "1000", side = "debit", amount = "0.01" }, + { account = "4000", side = "credit", amount = "0.01" }, +] + +[[events]] # event 8 +date = "2026-04-30" +description = "Close April, cumulatively" +close = { start = "2026-04-01", to = "members" } + +[[expect]] +query = "balances" +as_of = "2026-03-31" +after = 6 + +[expect.values] +"3000" = "0.03" +"3001" = "0.00" + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-04-30" +after = 7 + +[expect.values] +"A.ending" = "0.03" +"B.ending" = "0.01" + +[[expect]] +query = "entry" +entry = 8 + +[expect.values] +"4000.debit" = "0.01" +"3000.debit" = "0.01" +"3001.credit" = "0.02" + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-04-30" + +[expect.values] +"A.ending" = "0.02" +"B.ending" = "0.02" + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-03-31" + +[expect.values] +"A.ending" = "0.03" +"B.ending" = "0.00" diff --git a/crates/ledger-core/Cargo.toml b/crates/ledger-core/Cargo.toml index 71cc9ba..938e153 100644 --- a/crates/ledger-core/Cargo.toml +++ b/crates/ledger-core/Cargo.toml @@ -22,6 +22,8 @@ workspace = true [dev-dependencies] criterion = "0.8.2" proptest = "1" +# bookspec examples are TOML (tests/bookspec.rs). +toml = "1" [[bench]] name = "books" diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs index 8866ff7..ce029bd 100644 --- a/crates/ledger-core/src/k1.rs +++ b/crates/ledger-core/src/k1.rs @@ -32,7 +32,8 @@ //! with the MacBook read as cash.) //! 4. Within one entry, the property (at most the members' credits in //! it) is shared among the members credited in proportion to their -//! credits, to the cent, leftover cents by largest remainder +//! credits, to the base currency's minor unit (cents, whole yen), +//! leftover units by largest remainder //! (kernel::apportion, ties to the earlier member): each takes at //! most its own credit, and the split depends on the order the //! members are given only for a tied leftover cent (never on line @@ -167,6 +168,7 @@ pub fn k1_detail( )); } let me = members_equity(ledger, start, end, members)?; + let places = ledger.base_currency().allocation_places(); let receivables = ledger.role_account(&crate::RECEIVABLES); let closings = ledger.closing_entry_ids(); let cancelled = crate::reports::cancelled_in_span(ledger, start, end); @@ -219,7 +221,7 @@ pub fn k1_detail( .iter() .map(|m| sum(e, Side::Credit, m.capital_account)) .collect(); - let takes = share_property(pool, &credits)?; + let takes = share_property(pool, &credits, places)?; for (i, m) in members.iter().enumerate() { let (credit, take) = (credits[i], takes[i]); if !(credit - take).is_zero() { @@ -274,11 +276,16 @@ pub fn k1_detail( /// One entry's property shared among the members' credits in it /// (INVARIANT 4): at most their total credit, in proportion to each -/// credit, rounded toward zero to the cent, the leftover cents by largest -/// remainder. Each part is at most its own credit. +/// credit, rounded toward zero to `places` (the base currency's +/// Currency::allocation_places: cents, whole yen), the leftover units by +/// largest remainder. Each part is at most its own credit. /// Refused when whole × credit is beyond exact arithmetic (credits near /// the line limit; P0 review round 3, S1). -fn share_property(pool: Decimal, credits: &[Decimal]) -> Result, LedgerError> { +fn share_property( + pool: Decimal, + credits: &[Decimal], + places: u32, +) -> Result, LedgerError> { use rust_decimal::RoundingStrategy::ToZero; let too_large = || LedgerError::TooLargeToCompute("a member's share of contributed property".into()); @@ -291,7 +298,7 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result, Le return Ok(vec![Decimal::ZERO; credits.len()]); } // whole × credit is exact; ÷ total is exact whenever the share is a - // whole number of cents, so truncating never loses a cent it is owed. + // whole number of units, so truncating never loses a unit it is owed. let exact: Vec = credits .iter() .map(|c| whole.checked_mul(*c).and_then(|x| x.checked_div(total))) @@ -299,11 +306,11 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result, Le .ok_or_else(too_large)?; let mut parts: Vec = exact .iter() - .map(|x| x.round_dp_with_strategy(2, ToZero)) + .map(|x| x.round_dp_with_strategy(places, ToZero)) .collect(); let remainders: Vec = exact.iter().zip(&parts).map(|(x, p)| x - p).collect(); - crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2)) - .expect("each part is short by under a cent, so the shortfall is under a cent per member"); + crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, places)) + .expect("each part is short by under a unit, so the shortfall is under a unit per member"); Ok(parts) } @@ -840,20 +847,20 @@ mod tests { flipped.reverse(); assert_eq!(split(&bk, &flipped), vec![each, each]); assert_eq!( - share_property(d("401"), &[d("750"), d("250")]).unwrap(), + share_property(d("401"), &[d("750"), d("250")], 2).unwrap(), vec![d("300.75"), d("100.25")] ); assert_eq!( - share_property(d("0.01"), &[d("5"), d("5")]).unwrap(), + share_property(d("0.01"), &[d("5"), d("5")], 2).unwrap(), vec![d("0.01"), d("0.00")] ); // More property than credit (part financed): at most the credits. assert_eq!( - share_property(d("900"), &[d("300"), d("100")]).unwrap(), + share_property(d("900"), &[d("300"), d("100")], 2).unwrap(), vec![d("300"), d("100")] ); assert_eq!( - share_property(d("50"), &[d("0"), d("0")]).unwrap(), + share_property(d("50"), &[d("0"), d("0")], 2).unwrap(), vec![d("0"), d("0")] ); // Credits near the line limit: whole x credit is past exact @@ -861,18 +868,29 @@ mod tests { // round 3, S1); at a quarter of it, it still splits exactly. let near = d("900000000000000"); assert!(matches!( - share_property(near, &[near, near]), + share_property(near, &[near, near], 2), Err(LedgerError::TooLargeToCompute(_)) )); assert!(matches!( - share_property(near, &[Decimal::MAX, Decimal::MAX]), + share_property(near, &[Decimal::MAX, Decimal::MAX], 2), Err(LedgerError::TooLargeToCompute(_)) )); let quarter = d("250000000000"); assert_eq!( - share_property(quarter, &[quarter, quarter]).unwrap(), + share_property(quarter, &[quarter, quarter], 2).unwrap(), vec![d("125000000000"), d("125000000000")] ); + // To the base currency's minor unit (allocation_places): in whole + // yen, 101 of property by credits of 51 each is 50.5 each, the + // tied yen to the first; to cents it would be 50.50 each. + assert_eq!( + share_property(d("101"), &[d("51"), d("51")], 0).unwrap(), + vec![d("51"), d("50")] + ); + assert_eq!( + share_property(d("101"), &[d("51"), d("51")], 2).unwrap(), + vec![d("50.50"), d("50.50")] + ); } /// member_party doc: the party of that name with the member role only. diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 3f4671c..4f11ae9 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -26,7 +26,10 @@ //! columns differ by the net of all movements (B-6); //! * a closing line zeroes the account it closes (B-11); //! * an allocation's parts sum to the whole, to the last unit, by largest -//! remainder (B-13). +//! remainder (B-13); +//! * a cumulative allocation hands out exactly the new amount, and leaves +//! each running total one allocation of everything allocated, so it never +//! drifts a unit from its exact share (B-13). use crate::{AccountType, Side}; use rust_decimal::Decimal; @@ -352,6 +355,60 @@ pub fn apportion(whole: T, parts: &mut [T], remainders: &[T], unit: T Some(()) } +/// Cumulative apportionment: what one close gives each part when the +/// running `total` is apportioned afresh ([`apportion`]) and what earlier +/// closes gave each part (`given`) is taken back. `parts` and +/// `remainders` are the rounded-down shares of `total` and what rounding +/// took from each, as for [`apportion`]. +/// +/// ```text +/// GOAL: members' running totals that never drift a unit from their +/// exact shares, whatever earlier closes did. +/// SOURCE: docs/spec/rules.md B-13 (shares: 26 U.S.C. §704(a); capital +/// accounts carry the allocations: Treas. Reg. +/// §1.704-1(b)(2)(iv)(b); cent rounding: DECISION, owner +/// 2026-10-13; counted from the first close: D-10, 2026-10-16). +/// INVARIANTS: +/// 1. MUST make the parts sum to `total − Σ given` exactly. +/// 2. MUST leave given[i] + parts[i] equal to what `apportion(total, …)` +/// gives part i: a running total is one apportionment, so it carries +/// one apportionment's rounding, never a sum of them. +/// 3. On None, `parts` MUST be unchanged. +/// SCENARIOS: +/// 1. IF the lengths differ, the unit isn't positive, `total`, the +/// unit, a part or a `given` is out of line range, or apportion +/// refuses: SHOULD return None, leaving `parts` unchanged +/// BECAUSE in range, the subtraction can't overflow, so a refusal is +/// known before anything changes. +/// 2. IF 0.03 was closed as A 0.03 / B 0 and the running total is now +/// 0.04 (50/50, floors 0.02 / 0.02): SHOULD give A −0.01, B +0.02 +/// BECAUSE 0.04 apportions 0.02 each. +/// ``` +/// +/// Allocation-free, with no bulk copy (docs/verification.md, "Trusting +/// the checker"), so the model checker can prove it outright. +pub fn apportion_cumulative( + total: T, + parts: &mut [T], + remainders: &[T], + unit: T, + given: &[T], +) -> Option<()> { + if parts.len() != given.len() || !total.in_line_range() || !unit.in_line_range() { + return None; + } + if parts.iter().chain(given).any(|a| !a.in_line_range()) { + return None; + } + apportion(total, parts, remainders, unit)?; + // Each part is now under three line ranges and each `given` within + // one: the subtraction can't fail (Kani apportion_cumulative_is_exact). + for (part, &g) in parts.iter_mut().zip(given) { + *part = part.checked_sub(g)?; + } + Some(()) +} + #[cfg(test)] mod tests { use super::*; @@ -423,6 +480,51 @@ mod tests { assert_eq!(apportion(0, &mut none, &[], -1), None); } + /// apportion_cumulative scenario 1: mismatched lengths, a bad unit, an + /// input out of line range, or a shortfall apportion refuses: None, + /// and the parts untouched. + #[test] + fn apportion_cumulative_refuses_mismatched_or_out_of_range_inputs() { + let big = (1i64 << 40) + 1; + for (total, parts, rems, unit, given) in [ + (4i64, [2i64, 2], [0i64, 0], 1i64, &[0i64][..]), + (4, [2, 2], [0, 0], 0, &[0, 0][..]), + (4, [2, 2], [0, 0], big, &[0, 0][..]), + (big, [2, 2], [0, 0], 1, &[0, 0][..]), + (4, [big, 2], [0, 0], 1, &[0, 0][..]), + (4, [2, 2], [0, 0], 1, &[big, 0][..]), + // Three units short of two parts: not rounding. + (7, [2, 2], [0, 0], 1, &[0, 0][..]), + ] { + let mut p = parts; + assert_eq!( + apportion_cumulative(total, &mut p, &rems, unit, given), + None, + "{total} {parts:?} {unit} {given:?}" + ); + assert_eq!(p, parts); + } + // Remainders that don't pair with the parts: apportion's refusal. + let mut p = [2i64, 2]; + assert_eq!(apportion_cumulative(4, &mut p, &[0], 1, &[0, 0]), None); + assert_eq!(p, [2, 2]); + } + + /// apportion_cumulative scenario 2 (cents, i64): three cents closed + /// one at a time gave A all three; the fourth cent's close apportions + /// the running 4 cents (2 / 2) and takes back what A and B had. + #[test] + fn apportion_cumulative_trues_up_a_legacy_split() { + let mut parts = [2i64, 2]; + apportion_cumulative(4, &mut parts, &[0, 0], 1, &[3, 0]).unwrap(); + assert_eq!(parts, [-1, 2]); + // A running total of 5: floors 2 / 2 with equal remainders, so the + // tied unit goes to the first (3 / 2), less what each had (3 / 1). + let mut parts = [2i64, 2]; + apportion_cumulative(5, &mut parts, &[5, 5], 1, &[3, 1]).unwrap(); + assert_eq!(parts, [0, 1]); + } + /// With no parts, only a zero whole is fully apportioned. #[test] fn apportion_to_no_parts_accepts_only_zero() { diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 4c0ca92..dbe3ac9 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -2127,6 +2127,7 @@ impl Ledger { closing_entry: None, close_seq: None, member_close: None, + members_closed: None, }, ); Ok(id) @@ -2173,9 +2174,17 @@ impl Ledger { /// - `RetainedEarnings`: account `3999`, created if missing. /// - `Account`: one equity account (a sole proprietor's capital). /// - `Members`: each member's capital account gets their share of - /// the net, to the cent (odd cents by largest remainder; closes - /// journaled before 2026-10-13 replay with the last member - /// absorbing them), and their own distribution account. + /// everything closed to these members and shares so far, to the + /// currency's minor unit (odd units by largest remainder, ties to + /// the member listed first), less what earlier closes gave them + /// (rules.md B-13; D-10), and their own distribution account. The + /// count starts at the first members close, or at the first since + /// the members or shares last changed (`Ledger::members_regime`); + /// closes to anything else are skipped. A close that posts an + /// entry may carry a one-time true-up of rounding by closes made + /// under an earlier rule (the journaled `MembersRun`, + /// `MembersPerClose` and `MembersLastAbsorbs`, which replay as + /// they were made). /// 3. Marks the period closed. /// /// Returns the closing entry's id, or `None` if there was nothing to @@ -2191,12 +2200,20 @@ impl Ledger { /// balance as of the period's last day. /// 2. The closing entry MUST balance, be dated the period's last day, and /// credit equity with exactly the net income through that day (split - /// among members by share, to the cent, by largest remainder). + /// among members by share, to the currency's minor unit, by largest + /// remainder, cumulatively). /// 3. Entries dated after the period MUST stay untouched (their income /// belongs to their own period). /// 4. A sweep account (draws, distributions, dividends) MUST end at zero, /// its balance moved into its target. /// 5. NEVER post a closing entry when there is nothing to close. + /// 6. After a members close that posts, each member's total from the + /// count's closes MUST be one largest-remainder split of everything + /// they closed: under one minor unit from their exact share. A + /// members close that posts nothing credits no one (any true-up + /// waits for the next close that posts). + /// 7. NEVER changes a posted close: earlier closes replay under the + /// rule they were journaled with. /// SCENARIOS: /// 1. IF the period is unknown or already closed: SHOULD refuse. /// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod). @@ -2205,6 +2222,13 @@ impl Ledger { /// member shares aren't positive and summing to 1: SHOULD refuse. /// 5. IF closing to retained earnings and 3999 exists but isn't a /// postable equity account: SHOULD refuse (ControlAccountMismatch). + /// 6. IF a journaled MembersCumulative names a start other than the + /// one the books derive: SHOULD refuse (InvalidMembers) + /// BECAUSE replay would split differently from the journal. + /// 7. IF 50/50 members closed −399.23 in January (A −199.62, B + /// −199.61), February closed to retained earnings, and March closes + /// −399.23 to the members: SHOULD give A −199.61, B −199.62, so + /// each has −399.23 (the count skips February's close). /// ``` pub fn close_period_to( &mut self, @@ -2223,11 +2247,13 @@ impl Ledger { self.preview_close_period_to(id, to)?; let period = self.periods.get(&id).expect("previewed").clone(); let (closings, net) = self.income_closings(period.end); + let close_seq = self.next_close_seq(); // (account, amount to credit; negative debits): the net to its // targets, and each sweep account's balance into its target. let mut equity: Vec<(AccountId, Decimal)> = Vec::new(); let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new(); + let mut members_closed = None; let mut sweep = |from: AccountId, into: AccountId, this: &Self| { // An equity balance (credit-normal) is −movement; zeroing it // moves exactly that much into the target. @@ -2259,30 +2285,27 @@ impl Ledger { sweep(*a, *into, self); } } - periods::CloseTo::Members(members) - | periods::CloseTo::MembersPerClose(members) - | periods::CloseTo::MembersLastAbsorbs(members) => { - let shares: Vec = members.iter().map(|m| m.share).collect(); - let parts = match to { - periods::CloseTo::MembersLastAbsorbs(_) => { - periods::allocate_last_absorbs(net, &shares) - } - periods::CloseTo::MembersPerClose(_) => periods::allocate(net, &shares), - _ => { - // Cumulative: what the run gives each member with - // this close, less what it gave them before. - let before = self.member_close_run(&member_key(members)); - let after = periods::allocate(before + net, &shares); - let prior = periods::allocate(before, &shares); - after.iter().zip(&prior).map(|(a, p)| a - p).collect() - } - }; - for (m, part) in members.iter().zip(parts) { - equity.push((m.capital, part)); + _ => { + let members = to.members().expect("every other target is members"); + // The sweeps first: whether an entry posts decides whether + // the parts may carry a true-up. Their lines still follow + // the parts in the entry. + for m in members { if let Some(d) = m.distributions { sweep(d, m.capital, self); } } + let posts = !closings.is_empty() || !sweep_lines.is_empty(); + let (parts, rule) = self.members_split(to, net, posts, close_seq)?; + for (m, part) in members.iter().zip(&parts) { + equity.push((m.capital, *part)); + } + members_closed = Some(periods::MembersClosed { + members: member_key(members), + net, + parts, + rule, + }); } } @@ -2314,26 +2337,201 @@ impl Ledger { Some(entry) }; - let close_seq = self - .periods - .values() - .filter(|p| p.status == PeriodStatus::Closed) - .count() as u64 - + 1; let p = self.periods.get_mut(&id).expect("period exists"); p.status = PeriodStatus::Closed; p.closing_entry = closing_entry; p.close_seq = Some(close_seq); p.member_close = match to { - periods::CloseTo::Members(members) => Some(periods::MemberCloseRecord { + periods::CloseTo::MembersRun(members) => Some(periods::MemberCloseRecord { members: member_key(members), net, }), _ => None, }; + p.members_closed = members_closed; Ok(closing_entry) } + /// The number the next close takes ([`Period::close_seq`]): one more + /// than the closes so far. + fn next_close_seq(&self) -> u64 { + self.periods + .values() + .filter(|p| p.status == PeriodStatus::Closed) + .count() as u64 + + 1 + } + + /// What a members close (`to`, closing `net`; `posts` when its entry + /// posts; numbered `close_seq`) credits each member, in `to`'s order, + /// and the rule it is made under: the rule journaled for replayed + /// closes, else the cumulative rule (rules.md B-13): each member's + /// split of the count's running total, to the base currency's minor + /// unit, less what the count's earlier closes gave them. A close that + /// posts nothing credits nothing, so a true-up of earlier rounding + /// rides only on an entry that posts (INVARIANT 5 stays true). + fn members_split( + &self, + to: &periods::CloseTo, + net: Decimal, + posts: bool, + close_seq: u64, + ) -> Result<(Vec, periods::MemberRule), LedgerError> { + use periods::{CloseTo, MemberRule}; + let members = to.members().expect("a members close"); + let shares: Vec = members.iter().map(|m| m.share).collect(); + Ok(match to { + CloseTo::MembersLastAbsorbs(_) => ( + periods::allocate_last_absorbs(net, &shares), + MemberRule::LastAbsorbs, + ), + CloseTo::MembersPerClose(_) => (periods::allocate(net, &shares), MemberRule::PerClose), + CloseTo::MembersRun(_) => { + // The run rule: what the run gives each member with this + // close, less what it gave them before. + let before = self.member_close_run(&member_key(members)); + let after = periods::allocate(before + net, &shares); + let prior = periods::allocate(before, &shares); + let parts = after.iter().zip(&prior).map(|(a, p)| a - p).collect(); + (parts, MemberRule::Run) + } + _ => { + let key = member_key(members); + let regime = self.members_regime(&key, None); + let since_seq = regime.since_seq.unwrap_or(close_seq); + let parts = if posts { + regime.allocate( + &key, + regime.closed + net, + ®ime.given, + self.base_currency.allocation_places(), + )? + } else { + vec![Decimal::ZERO; members.len()] + }; + (parts, MemberRule::Cumulative { since_seq }) + } + }) + } + + /// The members regime a close with `members` ((capital account, share) + /// pairs) belongs to (rules.md B-13), counting only closes whose + /// period ends on or before `through` (all when `None`): walking the + /// closes newest first by [`Period::close_seq`], members closes under + /// any rule with the same SET of (capital, share) pairs (order and + /// distribution accounts ignored; 0.5 is 0.50), skipping closes to + /// anything else (they allocate nothing to members), and stopping at + /// the first members close with another set. A change of members or + /// shares starts a new count (26 U.S.C. §706(d)(1); Treas. Reg. + /// §1.706-4(a)(3)(vii): each segment its own distributive share + /// period); income closed under the old shares is never re-split. + /// + /// The date filter keeps a prefix of the closes in close order because + /// period ends rise with close order: a close is refused while a later + /// period is closed (CloseOutOfOrder) or when its end lies in a closed + /// period (`preview_close_period_to`), pinned by + /// `period_ends_rise_with_close_order`. + /// + /// As of a day no count with `members` holds yet, the earnings not + /// closed by then go to the count the next members close begins, when + /// it is these members': its first close's order is the regime's + /// [`Regime::order`], so their tied units go as they will once it + /// counts them, and a reorder of the members in the settings since + /// moves no cent on those days either. + pub(crate) fn members_regime( + &self, + members: &[(AccountId, Decimal)], + through: Option, + ) -> Regime { + let mut regime = self.regime_where(members, |p| through.is_none_or(|d| p.end <= d)); + if let Some(day) = through + && regime.order.is_empty() + { + let next = self + .periods + .values() + .filter(|p| p.status == PeriodStatus::Closed && p.end > day) + .filter_map(|p| Some((p.close_seq, p.members_closed.as_ref()?))) + .min_by_key(|&(seq, _)| seq); + if let Some((_, m)) = next + && same_member_set(&m.members, members) + { + regime.order.clone_from(&m.members); + } + } + regime + } + + /// [`Self::members_regime`] over the closed periods `include` keeps. + fn regime_where( + &self, + members: &[(AccountId, Decimal)], + include: impl Fn(&Period) -> bool, + ) -> Regime { + let mut closed: Vec<&Period> = self + .periods + .values() + .filter(|p| p.status == PeriodStatus::Closed && include(p)) + .collect(); + closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq)); + let mut regime = Regime { + since_seq: None, + closed: Decimal::ZERO, + given: vec![Decimal::ZERO; members.len()], + order: Vec::new(), + }; + for p in closed { + let Some(m) = &p.members_closed else { + continue; + }; + if !same_member_set(&m.members, members) { + break; + } + regime.since_seq = p.close_seq; + regime.order.clone_from(&m.members); + regime.closed += m.net; + for ((capital, _), part) in m.members.iter().zip(&m.parts) { + if let Some(i) = members.iter().position(|(c, _)| c == capital) { + regime.given[i] += *part; + } + } + } + regime + } + + /// A closed members period's split under the cumulative rule + /// (books.close_period's `members`): the close its count starts at, + /// and per member, in the close's order, what this close credited + /// them, their running total from the count's closes through it, and + /// their exact share of everything the count closed through it. `None` + /// for a period not closed to members, or closed under an earlier + /// rule. + pub fn member_close_split(&self, id: PeriodId) -> Option<(u64, Vec)> { + let p = self.periods.get(&id)?; + let m = p.members_closed.as_ref()?; + let periods::MemberRule::Cumulative { since_seq } = m.rule else { + return None; + }; + let seq = p.close_seq?; + let regime = self.regime_where(&m.members, |q| q.close_seq <= Some(seq)); + let split = m + .members + .iter() + .zip(&m.parts) + .zip(®ime.given) + .map( + |((&(capital, share), &part), &running)| periods::MemberSplit { + capital, + share, + part, + running, + exact: share * regime.closed, + }, + ) + .collect(); + Some((since_seq, split)) + } + /// The net closed by the run of cumulative members closes, with exactly /// these members and shares, that ends with the latest close: zero if /// the latest close was anything else (or there is none). @@ -2408,9 +2606,13 @@ impl Ledger { accounts.push(*into); accounts.extend(sweep); } - periods::CloseTo::Members(members) - | periods::CloseTo::MembersPerClose(members) - | periods::CloseTo::MembersLastAbsorbs(members) => { + _ => { + let members = to.members().expect("every other target is members"); + if let periods::CloseTo::MembersCumulative { since_seq: 0, .. } = to { + return Err(LedgerError::InvalidMembers( + "a count of members closes starts at close 1 or later, not 0".into(), + )); + } let total: Decimal = members.iter().map(|m| m.share).sum(); if members.is_empty() || total != Decimal::ONE @@ -2467,7 +2669,9 @@ impl Ledger { /// [`Self::close_period_to`] would succeed — the period exists and is /// open, no draft entries are dated inside it, no later period is /// closed, the closing date isn't inside another closed period, and the - /// target is valid — and returns the id the closing entry will receive, + /// target is valid (for members: their split computes exactly, and a + /// journaled start is the one the books derive) — and returns the id + /// the closing entry will receive, /// or `None` when the close posts no entry. Stores call this before /// journaling so a failed close leaves no journal line behind. pub fn preview_close_period_to( @@ -2510,20 +2714,36 @@ impl Ledger { // An entry posts exactly when there is income to close or a sweep // account with a balance. Account creation (Retained Earnings) does // not consume entry ids. - let (closings, _) = self.income_closings(period.end); + let (closings, net) = self.income_closings(period.end); let sweeps: Vec = match to { periods::CloseTo::RetainedEarnings { sweep } | periods::CloseTo::Account { sweep, .. } => sweep.clone(), - periods::CloseTo::Members(members) - | periods::CloseTo::MembersPerClose(members) - | periods::CloseTo::MembersLastAbsorbs(members) => { - members.iter().filter_map(|m| m.distributions).collect() - } + _ => to + .members() + .expect("every other target is members") + .iter() + .filter_map(|m| m.distributions) + .collect(), }; let any = !closings.is_empty() || sweeps .iter() .any(|a| !self.movement_through(*a, period.end).is_zero()); + if to.members().is_some() { + // The journaled start must be the derived one, and the split + // must be computable: a close this refuses journals nothing. + let (_, rule) = self.members_split(to, net, any, self.next_close_seq())?; + if let ( + periods::CloseTo::MembersCumulative { since_seq, .. }, + periods::MemberRule::Cumulative { since_seq: derived }, + ) = (to, rule) + && *since_seq != derived + { + return Err(LedgerError::InvalidMembers(format!( + "this close was journaled counting the members' closes from close {since_seq}, but the books count them from close {derived}" + ))); + } + } Ok(any.then_some(self.next_entry_id)) } @@ -5267,7 +5487,80 @@ fn check_line_size(amount: Decimal) -> Result<(), LedgerError> { } /// A members close's (capital account, share) list, in order: what a run -/// of cumulative closes must share. +/// of run-rule closes must share in that order, and a count of cumulative +/// closes as a set ([`same_member_set`]). fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> { members.iter().map(|m| (m.capital, m.share)).collect() } + +/// Two members closes' (capital account, share) lists name the same +/// members with the same shares, in any order (rules.md B-13: a reorder of +/// the members is not a change of members). Shares compare by value, so +/// 0.5 is 0.50. +fn same_member_set(a: &[(AccountId, Decimal)], b: &[(AccountId, Decimal)]) -> bool { + let sorted = |v: &[(AccountId, Decimal)]| { + let mut v = v.to_vec(); + v.sort_unstable(); + v + }; + a.len() == b.len() && sorted(a) == sorted(b) +} + +/// A members regime ([`Ledger::members_regime`]): the members closes with +/// one set of members and shares since the last close with another. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Regime { + /// The close the count starts at (its [`Period::close_seq`]); `None` + /// when no members close with this set is counted yet. + pub since_seq: Option, + /// The net those closes closed, together (negative for a loss). + pub closed: Decimal, + /// What those closes credited each member, in the order asked for, by + /// capital account. + pub given: Vec, + /// The members as the count's first close listed them: the order its + /// tied odd units go in. As of a day before any close of a count, the + /// count the next members close begins, when it is these members' + /// ([`Ledger::members_regime`]). Empty when there is none. + pub order: Vec<(AccountId, Decimal)>, +} + +impl Regime { + /// [`periods::allocate_cumulative`] of `total` among `members` against + /// `given` (both in `members`' order), the tied odd units to the member + /// the count's first close listed first ([`Regime::order`]; `members`' + /// own order when no close has begun the count), the parts back in `members`' + /// order. So a count's ties go to the same member whatever order later + /// closes, statements or the settings list the members in, and a + /// reorder of the members moves no cent (rules.md B-13, D-38). + pub(crate) fn allocate( + &self, + members: &[(AccountId, Decimal)], + total: Decimal, + given: &[Decimal], + places: u32, + ) -> Result, LedgerError> { + let order = if self.order.is_empty() { + members + } else { + &self.order + }; + let index: Vec = order + .iter() + .map(|(capital, _)| { + members + .iter() + .position(|(c, _)| c == capital) + .expect("a count's members are the members asked for") + }) + .collect(); + let shares: Vec = index.iter().map(|&i| members[i].1).collect(); + let given: Vec = index.iter().map(|&i| given[i]).collect(); + let split = periods::allocate_cumulative(total, &shares, &given, places)?; + let mut parts = vec![Decimal::ZERO; members.len()]; + for (&i, part) in index.iter().zip(split) { + parts[i] = part; + } + Ok(parts) + } +} diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 740966f..58f5a77 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -69,7 +69,8 @@ pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; pub use periods::{ - CloseTo, MemberClose, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE, + CloseTo, MemberClose, MemberCloseRecord, MemberRule, MemberSplit, MembersClosed, Period, + PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE, }; pub use reports::{ AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine, diff --git a/crates/ledger-core/src/money.rs b/crates/ledger-core/src/money.rs index 4eeb5fd..b1b2bf8 100644 --- a/crates/ledger-core/src/money.rs +++ b/crates/ledger-core/src/money.rs @@ -57,6 +57,18 @@ impl Currency { } } + /// The decimal places a share of an amount in this currency is split + /// to (members' shares of earnings, periods::allocate_cumulative): its + /// minor units (cents, whole yen, thousandths of a dinar), or, for a + /// code ISO 4217 gives none (gold, SDRs), the most a line may carry + /// (kernel::MAX_LINE_SCALE). Every posted amount is then a whole number + /// of the unit split to, so no member's running share is ever a unit + /// from exact (rules.md B-13); split to cents, a finer amount's + /// leftover fraction would all go to one member. + pub fn allocation_places(&self) -> u32 { + self.minor_units().unwrap_or(crate::kernel::MAX_LINE_SCALE) + } + /// The three-letter code, e.g. `"USD"`. pub fn code(&self) -> &str { std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction") diff --git a/crates/ledger-core/src/periods.rs b/crates/ledger-core/src/periods.rs index fdfd418..06888e4 100644 --- a/crates/ledger-core/src/periods.rs +++ b/crates/ledger-core/src/periods.rs @@ -51,11 +51,30 @@ pub enum CloseTo { /// Members' capital accounts by share (shares sum to 1): the /// partnership convention, where each member's capital account carries /// their allocated earnings and absorbs their distributions. Odd cents - /// go by largest remainder ([`allocate`]), cumulatively: a run of - /// member closes with the same members and shares gives each member, - /// in total, their share of the run's total to the cent, so rounding - /// never piles up close after close (rules.md B-13; since 2026-10-13). + /// go by largest remainder, cumulatively from the first members close + /// with these members and shares ([`allocate_cumulative`]): each close + /// gives a member their share of everything closed to the members so + /// far, to the currency's minor unit, less what earlier closes gave + /// them, so no member is ever a minor unit from their exact share + /// (rules.md B-13; D-10). What new closes are made with; the books + /// journal it as [`CloseTo::MembersCumulative`]. Members(Vec), + /// [`CloseTo::Members`] as journaled: the count of members closes + /// starts at the close numbered `since_seq` (its + /// [`Period::close_seq`]). Replay produces it and checks the start is + /// the one the books derive. + MembersCumulative { + /// The members and shares. + members: Vec, + /// The close the count starts at: the first members close with + /// these members and shares since the last with others. + since_seq: u64, + }, + /// The run rule of 2026-10-13 to P3 (journaled `"cumulative"`): a + /// back-to-back run of members closes with the same members and shares + /// in the same order splits cumulatively, and any other close ends the + /// run. Only replay of those journals produces it. + MembersRun(Vec), /// [`CloseTo::Members`] rounding each close on its own (largest /// remainder), the rule for closes journaled on 2026-10-13 before the /// cumulative one. Only replay of those journals produces it. @@ -66,6 +85,21 @@ pub enum CloseTo { MembersLastAbsorbs(Vec), } +impl CloseTo { + /// The members a close splits the net among, under whichever rule; + /// `None` for a close to retained earnings or one account. + pub fn members(&self) -> Option<&[MemberClose]> { + match self { + CloseTo::RetainedEarnings { .. } | CloseTo::Account { .. } => None, + CloseTo::Members(members) + | CloseTo::MembersCumulative { members, .. } + | CloseTo::MembersRun(members) + | CloseTo::MembersPerClose(members) + | CloseTo::MembersLastAbsorbs(members) => Some(members), + } + } +} + /// One member in a partnership close. #[derive(Clone, Debug, PartialEq)] pub struct MemberClose { @@ -84,6 +118,8 @@ pub struct MemberClose { /// ties to the earlier member ([`crate::kernel::apportion`]). The parts /// always sum to `amount` exactly, and no member is favored by position /// (owner, 2026-10-13). A loss splits as the negation of the same gain. +/// Closes journaled under the rules of 2026-10-13 replay with it, in cents +/// whatever the currency; new closes split with [`allocate_cumulative`]. pub fn allocate( amount: rust_decimal::Decimal, shares: &[rust_decimal::Decimal], @@ -122,6 +158,104 @@ pub fn allocate_last_absorbs( parts } +/// What one members close gives each member: [`allocate`]'s split of the +/// running `total` (everything closed to these members and shares, +/// this close included), to `places` decimal places +/// ([`crate::Currency::allocation_places`]) instead of cents, less what +/// earlier closes gave each member (`given`, in the order of `shares`). +/// Each member's running total is then one split of the running total +/// (rules.md B-13; [`crate::kernel::apportion_cumulative`]). A loss is +/// the negation of the same gain: it splits |total| against `given` +/// negated, then negates. +/// +/// ```text +/// GOAL: no member's running share of earnings is ever a minor unit from +/// their exact share, whatever earlier closes gave them. +/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg. +/// §1.704-1(b)(2)(iv)(b); largest remainder: DECISION, owner +/// 2026-10-13; from the first close: D-10); B-3 (the minor unit). +/// INVARIANTS: +/// 1. The parts MUST sum to total − Σ given exactly. +/// 2. given[i] + part[i] MUST be member i's largest-remainder split of +/// total to `places` (ties to the earlier member): one split. +/// 3. So when total is a whole number of units of `places` (every posted +/// line is at its currency's allocation_places: its minor unit, B-3, +/// or for a currency without one the finest a line carries), +/// |given[i] + part[i] − total × share[i]| is under one unit (Lean +/// allocW_drift, cumulative_drift; Kani +/// cumulative_close_never_drifts_a_unit_at_*). +/// 4. NEVER rounds a product: if |total| × share isn't exact in Decimal +/// (a share of many decimal places times a large total), refuse +/// (TooLargeToCompute): a rounded product could rank the remainders +/// wrong, and the bound above assumes exact ones (P3 review R-5). +/// SCENARIOS: +/// 1. IF 101 yen splits 0.5 / 0.25 / 0.25 with nothing given: SHOULD be +/// 51 / 25 / 25 (exact 50.5 / 25.25 / 25.25; the yen to the largest +/// remainder), never fractions of a yen. +/// 2. IF 0.03 was given as A 0.03 / B 0.00 (50/50) and the total is now +/// 0.04: SHOULD be A −0.01, B +0.02. +/// 3. IF the total is a loss: SHOULD be the negation of the same gain +/// against the negated given. +/// 4. IF the shares and given don't pair up, or places exceeds what a +/// line may carry: SHOULD refuse. +/// ``` +pub fn allocate_cumulative( + total: rust_decimal::Decimal, + shares: &[rust_decimal::Decimal], + given: &[rust_decimal::Decimal], + places: u32, +) -> Result, LedgerError> { + use rust_decimal::{Decimal, RoundingStrategy::ToZero}; + if shares.len() != given.len() { + return Err(LedgerError::InvalidMembers(format!( + "{} shares but {} earlier parts", + shares.len(), + given.len() + ))); + } + if places > crate::kernel::MAX_LINE_SCALE { + return Err(LedgerError::TooLargeToCompute(format!( + "a split to {places} decimal places" + ))); + } + let loss = total < Decimal::ZERO; + let magnitude = total.abs().normalize(); + let given: Vec = given.iter().map(|&g| if loss { -g } else { g }).collect(); + let mut parts = Vec::with_capacity(shares.len()); + let mut remainders = Vec::with_capacity(shares.len()); + for share in shares { + let share = share.normalize(); + // Exact exactly when Decimal kept every digit: a product it had to + // round comes back with fewer places than its factors carry (a + // zero product, which it returns without places, is exact). + let exact = magnitude + .checked_mul(share) + .filter(|e| e.is_zero() || e.scale() == magnitude.scale() + share.scale()) + .ok_or_else(|| { + LedgerError::TooLargeToCompute(format!( + "a member's share {share} of {magnitude}, to its last digit," + )) + })?; + let floor = exact.round_dp_with_strategy(places, ToZero); + parts.push(floor); + remainders.push(exact - floor); + } + crate::kernel::apportion_cumulative( + magnitude, + &mut parts, + &remainders, + Decimal::new(1, places), + &given, + ) + .ok_or_else(|| LedgerError::TooLargeToCompute(format!("the members' shares of {total}")))?; + if loss { + for p in parts.iter_mut().filter(|p| !p.is_zero()) { + *p = -*p; + } + } + Ok(parts) +} + /// Code of the Retained Earnings account that closing entries credit (or /// debit), created on the first close if the chart lacks it. pub const RETAINED_EARNINGS_CODE: &str = "3999"; @@ -145,13 +279,23 @@ pub struct Period { /// closed. #[serde(default)] pub close_seq: Option, - /// What a cumulative members close allocated, once closed that way. + /// What a close under the run rule ([`CloseTo::MembersRun`], journaled + /// `"cumulative"`) allocated, once closed that way. Only those closes + /// have one: its serialized form is what earlier versions wrote, and + /// stays so (rules.md B-28). Every members close's split, under any + /// rule, is [`Period::members_closed`]. #[serde(default)] pub member_close: Option, + /// What a members close (any rule) split, once closed to the members. + /// Derived on replay; never serialized, so a period reads as earlier + /// versions wrote it. + #[serde(skip)] + pub members_closed: Option, } -/// A cumulative members close ([`CloseTo::Members`]): who shared, and the -/// net it closed. The next close with the same members continues the run. +/// A run-rule members close ([`CloseTo::MembersRun`]): who shared, and the +/// net it closed. The next run-rule close with the same members continues +/// the run. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct MemberCloseRecord { /// (capital account, share), in the order given. @@ -160,6 +304,63 @@ pub struct MemberCloseRecord { pub net: rust_decimal::Decimal, } +/// The rounding rule a members close was made under, as journaled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MemberRule { + /// The last member absorbed the rounding (closes before 2026-10-13; + /// [`CloseTo::MembersLastAbsorbs`]). + LastAbsorbs, + /// Largest remainder, each close on its own (2026-10-13; + /// [`CloseTo::MembersPerClose`]). + PerClose, + /// Cumulative over a back-to-back run (2026-10-13 to P3; + /// [`CloseTo::MembersRun`]). + Run, + /// Cumulative from the members close numbered `since_seq` + /// ([`CloseTo::Members`], [`CloseTo::MembersCumulative`]). + Cumulative { + /// The close the count starts at. + since_seq: u64, + }, +} + +/// A members close's split, under any rule ([`Period::members_closed`]). +#[derive(Clone, Debug, PartialEq)] +pub struct MembersClosed { + /// (capital account, share), in the close's order. + pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>, + /// The net income this close allocated (negative for a loss). + pub net: rust_decimal::Decimal, + /// The income it credited each member's capital account (debited for + /// a negative part), in the same order: their share of `net` plus any + /// true-up. All zero when the close posted no entry. + pub parts: Vec, + /// The rule it was made under. + pub rule: MemberRule, +} + +/// One member's part of a close under the cumulative rule +/// ([`crate::Ledger::member_close_split`]). +#[derive(Clone, Debug, PartialEq)] +pub struct MemberSplit { + /// The member's capital account. + pub capital: crate::AccountId, + /// Their share. + pub share: rust_decimal::Decimal, + /// What this close credited them (negative: debited), any true-up of + /// earlier rounding included. + pub part: rust_decimal::Decimal, + /// What the count's closes have credited them in all, this one + /// included. + pub running: rust_decimal::Decimal, + /// Their exact share of everything the count has closed, this close + /// included (share × total, unrounded): `running` is within one minor + /// unit of it once a close of the count has posted under the + /// cumulative rule (until then it still carries the rounding of closes + /// made under an earlier rule, which that close trues up). + pub exact: rust_decimal::Decimal, +} + impl Period { /// True when `date` falls inside the period (inclusive). pub fn contains(&self, date: NaiveDate) -> bool { @@ -193,6 +394,101 @@ pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), Led mod tests { use super::*; use crate::{Currency, Ledger}; + use rust_decimal::Decimal; + + fn dec(s: &str) -> Decimal { + s.parse().unwrap() + } + + fn decs(v: &[&str]) -> Vec { + v.iter().map(|s| dec(s)).collect() + } + + /// allocate_cumulative scenario 1: 101 yen at 0.5 / 0.25 / 0.25 is + /// exactly 50.5 / 25.25 / 25.25; floors 50 / 25 / 25 leave one yen, + /// which goes to the largest remainder (0.5): 51 / 25 / 25. Never a + /// fraction of a yen. A dinar splits to thousandths. + #[test] + fn allocate_cumulative_splits_to_the_currencys_minor_unit() { + let shares = decs(&["0.5", "0.25", "0.25"]); + let none = decs(&["0", "0", "0"]); + assert_eq!( + allocate_cumulative(dec("101"), &shares, &none, 0).unwrap(), + decs(&["51", "25", "25"]) + ); + // KWD: 0.101 at 50/50 is 0.0505 each; floors 0.050 / 0.050 and + // the tied thousandth to the first. + let half = decs(&["0.5", "0.5"]); + assert_eq!( + allocate_cumulative(dec("0.101"), &half, &decs(&["0", "0"]), 3).unwrap(), + decs(&["0.051", "0.050"]) + ); + // Cents, as allocate splits them. + assert_eq!( + allocate_cumulative(dec("0.07"), &half, &decs(&["0", "0"]), 2).unwrap(), + allocate(dec("0.07"), &half) + ); + } + + /// allocate_cumulative scenarios 2 and 3: three cents given A 0.03 / + /// B 0.00 and a running 0.04 give A −0.01, B +0.02; a loss of the + /// same is the negation against the negated given. + #[test] + fn allocate_cumulative_trues_up_and_mirrors_a_loss() { + let half = decs(&["0.5", "0.5"]); + assert_eq!( + allocate_cumulative(dec("0.04"), &half, &decs(&["0.03", "0"]), 2).unwrap(), + decs(&["-0.01", "0.02"]) + ); + assert_eq!( + allocate_cumulative(dec("-0.04"), &half, &decs(&["-0.03", "0"]), 2).unwrap(), + decs(&["0.01", "-0.02"]) + ); + // Given that sums to the earlier total: the parts sum to the new + // amount (0.05 − 0.03 = 0.02); running totals 0.03 / 0.02. + let parts = allocate_cumulative(dec("0.05"), &half, &decs(&["0.01", "0.02"]), 2).unwrap(); + assert_eq!(parts, decs(&["0.02", "0.00"])); + assert_eq!(parts.iter().sum::(), dec("0.02")); + } + + /// allocate_cumulative INVARIANT 4 (P3 review R-5): a share of 28 + /// decimal places times 100.00 has 30, more than Decimal keeps, so the + /// product would be rounded: refused, never split from a rounded + /// product. Four places (33.33%) are exact at any line amount. + #[test] + fn allocate_cumulative_refuses_a_product_it_cannot_compute_exactly() { + let third = dec("0.3333333333333333333333333333"); + let last = Decimal::ONE - third - third; + let shares = [third, third, last]; + let none = decs(&["0", "0", "0"]); + assert!(matches!( + allocate_cumulative(dec("100.00"), &shares, &none, 2), + Err(LedgerError::TooLargeToCompute(_)) + )); + let shares = decs(&["0.3333", "0.3333", "0.3334"]); + let max = crate::kernel::MAX_LINE_AMOUNT - dec("0.01"); + let parts = allocate_cumulative(max, &shares, &none, 2).unwrap(); + assert_eq!(parts.iter().sum::(), max); + // Trailing zeros are not digits: "0.50" and "0.5" split alike. + assert_eq!( + allocate_cumulative(dec("0.01"), &decs(&["0.50", "0.50"]), &decs(&["0", "0"]), 2) + .unwrap(), + decs(&["0.01", "0.00"]) + ); + } + + /// allocate_cumulative scenario 4. + #[test] + fn allocate_cumulative_refuses_unpaired_inputs() { + assert!(matches!( + allocate_cumulative(dec("1"), &decs(&["1"]), &[], 2), + Err(LedgerError::InvalidMembers(_)) + )); + assert!(matches!( + allocate_cumulative(dec("1"), &decs(&["1"]), &decs(&["0"]), 11), + Err(LedgerError::TooLargeToCompute(_)) + )); + } fn period_ledger() -> Ledger { let mut l = Ledger::new(Currency::USD); diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index ea7b395..8bb12a9 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -543,6 +543,176 @@ fn apportion_is_fair() { ); } +/// `kernel::apportion_cumulative` is `kernel::apportion` less what was +/// given: it succeeds exactly when `apportion` would on the same inputs and +/// the running total is in line range (the parts, unit and `given` are, +/// here), and then each part is apportion's less its `given`, so each +/// running total (given + part) is apportion's. With `apportion_is_exact`, +/// the parts sum to the running total less what was given, and each +/// running total is nothing or one unit above its rounded-down part (one +/// of them may also take the sub-unit fraction). A refusal leaves the +/// parts as they were. +#[kani::proof] +#[kani::unwind(5)] +fn apportion_cumulative_is_exact() { + let (n, before, rems, unit, _, whole) = any_apportionment(); + let given: [i64; AP] = core::array::from_fn(|_| any_amount()); + let mut reference = before; + let apportioned = kernel::apportion(whole, &mut reference[..n], &rems[..n], unit); + let mut parts = before; + let ok = kernel::apportion_cumulative(whole, &mut parts[..n], &rems[..n], unit, &given[..n]); + assert_eq!(ok.is_some(), apportioned.is_some() && whole.in_line_range()); + let mut against = false; + for i in 0..n { + if ok.is_some() { + assert_eq!(parts[i] + given[i], reference[i]); + against |= parts[i] < 0; + } else { + assert_eq!(parts[i], before[i]); + } + } + kani::cover!( + ok.is_some() && n == AP && against && whole > given[0] + given[1] + given[2], + "a true-up against the net's sign" + ); +} + +/// Bound on the amounts the drift harnesses explore (symbolic multiply and +/// divide are costly for the solver; Lean's `allocW_drift` and +/// `cumulative_drift` carry any weights and any amounts). +const DRIFT: i64 = 1 << 12; + +/// **Bounded drift** (rules.md B-13) for members with these integer +/// weights (shares w_i / W, W their sum): a cumulative members close, +/// modelled as `periods::allocate_cumulative` runs it at whole minor units +/// (unit 1), hands out exactly its net, and leaves every member's running +/// total, given + part, within one unit of their exact share of +/// everything closed, whatever earlier closes gave them (any `given` +/// summing to what they closed: legacy rounding included). Scaled by W: +/// |(given_i + part_i)·W − T·w_i| < W. +fn drift_is_under_a_unit(weights: &[i64]) { + let n = weights.len(); + let mut total_weight = 0i64; + for w in weights { + total_weight += w; + } + let closed: i64 = kani::any(); + let net: i64 = kani::any(); + kani::assume((-DRIFT..=DRIFT).contains(&closed) && (-DRIFT..=DRIFT).contains(&net)); + // What earlier closes gave: anything, as long as it adds up. + let mut given = [0i64; AP]; + let mut others = 0i64; + for g in given.iter_mut().take(n - 1) { + *g = kani::any(); + kani::assume((-DRIFT..=DRIFT).contains(g)); + others += *g; + } + given[n - 1] = closed - others; + // allocate_cumulative: |T| split by exact share, rounded toward zero, + // the remainders ranked; a loss runs on |T| against given negated. + let total = closed + net; + let magnitude = total.abs(); + let loss = total < 0; + let mut parts = [0i64; AP]; + let mut rems = [0i64; AP]; + let mut against = [0i64; AP]; + for i in 0..n { + parts[i] = magnitude * weights[i] / total_weight; + rems[i] = magnitude * weights[i] % total_weight; + against[i] = if loss { -given[i] } else { given[i] }; + } + kernel::apportion_cumulative(magnitude, &mut parts[..n], &rems[..n], 1, &against[..n]) + .expect("exact shares are fewer units short than there are members"); + let mut sum = 0i64; + for i in 0..n { + if loss { + parts[i] = -parts[i]; + } + sum += parts[i]; + let drift = (given[i] + parts[i]) * total_weight - total * weights[i]; + assert!(-total_weight < drift && drift < total_weight); + } + assert_eq!(sum, net); + kani::cover!( + loss && given[0] * total_weight != closed * weights[0], + "a loss, after an earlier split off the exact share" + ); +} + +/// [`drift_is_under_a_unit`] for two members at half each: every odd cent +/// a tie. +#[kani::proof] +#[kani::unwind(4)] +fn cumulative_close_never_drifts_a_unit_at_halves() { + drift_is_under_a_unit(&[1, 1]); +} + +/// [`drift_is_under_a_unit`] for three members at 20% / 30% / 50%: +/// remainders that differ. +#[kani::proof] +#[kani::unwind(4)] +fn cumulative_close_never_drifts_a_unit_at_tenths() { + drift_is_under_a_unit(&[2, 3, 5]); +} + +/// [`drift_is_under_a_unit`] for three members at 33.33% / 33.33% / +/// 33.34%, the nearest decimal shares to a third each. +#[kani::proof] +#[kani::unwind(4)] +fn cumulative_close_never_drifts_a_unit_at_near_thirds() { + drift_is_under_a_unit(&[3333, 3333, 3334]); +} + +/// A cumulative members close balances: the nominal accounts' closing +/// lines and each member's part of the running split (any earlier split +/// of what was closed before) net to zero, whatever the true-up. +#[kani::proof] +#[kani::unwind(6)] +fn cumulative_closing_entry_balances() { + const NOMINAL: usize = 3; + let nominal: [i64; NOMINAL] = core::array::from_fn(|_| { + let a = any_amount(); + kani::assume((-(1 << 38)..=1 << 38).contains(&a)); + a + }); + let mut entry = [(Side::Debit, 0i64); NOMINAL + 2]; + let mut len = 0; + for &m in &nominal { + if let Some(line) = kernel::closing_line(m) { + entry[len] = line; + len += 1; + } + } + let net = kernel::net(entry[..len].iter().copied()).expect("no overflow"); + // Two members at half each; earlier closes gave them any split of + // what they closed. + let closed: i64 = kani::any(); + kani::assume((-(1 << 38)..=1 << 38).contains(&closed)); + let first = any_amount(); + let given = [first, closed - first]; + let total = closed + net; + let (magnitude, loss) = (total.abs(), total < 0); + let mut parts = [magnitude / 2, magnitude / 2]; + let rems = [magnitude % 2, magnitude % 2]; + let against = if loss { [-given[0], -given[1]] } else { given }; + if kernel::apportion_cumulative(magnitude, &mut parts, &rems, 1, &against).is_none() { + // Only an earlier split out of line range is refused here. + assert!(!given[1].in_line_range()); + return; + } + let mut true_up = false; + for p in parts { + let p = if loss { -p } else { p }; + if let Some(line) = kernel::credit_line(p) { + entry[len] = line; + len += 1; + true_up |= net != 0 && (p > 0) != (net > 0); + } + } + assert_eq!(wide_net(&entry[..len]), 0); + kani::cover!(true_up, "a true-up line on a member"); +} + // --------------------------------------------------------------------------- // The checker itself // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 93a3bed..007c907 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -4,7 +4,6 @@ //! (`docs/oracle.md`) diffs: the same fixture books through two engines must //! produce byte-identical [`snapshot`] output. -use crate::periods::allocate; use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError}; use chrono::NaiveDate; use rust_decimal::Decimal; @@ -842,32 +841,45 @@ pub fn cancelled_in_span( /// distributions (debits). Earnings are partnership earnings: net income /// for the period, and earnings from before it not yet distributed (retained /// earnings plus unclosed income), are allocated by share. Allocation is to -/// the cent, odd cents by largest remainder, so every row's total is exact -/// and the ending total equals balance-sheet equity. +/// the currency's minor unit, odd units by largest remainder, the same +/// running way the members closes split them, so every row's total is +/// exact and the ending total equals balance-sheet equity. /// /// ```text /// GOAL: each member's capital roll-forward for a partnership over a /// period: beginning, contributions, allocated net income, distributions, /// ending; with a total. -/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule -/// M-2), B-11; R-9c. +/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg. +/// §1.704-1(b)(2)(iv)(b); Form 1065 Schedule M-2; D-38), B-11; +/// R-9c. /// INVARIANTS: /// 1. Each row MUST roll forward exactly: beginning + contributions + /// income - distributions + other = ending. /// 2. The total ending MUST equal balance-sheet equity at `end`. -/// 3. Undistributed earnings MUST be split by share, to the cent, by -/// largest remainder (DECISION, owner 2026-10-13), cumulatively as -/// of each date: a member's beginning is their capital and share as -/// of the day before `start`, their ending as of `end`. +/// 3. Undistributed earnings U MUST be split by share, to the base +/// currency's minor unit, by largest remainder (DECISION, owner +/// 2026-10-13), as of each date d, the way the next members close +/// would split them: allocate(C + U) − allocate(C), where C is what +/// the members regime's closes dated on or before d closed +/// (Ledger::members_regime; D-38, P3 review R-1). A member's +/// beginning is their capital and share as of the day before +/// `start`, their ending as of `end`. Tied units, "other"'s +/// included, go to the member the count's first close listed first +/// (Regime::allocate; on a day before it, the count the next members +/// close begins), so the order `members` lists them in moves no unit +/// once a close has begun the count. /// 3a. A member's ending on one statement MUST equal their beginning on -/// the next, exactly (a capital account is one running balance: Form -/// 1065 Schedule K-1 instructions, Item L, "beginning capital account -/// and ending capital account ... based on the partnership's books"). +/// the next, exactly (a capital account is one running balance of +/// contributions, allocations and distributions: Treas. Reg. +/// §1.704-1(b)(2)(iv)(b)). /// 3b. A member's income is the roll-forward's remainder: their share of -/// the period's income, off by less than two cents (under one cent of -/// rounding at each end of the cumulative split); the members' -/// incomes MUST sum to the period's net income. (Earnings a close has -/// already moved into capital were rounded by that close, B-13.) +/// the period's income, off by less than two minor units (under one +/// at each end of the cumulative split); the members' incomes MUST +/// sum to the period's net income. +/// 3c. A member's capital as of a date MUST NOT change when a period +/// ending on or before it is later closed to the members, except by a +/// true-up of rounding by closes made under an earlier rule, which +/// shows only once a close posts it (D-38; R-1). /// 4. NEVER hide an unexplained change: it goes in "other". /// 5. An entry dated `start` is period activity, not beginning capital. /// 6. An entry and its reversal both dated in the span cancel @@ -981,8 +993,8 @@ pub fn members_equity( // earnings not yet distributed. Each member's share of it is allocated // cumulatively, as of a date, so a member's capital on a date never // depends on which statement shows it: one statement's ending is the - // next one's beginning, to the cent (a capital account is one running - // balance: Form 1065 Schedule K-1 instructions, Item L). + // next one's beginning, to the minor unit (a capital account is one + // running balance: Treas. Reg. §1.704-1(b)(2)(iv)(b)). let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal { let mut m = Decimal::ZERO; for e in ledger.posted_entries().filter(|e| e.date() <= through) { @@ -1003,19 +1015,45 @@ pub fn members_equity( .map_or(Decimal::ZERO, |d| capital_through(d, end)) }) .collect(); - let begin_alloc = allocate( + // As of a date, a member's share of the earnings not yet in capital is + // what the next members close would give them of it: their split of + // everything closed to these members and shares (the regime's closes + // through that date) plus it, less their split of the closed part + // alone. Closing a period moves earnings from here into capital + // without changing the sum, so a member's capital on a date is the + // same before and after its period closes (D-38); a true-up of + // rounding by closes made under an earlier rule shows only once a + // close posts it (P3 review R-1). + let places = ledger.base_currency().allocation_places(); + let key: Vec<(crate::AccountId, Decimal)> = members + .iter() + .map(|m| (m.capital_account, m.share)) + .collect(); + let nothing = vec![Decimal::ZERO; members.len()]; + let unclosed_share = |undistributed: Decimal, regime: &crate::ledger::Regime| { + let closed = regime.allocate(&key, regime.closed, ¬hing, places)?; + regime.allocate(&key, regime.closed + undistributed, &closed, places) + }; + let end_regime = ledger.members_regime(&key, Some(end)); + let begin_alloc = unclosed_share( begin_bs.total_equity - capital.iter().sum::(), - &shares, - ); - let end_alloc = allocate( + &ledger.members_regime(&key, Some(day_before)), + )?; + let end_alloc = unclosed_share( end_bs.total_equity - capital_end.iter().sum::(), - &shares, - ); + &end_regime, + )?; let explained = flow.iter().map(|(c, d)| c - d).sum::() + pnl.net_income(); - let other_alloc = allocate( + // "Other" is split anew on each statement, its tied unit to the member + // the count as of `end` lists first, as the ending's are: a reorder of + // the members in the settings moves no cent between their other (and + // so their income) either. + let other_alloc = end_regime.allocate( + &key, end_bs.total_equity - begin_bs.total_equity - explained, - &shares, - ); + ¬hing, + places, + )?; let mut cols = Vec::with_capacity(members.len()); for (i, m) in members.iter().enumerate() { diff --git a/crates/ledger-core/tests/bookspec_members.rs b/crates/ledger-core/tests/bookspec_members.rs new file mode 100644 index 0000000..6510256 --- /dev/null +++ b/crates/ledger-core/tests/bookspec_members.rs @@ -0,0 +1,398 @@ +//! The bookspec examples about closes to the members +//! (`bookspec/examples/members-close.*.toml`; P13, D-34; P3 defect 17), +//! checked against the engine on every run, so they can't rot. +//! +//! Each example is a setup, events and expected values worked by hand from +//! its cited source. Its shape is bookspec's (`bookspec/README.md`) with +//! what closes need: +//! +//! - `[[setup.members]]` takes `share`, an exact decimal fraction (the +//! shares sum to 1), beside `name` and `capital_account`; +//! - an event may be a close instead of an entry: `close = { start, to, +//! rounding? }` closes the period from `start` through the event's +//! `date` to `"members"` (by share, in the order listed) or +//! `"retained_earnings"`; `rounding = "per-close"` makes a members close +//! split its own net alone (books from an engine that rounded each close +//! by itself), and the default is the cumulative rule. A close is an +//! event like any other: event N posts entry #N, so a close must have +//! something to close; +//! - an `[[expect]]` may take `after = N`: the books after event N, not +//! after the last; +//! - three queries: `entry` (`entry = N`: its lines, `".debit"` and +//! `".credit"` summing each account's lines on that side, every +//! line of the entry named), `balances` (`as_of`: each account named, +//! `""`, its balance on its normal side, so a member's capital +//! after a loss is negative), and `members_equity` (`from`, `to`: the +//! statement of changes in members' equity, `".beginning"`, +//! `.contributions`, `.net_income`, `.distributions`, `.other`, +//! `.ending`). +//! +//! A key the shape doesn't have is refused, never ignored. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use chrono::NaiveDate; +use ledger_core::{ + AccountType, CloseTo, Currency, Ledger, MemberCapital, MemberClose, Money, Side, + members_equity, reports::trial_balance_as_of, +}; +use rust_decimal::Decimal; +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Example { + id: String, + title: String, + rule: String, + status: String, + sources: Vec, + tags: Vec, + setup: Setup, + events: Vec, + expect: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Setup { + currency: String, + fiscal_year_start: String, + basis: String, + rounding: String, + accounts: Vec, + #[serde(default)] + members: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AccountSpec { + code: String, + name: String, + #[serde(rename = "type")] + kind: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MemberSpec { + name: String, + capital_account: String, + share: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Event { + date: String, + description: String, + #[serde(default)] + lines: Vec, + #[serde(default)] + reverses: Option, + #[serde(default)] + close: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LineSpec { + account: String, + side: String, + amount: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CloseSpec { + start: String, + to: String, + #[serde(default)] + rounding: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Expect { + query: String, + #[serde(default)] + from: Option, + #[serde(default)] + to: Option, + #[serde(default)] + as_of: Option, + #[serde(default)] + entry: Option, + #[serde(default)] + after: Option, + values: BTreeMap, +} + +fn examples() -> Vec { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples"); + let mut paths: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("members-close.") && n.ends_with(".toml")) + }) + .collect(); + paths.sort(); + paths +} + +fn date(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap_or_else(|_| panic!("date {s:?}")) +} + +/// An exact decimal string, never a float. +fn exact(s: &str) -> Decimal { + assert!( + !s.contains(['e', 'E']) && s.trim() == s, + "{s:?} is not a decimal string" + ); + s.parse() + .unwrap_or_else(|_| panic!("{s:?} is not a decimal")) +} + +fn account_type(kind: &str) -> AccountType { + match kind { + "asset" => AccountType::Asset, + "liability" => AccountType::Liability, + "equity" => AccountType::Equity, + "revenue" => AccountType::Revenue, + "expense" => AccountType::Expense, + other => panic!("account type {other:?}"), + } +} + +fn side(s: &str) -> Side { + match s { + "debit" => Side::Debit, + "credit" => Side::Credit, + other => panic!("side {other:?}"), + } +} + +struct Books<'a> { + ex: &'a Example, + l: Ledger, + currency: Currency, +} + +impl Books<'_> { + fn id(&self, code: &str) -> ledger_core::AccountId { + self.l + .chart() + .find_by_code(code) + .unwrap_or_else(|| panic!("{}: no account {code}", self.ex.id)) + .id + } + + fn members(&self) -> Vec { + self.ex + .setup + .members + .iter() + .map(|m| MemberCapital { + name: m.name.clone(), + capital_account: self.id(&m.capital_account), + distribution_account: None, + share: exact(&m.share), + }) + .collect() + } + + /// Event `n` (1-based), which must post entry #n. + fn apply(&mut self, n: u64, e: &Event) { + let ex = &self.ex.id; + let day = date(&e.date); + let posted = match (&e.close, e.reverses, e.lines.is_empty()) { + (Some(c), None, true) => { + let p = self + .l + .create_period(&format!("event {n}"), date(&c.start), day) + .unwrap(); + let members: Vec = self + .members() + .iter() + .map(|m| MemberClose { + capital: m.capital_account, + share: m.share, + distributions: None, + }) + .collect(); + 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}: close {other:?}"), + }; + self.l + .close_period_to(p, &to) + .unwrap() + .unwrap_or_else(|| panic!("{ex}: event {n} closes nothing")) + } + (None, Some(k), true) => { + assert!(k >= 1 && k < n, "{ex}: event {n} reverses {k}"); + self.l.reverse_entry(k, day, &e.description).unwrap() + } + (None, None, false) => { + let id = self.l.create_entry(day, &e.description); + for line in &e.lines { + let account = self.id(&line.account); + let money = Money::new(exact(&line.amount), self.currency); + self.l + .add_line(id, account, side(&line.side), money) + .unwrap(); + } + self.l.post_entry(id).unwrap(); + id + } + _ => panic!("{ex}: event {n} is one of lines, reverses or close"), + }; + assert_eq!(posted, n, "{ex}: event {n} posted entry {posted}"); + } + + /// What `q` asks, for each of its keys. + fn answer(&self, q: &Expect) -> BTreeMap { + let ex = &self.ex.id; + match q.query.as_str() { + "entry" => { + let id = q + .entry + .unwrap_or_else(|| panic!("{ex}: entry query without entry")); + let mut out = BTreeMap::new(); + for line in self.l.get_entry(id).unwrap().lines() { + let code = &self.l.chart().get(line.account).unwrap().code; + let side = match line.side { + Side::Debit => "debit", + Side::Credit => "credit", + }; + *out.entry(format!("{code}.{side}")).or_default() += line.amount.amount; + } + out + } + "balances" => { + let as_of = date(q.as_of.as_deref().expect("balances take as_of")); + let tb = trial_balance_as_of(&self.l, as_of); + q.values + .keys() + .map(|code| { + let account = self.l.chart().get(self.id(code)).unwrap(); + let (dr, cr) = tb + .lines + .iter() + .find(|l| &l.code == code) + .map_or((Decimal::ZERO, Decimal::ZERO), |l| (l.debit, l.credit)); + let normal = match account.account_type.normal_side() { + Side::Debit => dr - cr, + Side::Credit => cr - dr, + }; + (code.clone(), normal) + }) + .collect() + } + "members_equity" => { + let from = date(q.from.as_deref().expect("members_equity takes from")); + let to = date(q.to.as_deref().expect("members_equity takes to")); + let st = members_equity(&self.l, from, to, &self.members()).unwrap(); + let mut out = BTreeMap::new(); + for m in &st.members { + for (field, v) in [ + ("beginning", m.beginning), + ("contributions", m.contributions), + ("net_income", m.net_income), + ("distributions", m.distributions), + ("other", m.other), + ("ending", m.ending), + ] { + out.insert(format!("{}.{field}", m.name), v); + } + } + out + } + other => panic!("{ex}: query {other:?}"), + } + } + + fn check(&self, q: &Expect) { + let ex = &self.ex.id; + let got = self.answer(q); + for (key, want) in &q.values { + let got = got + .get(key) + .unwrap_or_else(|| panic!("{ex}: {} has no {key}", q.query)); + assert_eq!(*got, exact(want), "{ex}: {} {key}", q.query); + } + if q.query == "entry" { + // Every line named: the entry is exactly what is expected. + let named: Vec<&String> = q.values.keys().collect(); + let lines: Vec<&String> = got.keys().collect(); + assert_eq!(lines, named, "{ex}: entry {:?}'s lines", q.entry); + } + } +} + +/// Every members-close example loads, and the engine computes every value +/// it expects, after the event it names or the last. +#[test] +fn the_members_close_examples_hold() { + let paths = examples(); + assert!(paths.len() >= 4, "{} examples", paths.len()); + for path in paths { + let text = std::fs::read_to_string(&path).unwrap(); + let ex: Example = + toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + assert_eq!( + path.file_stem().unwrap().to_str().unwrap(), + ex.id, + "an example's file is named for its id" + ); + assert!(!ex.title.is_empty() && !ex.rule.is_empty() && !ex.sources.is_empty()); + assert!(!ex.tags.is_empty()); + assert!(["AUTHORITY", "CONVENTION", "CHOICE"].contains(&ex.status.as_str())); + assert_eq!( + (ex.setup.basis.as_str(), ex.setup.rounding.as_str()), + ("accrual", "largest-remainder"), + "{}", + ex.id + ); + assert!(ex.setup.fiscal_year_start.len() == 5, "{}", ex.id); + let shares: Decimal = ex.setup.members.iter().map(|m| exact(&m.share)).sum(); + assert_eq!(shares, Decimal::ONE, "{}: shares", ex.id); + let currency = Currency::new(&ex.setup.currency).unwrap(); + let mut books = Books { + ex: &ex, + l: Ledger::new(currency), + currency, + }; + for a in &ex.setup.accounts { + books + .l + .add_account(&a.code, &a.name, account_type(&a.kind), None, false) + .unwrap(); + } + for q in &ex.expect { + assert!( + q.after.is_none_or(|n| n >= 1 && n <= ex.events.len()), + "{}: after {:?}", + ex.id, + q.after + ); + } + for (i, e) in ex.events.iter().enumerate() { + books.apply(i as u64 + 1, e); + for q in ex.expect.iter().filter(|q| q.after == Some(i + 1)) { + books.check(q); + } + } + for q in ex.expect.iter().filter(|q| q.after.is_none()) { + books.check(q); + } + } +} diff --git a/crates/ledger-core/tests/kernel_bridge.proptest-regressions b/crates/ledger-core/tests/kernel_bridge.proptest-regressions new file mode 100644 index 0000000..01004fb --- /dev/null +++ b/crates/ledger-core/tests/kernel_bridge.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 bd44337eb42456f907441e7938e4909e0b66386ce70d26b9b54e7173c4d72fa7 # shrinks to parts = [427221022393, 672290605285], rems = [0, 0, 0, 0, 0], given = [0, 0, 0, 0, 0], short = 99, unpaired = false diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 4293626..bdc1c19 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -171,4 +171,78 @@ proptest! { fn line_range_agrees(cents in arb_cents()) { prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range()); } + + #[test] + fn apportion_cumulative_agrees( + parts in prop::collection::vec(arb_cents(), 0..5), + rems in prop::collection::vec(0i64..100, 5), + given in prop::collection::vec(arb_cents(), 5), + short in 0i64..500, + unpaired in any::(), + ) { + let n = parts.len(); + let whole: i64 = parts.iter().sum::() + short; + // Past both line ranges the two differ by design (i64's is 2^40 + // units, Decimal's a quadrillion). + prop_assume!(whole.unsigned_abs() <= MAX_CENTS as u64); + let mut pi = parts.clone(); + let mut pd: Vec = parts.iter().copied().map(dec).collect(); + let ri = &rems[..n]; + let rd: Vec = ri.iter().copied().map(dec).collect(); + // Sometimes one `given` too many: both refuse. + let gi = &given[..(n + usize::from(unpaired)).min(5)]; + let gd: Vec = gi.iter().copied().map(dec).collect(); + let oi = kernel::apportion_cumulative(whole, &mut pi, ri, 100, gi); + let od = kernel::apportion_cumulative(dec(whole), &mut pd, &rd, dec(100), &gd); + prop_assert_eq!(oi.is_some(), od.is_some()); + prop_assert_eq!(pi.into_iter().map(dec).collect::>(), pd); + } + + /// periods::allocate_cumulative, on Decimal shares w_i / W (exact: + /// W is 100 or 10,000), against its integer model at cents: floors + /// |T|·w_i div W, remainders |T|·w_i mod W, kernel::apportion_cumulative + /// at i64, a loss as the negated gain (the model Kani's + /// cumulative_close_never_drifts_a_unit_at_* and Lean's allocW prove). + #[test] + fn allocate_cumulative_agrees_with_the_integer_model( + four_places in any::(), + cuts in prop::collection::vec(0i64..=10_000, 0..3), + closed in -10_000_000i64..10_000_000, + net in -10_000_000i64..10_000_000, + spread in prop::collection::vec(-5_000i64..5_000, 3), + ) { + // Weights summing to W: the gaps between sorted cut points. + let big_w: i64 = if four_places { 10_000 } else { 100 }; + let mut points: Vec = cuts.iter().map(|c| c % (big_w + 1)).collect(); + points.push(0); + points.push(big_w); + points.sort_unstable(); + let weights: Vec = points.windows(2).map(|w| w[1] - w[0]).filter(|&w| w > 0).collect(); + let n = weights.len(); + let scale = if four_places { 4 } else { 2 }; + let shares: Vec = weights.iter().map(|&w| Decimal::new(w, scale)).collect(); + // What earlier closes gave: anything summing to what they closed. + let mut given: Vec = spread[..n - 1].to_vec(); + given.push(closed - given.iter().sum::()); + let total = closed + net; + let got = ledger_core::periods::allocate_cumulative( + dec(total), + &shares, + &given.iter().copied().map(dec).collect::>(), + 2, + ) + .unwrap(); + let magnitude = total.abs(); + let mut parts: Vec = weights.iter().map(|w| magnitude * w / big_w).collect(); + let rems: Vec = weights.iter().map(|w| magnitude * w % big_w).collect(); + let against: Vec = given.iter().map(|&g| if total < 0 { -g } else { g }).collect(); + kernel::apportion_cumulative(magnitude, &mut parts, &rems, 1, &against).unwrap(); + let want: Vec = parts.iter().map(|&p| dec(if total < 0 { -p } else { p })).collect(); + prop_assert_eq!(&got, &want); + prop_assert_eq!(got.iter().sum::(), dec(net)); + for ((g, p), w) in given.iter().zip(&got).zip(&weights) { + let drift = (dec(*g) + p) * Decimal::from(big_w) - dec(total) * Decimal::from(*w); + prop_assert!(drift.abs() < Decimal::from(big_w) / Decimal::from(100), "{drift}"); + } + } } diff --git a/crates/ledger-core/tests/odd_cent.proptest-regressions b/crates/ledger-core/tests/odd_cent.proptest-regressions new file mode 100644 index 0000000..f025311 --- /dev/null +++ b/crates/ledger-core/tests/odd_cent.proptest-regressions @@ -0,0 +1,9 @@ +# 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 a0b16d56ce3a210978ce651702fc7c5596cfb0c7d64cd5d611d50be203ad9ec0 # shrinks to months = [(0, 0, 0, 16009, 50271), (0, 0, 0, 3114, 37245)], kinds = [Open, Reversed, Members, Members, Members, Members, Members, Members, Members, Members, Members, Members], split = 1 +cc 10fbfe6e4565f45a6d1d71fd7b7645ae57d9c720d7de39d603a93a61f90622cb # shrinks to months = [(0, 0, 0, 20832, 1771), (0, 0, 0, 25554, 22500), (0, 0, 0, 69131, 5447), (0, 0, 0, 12460, 27233), (0, 0, 0, 57651, 18336), (0, 0, 0, 12444, 27), (0, 0, 0, 0, 0)], kinds = [Members, Members, Members, Members, Retained, Members, Members, Members, Members, Members, Members, Members], split = 1 +cc 477dcd5ca8c61a8af904ba1b3186d03816172fc4287df775f424503268ac17a9 # shrinks to months = [(0, 0, 0, 1730, 13639), (0, 0, 0, 5261, 12342), (0, 0, 0, 4627, 5345), (0, 0, 0, 5564, 3560), (0, 0, 0, 24083, 44249), (0, 0, 0, 5576, 93)], kinds = [Members, Members, Members, Members, Retained, Reversed, Members, Members, Members, Members, Members, Members], split = 1 diff --git a/crates/ledger-core/tests/odd_cent.rs b/crates/ledger-core/tests/odd_cent.rs new file mode 100644 index 0000000..f75efe5 --- /dev/null +++ b/crates/ledger-core/tests/odd_cent.rs @@ -0,0 +1,1021 @@ +//! The odd cent (P3 defect 17; rules.md B-13; D-10, D-38): members closes +//! split earnings cumulatively, each member's share of everything closed +//! to these members and shares so far less what earlier closes gave them, +//! and the statement of members' equity, the K-1 worksheet and the viewer +//! split earnings not yet closed the same running way. +//! +//! Every expected figure is worked by hand in the comment beside it, from +//! the rule: a close gives member i allocate(C + net)_i − given_i, where C +//! is what the count's earlier closes closed, given_i what they gave i, +//! and allocate splits by largest remainder to the currency's minor unit, +//! ties to the member listed first (DECISION, owner 2026-10-13). + +use chrono::{Datelike, NaiveDate}; +use ledger_core::{ + AccountId, AccountType, CloseTo, Currency, EntryId, Ledger, LedgerError, MemberCapital, + MemberClose, MemberRule, Money, PeriodId, Side, members_equity, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +fn d(m: u32, dd: u32) -> NaiveDate { + day(2026, m, dd) +} + +fn dec(s: &str) -> Decimal { + s.parse().unwrap() +} + +fn decs(v: &[&str]) -> Vec { + v.iter().map(|s| dec(s)).collect() +} + +fn month_end(y: i32, m: u32) -> NaiveDate { + if m == 12 { + day(y, 12, 31) + } else { + day(y, m + 1, 1).pred_opt().unwrap() + } +} + +/// A partnership: cash 1000; member i's capital 300i and distributions +/// 310i; other equity 3500; sales 4000; fees 6000. +struct Co { + l: Ledger, + currency: Currency, + cash: AccountId, + caps: Vec, + dists: Vec, + other_equity: AccountId, + sales: AccountId, + fees: AccountId, +} + +fn company(currency: Currency, members: usize) -> Co { + let mut l = Ledger::new(currency); + let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let cash = add("1000", AccountType::Asset); + let caps = (0..members) + .map(|i| add(&format!("300{i}"), AccountType::Equity)) + .collect(); + let dists = (0..members) + .map(|i| add(&format!("310{i}"), AccountType::Equity)) + .collect(); + let other_equity = add("3500", AccountType::Equity); + let sales = add("4000", AccountType::Revenue); + let fees = add("6000", AccountType::Expense); + Co { + l, + currency, + cash, + caps, + dists, + other_equity, + sales, + fees, + } +} + +impl Co { + fn post(&mut self, date: NaiveDate, dr: AccountId, cr: AccountId, amount: Decimal) { + let e = self.l.create_entry(date, "x"); + let m = Money::new(amount, self.currency); + self.l.add_line(e, dr, Side::Debit, m).unwrap(); + self.l.add_line(e, cr, Side::Credit, m).unwrap(); + self.l.post_entry(e).unwrap(); + } + + /// Earn `net` on `date`: a sale for a gain, a fee for a loss, nothing + /// for zero. + fn earn(&mut self, date: NaiveDate, net: Decimal) { + if net > Decimal::ZERO { + self.post(date, self.cash, self.sales, net); + } else if net < Decimal::ZERO { + self.post(date, self.fees, self.cash, -net); + } + } + + /// Create month `m` of `y` as a period and close it to `to`. + fn close( + &mut self, + y: i32, + m: u32, + to: &CloseTo, + ) -> (PeriodId, Result, LedgerError>) { + let p = self + .l + .create_period(&format!("{y}-{m:02}"), day(y, m, 1), month_end(y, m)) + .unwrap(); + (p, self.l.close_period_to(p, to)) + } + + /// Earn `net` on the 10th of month `m` of 2026 and close the month. + fn month(&mut self, m: u32, net: &str, to: &CloseTo) -> PeriodId { + self.earn(d(m, 10), dec(net)); + let (p, r) = self.close(2026, m, to); + r.unwrap(); + p + } + + /// The members in this order (indexes into `caps`), member i at + /// `shares[i]`, without distribution accounts. + fn members(&self, order: &[usize], shares: &[&str]) -> Vec { + order + .iter() + .map(|&i| MemberClose { + capital: self.caps[i], + share: dec(shares[i]), + distributions: None, + }) + .collect() + } + + fn capitals(&self) -> Vec { + self.caps + .iter() + .map(|&c| self.l.account_balance(c).unwrap()) + .collect() + } + + /// The members as the statement of members' equity takes them, each + /// with their distributions account. + fn member_capitals(&self, shares: &[&str]) -> Vec { + shares + .iter() + .enumerate() + .map(|(i, s)| MemberCapital { + name: format!("M{i}"), + capital_account: self.caps[i], + distribution_account: Some(self.dists[i]), + share: dec(s), + }) + .collect() + } + + /// Each member's capital on the statement of members' equity as of + /// `as_of` (its ending, the statement from Jan 1, 2026). + fn statement(&self, shares: &[&str], as_of: NaiveDate) -> Vec { + self.statement_listing(&(0..shares.len()).collect::>(), shares, as_of) + } + + /// [`Co::statement`] listing the members in this order (indexes into + /// `caps`), as settings that list them so would. + fn statement_listing( + &self, + order: &[usize], + shares: &[&str], + as_of: NaiveDate, + ) -> Vec { + let all = self.member_capitals(shares); + let members: Vec = order.iter().map(|&i| all[i].clone()).collect(); + members_equity(&self.l, d(1, 1), as_of, &members) + .unwrap() + .members + .iter() + .map(|m| m.ending) + .collect() + } + + fn lines(&self, id: EntryId) -> Vec<(String, Side, Decimal)> { + self.l + .get_entry(id) + .unwrap() + .lines() + .iter() + .map(|x| { + let code = self.l.chart().get(x.account).unwrap().code.clone(); + (code, x.side, x.amount.amount) + }) + .collect() + } +} + +fn line(code: &str, side: Side, amount: &str) -> (String, Side, Decimal) { + (code.to_string(), side, dec(amount)) +} + +const HALF: [&str; 2] = ["0.5", "0.5"]; +const THIRDS: [&str; 3] = ["0.3333", "0.3333", "0.3334"]; + +// --------------------------------------------------------------------------- +// The regression (audit F52, 2026-10-16, anonymized) +// --------------------------------------------------------------------------- + +/// Regression (P3 defect 17; audit F52's −399.23, anonymized): members A +/// and B at half each. January's net loss of 399.23 closes to them: exact +/// −199.615 each, so A −199.62, B −199.61 (the tie to the member listed +/// first). February's 10.00 loss closes to retained earnings. March's +/// 399.23 loss closes to them again. The run rule (2026-10-13 to P3) +/// started over at March, handing A the tied cent a second time: A +/// −399.24, B −399.22 for an exact −399.23 each. Now February's close is +/// skipped and March continues January's count: the running −798.46 splits +/// −399.23 each, less January's −199.62 / −199.61, so March gives A +/// −199.61 and B −199.62. The March entry: Cr 6000 399.23, Dr 3000 199.61, +/// Dr 3001 199.62. +#[test] +fn the_audit_odd_cent_never_piles_up_across_closes() { + type Rule = fn(Vec) -> CloseTo; + let (run, cumulative): (Rule, Rule) = (CloseTo::MembersRun, CloseTo::Members); + for (rule, a, b) in [ + (run, "-399.24", "-399.22"), + (cumulative, "-399.23", "-399.23"), + ] { + let mut co = company(Currency::USD, 2); + let to = rule(co.members(&[0, 1], &HALF)); + let jan = co.month(1, "-399.23", &to); + let jan_entry = co.l.get_period(jan).unwrap().closing_entry.unwrap(); + assert_eq!( + co.lines(jan_entry), + [ + line("6000", Side::Credit, "399.23"), + line("3000", Side::Debit, "199.62"), + line("3001", Side::Debit, "199.61"), + ] + ); + co.month(2, "-10.00", &CloseTo::RetainedEarnings { sweep: vec![] }); + let mar = co.month(3, "-399.23", &to); + assert_eq!(co.capitals(), decs(&[a, b])); + let retained = co.l.chart().find_by_code("3999").unwrap().id; + assert_eq!(co.l.account_balance(retained).unwrap(), dec("-10.00")); + if a == "-399.23" { + let mar_entry = co.l.get_period(mar).unwrap().closing_entry.unwrap(); + assert_eq!( + co.lines(mar_entry), + [ + line("6000", Side::Credit, "399.23"), + line("3000", Side::Debit, "199.61"), + line("3001", Side::Debit, "199.62"), + ] + ); + let (since, split) = co.l.member_close_split(mar).unwrap(); + assert_eq!(since, 1, "the count starts at January's close"); + let rows: Vec<_> = split.iter().map(|m| (m.part, m.running, m.exact)).collect(); + assert_eq!( + rows, + [ + (dec("-199.61"), dec("-399.23"), dec("-399.23")), + (dec("-199.62"), dec("-399.23"), dec("-399.23")), + ] + ); + } + } +} + +// --------------------------------------------------------------------------- +// From inception (D-10): earlier rules trued up by the next close that posts +// --------------------------------------------------------------------------- + +/// Three cents, 50/50, closed one at a time under the per-close rule: +/// each tie to A, so A 0.03, B 0.00. +fn three_per_close_cents(co: &mut Co) { + let legacy = CloseTo::MembersPerClose(co.members(&[0, 1], &HALF)); + for m in 1..=3 { + co.month(m, "0.01", &legacy); + } + assert_eq!(co.capitals(), decs(&["0.03", "0.00"])); +} + +/// D-10 (from inception; P3 brief R2): after three per-close cent months +/// (A 0.03, B 0.00), April's cent closes under the cumulative rule from +/// the first close: the running 0.04 splits 0.02 / 0.02, less 0.03 / 0.00 +/// given, so A −0.01 and B +0.02. The entry: Dr 4000 0.01, Dr 3000 0.01, +/// Cr 3001 0.02. Both stand at their exact 0.02. +#[test] +fn members_closes_true_up_from_inception() { + let mut co = company(Currency::USD, 2); + three_per_close_cents(&mut co); + let apr = co.month(4, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF))); + let entry = co.l.get_period(apr).unwrap().closing_entry.unwrap(); + assert_eq!( + co.lines(entry), + [ + line("4000", Side::Debit, "0.01"), + line("3000", Side::Debit, "0.01"), + line("3001", Side::Credit, "0.02"), + ] + ); + assert_eq!(co.capitals(), decs(&["0.02", "0.02"])); + assert_eq!(co.l.member_close_split(apr).unwrap().0, 1); +} + +/// INVARIANT 6 and rule 4 (a true-up rides only on a close that posts): +/// after the three per-close cents (A 0.03, B 0.00), April has no +/// activity, so its close posts nothing and credits no one; the count +/// still includes it (its net is zero). May's cent posts and carries the +/// true-up: the running 0.04 splits 0.02 / 0.02, so A −0.01, B +0.02. +#[test] +fn a_close_that_posts_nothing_carries_no_true_up() { + let mut co = company(Currency::USD, 2); + three_per_close_cents(&mut co); + let to = CloseTo::Members(co.members(&[0, 1], &HALF)); + let (apr, posted) = co.close(2026, 4, &to); + assert_eq!(posted.unwrap(), None); + let record = + co.l.get_period(apr) + .unwrap() + .members_closed + .clone() + .unwrap(); + assert_eq!(record.parts, decs(&["0", "0"])); + assert_eq!(record.rule, MemberRule::Cumulative { since_seq: 1 }); + assert_eq!(co.capitals(), decs(&["0.03", "0.00"])); + co.month(5, "0.01", &to); + assert_eq!(co.capitals(), decs(&["0.02", "0.02"])); +} + +/// P3 review R-3: a close whose only lines would be a distribution sweep +/// still posts, so it carries the pending true-up. After the three +/// per-close cents (A 0.03, B 0.00), A takes 5.00 through their +/// distributions account in April and nothing is earned: the running 0.03 +/// splits 0.02 / 0.01, so A −0.01 and B +0.01, beside the sweep. The +/// entry: Dr 3000 0.01, Cr 3001 0.01, Cr 3100 5.00, Dr 3000 5.00. +#[test] +fn a_true_up_rides_on_a_distribution_sweep() { + let mut co = company(Currency::USD, 2); + three_per_close_cents(&mut co); + co.post(d(4, 20), co.dists[0], co.cash, dec("5.00")); + let mut members = co.members(&[0, 1], &HALF); + members[0].distributions = Some(co.dists[0]); + let (apr, posted) = co.close(2026, 4, &CloseTo::Members(members)); + let entry = posted.unwrap().unwrap(); + assert_eq!( + co.lines(entry), + [ + line("3000", Side::Debit, "0.01"), + line("3001", Side::Credit, "0.01"), + line("3100", Side::Credit, "5.00"), + line("3000", Side::Debit, "5.00"), + ] + ); + assert_eq!(co.capitals(), decs(&["-4.98", "0.01"])); + let (_, split) = co.l.member_close_split(apr).unwrap(); + let running: Vec<_> = split.iter().map(|m| m.running).collect(); + assert_eq!(running, decs(&["0.02", "0.01"])); +} + +// --------------------------------------------------------------------------- +// Where a count starts: a set of members and shares (B-13; §706(d)) +// --------------------------------------------------------------------------- + +/// A reorder of the members is not a change: [A, B] closes January's cent +/// (A 0.01); [B, A] closes February's and March's. February: the running +/// 0.02 splits 0.01 / 0.01, less A 0.01 / B 0.00, so B +0.01. March: 0.03 +/// is 0.015 each, and the tied cent goes to the member the count's first +/// close listed first, A, whatever order March lists them in: 0.02 / +/// 0.01, less 0.01 / 0.01, so A +0.01. A 0.02, B 0.01; every close counts +/// from close 1. The statement of members' equity, listing them [A, B] +/// or [B, A], shows the same before and after March closes. +#[test] +fn reordering_members_keeps_counting_from_the_first_close() { + let mut co = company(Currency::USD, 2); + co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF))); + let reversed = CloseTo::Members(co.members(&[1, 0], &HALF)); + let feb = co.month(2, "0.01", &reversed); + assert_eq!(co.capitals(), decs(&["0.01", "0.01"])); + co.earn(d(3, 10), dec("0.01")); + let listed_backwards = |co: &Co| { + let mut members = co.member_capitals(&HALF); + members.reverse(); + let st = members_equity(&co.l, d(1, 1), d(3, 31), &members).unwrap(); + st.members.iter().map(|m| m.ending).collect::>() + }; + assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.02", "0.01"])); + assert_eq!(listed_backwards(&co), decs(&["0.01", "0.02"])); + let (mar, r) = co.close(2026, 3, &reversed); + r.unwrap(); + assert_eq!(co.capitals(), decs(&["0.02", "0.01"])); + assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.02", "0.01"])); + assert_eq!(listed_backwards(&co), decs(&["0.01", "0.02"])); + for p in [feb, mar] { + assert_eq!(co.l.member_close_split(p).unwrap().0, 1); + } +} + +/// A change of shares starts a new count (26 U.S.C. §706(d)(1); Treas. +/// Reg. §1.706-4(a)(3)(vii)): what was closed at the old shares is never +/// split again at the new ones. January's cent at 50/50 goes to A (close +/// 1). February at 60/40 starts a count at close 2: 0.006 / 0.004, the +/// cent to A's larger remainder (re-splitting January's cent too would +/// have given B February's). March continues from close 2: 0.02 at 60/40 +/// is 0.012 / 0.008, so 0.01 / 0.01, less 0.01 / 0.00: B +0.01. A return +/// to 50/50 in April is a new count again (from close 4), not January's: +/// April's cent to A. A 0.03, B 0.01. +#[test] +fn a_share_change_starts_a_new_count() { + let mut co = company(Currency::USD, 2); + let jan = co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF))); + let sixty = CloseTo::Members(co.members(&[0, 1], &["0.6", "0.4"])); + let feb = co.month(2, "0.01", &sixty); + assert_eq!(co.capitals(), decs(&["0.02", "0.00"])); + let mar = co.month(3, "0.01", &sixty); + assert_eq!(co.capitals(), decs(&["0.02", "0.01"])); + let apr = co.month(4, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF))); + assert_eq!(co.capitals(), decs(&["0.03", "0.01"])); + let since: Vec = [jan, feb, mar, apr] + .iter() + .map(|&p| co.l.member_close_split(p).unwrap().0) + .collect(); + assert_eq!(since, [1, 2, 2, 4]); +} + +/// P3 review R-8: a member joining mid-year starts a new count, so the +/// under-a-cent bound holds per set of members and shares, and a member's +/// year can be off by up to a cent per count. January to June, A and B at +/// 50/50 close 0.01, 0.02, 0.01, 0.01, 0.01, 0.01: the count's 0.07 ends +/// A 0.04 / B 0.03 (0.035 each, the tie to A). From July C joins, all at +/// 33.33% / 33.33% / 33.34%, closing 1.00 then 0.01 a month to December: +/// the count's 1.05 is exactly 0.349965 / 0.349965 / 0.35007; floors 0.34 / +/// 0.34 / 0.35 leave two cents for the largest remainders (A's and B's +/// 0.009965): 0.35 each. A 0.39, B 0.38, C 0.35. +#[test] +fn a_member_joining_mid_year_starts_a_new_count() { + let mut co = company(Currency::USD, 3); + let two = CloseTo::Members(co.members(&[0, 1], &HALF)); + let three = CloseTo::Members(co.members(&[0, 1, 2], &THIRDS)); + let mut periods = Vec::new(); + for (m, net) in ["0.01", "0.02", "0.01", "0.01", "0.01", "0.01"] + .iter() + .enumerate() + { + periods.push(co.month(m as u32 + 1, net, &two)); + } + for (m, net) in ["1.00", "0.01", "0.01", "0.01", "0.01", "0.01"] + .iter() + .enumerate() + { + periods.push(co.month(m as u32 + 7, net, &three)); + } + assert_eq!(co.capitals(), decs(&["0.39", "0.38", "0.35"])); + let since: Vec = periods + .iter() + .map(|&p| co.l.member_close_split(p).unwrap().0) + .collect(); + assert_eq!(since, [1, 1, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7]); + // Within each count, every running total is under a cent from exact. + for &p in &periods { + for m in co.l.member_close_split(p).unwrap().1 { + assert!((m.running - m.exact).abs() < dec("0.01"), "{m:?}"); + } + } + // Over the year, A is under two cents (one per count) from its exact + // 0.035 + 0.349965 = 0.384965. + assert!((co.capitals()[0] - dec("0.384965")).abs() < dec("0.02")); +} + +/// Scenario 6 (a journaled start must be the derived one): with January +/// closed to the members (close 1), February journaled as counting from +/// close 2 is refused and changes nothing; from close 0, refused as +/// malformed; from close 1, closed. +#[test] +fn a_journaled_start_other_than_the_derived_one_is_refused() { + let mut co = company(Currency::USD, 2); + co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF))); + co.earn(d(2, 10), dec("0.01")); + let feb = co.l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap(); + let members = co.members(&[0, 1], &HALF); + let before = co.l.clone(); + for since_seq in [2, 0] { + let to = CloseTo::MembersCumulative { + members: members.clone(), + since_seq, + }; + assert!( + matches!( + co.l.preview_close_period_to(feb, &to), + Err(LedgerError::InvalidMembers(_)) + ), + "{since_seq}" + ); + assert!( + matches!( + co.l.close_period_to(feb, &to), + Err(LedgerError::InvalidMembers(_)) + ), + "{since_seq}" + ); + assert_eq!(co.l.list_periods(), before.list_periods()); + assert_eq!(co.l.next_entry_id(), before.next_entry_id()); + } + let to = CloseTo::MembersCumulative { + members, + since_seq: 1, + }; + co.l.close_period_to(feb, &to).unwrap(); + assert_eq!(co.capitals(), decs(&["0.01", "0.01"])); +} + +// --------------------------------------------------------------------------- +// The minor unit (B-3): whole yen, thousandths of a dinar +// --------------------------------------------------------------------------- + +/// P3 brief item 6: a yen book closes to its members in whole yen. 101 +/// yen at 0.5 / 0.25 / 0.25 is exactly 50.5 / 25.25 / 25.25; floors 50 / +/// 25 / 25 leave one yen for the largest remainder (0.5): 51 / 25 / 25. +/// February's yen: the running 102 is 51 / 25.5 / 25.5, floors 51 / 25 / +/// 25, the tied yen to B: B +1. The run rule split to cents (50.50 …) and +/// the post refused it (FinerThanMinorUnit). The statement splits in whole +/// yen too, its "other" included (P3 review R-10.2): one yen put into +/// other equity in March is 0.5 / 0.25 / 0.25 exactly, so A's. +#[test] +fn a_yen_book_closes_to_members_in_whole_yen() { + let shares = ["0.5", "0.25", "0.25"]; + let mut legacy = company(Currency::JPY, 3); + legacy.earn(d(1, 10), dec("101")); + let run = CloseTo::MembersRun(legacy.members(&[0, 1, 2], &shares)); + assert!(matches!( + legacy.close(2026, 1, &run).1, + Err(LedgerError::FinerThanMinorUnit { .. }) + )); + + let mut co = company(Currency::JPY, 3); + let to = CloseTo::Members(co.members(&[0, 1, 2], &shares)); + co.earn(d(1, 10), dec("101")); + assert_eq!(co.statement(&shares, d(1, 31)), decs(&["51", "25", "25"])); + let (jan, r) = co.close(2026, 1, &to); + r.unwrap(); + let entry = co.l.get_period(jan).unwrap().closing_entry.unwrap(); + assert_eq!( + co.lines(entry), + [ + line("4000", Side::Debit, "101"), + line("3000", Side::Credit, "51"), + line("3001", Side::Credit, "25"), + line("3002", Side::Credit, "25"), + ] + ); + co.earn(d(2, 10), dec("1")); + assert_eq!(co.statement(&shares, d(2, 28)), decs(&["51", "26", "25"])); + co.close(2026, 2, &to).1.unwrap(); + assert_eq!(co.capitals(), decs(&["51", "26", "25"])); + assert_eq!(co.statement(&shares, d(2, 28)), decs(&["51", "26", "25"])); + // One yen into other equity: unexplained by flows or income, so + // "other", split in whole yen. + co.post(d(3, 5), co.cash, co.other_equity, dec("1")); + let st = members_equity(&co.l, d(3, 1), d(3, 31), &co.member_capitals(&shares)).unwrap(); + let other: Vec<_> = st.members.iter().map(|m| m.other).collect(); + assert_eq!(other, decs(&["1", "0", "0"])); +} + +/// A dinar book splits to thousandths: 0.101 at 50/50 is 0.0505 each, the +/// tied thousandth to A (0.051 / 0.050); February's 0.001 makes the +/// running 0.102, 0.051 each: B +0.001. +#[test] +fn a_dinar_book_closes_to_members_in_thousandths() { + let kwd = Currency::new("KWD").unwrap(); + let mut co = company(kwd, 2); + let to = CloseTo::Members(co.members(&[0, 1], &HALF)); + co.month(1, "0.101", &to); + assert_eq!(co.capitals(), decs(&["0.051", "0.050"])); + co.month(2, "0.001", &to); + assert_eq!(co.capitals(), decs(&["0.051", "0.051"])); +} + +/// A currency ISO 4217 gives no minor unit (gold, XAU) splits to the finest +/// amount a line may carry (kernel::MAX_LINE_SCALE places), so every amount +/// is a whole number of the unit it splits to and no member is ever one +/// from exact (B-13's precondition). 0.019 at 33.33% / 33.33% / 33.34% is +/// exactly 0.0063327 / 0.0063327 / 0.0063346: each member takes theirs. +/// Split to cents instead, the floors (0.00 each) left 0.019, a cent and +/// 0.009 of one, all of it to the largest remainder: C 0.019, A and B +/// nothing, C 1.27 cents from exact. +#[test] +fn a_gold_book_splits_to_the_finest_unit_a_line_carries() { + let xau = Currency::new("XAU").unwrap(); + assert_eq!(xau.minor_units(), None); + assert_eq!(xau.allocation_places(), ledger_core::kernel::MAX_LINE_SCALE); + let mut co = company(xau, 3); + let to = CloseTo::Members(co.members(&[0, 1, 2], &THIRDS)); + co.earn(d(1, 10), dec("0.019")); + let exact = decs(&["0.0063327", "0.0063327", "0.0063346"]); + assert_eq!(co.statement(&THIRDS, d(1, 31)), exact); + co.close(2026, 1, &to).1.unwrap(); + assert_eq!(co.capitals(), exact); + assert_eq!(co.statement(&THIRDS, d(1, 31)), exact); +} + +/// K-1 INVARIANT 4 at the minor unit: property contributed jointly in a +/// yen book is shared in whole yen. Dr Equipment 101, Dr Cash 1 / Cr A's +/// capital 51, Cr B's 51: the 101 of property is 50.5 each by their +/// credits; floors 50 / 50 leave a yen for the tie, the member listed +/// first: A 51 property and no cash, B 50 property and 1 cash. Split to +/// cents it was 50.50 each: half a yen of property, which no yen line can +/// hold. +#[test] +fn a_yen_books_k1_shares_property_in_whole_yen() { + let mut co = company(Currency::JPY, 2); + let equipment = + co.l.add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let e = co.l.create_entry(d(3, 1), "contributed together"); + let yen = |n: &str| Money::new(dec(n), Currency::JPY); + co.l.add_line(e, equipment, Side::Debit, yen("101")) + .unwrap(); + co.l.add_line(e, co.cash, Side::Debit, yen("1")).unwrap(); + co.l.add_line(e, co.caps[0], Side::Credit, yen("51")) + .unwrap(); + co.l.add_line(e, co.caps[1], Side::Credit, yen("51")) + .unwrap(); + co.l.post_entry(e).unwrap(); + let members = co.member_capitals(&HALF); + let rows = + ledger_core::k1::k1_worksheet(&co.l, d(1, 1), d(12, 31), &members, &[co.cash]).unwrap(); + let split: Vec<_> = rows + .iter() + .map(|r| (r.contributions_property, r.contributions_cash)) + .collect(); + assert_eq!(split, [(dec("51"), dec("0")), (dec("50"), dec("1"))]); +} + +// --------------------------------------------------------------------------- +// The statement of members' equity (D-38; P3 review R-1) +// --------------------------------------------------------------------------- + +/// Each member's whole statement column (beginning, contributions, income, +/// distributions, other, ending) for `start..=end`, the members listed in +/// `order` (indexes into `caps`), keyed back to that index. +fn columns(co: &Co, order: &[usize], start: NaiveDate, end: NaiveDate) -> Vec<[Decimal; 6]> { + let all = co.member_capitals(&HALF); + let listed: Vec = order.iter().map(|&i| all[i].clone()).collect(); + let st = members_equity(&co.l, start, end, &listed).unwrap(); + let mut out = vec![[Decimal::ZERO; 6]; order.len()]; + for (&i, m) in order.iter().zip(&st.members) { + out[i] = [ + m.beginning, + m.contributions, + m.net_income, + m.distributions, + m.other, + m.ending, + ]; + } + out +} + +/// Regime::allocate's promise, on days before a count's first close too: a +/// reorder of the members in the settings moves no cent. January's cent, +/// earned on the 10th, closes to [A, B] (A's, the tie). As of Jan 15 no +/// close counts yet, but the cent will be closed into the count January's +/// close begins, so its order breaks the tie: A's whether the settings list +/// [A, B] or [B, A], as on Jan 31. In February a cent goes into other +/// equity (unexplained: "other"); its split follows the count's order too, +/// so neither a member's other nor their income moves with the listing. +#[test] +fn a_reorder_moves_no_cent_before_the_counts_first_close() { + let mut co = company(Currency::USD, 2); + co.month(1, "0.01", &CloseTo::Members(co.members(&[0, 1], &HALF))); + co.post(d(2, 5), co.cash, co.other_equity, dec("0.01")); + for (start, end) in [ + (d(1, 1), d(1, 15)), + (d(1, 1), d(1, 31)), + (d(2, 1), d(2, 28)), + ] { + assert_eq!( + columns(&co, &[1, 0], start, end), + columns(&co, &[0, 1], start, end), + "{start} to {end}" + ); + } + assert_eq!(co.statement(&HALF, d(1, 15)), decs(&["0.01", "0.00"])); +} + +/// D-38 (P3 brief R3): a member's capital on a date doesn't change when +/// its period is closed. 50/50; January's cent closes to A. February +/// earns a cent, not yet closed: as of Feb 28 each member's share of it is +/// what February's close will give, allocate(0.02) − allocate(0.01) = 0.00 +/// / 0.01, so A 0.01, B 0.01 (the split of February's cent alone gave A +/// 0.02 / B 0.00, and the close then moved it to 0.01 / 0.01). After the +/// close: the same. +#[test] +fn a_members_capital_on_a_date_does_not_change_when_its_period_closes() { + let mut co = company(Currency::USD, 2); + let to = CloseTo::Members(co.members(&[0, 1], &HALF)); + co.month(1, "0.01", &to); + co.earn(d(2, 10), dec("0.01")); + assert_eq!(co.statement(&HALF, d(2, 28)), decs(&["0.01", "0.01"])); + co.close(2026, 2, &to).1.unwrap(); + assert_eq!(co.capitals(), decs(&["0.01", "0.01"])); + assert_eq!(co.statement(&HALF, d(2, 28)), decs(&["0.01", "0.01"])); +} + +/// P3 review R-1: a true-up of rounding by closes made under an earlier +/// rule shows on the statement only once a close posts it. After three +/// per-close cents (A 0.03, B 0.00, nothing undistributed), the statement +/// as of Mar 31 is the capital accounts: A 0.03, B 0.00. April's cent, +/// not yet closed: allocate(0.04) − allocate(0.03) = 0.02 − 0.02 / 0.02 − +/// 0.01, so A 0.03, B 0.01 as of Apr 30. April's close posts Dr 4000 0.01, +/// Dr 3000 0.01, Cr 3001 0.02: as of Apr 30, A 0.02, B 0.02 (moved by +/// the true-up, dated at the close); as of Mar 31, still A 0.03, B 0.00. +#[test] +fn a_pending_true_up_shows_only_once_posted() { + let mut co = company(Currency::USD, 2); + three_per_close_cents(&mut co); + assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.03", "0.00"])); + co.earn(d(4, 10), dec("0.01")); + assert_eq!(co.statement(&HALF, d(4, 30)), decs(&["0.03", "0.01"])); + co.close(2026, 4, &CloseTo::Members(co.members(&[0, 1], &HALF))) + .1 + .unwrap(); + assert_eq!(co.statement(&HALF, d(4, 30)), decs(&["0.02", "0.02"])); + assert_eq!(co.statement(&HALF, d(3, 31)), decs(&["0.03", "0.00"])); +} + +/// Three members at 33.33% / 33.33% / 33.34% (a third each can't be +/// written as decimal shares summing to 1; written to 28 places, the +/// product with an amount can't be computed exactly and the close is +/// refused, P3 review R-5), twelve months each closed to them: 100.00, +/// 0.01, 0.02, 33.33, −0.05, 0.10, 1.00, 0.07, 0.01, 12.34, −5.00, 0.02. +/// +/// By hand: January 33.33 / 33.33 / 33.34 (exact). February's running +/// 100.01 is 33.333333 / 33.333333 / 33.343334; floors 33.33 / 33.33 / +/// 33.34 leave a cent for C's remainder (.003334): C +0.01. March's +/// 100.03 is 33.339999 / 33.339999 / 33.350002; floors 33.33 / 33.33 / +/// 33.35 leave two cents for A's and B's .009999: A +0.01, B +0.01, C +/// 0.00. December's running 141.85 is 47.278605 / 47.278605 / 47.29279; +/// floors 47.27 / 47.27 / 47.29 leave two cents for A and B: 47.28 / +/// 47.28 / 47.29. +/// +/// D-38 on every day: the statement as of each day of the year, the +/// books closed month by month, equals the same statement for the same +/// entries never closed (everything still undistributed); and the K-1 +/// worksheet ends each month where the statement does. +#[test] +fn three_members_at_a_third_each_over_twelve_months() { + let nets = [ + "100.00", "0.01", "0.02", "33.33", "-0.05", "0.10", "1.00", "0.07", "0.01", "12.34", + "-5.00", "0.02", + ]; + let mut co = company(Currency::USD, 3); + let mut open = company(Currency::USD, 3); + let to = CloseTo::Members(co.members(&[0, 1, 2], &THIRDS)); + let mut closed = Decimal::ZERO; + let mut parts = Vec::new(); + for (i, net) in nets.iter().enumerate() { + let m = i as u32 + 1; + open.earn(d(m, 10), dec(net)); + let before = co.capitals(); + let p = co.month(m, net, &to); + closed += dec(net); + let after = co.capitals(); + let part: Vec = after.iter().zip(&before).map(|(a, b)| a - b).collect(); + assert_eq!(part.iter().sum::(), dec(net), "month {m}"); + for (cap, share) in after.iter().zip(THIRDS) { + assert!((cap - closed * dec(share)).abs() < dec("0.01"), "month {m}"); + } + assert_eq!(co.l.member_close_split(p).unwrap().0, 1); + parts.push(part); + } + assert_eq!(parts[0], decs(&["33.33", "33.33", "33.34"])); + assert_eq!(parts[1], decs(&["0.00", "0.00", "0.01"])); + assert_eq!(parts[2], decs(&["0.01", "0.01", "0.00"])); + assert_eq!(co.capitals(), decs(&["47.28", "47.28", "47.29"])); + + let members = co.member_capitals(&THIRDS); + let mut date = d(1, 1); + while date <= d(12, 31) { + assert_eq!( + co.statement(&THIRDS, date), + open.statement(&THIRDS, date), + "{date}" + ); + if date.succ_opt().unwrap().day() == 1 { + let rows = + ledger_core::k1::k1_worksheet(&co.l, d(1, 1), date, &members, &[co.cash]).unwrap(); + let k1: Vec = rows.iter().map(|r| r.ending).collect(); + assert_eq!(k1, co.statement(&THIRDS, date), "{date}"); + } + date = date.succ_opt().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +/// How a close of the property below is made. +#[derive(Clone, Copy, Debug)] +enum Close { + /// The cumulative rule, the members in their order. + Members, + /// The cumulative rule, the members in reverse order. + Reversed, + /// To retained earnings. + Retained, + /// Not closed this month. + Open, +} + +fn close_kind() -> impl Strategy { + prop_oneof![ + Just(Close::Members), + Just(Close::Reversed), + Just(Close::Retained), + Just(Close::Open), + ] +} + +/// A legacy close (replay only): 0 per close, 1 the last absorbs, 2 the +/// run rule. +fn legacy_to(kind: usize, members: Vec) -> CloseTo { + match kind { + 0 => CloseTo::MembersPerClose(members), + 1 => CloseTo::MembersLastAbsorbs(members), + _ => CloseTo::MembersRun(members), + } +} + +/// Month `i` from January 2026. +fn nth_month(i: usize) -> (i32, u32) { + (2026 + (i / 12) as i32, (i % 12) as u32 + 1) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + /// Oracle: rules.md B-13 and close INVARIANT 6, hand-stated: after + /// any history (closes under the legacy rules, then months closed to + /// the members in either order, to retained earnings, or left open), + /// every members close hands out exactly its net; once a cumulative + /// close has posted, each member's capital is under a cent from their + /// share of everything closed to the members; a close that posts + /// nothing changes no capital (P3 review R-3); and member_close_split + /// agrees with the balances. Fails on the run rule (the audit's + /// pattern: a close to retained earnings restarts it). + #[test] + fn members_capital_never_drifts_a_cent_from_their_share( + thirds in any::(), + legacy in prop::collection::vec((0usize..3, -500_000i64..500_000), 0..4), + months in prop::collection::vec((close_kind(), -500_000i64..500_000), 1..13), + ) { + let shares: &[&str] = if thirds { &THIRDS } else { &HALF }; + let n = shares.len(); + let mut co = company(Currency::USD, n); + let order: Vec = (0..n).collect(); + let reversed: Vec = (0..n).rev().collect(); + // Everything closed to the members (any rule), and the close the + // count started at. + let mut closed = Decimal::ZERO; + let mut first: Option = None; + let mut month = 0; + for (kind, net) in legacy { + let (y, m) = nth_month(month); + month += 1; + co.earn(day(y, m, 10), Decimal::new(net, 2)); + let to = legacy_to(kind, co.members(&order, shares)); + let (p, r) = co.close(y, m, &to); + r.unwrap(); + closed += Decimal::new(net, 2); + first = first.or(co.l.get_period(p).unwrap().close_seq); + } + let mut pending = Decimal::ZERO; + let mut posted = false; + for (kind, net) in months { + let (y, m) = nth_month(month); + month += 1; + co.earn(day(y, m, 10), Decimal::new(net, 2)); + pending += Decimal::new(net, 2); + let to = match kind { + Close::Open => continue, + Close::Retained => { + co.close(y, m, &CloseTo::RetainedEarnings { sweep: vec![] }).1.unwrap(); + pending = Decimal::ZERO; + continue; + } + Close::Members => CloseTo::Members(co.members(&order, shares)), + Close::Reversed => CloseTo::Members(co.members(&reversed, shares)), + }; + let before = co.capitals(); + let (p, r) = co.close(y, m, &to); + let entry = r.unwrap(); + let period = co.l.get_period(p).unwrap().clone(); + first = first.or(period.close_seq); + closed += pending; + let record = period.members_closed.unwrap(); + prop_assert_eq!(record.net, pending); + pending = Decimal::ZERO; + prop_assert_eq!(record.parts.iter().sum::(), record.net); + prop_assert_eq!(record.rule, MemberRule::Cumulative { since_seq: first.unwrap() }); + posted |= entry.is_some(); + let after = co.capitals(); + if entry.is_none() { + // Posting nothing, it credits no one: any true-up waits. + prop_assert_eq!(&after, &before); + } + if !posted { + continue; + } + for (cap, share) in after.iter().zip(shares) { + prop_assert!((cap - closed * dec(share)).abs() < dec("0.01"), "{} vs {}", cap, closed * dec(share)); + } + let (since, split) = co.l.member_close_split(p).unwrap(); + prop_assert_eq!(since, first.unwrap()); + for s in split { + let i = co.caps.iter().position(|&c| c == s.capital).unwrap(); + prop_assert_eq!(s.running, after[i]); + prop_assert_eq!(s.exact, closed * s.share); + } + } + prop_assert!(ledger_core::reports::trial_balance(&co.l).balanced()); + } + + /// Oracle: members' equity INVARIANTS 3a and 3c (D-38): for random + /// books with contributions, distributions, profits and losses, and + /// months closed to the members, to retained earnings or not at all, + /// with the settings listing the members in either order from month to + /// month (the close and the statement list them as the settings do), + /// each member's capital on the statement as of a close's period end + /// is the same before and after the close; and each member's ending on + /// one statement is their beginning on the next, whatever the split + /// date. + #[test] + fn each_members_capital_chains_and_holds_through_closes( + months in prop::collection::vec((0usize..3, 0i64..50_000, 0i64..20_000, 0i64..100_000, 0i64..80_000), 1..12), + kinds in prop::collection::vec(close_kind(), 12), + split in 1u32..330, + ) { + let mut co = company(Currency::USD, 3); + let cents = |n: i64| Decimal::new(n, 2); + let order = [0, 1, 2]; + let reversed = [2, 1, 0]; + for (i, (who, contrib, dist, sales, costs)) in months.iter().enumerate() { + let m = i as u32 + 1; + let cap = co.caps[*who]; + if *contrib > 0 { + co.post(d(m, 3), co.cash, cap, cents(*contrib)); + } + co.earn(d(m, 10), cents(*sales)); + co.earn(d(m, 15), -cents(*costs)); + if *dist > 0 { + co.post(d(m, 20), co.dists[*who], co.cash, cents(*dist)); + } + let with_dists = |co: &Co, order: &[usize]| -> Vec { + let mut ms = co.members(order, &THIRDS); + for (m, &i) in ms.iter_mut().zip(order) { + m.distributions = Some(co.dists[i]); + } + ms + }; + let listed: &[usize] = match kinds[i] { + Close::Reversed => &reversed, + _ => &order, + }; + let to = match kinds[i] { + Close::Open => continue, + Close::Retained => CloseTo::RetainedEarnings { sweep: vec![] }, + Close::Members | Close::Reversed => CloseTo::Members(with_dists(&co, listed)), + }; + let end = month_end(2026, m); + let before = co.statement_listing(listed, &THIRDS, end); + co.close(2026, m, &to).1.unwrap(); + prop_assert_eq!(co.statement_listing(listed, &THIRDS, end), before, "month {}", m); + } + let members = co.member_capitals(&THIRDS); + let mid = d(1, 1) + chrono::Days::new(u64::from(split)); + let first = members_equity(&co.l, d(1, 1), mid, &members).unwrap(); + let second = members_equity(&co.l, mid.succ_opt().unwrap(), d(12, 31), &members).unwrap(); + let whole = members_equity(&co.l, d(1, 1), d(12, 31), &members).unwrap(); + for i in 0..3 { + prop_assert_eq!(first.members[i].ending, second.members[i].beginning); + prop_assert_eq!(second.members[i].ending, whole.members[i].ending); + } + for st in [&first, &second, &whole] { + let incomes: Decimal = st.members.iter().map(|m| m.net_income).sum(); + prop_assert_eq!(incomes, st.total.net_income); + } + } + + /// Oracle: members_regime's doc (P3 review R-10.6): whatever order + /// nested periods (months, quarters, the year) are tried in, the + /// periods that close end strictly later, close after close, so "the + /// closes dated on or before a day" is always the first closes in + /// order. + #[test] + fn period_ends_rise_with_close_order( + attempts in Just((0usize..17).collect::>()).prop_shuffle(), + ) { + let mut co = company(Currency::USD, 2); + let mut periods = Vec::new(); + for m in 1..=12 { + periods.push(co.l.create_period(&format!("m{m}"), d(m, 1), month_end(2026, m)).unwrap()); + } + for q in 0..4u32 { + periods.push(co.l.create_period(&format!("q{q}"), d(3 * q + 1, 1), month_end(2026, 3 * q + 3)).unwrap()); + } + periods.push(co.l.create_period("year", d(1, 1), d(12, 31)).unwrap()); + for m in 1..=12 { + co.earn(d(m, 10), dec("0.01")); + } + let to = CloseTo::Members(co.members(&[0, 1], &HALF)); + for i in attempts { + let _ = co.l.close_period_to(periods[i], &to); + } + let mut closed: Vec<_> = co.l.list_periods().into_iter().filter(|p| p.close_seq.is_some()).collect(); + closed.sort_by_key(|p| p.close_seq); + for w in closed.windows(2) { + prop_assert!(w[0].end < w[1].end, "{} then {}", w[0].name, w[1].name); + } + } +} diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index 6031669..d4a205f 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -667,9 +667,13 @@ fn a_partnership_closes_into_members_capital() { cents("733.33") ) ); + // February's 100.01 isn't closed yet: each member's share of it is + // what February's close will give them (D-38): the running split of + // 666.67 + 100.01 = 766.68 is 383.34 each, less January's 333.34 / + // 333.33, so Alice 50.00 and Bob 50.01. Bob ends 733.33 − 100 + 50.01. let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap(); assert_eq!(feb_me.members[0].beginning, cents("933.34")); - assert_eq!(feb_me.members[1].ending, cents("683.33")); + assert_eq!(feb_me.members[1].ending, cents("683.34")); assert_eq!( feb_me.total.ending, balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity @@ -1595,6 +1599,14 @@ mod cumulative_closes { (cents("0.02"), cents("0.01")), "cumulative: within a cent of half of 0.03 each" ); + // An unbroken run splits the same under the run rule of + // 2026-10-13 (journaled "cumulative"), which still replays. + let (l, caps) = three_cent_months(|c| CloseTo::MembersRun(half(c))); + assert_eq!( + (bal(&l, caps[0]), bal(&l, caps[1])), + (cents("0.02"), cents("0.01")), + "the run rule" + ); } proptest::proptest! { @@ -1645,11 +1657,34 @@ mod cumulative_closes { } } - /// A run is closes with the same members and shares, back to back: a - /// close to anything else (or with other shares) ends it, and the next - /// members close starts afresh. + /// Under the run rule of 2026-10-13 (CloseTo::MembersRun, journaled + /// "cumulative"; only replay makes it now), a run is closes with the + /// same members and shares, back to back: a close to anything else ends + /// it, and the next members close starts afresh, so a tied cent can go + /// to the same member again (P3 defect 17). Closes journaled under it + /// still replay that way. #[test] - fn a_different_close_ends_the_run() { + fn a_different_close_ends_a_legacy_run() { + let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::MembersRun); + assert_eq!((a, b), (cents("0.02"), cents("0"))); + } + + /// The rule since P3 (rules.md B-13, D-10): a close to retained + /// earnings allocates nothing to the members and is skipped, so March's + /// members close continues January's count: the running 0.02 splits + /// 0.01 / 0.01, and March gives B the cent. + #[test] + fn a_different_close_no_longer_restarts_the_count() { + let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::Members); + assert_eq!((a, b), (cents("0.01"), cents("0.01"))); + } + + /// A cent in each of January, February and March, 50/50; January and + /// March close to the members with `to`, February to retained + /// earnings. The members' capital balances after March. + fn cent_months_around_a_retained_earnings_close( + to: fn(Vec) -> CloseTo, + ) -> (Decimal, Decimal) { let mut l = Ledger::new(Currency::USD); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let cash = add("1000", AccountType::Asset); @@ -1684,15 +1719,16 @@ mod cumulative_closes { l.close_period_to(p, to).unwrap(); }; // Month 1 to members (A gets the cent), month 2 to retained - // earnings (ends the run), month 3 to members again: a new run, so - // A gets that cent too. + // earnings, month 3 to members again. cent(&mut l, 1); - close(&mut l, 1, &CloseTo::Members(half(&caps))); + close(&mut l, 1, &to(half(&caps))); cent(&mut l, 2); close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] }); cent(&mut l, 3); - close(&mut l, 3, &CloseTo::Members(half(&caps))); - assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02")); - assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0")); + close(&mut l, 3, &to(half(&caps))); + ( + l.account_balance(caps[0]).unwrap(), + l.account_balance(caps[1]).unwrap(), + ) } } diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 56e4cb7..eeb226a 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -405,11 +405,13 @@ pub struct CapitalLine { /// is empty, the liability accounts whose name says "due to" or /// "owed to" (DECISION: a name is a guess; the setting is the books' /// word). owed_to_member: this member's part of it by the members' -/// shares (ledger_core::periods::allocate over every member, so the -/// parts sum to the balance). +/// shares, to the base currency's minor unit (ledger_core::periods:: +/// allocate_cumulative over every member with nothing given, ties to +/// the member listed first, so the parts sum to the balance). /// 6. None when the party isn't a member, a [[company.members]] /// account isn't in the chart, or k1_detail refuses (shares not -/// summing to 1, accounts not distinct equity accounts). +/// summing to 1, accounts not distinct equity accounts), or the +/// split of what is owed can't be computed exactly (TooLargeToCompute). /// ``` #[derive(Clone, Debug, PartialEq)] pub struct CapitalStatement { @@ -1746,7 +1748,13 @@ impl Books { .map(|&a| self.balance_on(a, span.1)) .sum(); let shares: Vec = members.iter().map(|x| x.share).collect(); - let owed_to_member = ledger_core::periods::allocate(due_to_members, &shares)[i]; + let owed_to_member = ledger_core::periods::allocate_cumulative( + due_to_members, + &shares, + &vec![Decimal::ZERO; shares.len()], + l.base_currency().allocation_places(), + ) + .ok()?[i]; Some(CapitalStatement { party, capital: m.capital, diff --git a/crates/ledger-gui/tests/odd_cent.rs b/crates/ledger-gui/tests/odd_cent.rs new file mode 100644 index 0000000..ccb8481 --- /dev/null +++ b/crates/ledger-gui/tests/odd_cent.rs @@ -0,0 +1,219 @@ +//! The odd cent in the viewer (P3 defect 17, D-38): a member's page shows +//! their capital the way the closes split earnings, so the figure for a +//! day never moves by a cent when its month is later closed. + +use chrono::NaiveDate; +use ledger_core::{AccountType, CloseTo, Currency, MemberClose, Money, Role, Side}; +use ledger_gui::books::Books; +use ledger_gui::{Source, data}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::path::{Path, PathBuf}; + +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> TempDir { + let d = + std::env::temp_dir().join(format!("ledger-gui-odd-cent-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + TempDir(d) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn today() -> NaiveDate { + d(10, 15) +} + +fn load(dir: &Path) -> Books { + data::load_once(&Source { + data_dir: dir.to_path_buf(), + today: Some(today()), + }) + .unwrap() +} + +/// Each member's closing capital on their page for the spans from Jan 1 +/// to every day through `last`. +fn every_day(b: &Books, members: &[ledger_core::PartyId], last: NaiveDate) -> Vec> { + let mut out = Vec::new(); + let mut day = d(1, 1); + while day <= last { + out.push( + members + .iter() + .map(|&m| b.capital_statement(m, (d(1, 1), day)).unwrap().closing) + .collect(), + ); + day = day.succ_opt().unwrap(); + } + out +} + +/// D-38: two members, Ann and Bo, at 50% each; January to June each earn +/// a cent, each month closed in turn. The cumulative split, by hand: the +/// running 0.01, 0.02, …, 0.06 splits A 0.01 / B 0.00, 0.01 / 0.01, 0.02 +/// / 0.01, 0.02 / 0.02, 0.03 / 0.02, 0.03 / 0.03 (each odd cent's tie to +/// Ann, listed first). The viewer shows each member's capital for every +/// day of the year so far the same before and after each month closes +/// (the split of each month's cent alone gave Ann every cent until its +/// close moved it), and ends at 0.03 each. +#[test] +fn a_members_page_shows_the_same_capital_before_and_after_a_close() { + let dir = TempDir::new("pages"); + let books = dir.0.join("books-data"); + let mut s = Store::create(&books, Currency::USD).unwrap(); + let mut add = |code: &str, name: &str, t| s.add_account(code, name, t, None, false).unwrap(); + let cash = add("1000", "Cash", AccountType::Asset); + let caps = [ + add("3000", "Capital Ann", AccountType::Equity), + add("3001", "Capital Bo", AccountType::Equity), + ]; + let sales = add("4000", "Sales", AccountType::Revenue); + let members = [ + s.add_party("Ann", &[Role::Member]).unwrap(), + s.add_party("Bo", &[Role::Member]).unwrap(), + ]; + let import = ledger_config::codec::import( + "[books]\ncash_accounts = [\"1000\"]\n[company]\nname = \"Example Partners LLC\"\n\ + [[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + None, + s.ledger().settings(), + "USD", + ) + .unwrap(); + s.adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the test".into(), + decided_on: today(), + citation: None, + note: None, + }, + ) + .unwrap(); + let to = CloseTo::Members( + caps.iter() + .map(|&capital| MemberClose { + capital, + share: Decimal::new(5, 1), + distributions: None, + }) + .collect(), + ); + let cent = Money::new(Decimal::new(1, 2), Currency::USD); + drop(s); + for m in 1..=6 { + let mut s = Store::open(&books).unwrap(); + let e = s.create_entry(d(m, 10), "a cent").unwrap(); + s.add_line(e, cash, Side::Debit, cent).unwrap(); + s.add_line(e, sales, Side::Credit, cent).unwrap(); + s.post_entry(e).unwrap(); + drop(s); + let end = d(m + 1, 1).pred_opt().unwrap(); + let before = every_day(&load(&books), &members, end); + let mut s = Store::open(&books).unwrap(); + let p = s + .create_period(&format!("2026-{m:02}"), d(m, 1), end) + .unwrap(); + s.close_period_to(p, &to).unwrap(); + drop(s); + let after = every_day(&load(&books), &members, end); + assert_eq!(after, before, "month {m}"); + } + let b = load(&books); + let closing: Vec = members + .iter() + .map(|&m| b.capital_statement(m, (d(1, 1), d(6, 30))).unwrap().closing) + .collect(); + assert_eq!(closing, [Decimal::new(3, 2), Decimal::new(3, 2)]); +} + +/// A member's page in a yen book splits in whole yen (B-3, B-13), as the +/// closes and the statement do. Ann and Bo at 50% each. "Owed to them" +/// (CapitalStatement INVARIANT 5): 101 yen due to the members, 50.5 each +/// exactly, the tied yen to Ann, listed first: Ann 51, Bo 50. Property +/// they contributed together (K-1 INVARIANT 4): Dr Equipment 101, Dr Cash +/// 1 / Cr Ann's capital 51, Cr Bo's 51, so 50.5 each of property, the yen +/// to Ann: Ann 51 property, Bo 50 property and 1 cash. Split to cents, both +/// were 50.50 each: half a yen, which no yen amount holds. +#[test] +fn a_yen_members_page_splits_in_whole_yen() { + let dir = TempDir::new("yen"); + let books = dir.0.join("books-data"); + let mut s = Store::create(&books, Currency::JPY).unwrap(); + let mut add = |code: &str, name: &str, t| s.add_account(code, name, t, None, false).unwrap(); + let cash = add("1000", "Cash", AccountType::Asset); + let equipment = add("1500", "Equipment", AccountType::Asset); + let due = add("2100", "Due to members", AccountType::Liability); + let caps = [ + add("3000", "Capital Ann", AccountType::Equity), + add("3001", "Capital Bo", AccountType::Equity), + ]; + let fees = add("6000", "Fees", AccountType::Expense); + let members = [ + s.add_party("Ann", &[Role::Member]).unwrap(), + s.add_party("Bo", &[Role::Member]).unwrap(), + ]; + let import = ledger_config::codec::import( + "[books]\ncash_accounts = [\"1000\"]\n[company]\nname = \"Example Partners LLC\"\n\ + [[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + None, + s.ledger().settings(), + "JPY", + ) + .unwrap(); + s.adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the test".into(), + decided_on: today(), + citation: None, + note: None, + }, + ) + .unwrap(); + let yen = |n: i64| Money::new(Decimal::from(n), Currency::JPY); + let e = s.create_entry(d(3, 1), "contributed together").unwrap(); + s.add_line(e, equipment, Side::Debit, yen(101)).unwrap(); + s.add_line(e, cash, Side::Debit, yen(1)).unwrap(); + s.add_line(e, caps[0], Side::Credit, yen(51)).unwrap(); + s.add_line(e, caps[1], Side::Credit, yen(51)).unwrap(); + s.post_entry(e).unwrap(); + let e = s.create_entry(d(4, 1), "paid for the company").unwrap(); + s.add_line(e, fees, Side::Debit, yen(101)).unwrap(); + s.add_line(e, due, Side::Credit, yen(101)).unwrap(); + s.post_entry(e).unwrap(); + drop(s); + let b = load(&books); + let page: Vec<_> = members + .iter() + .map(|&m| { + let st = b.capital_statement(m, (d(1, 1), today())).unwrap(); + ( + st.due_to_members, + st.owed_to_member, + st.contributions_property, + st.contributions_cash, + ) + }) + .collect(); + let y = |n: i64| Decimal::from(n); + assert_eq!( + page, + [(y(101), y(51), y(51), y(0)), (y(101), y(50), y(50), y(1))] + ); +} diff --git a/crates/ledger-mcp/handbook/tracing-figures.md b/crates/ledger-mcp/handbook/tracing-figures.md index 199e938..25624ed 100644 --- a/crates/ledger-mcp/handbook/tracing-figures.md +++ b/crates/ledger-mcp/handbook/tracing-figures.md @@ -3,3 +3,4 @@ - Reports are pure functions of the posted entries. When the human questions a figure, trace it to its entries with the tools: the report (books.trial_balance, books.profit_and_loss, books.balance_sheet, books.cash_flow), then books.search_entries and books.get_entry for the entries behind it, and books.show to put them on the human's screen. - Never guess, and never look for the books' files: the tools are the evidence. - books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact. +- A member's odd cent: closes, the statement of members' equity, the K-1 worksheet and the viewer split earnings among the members one running way. Each close gives a member their share of everything closed to the same members and shares so far, to the cent (odd cents by largest remainder, ties to the member listed first when that count began), less what earlier closes gave them, so no member is ever a cent from their exact share. Earnings not yet closed are split the way their close will split them, so a member's capital on a day never changes when its month is closed. books.close_period's members shows each member's part, running total and exact share; the first close that posts may carry a one-time correction of the rounding left by closes made under an earlier rule (a cent or so per member; each member's this_close shows it) (books.list_periods' close_rule names each close's rule). A change of members or shares starts a new count. diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index f7d5747..5b4196c 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -5957,7 +5957,7 @@ impl BooksServer { #[tool( name = "books.close_period", title = "Close a period", - description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances as of that date into Retained Earnings, or, for a multi-member LLC (members in the settings), into each member's capital account by share. Periods close in order. Without confirmed=true, returns the exact closing entry it would post and closes nothing. With confirmed=true, posts it and marks the period closed. Closed periods reject new entries.", + description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances into Retained Earnings or, for a multi-member LLC (members in the settings), into each member's capital account by share, cumulatively: each close gives a member their share of everything closed to the members so far, less what earlier closes gave them (odd cents by largest remainder), so no member is ever a cent off their exact share; a close may carry a one-time correction of earlier rounding. Periods close in order. Without confirmed=true, returns the exact closing entry and members' split, closing nothing; with confirmed=true, posts it and closes the period to new entries.", annotations( read_only_hint = false, destructive_hint = true, @@ -5992,6 +5992,34 @@ impl BooksServer { }) .collect() }; + // The members' split, from the closed ledger (rules.md B-13). + let split = |ledger: &Ledger| -> Option { + let (since_close, parts) = ledger.member_close_split(p.period_id)?; + let names = self.member_capitals(ledger).unwrap_or_default(); + let members = parts + .iter() + .map(|m| MemberCloseOut { + name: names + .iter() + .find(|c| c.capital_account == m.capital) + .map(|c| c.name.clone()) + .unwrap_or_default(), + capital_account: ledger + .chart() + .get(m.capital) + .map(|a| a.code.clone()) + .unwrap_or_default(), + share: ledger_package::share_text(m.share), + this_close: money(m.part), + running: money(m.running), + exact: money(m.exact.normalize()), + }) + .collect(); + Some(MembersCloseOut { + since_close, + members, + }) + }; if !p.confirmed { // Preview: run the real close on a copy of the ledger, so the // preview is exactly what confirming would post. @@ -6004,6 +6032,7 @@ impl BooksServer { period_id: p.period_id, closing_entry: None, lines: describe(&scratch, closing), + members: split(&scratch), })); } let closing = store @@ -6015,13 +6044,14 @@ impl BooksServer { period_id: p.period_id, closing_entry: closing, lines, + members: split(store.ledger()), })) } #[tool( name = "books.list_periods", title = "List periods", - description = "List all accounting periods with their status.", + description = "List all accounting periods with their status, and for a period closed to the members, the rounding rule it was closed under.", annotations(read_only_hint = true, open_world_hint = false) )] async fn list_periods(&self) -> Result, String> { @@ -6040,6 +6070,14 @@ impl BooksServer { PeriodStatus::Closed => "closed".to_string(), }, closing_entry: p.closing_entry, + close_rule: p.members_closed.as_ref().map(|m| match m.rule { + ledger_core::MemberRule::Cumulative { since_seq } => { + format!("members_cumulative_since:{since_seq}") + } + ledger_core::MemberRule::Run => "members_run".to_string(), + ledger_core::MemberRule::PerClose => "members_per_close".to_string(), + ledger_core::MemberRule::LastAbsorbs => "members_last_absorbs".to_string(), + }), }) .collect(); Ok(Json(ListPeriodsOut { periods })) diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 7a5d23c..38c7e4e 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -2162,6 +2162,46 @@ pub struct ClosePeriodOut { pub closing_entry: Option, /// Human-readable summary of the closing lines. pub lines: Vec, + /// For a close to the members: how it splits among them (the same in + /// the preview and once confirmed). + #[serde(default)] + pub members: Option, +} + +/// How a close splits the net among the members: cumulatively, each +/// member's share of everything closed to these members and shares since +/// `since_close`, to the minor unit, less what earlier closes gave them. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MembersCloseOut { + /// The close the count starts at: the first members close with these + /// members and shares (1 is the books' first close of all). + pub since_close: u64, + /// Each member, in the settings' order. + pub members: Vec, +} + +/// One member's part of a close (`books.close_period`). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MemberCloseOut { + /// The member's name, as in the settings. + pub name: String, + /// Their capital account's code. + pub capital_account: String, + /// Their share, as a decimal fraction ("0.50"). + pub share: String, + /// What this close credits their capital (negative: debits), a true-up + /// of earlier rounding included. It can run against the period's + /// result by a cent. + pub this_close: String, + /// What the count's closes have credited them in all, this one + /// included. + pub running: String, + /// Their exact share of everything the count has closed (share × + /// total, unrounded): `running` is within one minor unit of it once a + /// close of the count has posted under the cumulative rule (before + /// that, a close that posted nothing still carries the rounding of + /// closes made under an earlier rule). + pub exact: String, } /// One period in `books.list_periods`. @@ -2179,6 +2219,13 @@ pub struct PeriodOut { pub status: String, /// Closing entry id, if closed with one. pub closing_entry: Option, + /// For a period closed to the members, the rounding rule it was closed + /// under: "members_cumulative_since:N" (counted from close N, the rule + /// since P3), "members_run" (2026-10-13 to P3), "members_per_close" + /// (2026-10-13) or "members_last_absorbs" (before 2026-10-13). Absent + /// (null) otherwise. + #[serde(default)] + pub close_rule: Option, } /// Output of `books.list_periods`. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 6d01823..94adaca 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -1690,6 +1690,243 @@ fn mcp_partnership_from_template_over_stdio() { ); } +/// Contract (rules.md B-13, D-10; docs/mcp-interface.md close_period and +/// list_periods): a close to the members shows each member's running +/// split, the same in the preview as once confirmed, and the periods say +/// which rule they closed under. Two members at 50% each, hand-computed: +/// January's cent is 0.005 each exactly, the tie to Ann (listed first): +/// Ann 0.01, Bo 0.00. February's cent makes the running 0.02, 0.01 each, +/// so February gives Bo 0.01. After a restart, both closes count from +/// close 1, the books verify, and the statement of members' equity shows +/// 0.01 each. +#[test] +fn close_period_shows_each_members_running_split() { + let dir = test_dir("members-split"); + let mut scratch = Mcp::spawn(&dir.join("scratch")); + init(&mut scratch); + let t = scratch.call( + "books.chart_template", + json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}), + ); + let settings = t["settings_toml"].as_str().unwrap().to_string(); + let toml = dir.join("wanderbooks.toml"); + std::fs::write( + &toml, + format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"), + ) + .unwrap(); + let spawn = || { + let mut mcp = Mcp::spawn_with( + &dir.join("books-data"), + &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + ); + init(&mut mcp); + mcp + }; + let mut mcp = spawn(); + mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]})); + let sale = |mcp: &mut Mcp, date: &str| { + let e = mcp.call( + "books.draft_entry", + json!({"date": date, "description": "a cent"}), + ); + let id = e["entry_id"].as_u64().unwrap(); + for (account, side) in [("1000", "debit"), ("4000", "credit")] { + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": "0.01"}), + ); + } + mcp.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + }; + let member = |name: &str, code: &str, this: &str, running: &str, exact: &str| { + json!({"name": name, "capital_account": code, "share": "0.50", + "this_close": this, "running": running, "exact": exact}) + }; + + sale(&mut mcp, "2026-01-10"); + let jan = mcp.call( + "books.create_period", + json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}), + ); + let preview = mcp.call( + "books.close_period", + json!({"period_id": jan["period_id"], "confirmed": false}), + ); + assert_eq!( + preview["members"], + json!({"since_close": 1, "members": [ + member("Ann", "3000", "0.01", "0.01", "0.005"), + member("Bo", "3001", "0.00", "0.00", "0.005"), + ]}) + ); + assert_eq!( + preview["lines"], + json!(["Debit 4000 0.01", "Credit 3000 0.01"]) + ); + let closed = mcp.call( + "books.close_period", + json!({"period_id": jan["period_id"], "confirmed": true}), + ); + assert_eq!(closed["members"], preview["members"]); + assert_eq!(closed["lines"], preview["lines"]); + + sale(&mut mcp, "2026-02-10"); + let feb = mcp.call( + "books.create_period", + json!({"name": "2026-02", "start": "2026-02-01", "end": "2026-02-28"}), + ); + let closed = mcp.call( + "books.close_period", + json!({"period_id": feb["period_id"], "confirmed": true}), + ); + assert_eq!( + closed["members"], + json!({"since_close": 1, "members": [ + member("Ann", "3000", "0.00", "0.01", "0.01"), + member("Bo", "3001", "0.01", "0.01", "0.01"), + ]}) + ); + assert_eq!( + closed["lines"], + json!(["Debit 4000 0.01", "Credit 3001 0.01"]) + ); + drop(mcp); + + let mut mcp = spawn(); + assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok"); + let periods = mcp.call("books.list_periods", json!({})); + let rules: Vec<&Value> = periods["periods"] + .as_array() + .unwrap() + .iter() + .map(|p| &p["close_rule"]) + .collect(); + assert_eq!( + rules, + [ + &json!("members_cumulative_since:1"), + &json!("members_cumulative_since:1") + ] + ); + let me = mcp.call( + "books.export", + json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}), + ); + let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); + assert_eq!(me["members"][0]["ending"], "0.01"); + assert_eq!(me["members"][1]["ending"], "0.01"); +} + +/// Contract (rules.md B-13, D-10; docs/mcp-interface.md list_periods and +/// close_period) on books an earlier version wrote: ledger-store's +/// old-books fixture x_member_closes (two members at 50%, A with a +/// distributions account; three closes untagged, three +/// "largest_remainder", then "cumulative" broken by a close to retained +/// earnings), opened by the server. list_periods names each close's rule; +/// December's first close under the rule since P3 counts from close 1 +/// and carries the true-up, the same in the preview as once confirmed. +/// By hand (the fixture's writer): the members closed 0.12, A 0.07 and B +/// 0.05 of it (exact 0.06 each). December earns 0.02: the running 0.14 +/// splits 0.07 each, less 0.07 / 0.05 given, so Ann gets 0.00 and Bo +/// 0.02, both at their exact 0.07. +#[test] +fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() { + let dir = test_dir("members-old"); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl"); + let books = dir.join("books-data"); + std::fs::create_dir_all(&books).unwrap(); + std::fs::copy(&fixture, books.join("journal.jsonl")).unwrap(); + let toml = dir.join("wanderbooks.toml"); + std::fs::write( + &toml, + "[company]\ntax_form = \"1065\"\n\n\ + [[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + distribution_account = \"3100\"\n\n\ + [[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\n\ + [books]\nreceipt_threshold = \"10000\"\n", + ) + .unwrap(); + let mut mcp = Mcp::spawn_with( + &books, + &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + ); + init(&mut mcp); + let rules = |mcp: &mut Mcp| -> Vec { + mcp.call("books.list_periods", json!({}))["periods"] + .as_array() + .unwrap() + .iter() + .map(|p| p["close_rule"].clone()) + .collect() + }; + let old = [ + "members_last_absorbs", + "members_last_absorbs", + "members_last_absorbs", + "members_per_close", + "members_per_close", + "members_per_close", + "members_run", + "members_run", + ]; + let mut want: Vec = old.iter().map(|r| json!(r)).collect(); + want.extend([Value::Null, json!("members_run"), json!("members_run")]); + assert_eq!(rules(&mut mcp), want); + + let e = mcp.call( + "books.draft_entry", + json!({"date": "2026-12-10", "description": "two cents"}), + ); + let id = e["entry_id"].as_u64().unwrap(); + for (account, side) in [("1000", "debit"), ("4000", "credit")] { + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": "0.02"}), + ); + } + mcp.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + let dec = mcp.call( + "books.create_period", + json!({"name": "2026-12", "start": "2026-12-01", "end": "2026-12-31"}), + ); + let preview = mcp.call( + "books.close_period", + json!({"period_id": dec["period_id"], "confirmed": false}), + ); + let member = |name: &str, code: &str, this: &str| { + json!({"name": name, "capital_account": code, "share": "0.50", + "this_close": this, "running": "0.07", "exact": "0.07"}) + }; + assert_eq!( + preview["members"], + json!({"since_close": 1, "members": [ + member("Ann", "3000", "0.00"), + member("Bo", "3001", "0.02"), + ]}) + ); + assert_eq!( + preview["lines"], + json!(["Debit 4000 0.02", "Credit 3001 0.02"]) + ); + let closed = mcp.call( + "books.close_period", + json!({"period_id": dec["period_id"], "confirmed": true}), + ); + assert_eq!(closed["members"], preview["members"]); + assert_eq!(closed["lines"], preview["lines"]); + want.push(json!("members_cumulative_since:1")); + assert_eq!(rules(&mut mcp), want); + assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok"); +} + #[test] fn mcp_schedules_and_recurring_over_stdio() { let dir = test_dir("schedules"); diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 43d90dd..0223db5 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -26,8 +26,21 @@ use serde_json::{Value, json}; /// The newest journal format this version reads and writes. pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2; -/// Optional journal features this version reads (none yet). -pub const KNOWN_FEATURES: &[&str] = &[]; +/// Optional journal features this version reads. Each is a field or value +/// on an event type an older version knows, which that version would +/// misread silently; a writer declares the feature (`journal.format_set`) +/// before its first such line, so the older version stops there instead +/// (rules.md B-27). Books that never use one stay readable by versions +/// before it. +pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE]; + +/// Feature: `period.closed` closes to members with `"rounding": +/// "cumulative_since"` and `since_seq`, the count of members closes +/// starting at the first since the members or shares last changed (P3 +/// defect 17, rules.md B-13). An older version would call the rounding +/// unknown, or (before the format gate) replay the close under the run +/// rule, which can split it differently. +pub const MEMBERS_CUMULATIVE: &str = "members_cumulative"; /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -188,6 +201,12 @@ impl JournalFormat { /// Read a `journal.format_set` line, refusing a format or feature this /// version doesn't read. pub(crate) fn declared(line: &JournalLine) -> Result { + declared_knowing(line, KNOWN_FEATURES) +} + +/// [`declared`] by a version that knows the features `known`: what an +/// older version, which knew fewer, does with the same line. +fn declared_knowing(line: &JournalLine, known: &[&str]) -> Result { let p = &line.payload; let format = p .get("format") @@ -216,7 +235,7 @@ pub(crate) fn declared(line: &JournalLine) -> Result let unknown: Vec<&str> = features .iter() .map(String::as_str) - .filter(|f| !KNOWN_FEATURES.contains(f)) + .filter(|f| !known.contains(f)) .collect(); if !unknown.is_empty() { return Err(newer( @@ -485,4 +504,30 @@ mod tests { )); } } + + /// P3 defect 17's feature (members_cumulative): this version reads it; + /// a version before it (KNOWN_FEATURES empty, as through P2) stops at + /// its declaration with NewerJournal, never replaying a close counted + /// from its regime's start under the run rule. + #[test] + fn an_older_reader_stops_at_the_members_cumulative_feature() { + assert!(KNOWN_FEATURES.contains(&MEMBERS_CUMULATIVE)); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [MEMBERS_CUMULATIVE]}), + ); + assert_eq!( + declared(&l).unwrap(), + JournalFormat { + format: 2, + features: vec![MEMBERS_CUMULATIVE.to_string()] + } + ); + match declared_knowing(&l, &[]) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features members_cumulative") + } + other => panic!("expected NewerJournal, got {other:?}"), + } + } } diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 53e3bfa..c5fadcd 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -583,6 +583,29 @@ impl Store { Ok(()) } + /// Declare a journal feature (crate::format::KNOWN_FEATURES) before the + /// first event that needs it: a field or value on a known event type + /// that an older version would otherwise misread silently. Declared + /// with the format in force (at least 2) and every feature already in + /// force; nothing is journaled when it is already declared. Books that + /// never use the feature stay readable by older versions. + /// + /// ```text + /// GOAL: an older wanderbooks stops at the declaration (NewerJournal) + /// instead of misreading a line it thinks it knows. + /// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate). + /// ``` + fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> { + if self.format.features.iter().any(|f| f == feature) { + return Ok(()); + } + let format = self.format.format.max(crate::meta::FORMAT); + let mut features = self.format.features.clone(); + features.push(feature.to_string()); + let features: Vec<&str> = features.iter().map(String::as_str).collect(); + self.declare_journal_format(format, &features) + } + /// 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 @@ -1798,6 +1821,11 @@ impl Store { // ensure then no-ops and replay stays deterministic. self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?; } + if matches!(to, CloseTo::Members(_) | CloseTo::MembersCumulative { .. }) { + // A close counted from its regime's start: an older version + // would replay it under the run rule (format gate, B-27). + self.ensure_feature(crate::format::MEMBERS_CUMULATIVE)?; + } let mut next = self.ledger.clone(); let closing_entry = next.close_period_to(id, to)?; let entry_detail = match closing_entry { @@ -1818,9 +1846,8 @@ impl Store { CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"), CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}), CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}), - CloseTo::Members(members) - | CloseTo::MembersPerClose(members) - | CloseTo::MembersLastAbsorbs(members) => { + _ => { + let members = to.members().expect("every other target is members"); let mut v = json!({ "members": members .iter() @@ -1835,7 +1862,18 @@ impl Store { // journaled before 2026-10-13 carry none (the last member // absorbed it). match to { - CloseTo::Members(_) => v["rounding"] = json!("cumulative"), + CloseTo::Members(_) | CloseTo::MembersCumulative { .. } => { + let rule = next + .get_period(id) + .and_then(|p| p.members_closed.as_ref()) + .map(|m| m.rule); + let Some(ledger_core::MemberRule::Cumulative { since_seq }) = rule else { + unreachable!("a members close records its cumulative rule") + }; + v["rounding"] = json!("cumulative_since"); + v["since_seq"] = json!(since_seq); + } + CloseTo::MembersRun(_) => v["rounding"] = json!("cumulative"), CloseTo::MembersPerClose(_) => v["rounding"] = json!("largest_remainder"), _ => {} } @@ -4739,7 +4777,7 @@ pub(crate) fn replay_line_into_ledger( // Deterministic: the close re-derives the closing entry from // the replayed state and the journaled target (absent: Retained // Earnings), so it must match the journal's id and every line. - let to = close_to_from_journal(p.get("close_to"))?; + let to = close_to_from_journal(line, p.get("close_to"))?; let got = ledger.close_period_to(id, &to)?; if got != want { return Err(StoreError::ReplayMismatch(format!( @@ -5021,7 +5059,10 @@ fn tax_basis_from_journal(p: &Value) -> Result) -> Result { +/// A journaled close's target. A rounding rule only a newer version +/// writes (an unknown name, or a value that isn't a name) is newer, never +/// corrupt (rules.md B-27). +fn close_to_from_journal(line: &JournalLine, v: Option<&Value>) -> Result { let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}")); let ids = |v: &Value| -> Result, StoreError> { v.as_array() @@ -5062,12 +5103,39 @@ fn close_to_from_journal(v: Option<&Value>) -> Result { .collect::>()?; // The rounding rule the close was made under: absent before // 2026-10-13, when the last member absorbed it; "largest_remainder" - // per close, then "cumulative" over a run of closes. - match v.get("rounding").and_then(|r| r.as_str()) { - None => Ok(CloseTo::MembersLastAbsorbs(members)), - Some("largest_remainder") => Ok(CloseTo::MembersPerClose(members)), - Some("cumulative") => Ok(CloseTo::Members(members)), - Some(_) => Err(corrupt("unknown rounding")), + // per close, then "cumulative" over a run of closes, then (P3) + // "cumulative_since" the close numbered since_seq. + let rounding = match v.get("rounding") { + None | Some(Value::Null) => None, + Some(Value::String(r)) => Some(r.as_str()), + Some(other) => { + return Err(crate::format::newer_content( + line, + format!("a members close's rounding {other}"), + )); + } + }; + let since_seq = v.get("since_seq"); + let older = |to: CloseTo| match since_seq { + // Only "cumulative_since" counts from a close. + Some(_) => Err(corrupt("since_seq beside another rounding")), + None => Ok(to), + }; + match rounding { + None => older(CloseTo::MembersLastAbsorbs(members)), + Some("largest_remainder") => older(CloseTo::MembersPerClose(members)), + Some("cumulative") => older(CloseTo::MembersRun(members)), + Some("cumulative_since") => Ok(CloseTo::MembersCumulative { + members, + since_seq: since_seq + .and_then(Value::as_u64) + .filter(|&s| s >= 1) + .ok_or_else(|| corrupt("since_seq"))?, + }), + Some(other) => Err(crate::format::newer_content( + line, + format!("a members close's rounding {other:?}"), + )), } } diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml index 0af343f..bdb3ecf 100644 --- a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml +++ b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml @@ -9,6 +9,9 @@ # cargo run --bin older -- OUT (x_older_rules, with its JournalWriter) # cargo run --bin expect -- OUT (expected.json beside each journal) # and copy each journal.jsonl, blobs/ and expected.json into old-books/. +# x_member_closes was written later, the same way, by main 00226a4c +# (2026-10-20, before P3's odd cent): `cargo run --bin members -- OUT` +# (members closes under every rounding rule main wrote; P3 review R-2). [package] name = "writer" version = "0.1.0" @@ -22,3 +25,5 @@ rust_decimal = "=1.43.0" serde_json = "1" [workspace] +# main's crates (wb-main) keep their own workspace. +exclude = ["wb-main"] diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/members.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/members.rs new file mode 100644 index 0000000..3ed31bf --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/members.rs @@ -0,0 +1,83 @@ +//! x_member_closes: members closes under each rounding rule main wrote +//! (P3 review R-2), through MAIN's Store API (main 00226a4c, 2026-10-20). +//! Two members, A and B, 50/50; A has a distributions account. Eleven +//! months, each closed in order: +//! Jan-Mar: untagged (the last member absorbs): +0.01, -0.03, +0.05; +//! A's 1.00 distribution in February is swept into A's capital. +//! Apr-Jun: "largest_remainder" (each close on its own): +0.07, -0.01, +0.03. +//! Jul-Nov: "cumulative" (the run rule), broken by a close to retained +//! earnings in September: +0.01, +0.01, (RE +0.01), +0.03, -0.05. +//! Hand-computed: A's earnings 0.07, B's 0.05 (of 0.12 closed to them; +//! exact 0.06 each); retained earnings 0.01. +use chrono::NaiveDate; +use ledger_core::*; +use ledger_store::Store; +use rust_decimal::Decimal; + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} +fn usd(s: &str) -> Money { + Money::new(s.parse::().unwrap(), Currency::USD) +} + +fn main() { + let dir = std::path::PathBuf::from(std::env::args().nth(1).unwrap()).join("x_member_closes"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let cash = s.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap(); + let cap_a = s.add_account("3000", "Capital A", AccountType::Equity, None, false).unwrap(); + let cap_b = s.add_account("3001", "Capital B", AccountType::Equity, None, false).unwrap(); + let dist_a = s.add_account("3100", "Distributions A", AccountType::Equity, None, false).unwrap(); + let rev = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap(); + let exp = s.add_account("6000", "Fees", AccountType::Expense, None, false).unwrap(); + let post = |s: &mut Store, date, dr, cr, amount: &str| { + let e = s.create_entry(date, "x").unwrap(); + s.add_line(e, dr, Side::Debit, usd(amount)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(amount)).unwrap(); + s.post_entry(e).unwrap(); + }; + post(&mut s, d(1, 2), cash, cap_a, "100.00"); + post(&mut s, d(1, 2), cash, cap_b, "100.00"); + post(&mut s, d(2, 20), dist_a, cash, "1.00"); + let members = vec![ + MemberClose { capital: cap_a, share: "0.5".parse().unwrap(), distributions: Some(dist_a) }, + MemberClose { capital: cap_b, share: "0.5".parse().unwrap(), distributions: None }, + ]; + let months: [(u32, &str, &str); 11] = [ + (1, "0.01", "last"), + (2, "-0.03", "last"), + (3, "0.05", "last"), + (4, "0.07", "per"), + (5, "-0.01", "per"), + (6, "0.03", "per"), + (7, "0.01", "run"), + (8, "0.01", "run"), + (9, "0.01", "re"), + (10, "0.03", "run"), + (11, "-0.05", "run"), + ]; + for (m, net, rule) in months { + match net.strip_prefix('-') { + Some(loss) => post(&mut s, d(m, 10), exp, cash, loss), + None => post(&mut s, d(m, 10), cash, rev, net), + } + let end = NaiveDate::from_ymd_opt(2026, m + 1, 1).unwrap().pred_opt().unwrap(); + let p = s.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap(); + let to = match rule { + "last" => CloseTo::MembersLastAbsorbs(members.clone()), + "per" => CloseTo::MembersPerClose(members.clone()), + "run" => CloseTo::Members(members.clone()), + _ => CloseTo::RetainedEarnings { sweep: vec![] }, + }; + s.close_period_to(p, &to).unwrap(); + } + let bal = |id| s.ledger().account_balance(id).unwrap(); + println!( + "WROTE x_member_closes: A {} B {} dist {}", + bal(cap_a), + bal(cap_b), + bal(dist_a) + ); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/x_member_closes/expected.json b/crates/ledger-store/tests/fixtures/old-books/x_member_closes/expected.json new file mode 100644 index 0000000..0826582 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/x_member_closes/expected.json @@ -0,0 +1,1141 @@ +{ + "assets": [], + "bills": [], + "entries": [ + { + "date": "2026-01-02", + "description": "x", + "id": 1, + "lines": [ + { + "account": 1, + "amount": { + "amount": "100.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "100.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-02", + "description": "x", + "id": 2, + "lines": [ + { + "account": 1, + "amount": { + "amount": "100.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "100.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-20", + "description": "x", + "id": 3, + "lines": [ + { + "account": 4, + "amount": { + "amount": "1.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "1.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-10", + "description": "x", + "id": 4, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-31", + "description": "Close period 2026-01", + "id": 5, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-10", + "description": "x", + "id": 6, + "lines": [ + { + "account": 6, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-28", + "description": "Close period 2026-02", + "id": 7, + "lines": [ + { + "account": 6, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 2, + "amount": { + "amount": "0.02", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 4, + "amount": { + "amount": "1.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 2, + "amount": { + "amount": "1.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-03-10", + "description": "x", + "id": 8, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.05", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.05", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-03-31", + "description": "Close period 2026-03", + "id": 9, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.05", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 3, + "amount": { + "amount": "0.02", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-04-10", + "description": "x", + "id": 10, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.07", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.07", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-04-30", + "description": "Close period 2026-04", + "id": 11, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.07", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "0.04", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 3, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-05-10", + "description": "x", + "id": 12, + "lines": [ + { + "account": 6, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-05-31", + "description": "Close period 2026-05", + "id": 13, + "lines": [ + { + "account": 6, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 2, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-06-10", + "description": "x", + "id": 14, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-06-30", + "description": "Close period 2026-06", + "id": 15, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "0.02", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 3, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-07-10", + "description": "x", + "id": 16, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-07-31", + "description": "Close period 2026-07", + "id": 17, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-08-10", + "description": "x", + "id": 18, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-08-31", + "description": "Close period 2026-08", + "id": 19, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-09-10", + "description": "x", + "id": 20, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-09-30", + "description": "Close period 2026-09", + "id": 21, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 7, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-10-10", + "description": "x", + "id": 22, + "lines": [ + { + "account": 1, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-10-31", + "description": "Close period 2026-10", + "id": 23, + "lines": [ + { + "account": 5, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "0.02", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 3, + "amount": { + "amount": "0.01", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-11-10", + "description": "x", + "id": 24, + "lines": [ + { + "account": 6, + "amount": { + "amount": "0.05", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "0.05", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-11-30", + "description": "Close period 2026-11", + "id": 25, + "lines": [ + { + "account": 6, + "amount": { + "amount": "0.05", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 2, + "amount": { + "amount": "0.03", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "0.02", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [], + "invoices": [], + "payroll_runs": [], + "periods": [ + { + "close_seq": 1, + "closing_entry": 5, + "end": "2026-01-31", + "id": 1, + "member_close": null, + "name": "2026-01", + "start": "2026-01-01", + "status": "Closed" + }, + { + "close_seq": 2, + "closing_entry": 7, + "end": "2026-02-28", + "id": 2, + "member_close": null, + "name": "2026-02", + "start": "2026-02-01", + "status": "Closed" + }, + { + "close_seq": 3, + "closing_entry": 9, + "end": "2026-03-31", + "id": 3, + "member_close": null, + "name": "2026-03", + "start": "2026-03-01", + "status": "Closed" + }, + { + "close_seq": 4, + "closing_entry": 11, + "end": "2026-04-30", + "id": 4, + "member_close": null, + "name": "2026-04", + "start": "2026-04-01", + "status": "Closed" + }, + { + "close_seq": 5, + "closing_entry": 13, + "end": "2026-05-31", + "id": 5, + "member_close": null, + "name": "2026-05", + "start": "2026-05-01", + "status": "Closed" + }, + { + "close_seq": 6, + "closing_entry": 15, + "end": "2026-06-30", + "id": 6, + "member_close": null, + "name": "2026-06", + "start": "2026-06-01", + "status": "Closed" + }, + { + "close_seq": 7, + "closing_entry": 17, + "end": "2026-07-31", + "id": 7, + "member_close": { + "members": [ + [ + 2, + "0.5" + ], + [ + 3, + "0.5" + ] + ], + "net": "0.01" + }, + "name": "2026-07", + "start": "2026-07-01", + "status": "Closed" + }, + { + "close_seq": 8, + "closing_entry": 19, + "end": "2026-08-31", + "id": 8, + "member_close": { + "members": [ + [ + 2, + "0.5" + ], + [ + 3, + "0.5" + ] + ], + "net": "0.01" + }, + "name": "2026-08", + "start": "2026-08-01", + "status": "Closed" + }, + { + "close_seq": 9, + "closing_entry": 21, + "end": "2026-09-30", + "id": 9, + "member_close": null, + "name": "2026-09", + "start": "2026-09-01", + "status": "Closed" + }, + { + "close_seq": 10, + "closing_entry": 23, + "end": "2026-10-31", + "id": 10, + "member_close": { + "members": [ + [ + 2, + "0.5" + ], + [ + 3, + "0.5" + ] + ], + "net": "0.03" + }, + "name": "2026-10", + "start": "2026-10-01", + "status": "Closed" + }, + { + "close_seq": 11, + "closing_entry": 25, + "end": "2026-11-30", + "id": 11, + "member_close": { + "members": [ + [ + 2, + "0.5" + ], + [ + 3, + "0.5" + ] + ], + "net": "-0.05" + }, + "name": "2026-11", + "start": "2026-11-01", + "status": "Closed" + } + ], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "199.13", + "balanced": true, + "current_earnings": "0", + "equity": "199.13", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "0", + "net_income": "0", + "revenue": "0" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "0", + "debit": "199.13", + "name": "Cash" + }, + { + "code": "3000", + "credit": "99.07", + "debit": "0", + "name": "Capital A" + }, + { + "code": "3001", + "credit": "100.05", + "debit": "0", + "name": "Capital B" + }, + { + "code": "3999", + "credit": "0.01", + "debit": "0", + "name": "Retained Earnings" + } + ], + "total_credits": "199.13", + "total_debits": "199.13" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl new file mode 100644 index 0000000..138f54a --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl @@ -0,0 +1,86 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-20T22:36:22Z","type":"store.created"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Cash","parent":null,"placeholder":false},"prev":"d48b7756f8b6c3d65159fd1483103d880bdf5526a6a1da756a618df657d96de3","seq":2,"ts":"2026-10-20T22:36:22Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital A","parent":null,"placeholder":false},"prev":"656d6610bd33f69658f1e1e05bff473f5a44af70cebcdac8954cae37a394f1aa","seq":3,"ts":"2026-10-20T22:36:22Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3001","currency":"USD","id":3,"name":"Capital B","parent":null,"placeholder":false},"prev":"ae10bfa37de84be844b468a30c68fe92fd8de23a686c248ad76ee375990dd0a8","seq":4,"ts":"2026-10-20T22:36:22Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3100","currency":"USD","id":4,"name":"Distributions A","parent":null,"placeholder":false},"prev":"232eaad799ea56d043213121ea8de0bf46b3ba8e9bb3b8f70dd338edfd55a944","seq":5,"ts":"2026-10-20T22:36:22Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":5,"name":"Sales","parent":null,"placeholder":false},"prev":"7114ae64eff24884344a6c9a541b2c7cfbfa648b23f1bbc69f199fa6b866bc91","seq":6,"ts":"2026-10-20T22:36:22Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":6,"name":"Fees","parent":null,"placeholder":false},"prev":"8c2bc4978cfd9035130a77332a3e3e943bef20dfc14b313fb67be9b1f1d2d6c4","seq":7,"ts":"2026-10-20T22:36:22Z","type":"account.added"} +{"payload":{"date":"2026-01-02","description":"x","id":1},"prev":"9e429057f4dfe113f5b437bd08ad6964dd60176f06bd4b374eae81647dfbc5ec","seq":8,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":1,"amount":"100.00","currency":"USD","id":1,"side":"debit"},"prev":"601be139abb99f295b38b8d92b369301f03dccd55b3a4da6c79b3197b4c6bd28","seq":9,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"100.00","currency":"USD","id":1,"side":"credit"},"prev":"c09df370b486aba0fe0b22c826fe8d0b07bf39c4c42ff08b7d2d48c04b893b48","seq":10,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-02","description":"x","id":1,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"debit"},{"account":2,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d6ddbcc8056897e9623aa7a0acd9a95e6b89d0fe8c976a114b5a54eb76bab8b3","seq":11,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"date":"2026-01-02","description":"x","id":2},"prev":"a208245587b17e1774d74b44d025ed8cea044495ccaa51822204558cc396b19c","seq":12,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":1,"amount":"100.00","currency":"USD","id":2,"side":"debit"},"prev":"be56d3de30706dc7498a6f8524bf52408813108dd29b56770918c5c46993acd0","seq":13,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"100.00","currency":"USD","id":2,"side":"credit"},"prev":"0c9847420f6d96e94f120c05da81e09c20a0e0068aca19ae67788515cd5cd404","seq":14,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-02","description":"x","id":2,"lines":[{"account":1,"amount":"100.00","currency":"USD","side":"debit"},{"account":3,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"3b7c5513b31ea4322b1bb89bc72db1adef5a76fec0601ea633171cb0a5e4864d","seq":15,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"date":"2026-02-20","description":"x","id":3},"prev":"77c977e18ab290db770da59a850ba0d250b8ba8dbcbdcde9602621d932a3e36c","seq":16,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":4,"amount":"1.00","currency":"USD","id":3,"side":"debit"},"prev":"a7a09a5cb7b392553451b7c3b4c4daf558af8dfb900be27a3dd1616c7f162e27","seq":17,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"1.00","currency":"USD","id":3,"side":"credit"},"prev":"c4ef34b79227afb83fe3ba174d2532270dfd93bb200bb0ef1224bd88e1400dc5","seq":18,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-20","description":"x","id":3,"lines":[{"account":4,"amount":"1.00","currency":"USD","side":"debit"},{"account":1,"amount":"1.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"436b4b244cb9936169ec3f9a126a49fbed1f4b6345eb1ebc94f5f0c32a109e7d","seq":19,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"date":"2026-01-10","description":"x","id":4},"prev":"7c9678ee33efabfe40c11ca97d09f3c85e127b6f927747947b1678ce9397df5c","seq":20,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.01","currency":"USD","id":4,"side":"debit"},"prev":"06f89be795ac91344f2449c6703c25e0285ab0044dbacaed3659c494b599fcfe","seq":21,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.01","currency":"USD","id":4,"side":"credit"},"prev":"267ac19f0fee58f5b7fa6da19de3e21f623814a3b9373d98cba8a875b2514495","seq":22,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-10","description":"x","id":4,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2d8c53df64faecd0bf0834edac009ad0e315c6c3458ca1b5991d636fad153809","seq":23,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"82247efbe44c98efb1276c5678b97121d1fe8b16071c725ea460d5f282fc044a","seq":24,"ts":"2026-10-20T22:36:22Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}]},"closing_entry":5,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":5,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":2,"amount":"0.01","currency":"USD","side":"credit"}]},"id":1},"prev":"6280e3a7a5e5581346d562f82501c28e5d56da4f3a46e47da9194c58c0904a2e","seq":25,"ts":"2026-10-20T22:36:22Z","type":"period.closed"} +{"payload":{"date":"2026-02-10","description":"x","id":6},"prev":"fd348f69afd6c2810cd8ee309a0ffbe907a4a1b2ebf1054c509bcbaabdc4bf17","seq":26,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":6,"amount":"0.03","currency":"USD","id":6,"side":"debit"},"prev":"c241f51dcaebac1313c025be952019ccd08ab604a87c46698a6eb3a1c09f2e5b","seq":27,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"0.03","currency":"USD","id":6,"side":"credit"},"prev":"116374477240159a9cc2b8a7944f228bab54eb1c952031b7d4c87cd994a90f5f","seq":28,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-10","description":"x","id":6,"lines":[{"account":6,"amount":"0.03","currency":"USD","side":"debit"},{"account":1,"amount":"0.03","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"aa7d8169faab35408e4465a8b24363730b24fc9ccffce8cd7ed3a3c78bd23eb3","seq":29,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"end":"2026-02-28","id":2,"name":"2026-02","start":"2026-02-01"},"prev":"d84c26dd5d2a2b6c3b448a1ccd9394768a9ecac2827a9bb7e420db2e82c3699e","seq":30,"ts":"2026-10-20T22:36:22Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}]},"closing_entry":7,"entry":{"date":"2026-02-28","description":"Close period 2026-02","id":7,"lines":[{"account":6,"amount":"0.03","currency":"USD","side":"credit"},{"account":2,"amount":"0.02","currency":"USD","side":"debit"},{"account":3,"amount":"0.01","currency":"USD","side":"debit"},{"account":4,"amount":"1.00","currency":"USD","side":"credit"},{"account":2,"amount":"1.00","currency":"USD","side":"debit"}]},"id":2},"prev":"f1e4617e0238ddcc92bb6e38d67323e97cbed40cbb59995205c143f463ce3866","seq":31,"ts":"2026-10-20T22:36:22Z","type":"period.closed"} +{"payload":{"date":"2026-03-10","description":"x","id":8},"prev":"43be78882bc9bef04dacdbb2428cd96276390b853bbc7cb4ff782069c6663db3","seq":32,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.05","currency":"USD","id":8,"side":"debit"},"prev":"972871a9cf053a3fdfad69b14b8194f94dc83b27d69fd7850b9a1c1829cc0b98","seq":33,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.05","currency":"USD","id":8,"side":"credit"},"prev":"900444859549026f0e81bc4d207e6cbd4a64c05ccd66c4021b76b9042e73623d","seq":34,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-03-10","description":"x","id":8,"lines":[{"account":1,"amount":"0.05","currency":"USD","side":"debit"},{"account":5,"amount":"0.05","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"67886299023903711f639c2f2beccfc09113aebcd25743c1f491cadb3675de6a","seq":35,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"end":"2026-03-31","id":3,"name":"2026-03","start":"2026-03-01"},"prev":"8e70a44717e1593afe8f9979d1bf2c5e6a663dec66eb0d2511458582c5757e20","seq":36,"ts":"2026-10-20T22:36:22Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}]},"closing_entry":9,"entry":{"date":"2026-03-31","description":"Close period 2026-03","id":9,"lines":[{"account":5,"amount":"0.05","currency":"USD","side":"debit"},{"account":2,"amount":"0.03","currency":"USD","side":"credit"},{"account":3,"amount":"0.02","currency":"USD","side":"credit"}]},"id":3},"prev":"7402d428c3cf959ed87d815be5b6f8832e92f4e839f7d016af6c2adfbfed9fdd","seq":37,"ts":"2026-10-20T22:36:22Z","type":"period.closed"} +{"payload":{"date":"2026-04-10","description":"x","id":10},"prev":"cf6379a5c59b3ea659f37e133b75851c2f64501ff691f9b598c46174d530ecf3","seq":38,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.07","currency":"USD","id":10,"side":"debit"},"prev":"b9177ac8026f3153a671e52760d2d84c4c8c6f3a51fa2d87ceed4e1b365ab991","seq":39,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.07","currency":"USD","id":10,"side":"credit"},"prev":"97fe44dc61a0daa1e314684d72f3d113fbe2f3f72a4af2313caed05e78b68833","seq":40,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-04-10","description":"x","id":10,"lines":[{"account":1,"amount":"0.07","currency":"USD","side":"debit"},{"account":5,"amount":"0.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c35ccd51b0626b522185bf76842c88992e3cd7f79ba1089d0473374a60cf61a2","seq":41,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"end":"2026-04-30","id":4,"name":"2026-04","start":"2026-04-01"},"prev":"b99b2a67c5ba001e8ce7745dd14d7f70e423c319c04486182254e1065793328c","seq":42,"ts":"2026-10-20T22:36:22Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"largest_remainder"},"closing_entry":11,"entry":{"date":"2026-04-30","description":"Close period 2026-04","id":11,"lines":[{"account":5,"amount":"0.07","currency":"USD","side":"debit"},{"account":2,"amount":"0.04","currency":"USD","side":"credit"},{"account":3,"amount":"0.03","currency":"USD","side":"credit"}]},"id":4},"prev":"5849509840c3033bff1ccf60e96a20dec9c7adf0e34ffd0862523fa6acea2e82","seq":43,"ts":"2026-10-20T22:36:22Z","type":"period.closed"} +{"payload":{"date":"2026-05-10","description":"x","id":12},"prev":"c31ca5fa010b15f00c157f464b7c4cede3dcdef9007271fab7ce0c2e7017ef08","seq":44,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":6,"amount":"0.01","currency":"USD","id":12,"side":"debit"},"prev":"176c4f829ed8a51c1000fca24f6ada374eddae5fcd0ba4e795880248de4add88","seq":45,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"0.01","currency":"USD","id":12,"side":"credit"},"prev":"59ef20ce6f87a178e59a6fd776df92469cf1b7d2600df44efc455f895f7dc9a8","seq":46,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-05-10","description":"x","id":12,"lines":[{"account":6,"amount":"0.01","currency":"USD","side":"debit"},{"account":1,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"04b41e6f7ac0f0183596f59c3b1d59f6686ce8119614d16b4cc399d571c30f34","seq":47,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"end":"2026-05-31","id":5,"name":"2026-05","start":"2026-05-01"},"prev":"2d9afc259b1dba167ca3fe21d57fbc1a3d82188d351900709440dce4c68e1173","seq":48,"ts":"2026-10-20T22:36:22Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"largest_remainder"},"closing_entry":13,"entry":{"date":"2026-05-31","description":"Close period 2026-05","id":13,"lines":[{"account":6,"amount":"0.01","currency":"USD","side":"credit"},{"account":2,"amount":"0.01","currency":"USD","side":"debit"}]},"id":5},"prev":"c7cdb2f75922c5b362c4002bdf09fb34df19359414221e426a52df6790b3f5c5","seq":49,"ts":"2026-10-20T22:36:22Z","type":"period.closed"} +{"payload":{"date":"2026-06-10","description":"x","id":14},"prev":"3d19bf47eb75ec57e51442a4e7aa888f45ae1b8b4ced9f343129181cad356837","seq":50,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.03","currency":"USD","id":14,"side":"debit"},"prev":"0c15f70e6c6e7fd0feb83aa7b21eb3b1a39148ec13e29d2e4e44c873ad383c9f","seq":51,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.03","currency":"USD","id":14,"side":"credit"},"prev":"7e822401f33a9977406626fa1ab01e1d113233dd61aaca9da71ba300b6b05be6","seq":52,"ts":"2026-10-20T22:36:22Z","type":"entry.line_added"} +{"payload":{"date":"2026-06-10","description":"x","id":14,"lines":[{"account":1,"amount":"0.03","currency":"USD","side":"debit"},{"account":5,"amount":"0.03","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5a7691df36edec88888faa6230cf118dbd0aed36a323049ae3ce613d12589997","seq":53,"ts":"2026-10-20T22:36:22Z","type":"entry.posted"} +{"payload":{"end":"2026-06-30","id":6,"name":"2026-06","start":"2026-06-01"},"prev":"2a8639154a4c9e9209666bf3e79770fe3aef8d65c544c36a63199d70094054ae","seq":54,"ts":"2026-10-20T22:36:22Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"largest_remainder"},"closing_entry":15,"entry":{"date":"2026-06-30","description":"Close period 2026-06","id":15,"lines":[{"account":5,"amount":"0.03","currency":"USD","side":"debit"},{"account":2,"amount":"0.02","currency":"USD","side":"credit"},{"account":3,"amount":"0.01","currency":"USD","side":"credit"}]},"id":6},"prev":"f07f0f33e40c04aa0d5d01bd6fb5b657544697f74cb666c18e1a477d402c3401","seq":55,"ts":"2026-10-20T22:36:22Z","type":"period.closed"} +{"payload":{"date":"2026-07-10","description":"x","id":16},"prev":"151e2397daf4eaa0f2e80e9efdf2477a2efc7a20741c887db00bbdae850756a1","seq":56,"ts":"2026-10-20T22:36:22Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.01","currency":"USD","id":16,"side":"debit"},"prev":"b547976858511f9ce393616f51b5756dc209f57092e48a989eed7e1f56864e43","seq":57,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.01","currency":"USD","id":16,"side":"credit"},"prev":"d5193903dafb864592ee04f832e2e3e39393a6b7a1a68133b6ed64d7a61deeb5","seq":58,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"date":"2026-07-10","description":"x","id":16,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"facf5e345f9e12f532640dcd26c103d0b445a71d3df2e73a8b9fc2447d586a8a","seq":59,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"} +{"payload":{"end":"2026-07-31","id":7,"name":"2026-07","start":"2026-07-01"},"prev":"19a977322c7656907e0c1963ea3509d777466b3cac40602158cfc9d35e2d5be3","seq":60,"ts":"2026-10-20T22:36:23Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":17,"entry":{"date":"2026-07-31","description":"Close period 2026-07","id":17,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":2,"amount":"0.01","currency":"USD","side":"credit"}]},"id":7},"prev":"9ae96db6a36a943e27213f8f186947b720ff57aee51b3f9f1ce8a12b069500d7","seq":61,"ts":"2026-10-20T22:36:23Z","type":"period.closed"} +{"payload":{"date":"2026-08-10","description":"x","id":18},"prev":"cf6b81c159292b4d2b4cee2716e2c184e15041639b929944146694fedada6078","seq":62,"ts":"2026-10-20T22:36:23Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.01","currency":"USD","id":18,"side":"debit"},"prev":"a69ec51e484e61b444b097886984d493619ec0e88f90d04d674b6f716328a11a","seq":63,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.01","currency":"USD","id":18,"side":"credit"},"prev":"ff5d291ffe60a1519fb50e030ead50faf0adca786b98dae951a2fc30f718fc9b","seq":64,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"date":"2026-08-10","description":"x","id":18,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b5a12d4c2f0007b790bc0bf977151e23865498a935f92dbd9f5c992cb8a4c71e","seq":65,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"} +{"payload":{"end":"2026-08-31","id":8,"name":"2026-08","start":"2026-08-01"},"prev":"1aef626e3b26cce874c5b474990be8c333c5d1eb2327be739f59163bc7103c49","seq":66,"ts":"2026-10-20T22:36:23Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":19,"entry":{"date":"2026-08-31","description":"Close period 2026-08","id":19,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":3,"amount":"0.01","currency":"USD","side":"credit"}]},"id":8},"prev":"3aebd760694ae27241a4241923035c08b4b1a29dc61726a8203c3f05f4922cb5","seq":67,"ts":"2026-10-20T22:36:23Z","type":"period.closed"} +{"payload":{"date":"2026-09-10","description":"x","id":20},"prev":"1f848b707680af75484084b4540dfe71c168c9cb54db761ea429d613d5318267","seq":68,"ts":"2026-10-20T22:36:23Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.01","currency":"USD","id":20,"side":"debit"},"prev":"f9bbb823ec0e036d7246ab5d05dfb3617f7ad0b9988e622f48e4d4afd4b8e4a0","seq":69,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.01","currency":"USD","id":20,"side":"credit"},"prev":"4009cf504d97e2bf46f0ad1edaeb5e7e5484d3a8c26580b753c09abbb6744802","seq":70,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"date":"2026-09-10","description":"x","id":20,"lines":[{"account":1,"amount":"0.01","currency":"USD","side":"debit"},{"account":5,"amount":"0.01","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5892d0131f14095e5d2426d0303d362687f4793b34e37993d50e6814da0ece9c","seq":71,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"} +{"payload":{"end":"2026-09-30","id":9,"name":"2026-09","start":"2026-09-01"},"prev":"03649f77b722b97c214637744a5b458334998548196ee392019a6e4c5597d4ef","seq":72,"ts":"2026-10-20T22:36:23Z","type":"period.created"} +{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"52e2aa39b666fa0ae081d1135a8d84b34d87530dc56fc7816f98f9d52baafacb","seq":73,"ts":"2026-10-20T22:36:23Z","type":"account.added"} +{"payload":{"close_to":"retained_earnings","closing_entry":21,"entry":{"date":"2026-09-30","description":"Close period 2026-09","id":21,"lines":[{"account":5,"amount":"0.01","currency":"USD","side":"debit"},{"account":7,"amount":"0.01","currency":"USD","side":"credit"}]},"id":9},"prev":"20b0e0ea81e4449eecfd3db0847718c97d90a71a4e4ca012c45662619ee95580","seq":74,"ts":"2026-10-20T22:36:23Z","type":"period.closed"} +{"payload":{"date":"2026-10-10","description":"x","id":22},"prev":"2c538d1e71039b6110c347cebde881ce0e4e0b5f5e63d7ec62a6b389ee19c1c7","seq":75,"ts":"2026-10-20T22:36:23Z","type":"entry.created"} +{"payload":{"account":1,"amount":"0.03","currency":"USD","id":22,"side":"debit"},"prev":"ba25543d5aada70e19f43591d6ee97ce40ac174e580d13746a114912b17877b7","seq":76,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"account":5,"amount":"0.03","currency":"USD","id":22,"side":"credit"},"prev":"c4197697cfc61e1e8569ca363cd9d689f582233f53c6365c2cd69bbf0444d6be","seq":77,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"date":"2026-10-10","description":"x","id":22,"lines":[{"account":1,"amount":"0.03","currency":"USD","side":"debit"},{"account":5,"amount":"0.03","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"135a8dd9eef94a66eefa5d03065fb9738595773ed7fa3e78275d20c1e8e325f2","seq":78,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"} +{"payload":{"end":"2026-10-31","id":10,"name":"2026-10","start":"2026-10-01"},"prev":"9b571865ef045c315429db710a9fb981b5ced04e49e458a42494c906ae471fa1","seq":79,"ts":"2026-10-20T22:36:23Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":23,"entry":{"date":"2026-10-31","description":"Close period 2026-10","id":23,"lines":[{"account":5,"amount":"0.03","currency":"USD","side":"debit"},{"account":2,"amount":"0.02","currency":"USD","side":"credit"},{"account":3,"amount":"0.01","currency":"USD","side":"credit"}]},"id":10},"prev":"067522b0fb85b0165a90d9fdaf0e600507ef8c1cd2d90df4369c8e7f755ffe0f","seq":80,"ts":"2026-10-20T22:36:23Z","type":"period.closed"} +{"payload":{"date":"2026-11-10","description":"x","id":24},"prev":"4cad60fbb38a6fe5324adb8a9f9fcadc2e4ce6baa5e9b714138b2e74d77bc9aa","seq":81,"ts":"2026-10-20T22:36:23Z","type":"entry.created"} +{"payload":{"account":6,"amount":"0.05","currency":"USD","id":24,"side":"debit"},"prev":"bba5273b15c5dbd1fcf0f9b72e003e357bc782bb7984fb8db277c18997e039c6","seq":82,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"0.05","currency":"USD","id":24,"side":"credit"},"prev":"3117a5c4f737f9b73e88cb92e316e299fac02c4ddd1f61fe190bfcc23ebcb690","seq":83,"ts":"2026-10-20T22:36:23Z","type":"entry.line_added"} +{"payload":{"date":"2026-11-10","description":"x","id":24,"lines":[{"account":6,"amount":"0.05","currency":"USD","side":"debit"},{"account":1,"amount":"0.05","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7e26fd3e79116b352465755e53411d5e302a0abd89a680a4c1e49d9709094a65","seq":84,"ts":"2026-10-20T22:36:23Z","type":"entry.posted"} +{"payload":{"end":"2026-11-30","id":11,"name":"2026-11","start":"2026-11-01"},"prev":"2eafcaaa0ae4aea57eb8e700ef940d9318a9a8601175dcafa5356300e6650775","seq":85,"ts":"2026-10-20T22:36:23Z","type":"period.created"} +{"payload":{"close_to":{"members":[{"account":2,"distributions":4,"share":"0.5"},{"account":3,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":25,"entry":{"date":"2026-11-30","description":"Close period 2026-11","id":25,"lines":[{"account":6,"amount":"0.05","currency":"USD","side":"credit"},{"account":2,"amount":"0.03","currency":"USD","side":"debit"},{"account":3,"amount":"0.02","currency":"USD","side":"debit"}]},"id":11},"prev":"392fc8390543e580898401ba489ef0c05fe78646612581c9357145e80a2c2a3d","seq":86,"ts":"2026-10-20T22:36:23Z","type":"period.closed"} diff --git a/crates/ledger-store/tests/member_closes.rs b/crates/ledger-store/tests/member_closes.rs new file mode 100644 index 0000000..6d2bffe --- /dev/null +++ b/crates/ledger-store/tests/member_closes.rs @@ -0,0 +1,525 @@ +//! Members closes in the journal (P3 defect 17, rules.md B-13, B-27): +//! the cumulative rule's feature declaration, its `since_seq`, what a +//! newer or damaged rounding tag does on replay, and the first close after +//! the upgrade on books an earlier version wrote. Every expected figure is +//! worked by hand in the comment beside it. + +use chrono::NaiveDate; +use ledger_core::{ + AccountId, AccountType, CloseTo, Currency, Ledger, MemberCapital, MemberClose, Money, Side, + members_equity, +}; +use ledger_store::journal::read_and_verify_journal; +use ledger_store::{JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let dir = std::env::temp_dir().join(format!( + "wanderbooks-member-closes-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn dec(s: &str) -> Decimal { + s.parse().unwrap() +} + +fn month_end(m: u32) -> NaiveDate { + if m == 12 { + d(12, 31) + } else { + d(m + 1, 1).pred_opt().unwrap() + } +} + +/// Two members at half each, capital 3000 and 3001; sales 4000. +struct Books { + store: Store, + cash: AccountId, + caps: [AccountId; 2], + sales: AccountId, +} + +fn books(dir: &Path) -> Books { + let mut store = Store::create(dir, Currency::USD).unwrap(); + let mut add = |code: &str, t| store.add_account(code, code, t, None, false).unwrap(); + let cash = add("1000", AccountType::Asset); + let caps = [ + add("3000", AccountType::Equity), + add("3001", AccountType::Equity), + ]; + let sales = add("4000", AccountType::Revenue); + Books { + store, + cash, + caps, + sales, + } +} + +/// [`books`] opened again. +fn reopen(dir: &Path) -> Books { + let store = Store::open(dir).unwrap(); + let id = |code: &str| store.ledger().chart().find_by_code(code).unwrap().id; + let (cash, caps, sales) = (id("1000"), [id("3000"), id("3001")], id("4000")); + Books { + store, + cash, + caps, + sales, + } +} + +fn half(caps: &[AccountId]) -> Vec { + caps.iter() + .map(|&capital| MemberClose { + capital, + share: dec("0.5"), + distributions: None, + }) + .collect() +} + +/// Earn `amount` on the 10th of month `m`, then close the month to `to`. +fn earn_and_close(b: &mut Books, m: u32, amount: &str, to: &CloseTo) { + let e = b.store.create_entry(d(m, 10), "sale").unwrap(); + let money = Money::new(dec(amount), Currency::USD); + b.store.add_line(e, b.cash, Side::Debit, money).unwrap(); + b.store.add_line(e, b.sales, Side::Credit, money).unwrap(); + b.store.post_entry(e).unwrap(); + let p = b + .store + .create_period(&format!("2026-{m:02}"), d(m, 1), month_end(m)) + .unwrap(); + b.store.close_period_to(p, to).unwrap(); +} + +/// The journal's lines as (type, payload). +fn journal(dir: &Path) -> Vec<(String, Value)> { + read_and_verify_journal(&dir.join(JOURNAL_FILE)) + .unwrap() + .into_iter() + .map(|l| (l.line_type, l.payload)) + .collect() +} + +/// Copy `from`'s journal into `to` through `JournalWriter` (a valid +/// chain), letting `edit` rewrite each payload. +fn rewrite_journal(from: &Path, to: &Path, edit: impl Fn(&str, Value) -> Value) { + let mut w = JournalWriter::open(&to.join(JOURNAL_FILE)).unwrap(); + for (t, p) in journal(from) { + w.append(&t, edit(&t, p)).unwrap(); + } +} + +/// Books with three members closes of a cent each (Jan to Mar, 50/50). +fn three_cumulative_closes(dir: &Path) -> Books { + let mut b = books(dir); + let to = CloseTo::Members(half(&b.caps)); + for m in 1..=3 { + earn_and_close(&mut b, m, "0.01", &to); + } + b +} + +/// Contract (rules.md B-27; P3 defect 17): the first members close counted +/// from its regime's start declares the `members_cumulative` feature, on +/// the line just before it and once only, so an older version stops at the +/// declaration (it would replay the close under the run rule). Books that +/// never close to the members never declare it. +#[test] +fn the_first_cumulative_close_declares_its_feature() { + let dir = test_dir("declares"); + let b = three_cumulative_closes(&dir); + drop(b); + let lines = journal(&dir); + let declares = |p: &Value| { + p["features"] + .as_array() + .is_some_and(|f| f.contains(&json!("members_cumulative"))) + }; + let declarations: Vec = lines + .iter() + .enumerate() + .filter(|(_, (t, p))| t == "journal.format_set" && declares(p)) + .map(|(i, _)| i) + .collect(); + let first_close = lines + .iter() + .position(|(t, p)| t == "period.closed" && p["close_to"]["rounding"] == "cumulative_since") + .unwrap(); + assert_eq!(declarations, [first_close - 1]); + assert_eq!(lines[first_close - 1].1["format"], 2); + assert_eq!( + lines + .iter() + .filter(|(t, p)| t == "period.closed" && p["close_to"]["since_seq"] == 1) + .count(), + 3 + ); + // Reopened, a fourth close declares nothing again. + let mut b = reopen(&dir); + let to = CloseTo::Members(half(&b.caps)); + earn_and_close(&mut b, 4, "0.01", &to); + drop(b); + let again = journal(&dir) + .iter() + .filter(|(t, p)| t == "journal.format_set" && declares(p)) + .count(); + assert_eq!(again, 1); + + // Books that close only to retained earnings never declare it. + let dir = test_dir("declares-not"); + let mut b = books(&dir); + earn_and_close( + &mut b, + 1, + "0.01", + &CloseTo::RetainedEarnings { sweep: vec![] }, + ); + drop(b); + assert!(!journal(&dir).iter().any(|(_, p)| declares(p))); +} + +/// A change to a journaled close's target. +type Edit = dyn Fn(&mut Value); + +/// Contract (rules.md B-27; P3 review R-9): a rounding rule only a newer +/// version writes is newer, never corrupt: an unknown name, or a value +/// that isn't a name. A `since_seq` beside another rule, or a +/// "cumulative_since" without a usable one, is damage. +#[test] +fn an_unknown_rounding_is_newer_not_corrupt() { + let src = test_dir("rounding-src"); + drop(three_cumulative_closes(&src)); + let edit_last_close = |name: &str, f: &Edit| -> TestDir { + let dir = test_dir(&format!("rounding-{name}")); + let closes = journal(&src) + .iter() + .filter(|(t, _)| t == "period.closed") + .count(); + let seen = std::cell::Cell::new(0); + rewrite_journal(&src, &dir, |t, mut p| { + if t == "period.closed" { + seen.set(seen.get() + 1); + if seen.get() == closes { + f(&mut p["close_to"]); + } + } + p + }); + dir + }; + let newer: [(&str, &Edit); 2] = [ + ("unknown", &|c| c["rounding"] = json!("cumulative_by_day")), + ("object", &|c| c["rounding"] = json!({"rule": "cumulative"})), + ]; + for (name, f) in newer { + let dir = edit_last_close(name, f); + let err = Store::open(&dir).err().expect(name); + assert!( + matches!(err, StoreError::NewerJournal { ref event_type, .. } if event_type == "period.closed"), + "{name}: {err}" + ); + assert!( + matches!( + Snapshot::load(&dir).err(), + Some(StoreError::NewerJournal { .. }) + ), + "{name}: the viewer" + ); + } + let damaged: [(&str, &Edit); 4] = [ + ("since-beside-run", &|c| c["rounding"] = json!("cumulative")), + ("no-since", &|c| { + c.as_object_mut().unwrap().remove("since_seq"); + }), + ("since-zero", &|c| c["since_seq"] = json!(0)), + ("since-text", &|c| c["since_seq"] = json!("1")), + ]; + for (name, f) in damaged { + let dir = edit_last_close(name, f); + let err = Store::open(&dir).err().expect(name); + assert!( + matches!(err, StoreError::JournalCorrupt(_)), + "{name}: {err}" + ); + } +} + +/// Contract (B-13, B-8): a count that starts after the books' first close +/// journals the close it starts at, and replays to it. January's cent +/// closes at 50/50 (close 1, A's: the tie). February's at 60/40 starts a +/// new count at close 2 (0.006 / 0.004: A's), and March's continues it +/// (the running 0.02 is 0.012 / 0.008, so 0.01 / 0.01, less 0.01 / 0.00: +/// B's). The journal says since_seq 1, 2, 2; the books reopen, verify, and +/// stand at A 0.02, B 0.01. +#[test] +fn a_count_that_starts_later_journals_its_start() { + let dir = test_dir("later-start"); + let mut b = books(&dir); + let sixty = CloseTo::Members( + b.caps + .iter() + .zip(["0.6", "0.4"]) + .map(|(&capital, share)| MemberClose { + capital, + share: dec(share), + distributions: None, + }) + .collect(), + ); + let fifty = CloseTo::Members(half(&b.caps)); + earn_and_close(&mut b, 1, "0.01", &fifty); + earn_and_close(&mut b, 2, "0.01", &sixty); + earn_and_close(&mut b, 3, "0.01", &sixty); + drop(b); + let since: Vec = journal(&dir) + .into_iter() + .filter(|(t, _)| t == "period.closed") + .map(|(_, p)| p["close_to"]["since_seq"].clone()) + .collect(); + assert_eq!(since, [json!(1), json!(2), json!(2)]); + let b = reopen(&dir); + b.store.verify().unwrap(); + let bal = |id| b.store.ledger().account_balance(id).unwrap(); + assert_eq!((bal(b.caps[0]), bal(b.caps[1])), (dec("0.02"), dec("0.01"))); +} + +/// Contract (B-8, B-13): replay re-derives where each count of members +/// closes starts and refuses a close journaled with another start, so a +/// wrong `since_seq` makes the books refuse to open, never show figures +/// split some other way. +#[test] +fn a_close_journaled_with_another_start_refuses_to_open() { + let src = test_dir("start-src"); + drop(three_cumulative_closes(&src)); + let dir = test_dir("start"); + rewrite_journal(&src, &dir, |t, mut p| { + if t == "period.closed" && p["id"] == 2 { + p["close_to"]["since_seq"] = json!(2); + } + p + }); + for err in [ + Store::open(&dir).err().expect("the writer"), + Snapshot::load(&dir).err().expect("the viewer"), + ] { + assert!( + err.to_string().contains("from close 2") && err.to_string().contains("from close 1"), + "{err}" + ); + } +} + +/// The members of x_member_closes as the settings would name them: A +/// (capital 3000, distributions 3100) and B (3001), 50/50. +fn fixture_members(l: &Ledger) -> Vec { + let id = |code: &str| l.chart().find_by_code(code).unwrap().id; + vec![ + MemberCapital { + name: "A".into(), + capital_account: id("3000"), + distribution_account: Some(id("3100")), + share: dec("0.5"), + }, + MemberCapital { + name: "B".into(), + capital_account: id("3001"), + distribution_account: None, + share: dec("0.5"), + }, + ] +} + +/// Each member's capital on the statement of members' equity as of `end`. +fn capital_on(l: &Ledger, end: NaiveDate) -> Vec { + members_equity(l, d(1, 1), end, &fixture_members(l)) + .unwrap() + .members + .iter() + .map(|m| m.ending) + .collect() +} + +/// Regression (P3 review R-2, D-10): books main wrote with members closes +/// under every rounding rule it had (fixture x_member_closes: three +/// untagged, three "largest_remainder", "cumulative" broken by a close to +/// retained earnings) open to the figures main computed (A 99.07, B +/// 100.05: earnings 0.07 / 0.05 of the 0.12 closed to them, exact 0.06 +/// each), and the first close after the upgrade counts from the first +/// close of all (September's close to retained earnings skipped) and +/// carries the true-up. December earns 0.02: the running 0.14 splits +/// 0.07 / 0.07, less 0.07 / 0.05 given, so A gets 0.00 and B 0.02, and +/// both stand at their exact 0.07. +/// +/// The statement of members' equity (D-38, R-1): as of Nov 30, A's share +/// of the 0.01 in retained earnings is what the next members close would +/// give of it, allocate(0.13) − allocate(0.12) = 0.07 − 0.06 = 0.01 (B: +/// 0.06 − 0.06 = 0), so A 99.08 and B 100.05, before and after December +/// closes: the pending true-up never shows early. As of Dec 31, before the +/// close, December's 0.02 and the 0.01 split allocate(0.15) − +/// allocate(0.12) = 0.08 − 0.06 / 0.07 − 0.06: A 99.09, B 100.06; after it, +/// allocate(0.15) − allocate(0.14) = 0.01 / 0.00 on capital 99.07 / 100.07: +/// A 99.08, B 100.07. The figure moves by exactly the true-up the close +/// posts (A −0.01, B +0.01), dated at it. +#[test] +fn the_first_close_after_the_upgrade_trues_up_the_old_rules() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/old-books/x_member_closes/journal.jsonl"); + let dir = test_dir("upgrade"); + std::fs::copy(&fixture, dir.join(JOURNAL_FILE)).unwrap(); + let mut store = Store::open(&dir).unwrap(); + let id = |s: &Store, code: &str| s.ledger().chart().find_by_code(code).unwrap().id; + let (cash, a, b, sales) = ( + id(&store, "1000"), + id(&store, "3000"), + id(&store, "3001"), + id(&store, "4000"), + ); + let bal = |s: &Store, acct| s.ledger().account_balance(acct).unwrap(); + assert_eq!( + (bal(&store, a), bal(&store, b)), + (dec("99.07"), dec("100.05")) + ); + let rules: Vec<_> = store + .ledger() + .list_periods() + .iter() + .map(|p| p.members_closed.as_ref().map(|m| m.rule)) + .collect(); + use ledger_core::MemberRule::{LastAbsorbs, PerClose, Run}; + assert_eq!( + rules, + [ + Some(LastAbsorbs), + Some(LastAbsorbs), + Some(LastAbsorbs), + Some(PerClose), + Some(PerClose), + Some(PerClose), + Some(Run), + Some(Run), + None, + Some(Run), + Some(Run), + ] + ); + assert_eq!( + capital_on(store.ledger(), d(11, 30)), + [dec("99.08"), dec("100.05")] + ); + + let e = store.create_entry(d(12, 10), "sale").unwrap(); + let cents = Money::new(dec("0.02"), Currency::USD); + store.add_line(e, cash, Side::Debit, cents).unwrap(); + store.add_line(e, sales, Side::Credit, cents).unwrap(); + store.post_entry(e).unwrap(); + assert_eq!( + capital_on(store.ledger(), d(12, 31)), + [dec("99.09"), dec("100.06")] + ); + let dec_period = store.create_period("2026-12", d(12, 1), d(12, 31)).unwrap(); + let members = vec![ + MemberClose { + capital: a, + share: dec("0.5"), + distributions: Some(id(&store, "3100")), + }, + MemberClose { + capital: b, + share: dec("0.5"), + distributions: None, + }, + ]; + let closing = store + .close_period_to(dec_period, &CloseTo::Members(members)) + .unwrap() + .unwrap(); + let lines: Vec<(String, Side, Decimal)> = store + .ledger() + .get_entry(closing) + .unwrap() + .lines() + .iter() + .map(|l| { + let code = store.ledger().chart().get(l.account).unwrap().code.clone(); + (code, l.side, l.amount.amount) + }) + .collect(); + assert_eq!( + lines, + [ + ("4000".to_string(), Side::Debit, dec("0.02")), + ("3001".to_string(), Side::Credit, dec("0.02")), + ] + ); + let figures = |s: &Ledger| { + let (since, split) = s.member_close_split(dec_period).unwrap(); + let rows: Vec<_> = split.iter().map(|m| (m.part, m.running, m.exact)).collect(); + (since, rows) + }; + let expected = ( + 1, + vec![ + (dec("0.00"), dec("0.07"), dec("0.07")), + (dec("0.02"), dec("0.07"), dec("0.07")), + ], + ); + assert_eq!(figures(store.ledger()), expected); + assert_eq!( + (bal(&store, a), bal(&store, b)), + (dec("99.07"), dec("100.07")) + ); + assert_eq!( + capital_on(store.ledger(), d(11, 30)), + [dec("99.08"), dec("100.05")] + ); + assert_eq!( + capital_on(store.ledger(), d(12, 31)), + [dec("99.08"), dec("100.07")] + ); + // One declaration, just before the close. + drop(store); + let lines = journal(&dir); + let n = lines.len(); + assert_eq!(lines[n - 1].0, "period.closed"); + assert_eq!(lines[n - 1].1["close_to"]["since_seq"], 1); + assert_eq!(lines[n - 2].0, "journal.format_set"); + assert_eq!(lines[n - 2].1["features"], json!(["members_cumulative"])); + // Reopened, and in the viewer: the same. + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert_eq!(figures(store.ledger()), expected); + assert_eq!( + (bal(&store, a), bal(&store, b)), + (dec("99.07"), dec("100.07")) + ); + let snap = Snapshot::load(&dir).unwrap(); + assert_eq!(figures(snap.ledger()), expected); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 160397c..3bcda2c 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -2087,16 +2087,64 @@ fn store_add(store: &mut Store, code: &str, t: AccountType) -> ledger_core::Acco } /// Contract (rules.md B-13): each members close journals its rounding rule -/// and replays under it: "cumulative" for new closes, "largest_remainder" -/// for the per-close rule of earlier on 2026-10-13. Three one-cent months, -/// 50/50: cumulative ends A 0.02 / B 0.01, per close A 0.03 / B 0.00. +/// and replays under it: "cumulative_since" with the close its count starts +/// at for new closes (P3), "cumulative" for the run rule of 2026-10-13 to +/// P3, "largest_remainder" for the per-close rule of earlier on 2026-10-13. +/// Three one-cent months, 50/50: both cumulative rules end A 0.02 / B +/// 0.01, per close A 0.03 / B 0.00. With February closed to retained +/// earnings instead, the two cumulative rules differ: cumulative_since +/// skips it (the running 0.02 splits A 0.01 / B 0.01), the run rule +/// restarts at March (A gets both cents: A 0.02 / B 0.00). #[test] fn members_closes_replay_under_the_rule_they_were_journaled_with() { - for (cumulative, rule, a, b) in [ - (true, "\"rounding\":\"cumulative\"", "0.02", "0.01"), - (false, "\"rounding\":\"largest_remainder\"", "0.03", "0.00"), + use ledger_core::{CloseTo, MemberClose}; + type Rule = fn(Vec) -> CloseTo; + let since: Rule = CloseTo::Members; + let run: Rule = CloseTo::MembersRun; + let per: Rule = CloseTo::MembersPerClose; + for (name, to, re_february, rule, a, b) in [ + ( + "since", + since, + false, + "\"rounding\":\"cumulative_since\"", + "0.02", + "0.01", + ), + ( + "run", + run, + false, + "\"rounding\":\"cumulative\"", + "0.02", + "0.01", + ), + ( + "per", + per, + false, + "\"rounding\":\"largest_remainder\"", + "0.03", + "0.00", + ), + ( + "since-re", + since, + true, + "\"rounding\":\"cumulative_since\"", + "0.01", + "0.01", + ), + ( + "run-re", + run, + true, + "\"rounding\":\"cumulative\"", + "0.02", + "0.00", + ), ] { - let dir = test_dir(&format!("close-rule-{cumulative}")); + let dir = test_dir(&format!("close-rule-{name}")); let mut store = Store::create(&dir, Currency::USD).unwrap(); let cash = store .add_account("1000", "Cash", AccountType::Asset, None, false) @@ -2112,19 +2160,14 @@ fn members_closes_replay_under_the_rule_they_were_journaled_with() { let rev = store .add_account("4000", "Sales", AccountType::Revenue, None, false) .unwrap(); - let members: Vec = caps + let members: Vec = caps .iter() - .map(|&capital| ledger_core::MemberClose { + .map(|&capital| MemberClose { capital, share: Decimal::new(5, 1), distributions: None, }) .collect(); - let to = if cumulative { - ledger_core::CloseTo::Members(members) - } else { - ledger_core::CloseTo::MembersPerClose(members) - }; let cent = Money::new(Decimal::new(1, 2), Currency::USD); for m in 1..=3u32 { let day = |d| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); @@ -2139,16 +2182,26 @@ fn members_closes_replay_under_the_rule_they_were_journaled_with() { let p = store .create_period(&format!("2026-{m:02}"), day(1), end) .unwrap(); - store.close_period_to(p, &to).unwrap(); + if re_february && m == 2 { + store.close_period(p).unwrap(); + } else { + store.close_period_to(p, &to(members.clone())).unwrap(); + } } let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap(); - assert_eq!(journal.matches(rule).count(), 3, "{rule}"); + let closes = if re_february { 2 } else { 3 }; + assert_eq!(journal.matches(rule).count(), closes, "{name}"); + if rule.contains("cumulative_since") { + // Every count starts at the first close: February's close to + // retained earnings is skipped, not a restart. + assert_eq!(journal.matches("\"since_seq\":1").count(), closes, "{name}"); + } drop(store); let store = Store::open(&dir).unwrap(); store.verify().unwrap(); let bal = |id| store.ledger().account_balance(id).unwrap(); - assert_eq!(bal(caps[0]), a.parse::().unwrap(), "{rule}"); - assert_eq!(bal(caps[1]), b.parse::().unwrap(), "{rule}"); + assert_eq!(bal(caps[0]), a.parse::().unwrap(), "{name}"); + assert_eq!(bal(caps[1]), b.parse::().unwrap(), "{name}"); } } diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index adc6d6d..a5cf633 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -1134,7 +1134,10 @@ posting. financing_accounts?, investing_accounts? }` → `{ content, content_encoding? }`. - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs `cash_accounts`), `trial_balance`, `members_equity` (statement of - changes in members' equity; members from `wanderbooks.toml`), + changes in members' equity; members from `wanderbooks.toml`; earnings + not yet closed are split the way the next members close will split + them, so a member's capital on a day never changes when its month is + closed: rules.md B-13, D-38), `general_ledger` (by account: beginning balance, postings with running balance and split, ending balance), `general_journal` (alias `journal`: every entry, line by line, in date order). @@ -1258,15 +1261,37 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. - `books.create_period { name, start, end }` → `{ period_id }`. Dates are YYYY-MM-DD, inclusive. - `books.close_period { period_id, confirmed? }` → without confirmation: - `{ status: "preview", lines: [...] }`: the exact closing entry (the close - run on a copy of the ledger), posting nothing; with `confirmed: true`: - posts it and marks the period closed. The entry zeroes revenue and - expense balances as of the period end; the net goes to `3999` Retained - Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each - member's capital account by share. Periods close in order. Closed periods - reject new entries. + `{ status: "preview", period_id, closing_entry: null, lines: [...], + members }`: the exact closing entry (the close run on a copy of the + ledger), posting nothing; with `confirmed: true`: `{ status: "closed", + period_id, closing_entry, lines, members }`, posting it and marking the + period closed. The entry zeroes revenue and expense balances as of the + period end; the net goes to `3999` Retained Earnings, or, with + `[[company.members]]` in the settings, to each member's capital account + by share, cumulatively (rules.md B-13; D-10): each close gives a member + their share of everything closed to these members and shares so far, to + the currency's minor unit (odd units by largest remainder, ties to the + member listed first when the count began), less what earlier closes gave + them, so no member is ever a minor unit from their exact share. A close + may carry a one-time true-up of rounding by closes made under an earlier + rule, which can run against the period's result by a cent. `members` + (null for a close not to the members) is `{ since_close, members: [{ + name, capital_account, share, this_close, running, exact }] }`: the + close the count starts at (the first members close with these members + and shares; 1 is the books' first close of all), and per member, in the + settings' order, their capital account's code, share ("0.50"), what this + close credits them (negative: debits), what the count's closes have + credited them in all, and their exact share of everything the count + closed (unrounded, e.g. "0.005"). The preview's `members` is exactly what + confirming posts. Periods close in order. Closed periods reject new + entries. - `books.list_periods {}` → `{ periods: [{ id, name, start, end, status, - closing_entry }] }`. + closing_entry, close_rule }] }`. `close_rule` says which rounding rule a + period closed to the members was closed under: + `"members_cumulative_since:N"` (the rule since P3, counted from close N), + `"members_run"` (2026-10-13 to P3), `"members_per_close"` (2026-10-13) or + `"members_last_absorbs"` (before 2026-10-13); null for an open period or + one closed to anything else. ## Tools — bank reconciliation diff --git a/docs/requirements.md b/docs/requirements.md index 16b63b2..9ca6d78 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -63,9 +63,11 @@ requirement conflict, the requirement wins. (loss), distributions (debits), ending — plus a total that equals balance-sheet equity. Earnings not yet distributed (retained earnings and unclosed income) and the period's income are allocated by ownership share - to the cent by largest remainder (the odd cent goes to the largest - remainder, ties to the earlier member; the owner, 2026-10-13), so every - row's total is exact. Any equity change the roll-forward doesn't explain is shown as + to the currency's minor unit by largest remainder (the odd cent goes to + the largest remainder, ties to the earlier member; the owner, 2026-10-13), + earnings not yet closed the way their close will split them (D-38, + rules.md B-13), so every row's total is exact and a member's capital on a + day never changes when its month is closed. Any equity change the roll-forward doesn't explain is shown as "other", never hidden. - **R-9d — Tax categories.** Revenue and expense accounts map to stable tax categories (`ledger_core::tax::CATEGORIES`: key, label, income / @@ -127,9 +129,13 @@ requirement conflict, the requirement wins. end** (later-dated entries belong to later periods) and puts the net in `to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention; `close_period` is shorthand) or `CloseTo::Members` (each member's capital - account by share, to the cent, odd cents by largest remainder: the - partnership convention; closes journaled before 2026-10-13 replay with - the rule they were made under, the last member absorbing rounding), or `CloseTo::Account` (any equity account, e.g. + account by share, to the currency's minor unit, odd units by largest + remainder, cumulatively from the first members close with these members + and shares, so no member is ever a unit from their exact share: the + partnership convention, rules.md B-13; closes journaled under earlier + rules replay with the rule they were made under: `CloseTo::MembersRun`, + the run rule of 2026-10-13 to P3, `CloseTo::MembersPerClose`, and + before 2026-10-13 the last member absorbing rounding), or `CloseTo::Account` (any equity account, e.g. a sole proprietor's capital). Every target can sweep owner-pay equity accounts — draws, distributions, dividends — into it: a member's distributions account into their own capital. The members' equity diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 5ebd9c9..010269c 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -206,34 +206,109 @@ closed period). ## B-13 Partners share income by the partnership agreement; capital accounts carry it -**Status:** AUTHORITY (shares); DECISION (cent rounding) +**Status:** AUTHORITY (shares; capital accounts; a count ends where the +members or shares change: §706(d)(1), Treas. Reg. §1.706-4(a)); DECISION +(cent rounding, the owner 2026-10-13); DECISION (counted from the company's +first close, D-10, 2026-10-16); DECISION (the statement splits unclosed +earnings the same running way, D-38, 2026-10-20) - 26 U.S.C. §704(a) (`sources/irs-partnerships.md`): "A partner's distributive share of income, gain, loss, deduction, or credit shall, except as otherwise provided in this chapter, be determined by the partnership agreement." +- Treas. Reg. §1.704-1(b)(2)(iv)(b) (`sources/irs-partnerships.md`): each + partner's capital account "is increased by … (3) allocations to him of + partnership income and gain" and "is decreased by … (7) allocations of + partnership loss and deduction", beside the money and property + contributed and distributed. A capital account is one running balance of + contributions, allocations and distributions: the closes are the + allocations. - Form 1065 Schedule M-2: "Show what caused changes during the tax year in - the partners' tax-basis capital accounts." + the partners' tax-basis capital accounts." Schedule K-1 Item L reports + each partner's capital account "using the tax-basis method"; that + analysis "is based on the partnership's books and records" (the Partner's + Instructions' Basis Limitations section, `sources/irs-partnerships.md`; + not Item L, which an earlier version of this rule spliced in). The K-1 + worksheet is the book capital analysis, which the CPA converts for + Item L. - **No authority** says how to round a share to the cent. Largest - remainder, ties to the earlier member: DECISION (the owner, 2026-10-13). - Closes made before then replay with the last member absorbing it. -- Rounding never piles up: a run of members closes (same members and - shares, back to back) splits cumulatively, each close giving a member - their allocation of the run's total so far less their allocation before - it, so each member's total from the run is within a cent of their exact - share (follows from the running capital account below; found - 2026-10-13: closing each month on its own gave every tied cent to the - earlier member). Journaled as `"rounding": "cumulative"`; closes - journaled `"largest_remainder"` replay one at a time. -- A capital account is one running balance: Form 1065 Schedule K-1 - instructions, Item L: "The partnership must report your beginning - capital account and ending capital account for the year", "based on the - partnership's books and records." So a statement of members' equity - splits undistributed earnings cumulatively, as of each date (beginning - as of the day before the period, ending as of its last day), never one - period at a time: a member's ending on one statement is their beginning - on the next, to the cent, and a member's period income is the - roll-forward's remainder (their share within a rounding cent). + remainder, ties to the member listed first: DECISION (the owner, + 2026-10-13). Closes made before then replay with the last member + absorbing it. +- **Members closes split cumulatively, counted from the company's first + close** (P3 defect 17; D-10). A *count* is the members closes with one + set of (capital account, share) pairs, from the first such close since + the last members close with another set: closes to retained earnings or + to one account are skipped (they allocate nothing to the members), and + neither the members' order nor their distribution accounts matter. Each + close gives member *i* allocate(C + net)*ᵢ* − given*ᵢ*: their split of + everything the count has closed (C before it, plus its own net), to the + base currency's minor unit (cents, whole yen, thousandths of a dinar; + B-3; for a code ISO 4217 gives none, such as gold, the finest a line + carries, `kernel::MAX_LINE_SCALE` places, so every amount is still whole + units), less what the count's earlier closes gave them under whatever + rule they were made. A tied odd unit goes to the member the count's + first close listed first, whatever order later closes list the members + in, so a reorder of the members moves no cent; the statement below + breaks ties the same way, on a day before a count's first close in the + order of the count the next members close begins. Therefore each close hands out + exactly its net; after every close that posts, each member's total from + the count is one split of the count's total, under one minor unit from + their exact share; and rounding by closes made under earlier rules is + trued up by the first close that posts (D-10: from inception; listed in + the close's `members`). The true-up is under a unit per member for each + break in an earlier run and each per-close close; a last-absorbs close + rounded each member but the last to within half a unit, and the last + absorbed up to half a unit for each of the others (a unit with three + members, more with four or more). + - A change of members or shares starts a new count (26 U.S.C. + §706(d)(1): a year's distributive shares are determined by a method + "which takes into account the varying interests of the partners in the + partnership during such taxable year"; Treas. Reg. §1.706-4(a)(3)(vii): + the partnership "shall treat each segment as though the segment were a + separate distributive share period"). Income closed under + the old shares is never split again under the new ones. So the bound + holds per count: across k changes a member's total can be up to one + unit per count from the sum of their exact shares (the D-10 lifetime + bound holds while members and shares are unchanged). + - A true-up rides only on a close that posts an entry (income to close, + or a distribution to sweep): a close that posts nothing credits no one, + and the true-up waits for the next close that posts (close INVARIANT + 5: never post a closing entry when there is nothing to close). + - The bound's precondition: every amount is a whole number of minor + units (B-3: every posted line is), and every product |total| × share + is exact. A product Decimal would round (a share of many decimal places + times a large total) is refused, never split + (`periods::allocate_cumulative` INVARIANT 4). Replaying a journal from + before B-3, a sub-unit fraction can make the bound two units. + - A year's income per member is the difference of two running totals, + each under a unit from exact, so under two units from share × the + year's income; the return is in whole dollars ("Rounding on the + return", `sources/irs-partnerships.md`). + - A period closes at the shares in force at the close: a share change + inside a period isn't split at its date (§706(d) and §1.706-4 would); + close the period at the change. + - Journaled `"rounding": "cumulative_since"` with `"since_seq": N`, the + close the count starts at, under the journal feature + `members_cumulative` (B-27); replay derives N again and refuses a close + journaled with another. Closes journaled `"cumulative"` (the run rule of + 2026-10-13 to P3: a back-to-back run with the same members in the same + order, restarted by any other close, so a tied cent could go to the + same member at every restart), `"largest_remainder"` (each close on its + own) and none (the last member absorbs) replay under their own rules, + never changed (B-8). +- A capital account is one running balance (§1.704-1(b)(2)(iv)(b)). So the + statement of members' equity, the K-1 worksheet and the viewer split + undistributed earnings U as of each date the way the count's next close + would split them: allocate(C(d) + U(d)) − allocate(C(d)), C(d) being what + the count's closes dated on or before d closed (D-38; P3 review R-1). A + member's capital on a date never moves when a period ending on or before + it is later closed; a true-up of rounding by closes made under an earlier + rule shows only once a close posts it, dated at that close. A + statement's beginning is as of the day before its period, its ending as + of its last day: a member's ending on one statement is their beginning on + the next, to the unit, and a member's period income is the roll-forward's + remainder (their share within a rounding unit at each end). - A correction undoes what it corrects (B-9): an entry and its reversal both in a statement's span are neither contributions nor distributions (`reports::cancelled_in_span`; the K-1 worksheet reads @@ -247,15 +322,39 @@ closed period). members credited in one entry in proportion to their credits (`k1` INVARIANTS 3, 4, 6). -**Enforced:** `CloseTo::Members`, `periods::allocate`, -`kernel::apportion`, `members_equity`. **Checked:** Kani -`apportion_is_exact`, `apportion_is_fair`; Lean `runPart_telescopes`, -`closePart_total`; properties `prop_split_*`, -`each_members_capital_chains_across_statements`, -`each_member_stays_within_a_cent_of_their_share`; -`a_run_of_closes_never_piles_up_rounding`, -`members_closes_replay_under_the_rule_they_were_journaled_with`; -`closes_journaled_under_the_old_rule_replay_with_it`. +**Enforced:** `CloseTo::Members`, `CloseTo::MembersCumulative`, +`Ledger::members_regime`, `Regime::allocate`, +`periods::allocate_cumulative`, `kernel::apportion_cumulative`, +`periods::allocate`, `kernel::apportion`, `members_equity`, +`Store::close_period_to` (the feature declaration), the store's +`close_to_from_journal`. **Checked:** Kani `apportion_is_exact`, +`apportion_is_fair`, `apportion_cumulative_is_exact`, +`cumulative_close_never_drifts_a_unit_at_halves`, `_at_tenths`, +`_at_near_thirds`, +`cumulative_closing_entry_balances`; Lean `runPart_telescopes`, +`closePart_total`, `trueUp_total`, `trueUp_running`, +`closePart_eq_trueUp`, `cumulative_drift`, `statement_after_close`, +`statement_unmoved_by_close`, `Apportion.rem_pos_of_rank_lt`, +`apportion_drift`, `allocW_sum`, `allocW_drift`, +`allocW_cumulative_drift`, `allocW_cumulative_total`; properties +`prop_split_*`, `each_members_capital_chains_across_statements`, +`each_member_stays_within_a_cent_of_their_share`, +`members_capital_never_drifts_a_cent_from_their_share`, +`each_members_capital_chains_and_holds_through_closes`, +`period_ends_rise_with_close_order`, `apportion_cumulative_agrees`, +`allocate_cumulative_agrees_with_the_integer_model`; +`the_audit_odd_cent_never_piles_up_across_closes` and the rest of +`ledger-core` `tests/odd_cent.rs`; `a_run_of_closes_never_piles_up_rounding`, +`a_different_close_ends_a_legacy_run`, +`a_different_close_no_longer_restarts_the_count`, +`members_closes_replay_under_the_rule_they_were_journaled_with`, +`ledger-store` `tests/member_closes.rs` and the old-books fixture +`x_member_closes`; `closes_journaled_under_the_old_rule_replay_with_it`; +`ledger-mcp` `close_period_shows_each_members_running_split`, +`old_books_name_each_closes_rule_and_true_up_at_the_next_close`; +`ledger-gui` `a_members_page_shows_the_same_capital_before_and_after_a_close`, +`a_yen_members_page_splits_in_whole_yen`; the bookspec examples +`members-close.*`. ## B-14 Statements: P&L for a period; cash flow by the indirect method @@ -716,6 +815,13 @@ lowers the format in force. Opening the books checks every complete line before it changes anything: a cut-off last line is set aside only when the lines before it pass, so an older version never alters newer books. +A feature (`ledger_store::format::KNOWN_FEATURES`) is a field or value on +an event type an older version knows but would misread; the writer declares +it with the format in force (at least 2) just before the first line that +needs it, so books that never use it stay readable by versions before it: +`members_cumulative` (P3, B-13: a members close counted from its count's +first close, `"rounding": "cumulative_since"`). + **Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`, `gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`, `JournalLine::parse`, `journal::read_journal_and_format`, diff --git a/docs/spec/sources/irs-partnerships.md b/docs/spec/sources/irs-partnerships.md index 90c1c1a..885916f 100644 --- a/docs/spec/sources/irs-partnerships.md +++ b/docs/spec/sources/irs-partnerships.md @@ -1,5 +1,5 @@ # Partnerships: shares of income and capital accounts -Governs `CloseTo::Members` and the members' equity statement. Partners' shares come from the partnership agreement (IRC §704(a)); Form 1065 reports each partner's capital account (Schedule K-1 item L) and the changes in them (Schedule M-2). No authority prescribes how to round a share to the cent: that rule is the owner's decision (largest remainder, 2026-10-13). +Governs `CloseTo::Members` and the members' equity statement. Partners' shares come from the partnership agreement (IRC §704(a)); a partner's capital account is one running balance of what they contributed, their allocations of income and loss, and what was distributed to them (Treas. Reg. §1.704-1(b)(2)(iv)(b)); when a partner's interest changes during the year, the distributive shares take the varying interests into account, period by period (IRC §706(d)(1), Treas. Reg. §1.706-4(a)); Form 1065 reports each partner's capital account on the tax basis (Schedule K-1 item L) and the changes in them (Schedule M-2). No authority prescribes how to round a share to the cent: that rule is the owner's decision (largest remainder, 2026-10-13), counted from the company's first close (D-10, 2026-10-16). ## 26 U.S.C. §704(a)-(b) Source: @@ -24,3 +24,47 @@ SHA-256 of the retrieved file (`i1065.txt`): `364ec05f6a688447def50169604ec0aa97 **Rounding on the return (not in the books)** > The partnership may enter decimal points and cents when preparing its return. However, it should round off cents to whole dollars on its return, forms, and schedules to make completing its return easier. The partnership must either round off all amounts on the return to whole dollars, or use cents for all amounts. To round, drop amounts under 50 cents and increase amounts from 50 to 99 cents to the next dollar. For example, $8.40 rounds to $8 and $8.50 rounds to $9. If two or more amounts are added to figure the amount to enter on a line, include cents when adding the amounts and round off only the total. + +## Treas. Reg. §1.704-1(b)(2)(iv)(b): what a capital account carries +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr17041.html`): `0eafa6f27d7df3a3cc93ebad7cbdc0c37a49b055c53a43d5e5e8efa29f80beeb` + +> (b) Basic rules. Except as otherwise provided in this paragraph (b)(2)(iv), the partners' capital accounts will be considered to be determined and maintained in accordance with the rules of this paragraph (b)(2)(iv) if, and only if, each partner's capital account is increased by (1) the amount of money contributed by him to the partnership, (2) the fair market value of property contributed by him to the partnership (net of liabilities that the partnership is considered to assume or take subject to), and (3) allocations to him of partnership income and gain (or items thereof), including income and gain exempt from tax and income and gain described in paragraph (b)(2)(iv)(g) of this section, but excluding income and gain described in paragraph (b)(4)(i) of this section; and is decreased by (4) the amount of money distributed to him by the partnership, (5) the fair market value of property distributed to him by the partnership (net of liabilities that such partner is considered to assume or take subject to), (6) allocations to him of expenditures of the partnership described in section 705 (a)(2)(B), and (7) allocations of partnership loss and deduction (or item thereof), including loss and deduction described in paragraph (b)(2)(iv)(g) of this section, but excluding items described in (6) above and loss or deduction described in paragraphs (b)(4)(i) or (b)(4)(iii) of this section; and is otherwise adjusted in accordance with the additional rules set forth in this paragraph (b)(2)(iv). For purposes of this paragraph, a partner who has more than one interest in a partnership shall have a single capital account that reflects all such interests, regardless of the class of interests owned by such partner (e.g., general or limited) and regardless of the time or manner in which such interests were acquired. + +## 26 U.S.C. §706(d)(1): a change in a partner's interest +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`usc706.html`): `d716b72177fb0f8e7ae0f5b98faf63d6e40c444f7e9368443060042b761fd53c` + +> (d) Determination of distributive share when partner’s interest changes (1) In general Except as provided in paragraphs (2) and (3), if during any taxable year of the partnership there is a change in any partner’s interest in the partnership, each partner’s distributive share of any item of income, gain, loss, deduction, or credit of the partnership for such taxable year shall be determined by the use of any method prescribed by the Secretary by regulations which takes into account the varying interests of the partners in the partnership during such taxable year. + +## Treas. Reg. §1.706-4(a): varying interests, segment by segment +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr17064.html`): `e7b07463190db6a610dee37660efefdd73be81b21efd81517dc714dcd72c4277` + +**§1.706-4(a)(1)** + +> (1) Variations subject to this section. Except as provided in paragraph (a)(2) of this section, this section provides rules for determining the partners' distributive shares of partnership items when a partner's interest in a partnership varies during the taxable year as a result of the disposition of a partial or entire interest in a partnership as described in § 1.706-1(c)(2) and (3), or with respect to a partner whose interest in a partnership is reduced as described in § 1.706-1(c)(3), including by the entry of a new partner (collectively, a “variation”). + +**§1.706-4(a)(2), its last sentence** + +> In all cases, all partnership items for each taxable year must be allocated among the partners, and no partnership items may be duplicated, regardless of the particular provision of section 706 (or other Code section) which applies, and regardless of the method or convention adopted by the partnership. + +**§1.706-4(a)(3)(vii), its first three sentences** + +> (vii) Seventh, apportion the partnership's items for the year among its segments. The partnership shall determine the items of income, gain, loss, deduction, and credit of the partnership for each segment. In general, a partnership shall treat each segment as though the segment were a separate distributive share period. + +## Partner's Instructions for Schedule K-1 (Form 1065) (2025) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`i1065sk1.html`): `d7d6298286601fd95ab8e445c10fd62499ea1e37f8e5c3531f8e1783862d8d03` + +**Item L, its first paragraph** (the partner's capital account, on the tax basis; the K-1 worksheet carries the book capital account, which the CPA converts) + +> The partnership must report your beginning capital account and ending capital account for the year using the tax-basis method, including the amount of capital you contributed to the partnership during the year, your share of the partnership’s current-year net income or loss as computed for tax purposes, any withdrawals and distributions made to you by the partnership, and any other increases or decreases to your capital account determined in a manner generally consistent with figuring the partner’s adjusted tax basis in its partnership interest (without regard to partnership liabilities), taking into account the rules and principles of sections 705, 722, 733, and 742. See the Instructions for Form 1065 for more details. + +**Basis Limitations, its third paragraph of five** (where "based on the partnership's books and records" comes from: not Item L) + +> Although the partnership provides an analysis of the partner’s capital account in item L of Schedule K-1, that information is based on the partnership’s books and records and can’t be used to figure the partner’s adjusted basis. diff --git a/docs/testing.md b/docs/testing.md index 23b5b25..7464c9a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -595,6 +595,111 @@ scratch (`ledger-store` `backup.rs` and `root.rs`, `ledger-core` `admin.rs`, `ledger-host` `commands.rs`, `ledger-cli`). The survivor lists are kept with the run's output, outside the repository. +## P3: odd cents (2026-10-20) + +Defect 17 (rules.md B-13; D-10, D-38), built without its mutation run +(D-33); what P12 must cover is listed at the end. Every expected figure is +worked by hand in the test's comment. + +**The named regression.** `the_audit_odd_cent_never_piles_up_across_closes` +(`ledger-core` `tests/odd_cent.rs`): audit F52's −399.23 at 50/50, +anonymized, with a close to retained earnings between two members closes. +The run rule left A −399.24 / B −399.22; the rule since P3 gives −399.23 +each (March: Dr 3000 199.61, Dr 3001 199.62). Its bookspec example is +`bookspec/examples/members-close.odd-cent-across-closes.toml`. + +**Core** (`ledger-core` `tests/odd_cent.rs`): a true-up from inception +(`members_closes_true_up_from_inception`); a close that posts nothing +(`a_close_that_posts_nothing_carries_no_true_up`) and one that posts only a +sweep (`a_true_up_rides_on_a_distribution_sweep`, P3 review R-3); a reorder +(`reordering_members_keeps_counting_from_the_first_close`); a share change +and a return to the old shares (`a_share_change_starts_a_new_count`); a +member joining mid-year (`a_member_joining_mid_year_starts_a_new_count`, +R-8); a journaled start other than the derived one +(`a_journaled_start_other_than_the_derived_one_is_refused`); whole yen and +thousandths of a dinar (`a_yen_book_closes_to_members_in_whole_yen`, +`a_dinar_book_closes_to_members_in_thousandths`); the statement on a date +before and after its period closes +(`a_members_capital_on_a_date_does_not_change_when_its_period_closes`); a +pending true-up that shows only once posted +(`a_pending_true_up_shows_only_once_posted`, R-1); three members at 33.33% +/ 33.33% / 33.34% over twelve months, the statement checked against the +same entries never closed on every day of the year and the K-1 worksheet +at every month end (`three_members_at_a_third_each_over_twelve_months`). +Added by the P3 review: a gold book splits to the finest unit a line +carries, not cents (`a_gold_book_splits_to_the_finest_unit_a_line_carries`); +the K-1 worksheet shares contributed property in whole yen +(`a_yen_books_k1_shares_property_in_whole_yen`, and `share_property`'s unit +test in `k1.rs`); a reorder of the members in the settings moves no cent on +a day before the count's first close, nor in "other" +(`a_reorder_moves_no_cent_before_the_counts_first_close`). Each fails on +the code before the review's fix. +Properties (512 cases each): `members_capital_never_drifts_a_cent_from_their_share` +(a legacy prefix under the three earlier rules, then months closed to the +members in either order, to retained earnings or not at all; a close that +posts nothing changes no capital, R-3), +`each_members_capital_chains_and_holds_through_closes`, +`period_ends_rise_with_close_order` (R-10.6). In `statements.rs`: +`a_different_close_ends_a_legacy_run` (the run rule, still replayed) and +`a_different_close_no_longer_restarts_the_count`, the run rule's arm of +`a_run_of_closes_never_piles_up_rounding`, and February in +`a_partnership_closes_into_members_capital` (Bob 683.33 → 683.34: his share +of February's unclosed income is what its close will give, D-38). Unit +tests in `kernel.rs` (`apportion_cumulative_refuses_mismatched_or_out_of_range_inputs`, +`apportion_cumulative_trues_up_a_legacy_split`) and `periods.rs` +(`allocate_cumulative_*`: whole yen, a dinar, a loss, a 28-place share +refused, R-5). Bridges (`tests/kernel_bridge.rs`): +`apportion_cumulative_agrees`, +`allocate_cumulative_agrees_with_the_integer_model`. + +**Store** (`ledger-store`): `members_closes_replay_under_the_rule_they_were_journaled_with` +(each rule; with a close to retained earnings between, the rule since P3 +ends 0.01 / 0.01 and the run rule 0.02 / 0.00); `tests/member_closes.rs`: +`the_first_cumulative_close_declares_its_feature`, +`an_unknown_rounding_is_newer_not_corrupt` (R-9), +`a_count_that_starts_later_journals_its_start` (a share change: since_seq +2 journaled and replayed; added by the P3 review), +`a_close_journaled_with_another_start_refuses_to_open`, +`the_first_close_after_the_upgrade_trues_up_the_old_rules`, on the new +old-books fixture `x_member_closes` (written by main 00226a4c's Store API: +three untagged closes, three `"largest_remainder"`, `"cumulative"` broken by +a close to retained earnings; it opens to main's figures in +`books_an_earlier_version_wrote_open_to_its_figures`, R-2); `format.rs` +`an_older_reader_stops_at_the_members_cumulative_feature`. + +**MCP and viewer**: `ledger-mcp` `close_period_shows_each_members_running_split` +(`tests/mcp_stdio.rs`: the preview's `members` is the confirmed one; after +a restart, `list_periods`' `close_rule` and the statement of members' +equity); `old_books_name_each_closes_rule_and_true_up_at_the_next_close` +(the old-books fixture `x_member_closes` through the server: every +`close_rule` an earlier rule gives, and December's true-up, the preview the +confirmed close); `ledger-gui` `a_members_page_shows_the_same_capital_before_and_after_a_close` +and `a_yen_members_page_splits_in_whole_yen` (what is owed to each member +and their property, in whole yen) (`tests/odd_cent.rs`). + +**Proofs**: docs/verification.md (the three P3 laws). + +**For P12** (nothing run here, D-33): `kernel::apportion_cumulative` (the +length and range guards; `checked_sub` for `checked_add`; the subtraction +skipped); `periods::allocate_cumulative` (the sign; `given` not negated for +a loss; `places` ignored; the exactness filter, zero products included); +`Ledger::members_regime` and `regime_where` (skip versus break at a close +to anything else; the order-free compare made ordered; the date filter +`<=` for `<`; `given` added to the wrong member; the count's order; the +next count's order as of a day before it begins); +`Regime::allocate` (the order); `Ledger::members_split` and +`close_period_to_steps` (the start check; the `posts` gate; the recorded +rule; the sweeps before the parts); `member_close_run` (it reads only +run-rule records); `member_close_split`; `reports::members_equity` +(`allocate(C)` replaced by `given`, R-1; `places`; "other"'s order); +`k1::share_property` and the viewer's `owed_to_member` (`places`); the store's +`close_to_from_journal` and `close_period_to` (the tag mapping; `since_seq`'s +parse; a tag that isn't text; the feature declaration); +`format::declared_knowing`; the MCP's `close_period` and `list_periods` +fields; `Currency::allocation_places`. P3's accept counts `ledger-core` and +`ledger-import` (R-6); the store and MCP mutants above are for the whole +pass. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work @@ -638,12 +743,12 @@ accounting question), CHANGE (a source says otherwise; work pending). | 4 | Closing a period with nothing to close still locks it | CONVENTION | A close is a lock (B-12); a dormant month must still lock and count for close-in-order | | 5 | Draws and distributions are closed into capital at period end | CONFIRMED | OpenStax 5.1: dividends "are closed to the permanent account"; drawing accounts close to owner's capital. Monthly closes mean the year's K-1 Item L withdrawals sum across months | | 6 | Reconciliation balances | CONFIRMED | Signed by the account's normal side (a card reads as owed), as statements show it; `reconciling_a_card_reads_its_normal_side_...` | -| 7 | A member's ending on one equity statement may differ by a cent from the next statement's beginning | CHANGED 2026-10-13 | A capital account is one running balance (Form 1065 K-1 instructions, Item L). Undistributed earnings now split cumulatively as of each date, so each member's capital chains exactly (`each_members_capital_chains_across_statements`; it fails on the old code); rules.md B-13 | +| 7 | A member's ending on one equity statement may differ by a cent from the next statement's beginning | CHANGED 2026-10-13; CHANGED P3 | A capital account is one running balance (Treas. Reg. §1.704-1(b)(2)(iv)(b); the Item L quote this row once gave was misread, P3 review R-4). Undistributed earnings now split cumulatively as of each date, so each member's capital chains exactly (`each_members_capital_chains_across_statements`; it fails on the old code); since P3 the split is what the count's next close would give, so a date's capital never moves when its month is closed (D-38; `each_members_capital_chains_and_holds_through_closes`); rules.md B-13 | | 8 | Cash flow nets one line per account; investing comes only from registered assets | CHANGED 2026-10-13 | ASC 230-10-45-7: gross receipts and payments "more relevant" than net; 45-8 limits netting to quick-turnover items. Financing and investing are now gross; loans made and investments bought are investing (230-10-20, 45-13) via `[books] investing_accounts`. Owner-advanced expenses: PwC 6.9.3.1 constructive treatment via `[books] financing_accounts` (rules.md B-14) | | 9 | An asset's life ends at purchase + life months, that day not held; day-count proration | CONVENTION | Exactly N months of service; day proration and the mid-month convention are both accepted book policies (ASC 360-10-35-4, read 2026-10-14: any systematic and rational allocation) | | 10 | A payroll entry has one line per account total | CONFIRMED | The per-employee detail the IRS requires ("Amounts and dates of all wage ... payments", Employment Tax Recordkeeping) is kept in the run; a summary entry is standard | | 11 | A period with exactly the dates of an existing one is allowed | DESIGN | Nothing governs duplicate periods; harmless (the second close locks only). Refusing exact duplicates is an option | -| 12 | Each close into members' capital rounds on its own (ties to the earlier member) | CHANGED 2026-10-13 | Twelve monthly 50/50 closes could leave one member several cents ahead. A run of members closes now splits cumulatively (rules.md B-13; Lean `runPart_telescopes`; property `each_member_stays_within_a_cent_of_their_share` fails on the per-close rule); journaled `"cumulative"`, older closes replay as booked | +| 12 | Each close into members' capital rounds on its own (ties to the earlier member) | CHANGED 2026-10-13; CHANGED P3 | Twelve monthly 50/50 closes could leave one member several cents ahead. A run of members closes split cumulatively from 2026-10-13 (Lean `runPart_telescopes`; property `each_member_stays_within_a_cent_of_their_share` fails on the per-close rule), but any other close restarted the run. Since P3 a count of members closes runs from the company's first close (D-10), skipping closes to anything else and ignoring the members' order, so no member is ever a cent from their share (`the_audit_odd_cent_never_piles_up_across_closes` fails on the run rule; Kani `cumulative_close_never_drifts_a_unit_at_*`, Lean `allocW_cumulative_drift`); journaled `"cumulative_since"`, older closes replay as booked (rules.md B-13) | Questions are settled from the sources, not by any one company's CPA (wanderbooks is general software). Where the standards leave a real diff --git a/docs/verification.md b/docs/verification.md index 923a720..e29fb2a 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -142,10 +142,58 @@ sequence of commands, of any length**: of cumulative closes is their allocation of everything the run closed (so its rounding is one allocation's, never a sum of them), and each close hands out exactly its net, for any allocation whose parts sum to - the whole (rules.md B-13). + the whole (rules.md B-13). Since P3 this is the run rule closes + journaled `"cumulative"` replay under (`CloseTo::MembersRun`). - Lean `runPart_telescopes`, `closePart_total` · property `each_member_stays_within_a_cent_of_their_share` +The P3 odd cent (defect 17; rules.md B-13; D-10, D-38) adds three laws; +their numbers are assigned when P3 lands: + +- **A cumulative members close hands out exactly its net, and leaves each + member's running total one allocation of everything the count closed**, + whatever rule the count's earlier closes were made under, as long as + they handed out what they closed. The run rule is the special case where + they gave exactly the allocation. A refusal leaves the parts as they + were. + - Kani `apportion_cumulative_is_exact`, + `cumulative_closing_entry_balances` (the closing entry nets to zero + whatever the true-up) + - Lean `trueUp_total`, `trueUp_running`, `closePart_eq_trueUp`, + `Apportion.allocW_sum`, `Apportion.allocW_cumulative_total` + - properties `apportion_cumulative_agrees`, + `allocate_cumulative_agrees_with_the_integer_model` +- **Bounded drift: no member's running total is ever a minor unit from + their exact share.** With shares w_i / W (exact decimals) and every + amount a whole number of minor units (B-3), after every cumulative + close |(given_i + part_i)·W − T·w_i| < W, whatever earlier closes gave. + The precondition is the code's: `periods::allocate_cumulative` refuses a + product Decimal would round, so the remainders it ranks are exact, and + they sum to whole units (the Lean hypothesis of `rem_pos_of_rank_lt`). + - Kani `cumulative_close_never_drifts_a_unit_at_halves`, `_at_tenths` + and `_at_near_thirds` (two members at 50/50, three at 20/30/50%, three + at 33.33/33.33/33.34%; every running total, net and earlier split to + 2^12 units) + - Lean `Apportion.rem_pos_of_rank_lt` (a member given a unit had a + positive remainder), `Apportion.apportion_drift` (one apportionment + is within a unit), `Apportion.allocW_drift` (the model of + `allocate_cumulative`, gain or loss), `cumulative_drift` (any + allocation within a unit stays so under the true-up), + `Apportion.allocW_cumulative_drift` (the two together): any number of + members, any amounts + - property `members_capital_never_drifts_a_cent_from_their_share` +- **A member's capital on a day never moves when its period is closed.** + The statement of members' equity gives each member, of the earnings not + yet closed, what the count's next close would give them; closing moves + those earnings into capital without changing the sum. When the count's + earlier closes gave something else (closes under an earlier rule), the + figure moves by exactly the true-up the close posts, never before. + - Lean `statement_after_close`, `statement_unmoved_by_close` + - property `each_members_capital_chains_and_holds_through_closes`; + `three_members_at_a_third_each_over_twelve_months` (every day of a + year, closed and never closed), the viewer's + `a_members_page_shows_the_same_capital_before_and_after_a_close` + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, @@ -244,6 +292,31 @@ TINs kept out of the books (B-26), and the agent text lint. Out of scope for proof today: the format gate and the walk are I/O, and the model in `verify/lean` stops at the ledger. +### P3: the odd cent (2026-10-20) + +The cumulative members close (rules.md B-13) adds a kernel function, +`kernel::apportion_cumulative`, which Kani proves exact and refusal-safe +for up to three parts and checks against the integer model of +`periods::allocate_cumulative` for bounded drift; Lean proves the drift +bound and the true-up laws for any number of members and any amounts. Not +proved: the regime query (`Ledger::members_regime`: which closes count), +the Decimal exactness check in `allocate_cumulative`, and the journal's +`since_seq`. They are checked by the tests in `ledger-core` +`tests/odd_cent.rs`, `ledger-store` `tests/member_closes.rs` and the +old-books fixture `x_member_closes` (docs/testing.md, "P3: odd cents"). +Kani times, each harness alone on a busy laptop (Kani 0.68, 2026-10-20): +`apportion_cumulative_is_exact` 257 s, `cumulative_close_never_drifts_a_unit_at_halves` +17 s, `_at_tenths` 320 s, `_at_near_thirds` 449 s, +`cumulative_closing_entry_balances` 202 s (`apportion_is_exact`, for +scale: 121 s). Two shapes were too slow and were changed, not weakened in +what they prove: restating apportion's postconditions on the cumulative +parts directly ran past ten minutes, so `apportion_cumulative_is_exact` +proves the parts are apportion's less `given` on the same inputs (the +solver shares the two runs' formulas) and `apportion_is_exact` supplies +the rest; and the drift bound with symbolic weights ran past ten minutes, +so it is checked at three fixed sets of shares (the brief's fallback), +Lean carrying any weights. + ## Running it ```sh diff --git a/plan/served.md b/plan/served.md index 0997bf3..9851f8a 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1295,7 +1295,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form |---|---|---| | D-5 | Company EIN | (a) A journaled `company.ein` with sensitivity `identity`: printed on every 1099, masked in `books.start`, full in packages. | | D-9 | Attestation bar default | (a) Receipts and month-end: reasoning effort ≥ high. Annual and CPA handoff: adversarial verification with ≥ 2 independent verifier agents. No model allow-list. The CPA package refuses without a counted full audit unless given a reason (`policy.cpa_package_requires_audit = refuse`). | -| D-10 | Odd-cent cumulative apportionment start | (a) From inception, so the next close carries any historical true-up of a cent or two; lifetime drift stays under one cent. | +| D-10 | Odd-cent cumulative apportionment start | (a) From inception, so the next close carries any historical true-up of a cent or two; lifetime drift stays under one cent while members and shares are unchanged (a change of members or shares starts a new count, §706(d); P3 review R-8). | | D-11 | Default proration for new prepaid schedules | (a) Daily, matching depreciation; existing schedules stay `monthly_even`. | | D-12 | Legacy reversals already posted without their own evidence | (a) During the re-audit, link the original's receipts with `inherit_evidence`, so the audit is fully clean. | | D-13 | The first company timezone | (a) `company.timezone = America/Denver`. | diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index 886727f..5fc961c 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -21,6 +21,12 @@ open Wanderbooks #print axioms plugLast_keeps #print axioms runPart_telescopes #print axioms closePart_total +#print axioms trueUp_total +#print axioms trueUp_running +#print axioms closePart_eq_trueUp +#print axioms cumulative_drift +#print axioms statement_after_close +#print axioms statement_unmoved_by_close #print axioms Reachable.inv #print axioms Reachable.books_net_zero #print axioms Reachable.trial_balance @@ -35,5 +41,11 @@ open Wanderbooks #print axioms Apportion.ranks_perm #print axioms Apportion.apportion_sum #print axioms Apportion.apportion_sum_of_shortfall +#print axioms Apportion.rem_pos_of_rank_lt +#print axioms Apportion.apportion_drift +#print axioms Apportion.allocW_sum +#print axioms Apportion.allocW_drift +#print axioms Apportion.allocW_cumulative_drift +#print axioms Apportion.allocW_cumulative_total #print axioms Step.record_changes_no_figure #print axioms Reachable.records diff --git a/verify/lean/Wanderbooks/Apportion.lean b/verify/lean/Wanderbooks/Apportion.lean index f775096..2e7154c 100644 --- a/verify/lean/Wanderbooks/Apportion.lean +++ b/verify/lean/Wanderbooks/Apportion.lean @@ -1,3 +1,5 @@ +import Wanderbooks.Closing + /-! # Apportioning: the parts sum to the whole, for any number of parts @@ -238,4 +240,270 @@ theorem apportion_sum_of_shortfall (parts r : Nat → Int) (n : Nat) (unit whole rw [Int.mul_comm] at this exact this.symm +/-! ## Bounded drift + +With exact shares there is no sub-unit fraction: every amount is a whole +number of minor units (B-3) and every product `|T| × share` is exact +(`periods::allocate_cumulative` INVARIANT 4), so the remainders, scaled to +whole numbers below `m`, sum to whole units of `m`. Then a part that gets +a unit had a positive remainder, and every apportioned part is under one +unit from its exact share (rules.md B-13). -/ + +/-- Values in `[0, m)` that are zero wherever `p` fails sum to at most +`(m - 1)` per element where `p` holds. -/ +theorem sum_le_count_mul (r : Nat → Int) (p : Nat → Bool) (m : Int) : ∀ (l : List Nat), + (∀ j ∈ l, 0 ≤ r j ∧ r j < m) → (∀ j ∈ l, p j = false → r j = 0) → + (l.map r).sum ≤ ((l.filter p).length : Int) * (m - 1) + | [], _, _ => by simp + | a :: t, hr, hz => by + have ih := sum_le_count_mul r p m t (fun j hj => hr j (List.mem_cons_of_mem a hj)) + (fun j hj => hz j (List.mem_cons_of_mem a hj)) + have ha := hr a (List.mem_cons_self ..) + simp only [List.map_cons, List.sum_cons, List.filter_cons] + cases hp : p a + · have := hz a (List.mem_cons_self ..) hp + simp only [Bool.false_eq_true, ↓reduceIte] + omega + · simp only [↓reduceIte, List.length_cons] + have e : (((t.filter p).length + 1 : Nat) : Int) * (m - 1) + = ((t.filter p).length : Int) * (m - 1) + (m - 1) := by + rw [Int.natCast_add, Int.add_mul, Int.natCast_one, Int.one_mul] + rw [e] + omega + +/-- With remainders in `[0, m)` summing to exactly `u · m` (no sub-unit +fraction), every part ranked below `u` (so given a unit) has a positive +remainder. -/ +theorem rem_pos_of_rank_lt (r : Nat → Int) (n u : Nat) (m : Int) + (hr : ∀ i < n, 0 ≤ r i ∧ r i < m) + (hs : ((List.range n).map r).sum = u * m) + {i : Nat} (hi : i < n) (hk : rank r n i < u) : 0 < r i := by + apply Classical.byContradiction + intro hneg + have hri : r i = 0 := by have := hr i hi; omega + -- Every part not ahead of `i` has a remainder no larger than `i`'s: 0. + have hz : ∀ j ∈ List.range n, ahead r j i = false → r j = 0 := by + intro j hj hna + have hj' := List.mem_range.mp hj + have hrj := hr j hj' + by_cases e : j = i + · subst e + exact hri + · rcases ahead_total r e with h | h + · rw [h] at hna + exact absurd hna (by decide) + · simp only [ahead, Bool.or_eq_true, Bool.and_eq_true, decide_eq_true_eq] at h + omega + have hle := sum_le_count_mul r (fun j => ahead r j i) m (List.range n) + (fun j hj => hr j (List.mem_range.mp hj)) hz + have hm : 0 < m := by have := hr i hi; omega + have hrank : ((List.range n).filter (fun j => ahead r j i)).length = rank r n i := rfl + rw [hrank, hs] at hle + have h1 : (rank r n i : Int) * (m - 1) ≤ ((u : Int) - 1) * (m - 1) := + Int.mul_le_mul_of_nonneg_right (by omega) (by omega) + have h2 : ((u : Int) - 1) * (m - 1) = (u : Int) * m - u - m + 1 := by + simp only [Int.sub_mul, Int.mul_sub, Int.one_mul, Int.mul_one] + omega + omega + +/-- **Bounded drift of one apportionment** (rules.md B-13): scaled by `m`, +each apportioned part (rounded down, plus its unit if any) is within one +unit of its exact share `parts i + r i / m`, when there is no sub-unit +fraction. -/ +theorem apportion_drift (parts r : Nat → Int) (n u : Nat) (m : Int) + (hr : ∀ i < n, 0 ≤ r i ∧ r i < m) + (hs : ((List.range n).map r).sum = u * m) {i : Nat} (hi : i < n) : + -m < (parts i + add r n u 1 0 i) * m - (parts i * m + r i) ∧ + (parts i + add r n u 1 0 i) * m - (parts i * m + r i) < m := by + have hri := hr i hi + unfold add + rw [ite_self, Int.add_zero] + by_cases hk : rank r n i < u + · have hpos := rem_pos_of_rank_lt r n u m hr hs hi hk + simp only [hk, ↓reduceIte] + rw [Int.add_mul, Int.one_mul] + omega + · simp only [hk, ↓reduceIte, Int.add_zero] + omega + +/-- Σ c · f = c · Σ f. -/ +theorem sum_map_mul_left (c : Int) (f : Nat → Int) : ∀ (l : List Nat), + (l.map (fun j => c * f j)).sum = c * (l.map f).sum + | [] => by simp + | a :: t => by simp [sum_map_mul_left c f t, Int.mul_add] + +/-- Σ -f = -Σ f. -/ +theorem sum_map_neg (f : Nat → Int) : ∀ (l : List Nat), + (l.map (fun j => -f j)).sum = -(l.map f).sum + | [] => by simp + | a :: t => by simp [sum_map_neg f t]; omega + +/-- Values at most `c` sum to at most `c` per element. -/ +theorem sum_le_length_mul (f : Nat → Int) (c : Int) : ∀ (l : List Nat), + (∀ j ∈ l, f j ≤ c) → (l.map f).sum ≤ (l.length : Int) * c + | [], _ => by simp + | a :: t, h => by + have ih := sum_le_length_mul f c t (fun j hj => h j (List.mem_cons_of_mem a hj)) + have ha := h a (List.mem_cons_self ..) + simp only [List.map_cons, List.sum_cons, List.length_cons] + rw [Int.natCast_add, Int.add_mul, Int.natCast_one, Int.one_mul] + omega + +/-- Nonnegative values have a nonnegative sum. -/ +theorem sum_nonneg (f : Nat → Int) : ∀ (l : List Nat), (∀ j ∈ l, 0 ≤ f j) → 0 ≤ (l.map f).sum + | [], _ => by simp + | a :: t, h => by + have ih := sum_nonneg f t (fun j hj => h j (List.mem_cons_of_mem a hj)) + have ha := h a (List.mem_cons_self ..) + simp only [List.map_cons, List.sum_cons] + omega + +/-- Member `i`'s share of `x` rounded down to the unit: `x · w i / W` +(`periods::allocate_cumulative`'s floor; shares `w i / W`). -/ +def floorW (w : Nat → Int) (W x : Int) (i : Nat) : Int := x * w i / W + +/-- What rounding down took from it, scaled by `W`: `x · w i % W` (it +ranks the members as the Decimal remainder does). -/ +def remW (w : Nat → Int) (W x : Int) (i : Nat) : Int := x * w i % W + +/-- The whole units the floors fall short of `x`. -/ +def shortW (w : Nat → Int) (W : Int) (n : Nat) (x : Int) : Nat := + (x - ((List.range n).map (floorW w W x)).sum).toNat + +/-- The model of `periods::allocate_cumulative`'s split of a gain (`x ≥ +0`) at whole minor units: each floor, plus a unit to each of the members +with the largest remainders (ties to the earlier member). -/ +def allocNat (w : Nat → Int) (W : Int) (n : Nat) (x : Int) (i : Nat) : Int := + floorW w W x i + add (remW w W x) n (shortW w W n x) 1 0 i + +/-- With the sign: a loss splits as the negation of the same gain +(`allocate_cumulative` scenario 3). -/ +def allocW (w : Nat → Int) (W : Int) (n : Nat) (x : Int) (i : Nat) : Int := + if x < 0 then -allocNat w W n (-x) i else allocNat w W n x i + +/-- The floors and remainders of a gain, with shares `w i / W` summing to +one: the remainders sum to whole units, `W · (x − Σ floors)`, each in +`[0, W)`, and the floors fall short by fewer units than there are +members. -/ +theorem remW_facts (w : Nat → Int) (W : Int) (n : Nat) (x : Int) (hW : 0 < W) + (hsum : ((List.range n).map w).sum = W) (hn : 0 < n) : + (∀ i < n, 0 ≤ remW w W x i ∧ remW w W x i < W) ∧ + ((List.range n).map (remW w W x)).sum = (shortW w W n x : Int) * W ∧ + x - ((List.range n).map (floorW w W x)).sum = (shortW w W n x : Int) ∧ + shortW w W n x ≤ n := by + have hW0 : W ≠ 0 := by omega + have bounds : ∀ i < n, 0 ≤ remW w W x i ∧ remW w W x i < W := fun i _ => + ⟨Int.emod_nonneg _ hW0, Int.emod_lt_of_pos _ hW⟩ + -- x · w i = W · floor + rem, summed: x · W = W · Σ floors + Σ rems. + have each : ∀ i, x * w i = W * floorW w W x i + remW w W x i := fun i => + (Int.mul_ediv_add_emod (x * w i) W).symm + have total : x * W = W * ((List.range n).map (floorW w W x)).sum + + ((List.range n).map (remW w W x)).sum := by + have h1 : ((List.range n).map (fun j => x * w j)).sum = x * W := by + rw [sum_map_mul_left, hsum] + have h2 : ((List.range n).map (fun j => x * w j)).sum + = ((List.range n).map (fun j => W * floorW w W x j + remW w W x j)).sum := by + congr 1 + apply List.map_congr_left + intro i _ + exact each i + rw [← h1, h2, sum_map_add (fun j => W * floorW w W x j) (remW w W x), sum_map_mul_left] + let d := x - ((List.range n).map (floorW w W x)).sum + have hd : ((List.range n).map (remW w W x)).sum = W * d := by + show _ = W * (x - _) + rw [Int.mul_sub, Int.mul_comm W x] + omega + have rems_nonneg := sum_nonneg (remW w W x) (List.range n) + (fun j hj => (bounds j (List.mem_range.mp hj)).1) + have rems_lt := sum_le_length_mul (remW w W x) (W - 1) (List.range n) + (fun j hj => by have := (bounds j (List.mem_range.mp hj)).2; omega) + rw [List.length_range] at rems_lt + have d_nonneg : 0 ≤ d := by + apply Classical.byContradiction + intro h + have : W * d < 0 := Int.mul_neg_of_pos_of_neg hW (by omega) + omega + have d_lt : d < n := by + apply Classical.byContradiction + intro h + have h1 : (n : Int) * W ≤ d * W := Int.mul_le_mul_of_nonneg_right (by omega) (by omega) + have h2 : (n : Int) * (W - 1) = n * W - n := by + rw [Int.mul_sub, Int.mul_one] + rw [Int.mul_comm W d] at hd + omega + have hshort : (shortW w W n x : Int) = d := Int.toNat_of_nonneg d_nonneg + refine ⟨bounds, ?_, hshort.symm, ?_⟩ + · rw [hshort, hd, Int.mul_comm] + · have : ((shortW w W n x : Nat) : Int) < n := by rw [hshort]; exact d_lt + omega + +/-- **The split of a gain sums to it**, exactly. -/ +theorem allocNat_sum (w : Nat → Int) (W : Int) (n : Nat) (x : Int) (hW : 0 < W) + (hsum : ((List.range n).map w).sum = W) (hn : 0 < n) : + ((List.range n).map (allocNat w W n x)).sum = x := by + obtain ⟨_, _, hx, hu⟩ := remW_facts w W n x hW hsum hn + apply apportion_sum (floorW w W x) (remW w W x) n (shortW w W n x) 1 0 x hn hu + rw [hx] + omega + +/-- **`allocate_cumulative`'s split sums to the amount**, gain or loss. -/ +theorem allocW_sum (w : Nat → Int) (W : Int) (n : Nat) (x : Int) (hW : 0 < W) + (hsum : ((List.range n).map w).sum = W) (hn : 0 < n) : + ((List.range n).map (allocW w W n x)).sum = x := by + unfold allocW + by_cases hx : x < 0 + · simp only [hx, ↓reduceIte] + rw [sum_map_neg, allocNat_sum w W n (-x) hW hsum hn] + omega + · simp only [hx, ↓reduceIte] + exact allocNat_sum w W n x hW hsum hn + +/-- **Bounded drift** (rules.md B-13): each member's part of an amount is +under one minor unit from their exact share, `|allocW x i · W − x · w i| < +W`, gain or loss. -/ +theorem allocW_drift (w : Nat → Int) (W : Int) (n : Nat) (x : Int) (hW : 0 < W) + (hsum : ((List.range n).map w).sum = W) (hn : 0 < n) {i : Nat} (hi : i < n) : + -W < allocW w W n x i * W - x * w i ∧ allocW w W n x i * W - x * w i < W := by + have gain : ∀ y, 0 ≤ y → + -W < allocNat w W n y i * W - y * w i ∧ allocNat w W n y i * W - y * w i < W := by + intro y _ + obtain ⟨hr, hs, _, _⟩ := remW_facts w W n y hW hsum hn + have h := apportion_drift (floorW w W y) (remW w W y) n (shortW w W n y) W hr hs hi + have e : floorW w W y i * W + remW w W y i = y * w i := by + rw [Int.mul_comm] + exact Int.mul_ediv_add_emod (y * w i) W + rw [e] at h + exact h + unfold allocW + by_cases hx : x < 0 + · simp only [hx, ↓reduceIte] + have h := gain (-x) (by omega) + simp only [Int.neg_mul] at h ⊢ + omega + · simp only [hx, ↓reduceIte] + exact gain x (by omega) + +/-- **No member's running total is ever a minor unit from their exact +share**, after a cumulative close, whatever earlier closes gave them +(rules.md B-13; D-10): `Closing.trueUp` with `allocate_cumulative`'s +split (Kani `cumulative_close_never_drifts_a_unit_at_*` check the code on +small amounts). -/ +theorem allocW_cumulative_drift (w : Nat → Int) (W : Int) (n : Nat) (hW : 0 < W) + (hsum : ((List.range n).map w).sum = W) (hn : 0 < n) (closed net : Int) + (given : Nat → Int) {i : Nat} (hi : i < n) : + -W < (given i + trueUp (allocW w W n) closed net given i) * W - (closed + net) * w i ∧ + (given i + trueUp (allocW w W n) closed net given i) * W - (closed + net) * w i < W := by + rw [trueUp_running] + exact allocW_drift w W n (closed + net) hW hsum hn hi + +/-- **A cumulative close hands out exactly its net** with +`allocate_cumulative`'s split, gain or loss, whatever the earlier closes +gave, as long as they gave what they closed. -/ +theorem allocW_cumulative_total (w : Nat → Int) (W : Int) (n : Nat) (hW : 0 < W) + (hsum : ((List.range n).map w).sum = W) (hn : 0 < n) (closed net : Int) + (given : Nat → Int) (hg : ((List.range n).map given).sum = closed) : + ((List.range n).map (trueUp (allocW w W n) closed net given)).sum = net := + trueUp_total (allocW w W n) (List.range n) (fun x => allocW_sum w W n x hW hsum hn) + closed net given hg + end Wanderbooks.Apportion diff --git a/verify/lean/Wanderbooks/Closing.lean b/verify/lean/Wanderbooks/Closing.lean index a6bda74..cecaef0 100644 --- a/verify/lean/Wanderbooks/Closing.lean +++ b/verify/lean/Wanderbooks/Closing.lean @@ -319,7 +319,8 @@ theorem plugLast_keeps (whole : Int) (ps : List Int) : /-! ## Cumulative member closes -A run of members closes (`CloseTo::Members`, rules.md B-13) gives each +A run of members closes under the run rule of 2026-10-13 to P3 +(`CloseTo::MembersRun`, journaled `"cumulative"`; rules.md B-13) gives each member, at each close, their allocation of the run's total so far minus their allocation of the total before it. `alloc` is any allocation of an amount to members (member `i` gets `alloc x i`), such as @@ -367,4 +368,84 @@ theorem closePart_total (alloc : Int → Nat → Int) (members : List Nat) rw [h, sums, sums] omega +/-! ## Members closes counted from a count's start (P3) + +The rule since P3 (`CloseTo::Members`, rules.md B-13; D-10): a close gives +member `i` their allocation of everything the count has closed (`closed` +before it, plus its `net`), less what the count's earlier closes gave them +(`given i`), whatever rule those closes were made under +(`periods::allocate_cumulative`, `kernel::apportion_cumulative`). The run +rule of 2026-10-13 (`CloseTo::MembersRun`, `closePart`) is the special case +where the earlier closes gave exactly the allocation. -/ + +/-- What a cumulative close gives member `i`. -/ +def trueUp (alloc : Int → Nat → Int) (closed net : Int) (given : Nat → Int) (i : Nat) : Int := + alloc (closed + net) i - given i + +/-- **A cumulative close hands out exactly its net**, whatever rule the +earlier closes used, as long as they handed out what they closed. -/ +theorem trueUp_total (alloc : Int → Nat → Int) (members : List Nat) + (sums : ∀ x, (members.map (alloc x)).sum = x) (closed net : Int) (given : Nat → Int) + (hg : (members.map given).sum = closed) : + (members.map (trueUp alloc closed net given)).sum = net := by + have h := sum_map_sub (alloc (closed + net)) given members + unfold trueUp + rw [h, sums, hg] + omega + +/-- **A running total is one allocation**: after the close, what the count +has given member `i` is their allocation of everything it closed, so its +rounding is one allocation's, never a sum of them. -/ +theorem trueUp_running (alloc : Int → Nat → Int) (closed net : Int) (given : Nat → Int) + (i : Nat) : given i + trueUp alloc closed net given i = alloc (closed + net) i := by + unfold trueUp + omega + +/-- The run rule is the special case `given = alloc closed`. -/ +theorem closePart_eq_trueUp (alloc : Int → Nat → Int) (before net : Int) (i : Nat) : + closePart alloc before net i = trueUp alloc before net (alloc before) i := rfl + +/-- **Bounded drift, for any history**: when every allocation is under one +unit from exact (scaled by `W`, the shares being `w i / W`), so is every +member's running total after a cumulative close, whatever the earlier +closes gave (`Apportion.allocW_drift` supplies the hypothesis). -/ +theorem cumulative_drift (alloc : Int → Nat → Int) (w : Nat → Int) (W : Int) + (hd : ∀ x i, -W < alloc x i * W - x * w i ∧ alloc x i * W - x * w i < W) + (closed net : Int) (given : Nat → Int) (i : Nat) : + -W < (given i + trueUp alloc closed net given i) * W - (closed + net) * w i ∧ + (given i + trueUp alloc closed net given i) * W - (closed + net) * w i < W := by + rw [trueUp_running] + exact hd _ _ + +/-- The statement of members' equity's share of the earnings `U` not yet +closed, as of a day: what the count's next close would give of them, its +allocation of `closed + U` less its allocation of `closed` (D-38; P3 review +R-1: never the true-up a close hasn't posted). -/ +def unclosedShare (alloc : Int → Nat → Int) (closed U : Int) (i : Nat) : Int := + alloc (closed + U) i - alloc closed i + +/-- **Closing a period moves a member's capital on a day by exactly the +true-up the close posts** (D-38, R-1): before, their capital `cap` plus +their share of the unclosed `U`; after closing `net` of it, `cap` plus the +close's part plus their share of the rest. The difference is +`alloc closed i - given i`. -/ +theorem statement_after_close (alloc : Int → Nat → Int) (cap closed net U : Int) + (given : Nat → Int) (i : Nat) : + cap + trueUp alloc closed net given i + unclosedShare alloc (closed + net) (U - net) i + = cap + unclosedShare alloc closed U i + (alloc closed i - given i) := by + unfold trueUp unclosedShare + rw [show closed + net + (U - net) = closed + U by omega] + omega + +/-- **A member's capital on a day never moves when its period is closed** +(D-38), once the count's closes gave what the cumulative rule gives +(`given = alloc closed`: every count the rule began, and every count after +its first close that posts). -/ +theorem statement_unmoved_by_close (alloc : Int → Nat → Int) (cap closed net U : Int) + (i : Nat) : + cap + trueUp alloc closed net (alloc closed) i + unclosedShare alloc (closed + net) (U - net) i + = cap + unclosedShare alloc closed U i := by + rw [statement_after_close] + omega + end Wanderbooks