P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
44 files changed
+6636
-195
No files matched your search
Generated
+1
@@ -3866,6 +3866,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"toml 1.1.8+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -22,6 +22,8 @@ workspace = true
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = "0.8.2"
|
criterion = "0.8.2"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
# bookspec examples are TOML (tests/bookspec.rs).
|
||||||
|
toml = "1"
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "books"
|
name = "books"
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
//! with the MacBook read as cash.)
|
//! with the MacBook read as cash.)
|
||||||
//! 4. Within one entry, the property (at most the members' credits in
|
//! 4. Within one entry, the property (at most the members' credits in
|
||||||
//! it) is shared among the members credited in proportion to their
|
//! 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
|
//! (kernel::apportion, ties to the earlier member): each takes at
|
||||||
//! most its own credit, and the split depends on the order the
|
//! most its own credit, and the split depends on the order the
|
||||||
//! members are given only for a tied leftover cent (never on line
|
//! 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 me = members_equity(ledger, start, end, members)?;
|
||||||
|
let places = ledger.base_currency().allocation_places();
|
||||||
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
||||||
let closings = ledger.closing_entry_ids();
|
let closings = ledger.closing_entry_ids();
|
||||||
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||||
@@ -219,7 +221,7 @@ pub fn k1_detail(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|m| sum(e, Side::Credit, m.capital_account))
|
.map(|m| sum(e, Side::Credit, m.capital_account))
|
||||||
.collect();
|
.collect();
|
||||||
let takes = share_property(pool, &credits)?;
|
let takes = share_property(pool, &credits, places)?;
|
||||||
for (i, m) in members.iter().enumerate() {
|
for (i, m) in members.iter().enumerate() {
|
||||||
let (credit, take) = (credits[i], takes[i]);
|
let (credit, take) = (credits[i], takes[i]);
|
||||||
if !(credit - take).is_zero() {
|
if !(credit - take).is_zero() {
|
||||||
@@ -274,11 +276,16 @@ pub fn k1_detail(
|
|||||||
|
|
||||||
/// One entry's property shared among the members' credits in it
|
/// One entry's property shared among the members' credits in it
|
||||||
/// (INVARIANT 4): at most their total credit, in proportion to each
|
/// (INVARIANT 4): at most their total credit, in proportion to each
|
||||||
/// credit, rounded toward zero to the cent, the leftover cents by largest
|
/// credit, rounded toward zero to `places` (the base currency's
|
||||||
/// remainder. Each part is at most its own credit.
|
/// 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
|
/// Refused when whole × credit is beyond exact arithmetic (credits near
|
||||||
/// the line limit; P0 review round 3, S1).
|
/// the line limit; P0 review round 3, S1).
|
||||||
fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
|
fn share_property(
|
||||||
|
pool: Decimal,
|
||||||
|
credits: &[Decimal],
|
||||||
|
places: u32,
|
||||||
|
) -> Result<Vec<Decimal>, LedgerError> {
|
||||||
use rust_decimal::RoundingStrategy::ToZero;
|
use rust_decimal::RoundingStrategy::ToZero;
|
||||||
let too_large =
|
let too_large =
|
||||||
|| LedgerError::TooLargeToCompute("a member's share of contributed property".into());
|
|| LedgerError::TooLargeToCompute("a member's share of contributed property".into());
|
||||||
@@ -291,7 +298,7 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, Le
|
|||||||
return Ok(vec![Decimal::ZERO; credits.len()]);
|
return Ok(vec![Decimal::ZERO; credits.len()]);
|
||||||
}
|
}
|
||||||
// whole × credit is exact; ÷ total is exact whenever the share is a
|
// 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<Decimal> = credits
|
let exact: Vec<Decimal> = credits
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| whole.checked_mul(*c).and_then(|x| x.checked_div(total)))
|
.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<Vec<Decimal>, Le
|
|||||||
.ok_or_else(too_large)?;
|
.ok_or_else(too_large)?;
|
||||||
let mut parts: Vec<Decimal> = exact
|
let mut parts: Vec<Decimal> = exact
|
||||||
.iter()
|
.iter()
|
||||||
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
.map(|x| x.round_dp_with_strategy(places, ToZero))
|
||||||
.collect();
|
.collect();
|
||||||
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
|
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
|
||||||
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2))
|
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, places))
|
||||||
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
|
.expect("each part is short by under a unit, so the shortfall is under a unit per member");
|
||||||
Ok(parts)
|
Ok(parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,20 +847,20 @@ mod tests {
|
|||||||
flipped.reverse();
|
flipped.reverse();
|
||||||
assert_eq!(split(&bk, &flipped), vec![each, each]);
|
assert_eq!(split(&bk, &flipped), vec![each, each]);
|
||||||
assert_eq!(
|
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")]
|
vec![d("300.75"), d("100.25")]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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")]
|
vec![d("0.01"), d("0.00")]
|
||||||
);
|
);
|
||||||
// More property than credit (part financed): at most the credits.
|
// More property than credit (part financed): at most the credits.
|
||||||
assert_eq!(
|
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")]
|
vec![d("300"), d("100")]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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")]
|
vec![d("0"), d("0")]
|
||||||
);
|
);
|
||||||
// Credits near the line limit: whole x credit is past exact
|
// 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.
|
// round 3, S1); at a quarter of it, it still splits exactly.
|
||||||
let near = d("900000000000000");
|
let near = d("900000000000000");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
share_property(near, &[near, near]),
|
share_property(near, &[near, near], 2),
|
||||||
Err(LedgerError::TooLargeToCompute(_))
|
Err(LedgerError::TooLargeToCompute(_))
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
share_property(near, &[Decimal::MAX, Decimal::MAX]),
|
share_property(near, &[Decimal::MAX, Decimal::MAX], 2),
|
||||||
Err(LedgerError::TooLargeToCompute(_))
|
Err(LedgerError::TooLargeToCompute(_))
|
||||||
));
|
));
|
||||||
let quarter = d("250000000000");
|
let quarter = d("250000000000");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
share_property(quarter, &[quarter, quarter]).unwrap(),
|
share_property(quarter, &[quarter, quarter], 2).unwrap(),
|
||||||
vec![d("125000000000"), d("125000000000")]
|
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.
|
/// member_party doc: the party of that name with the member role only.
|
||||||
|
|||||||
@@ -26,7 +26,10 @@
|
|||||||
//! columns differ by the net of all movements (B-6);
|
//! columns differ by the net of all movements (B-6);
|
||||||
//! * a closing line zeroes the account it closes (B-11);
|
//! * a closing line zeroes the account it closes (B-11);
|
||||||
//! * an allocation's parts sum to the whole, to the last unit, by largest
|
//! * 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 crate::{AccountType, Side};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -352,6 +355,60 @@ pub fn apportion<T: Amount>(whole: T, parts: &mut [T], remainders: &[T], unit: T
|
|||||||
Some(())
|
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<T: Amount>(
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -423,6 +480,51 @@ mod tests {
|
|||||||
assert_eq!(apportion(0, &mut none, &[], -1), None);
|
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.
|
/// With no parts, only a zero whole is fully apportioned.
|
||||||
#[test]
|
#[test]
|
||||||
fn apportion_to_no_parts_accepts_only_zero() {
|
fn apportion_to_no_parts_accepts_only_zero() {
|
||||||
|
|||||||
@@ -2127,6 +2127,7 @@ impl Ledger {
|
|||||||
closing_entry: None,
|
closing_entry: None,
|
||||||
close_seq: None,
|
close_seq: None,
|
||||||
member_close: None,
|
member_close: None,
|
||||||
|
members_closed: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(id)
|
Ok(id)
|
||||||
@@ -2173,9 +2174,17 @@ impl Ledger {
|
|||||||
/// - `RetainedEarnings`: account `3999`, created if missing.
|
/// - `RetainedEarnings`: account `3999`, created if missing.
|
||||||
/// - `Account`: one equity account (a sole proprietor's capital).
|
/// - `Account`: one equity account (a sole proprietor's capital).
|
||||||
/// - `Members`: each member's capital account gets their share of
|
/// - `Members`: each member's capital account gets their share of
|
||||||
/// the net, to the cent (odd cents by largest remainder; closes
|
/// everything closed to these members and shares so far, to the
|
||||||
/// journaled before 2026-10-13 replay with the last member
|
/// currency's minor unit (odd units by largest remainder, ties to
|
||||||
/// absorbing them), and their own distribution account.
|
/// 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.
|
/// 3. Marks the period closed.
|
||||||
///
|
///
|
||||||
/// Returns the closing entry's id, or `None` if there was nothing to
|
/// 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.
|
/// balance as of the period's last day.
|
||||||
/// 2. The closing entry MUST balance, be dated the period's last day, and
|
/// 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
|
/// 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
|
/// 3. Entries dated after the period MUST stay untouched (their income
|
||||||
/// belongs to their own period).
|
/// belongs to their own period).
|
||||||
/// 4. A sweep account (draws, distributions, dividends) MUST end at zero,
|
/// 4. A sweep account (draws, distributions, dividends) MUST end at zero,
|
||||||
/// its balance moved into its target.
|
/// its balance moved into its target.
|
||||||
/// 5. NEVER post a closing entry when there is nothing to close.
|
/// 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:
|
/// SCENARIOS:
|
||||||
/// 1. IF the period is unknown or already closed: SHOULD refuse.
|
/// 1. IF the period is unknown or already closed: SHOULD refuse.
|
||||||
/// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod).
|
/// 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.
|
/// member shares aren't positive and summing to 1: SHOULD refuse.
|
||||||
/// 5. IF closing to retained earnings and 3999 exists but isn't a
|
/// 5. IF closing to retained earnings and 3999 exists but isn't a
|
||||||
/// postable equity account: SHOULD refuse (ControlAccountMismatch).
|
/// 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(
|
pub fn close_period_to(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -2223,11 +2247,13 @@ impl Ledger {
|
|||||||
self.preview_close_period_to(id, to)?;
|
self.preview_close_period_to(id, to)?;
|
||||||
let period = self.periods.get(&id).expect("previewed").clone();
|
let period = self.periods.get(&id).expect("previewed").clone();
|
||||||
let (closings, net) = self.income_closings(period.end);
|
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
|
// (account, amount to credit; negative debits): the net to its
|
||||||
// targets, and each sweep account's balance into its target.
|
// targets, and each sweep account's balance into its target.
|
||||||
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
|
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
|
||||||
let mut sweep_lines: Vec<(AccountId, 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| {
|
let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
|
||||||
// An equity balance (credit-normal) is −movement; zeroing it
|
// An equity balance (credit-normal) is −movement; zeroing it
|
||||||
// moves exactly that much into the target.
|
// moves exactly that much into the target.
|
||||||
@@ -2259,30 +2285,27 @@ impl Ledger {
|
|||||||
sweep(*a, *into, self);
|
sweep(*a, *into, self);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
periods::CloseTo::Members(members)
|
_ => {
|
||||||
| periods::CloseTo::MembersPerClose(members)
|
let members = to.members().expect("every other target is members");
|
||||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
// The sweeps first: whether an entry posts decides whether
|
||||||
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
// the parts may carry a true-up. Their lines still follow
|
||||||
let parts = match to {
|
// the parts in the entry.
|
||||||
periods::CloseTo::MembersLastAbsorbs(_) => {
|
for m in members {
|
||||||
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));
|
|
||||||
if let Some(d) = m.distributions {
|
if let Some(d) = m.distributions {
|
||||||
sweep(d, m.capital, self);
|
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)
|
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");
|
let p = self.periods.get_mut(&id).expect("period exists");
|
||||||
p.status = PeriodStatus::Closed;
|
p.status = PeriodStatus::Closed;
|
||||||
p.closing_entry = closing_entry;
|
p.closing_entry = closing_entry;
|
||||||
p.close_seq = Some(close_seq);
|
p.close_seq = Some(close_seq);
|
||||||
p.member_close = match to {
|
p.member_close = match to {
|
||||||
periods::CloseTo::Members(members) => Some(periods::MemberCloseRecord {
|
periods::CloseTo::MembersRun(members) => Some(periods::MemberCloseRecord {
|
||||||
members: member_key(members),
|
members: member_key(members),
|
||||||
net,
|
net,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
p.members_closed = members_closed;
|
||||||
Ok(closing_entry)
|
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<Decimal>, periods::MemberRule), LedgerError> {
|
||||||
|
use periods::{CloseTo, MemberRule};
|
||||||
|
let members = to.members().expect("a members close");
|
||||||
|
let shares: Vec<Decimal> = 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<NaiveDate>,
|
||||||
|
) -> 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<periods::MemberSplit>)> {
|
||||||
|
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
|
/// The net closed by the run of cumulative members closes, with exactly
|
||||||
/// these members and shares, that ends with the latest close: zero if
|
/// these members and shares, that ends with the latest close: zero if
|
||||||
/// the latest close was anything else (or there is none).
|
/// the latest close was anything else (or there is none).
|
||||||
@@ -2408,9 +2606,13 @@ impl Ledger {
|
|||||||
accounts.push(*into);
|
accounts.push(*into);
|
||||||
accounts.extend(sweep);
|
accounts.extend(sweep);
|
||||||
}
|
}
|
||||||
periods::CloseTo::Members(members)
|
_ => {
|
||||||
| periods::CloseTo::MembersPerClose(members)
|
let members = to.members().expect("every other target is members");
|
||||||
| periods::CloseTo::MembersLastAbsorbs(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();
|
let total: Decimal = members.iter().map(|m| m.share).sum();
|
||||||
if members.is_empty()
|
if members.is_empty()
|
||||||
|| total != Decimal::ONE
|
|| total != Decimal::ONE
|
||||||
@@ -2467,7 +2669,9 @@ impl Ledger {
|
|||||||
/// [`Self::close_period_to`] would succeed — the period exists and is
|
/// [`Self::close_period_to`] would succeed — the period exists and is
|
||||||
/// open, no draft entries are dated inside it, no later period 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
|
/// 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
|
/// or `None` when the close posts no entry. Stores call this before
|
||||||
/// journaling so a failed close leaves no journal line behind.
|
/// journaling so a failed close leaves no journal line behind.
|
||||||
pub fn preview_close_period_to(
|
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
|
// An entry posts exactly when there is income to close or a sweep
|
||||||
// account with a balance. Account creation (Retained Earnings) does
|
// account with a balance. Account creation (Retained Earnings) does
|
||||||
// not consume entry ids.
|
// not consume entry ids.
|
||||||
let (closings, _) = self.income_closings(period.end);
|
let (closings, net) = self.income_closings(period.end);
|
||||||
let sweeps: Vec<AccountId> = match to {
|
let sweeps: Vec<AccountId> = match to {
|
||||||
periods::CloseTo::RetainedEarnings { sweep }
|
periods::CloseTo::RetainedEarnings { sweep }
|
||||||
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||||||
periods::CloseTo::Members(members)
|
_ => to
|
||||||
| periods::CloseTo::MembersPerClose(members)
|
.members()
|
||||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
.expect("every other target is members")
|
||||||
members.iter().filter_map(|m| m.distributions).collect()
|
.iter()
|
||||||
}
|
.filter_map(|m| m.distributions)
|
||||||
|
.collect(),
|
||||||
};
|
};
|
||||||
let any = !closings.is_empty()
|
let any = !closings.is_empty()
|
||||||
|| sweeps
|
|| sweeps
|
||||||
.iter()
|
.iter()
|
||||||
.any(|a| !self.movement_through(*a, period.end).is_zero());
|
.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))
|
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
|
/// 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)> {
|
fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> {
|
||||||
members.iter().map(|m| (m.capital, m.share)).collect()
|
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<u64>,
|
||||||
|
/// 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<Decimal>,
|
||||||
|
/// 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<Vec<Decimal>, LedgerError> {
|
||||||
|
let order = if self.order.is_empty() {
|
||||||
|
members
|
||||||
|
} else {
|
||||||
|
&self.order
|
||||||
|
};
|
||||||
|
let index: Vec<usize> = order
|
||||||
|
.iter()
|
||||||
|
.map(|(capital, _)| {
|
||||||
|
members
|
||||||
|
.iter()
|
||||||
|
.position(|(c, _)| c == capital)
|
||||||
|
.expect("a count's members are the members asked for")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let shares: Vec<Decimal> = index.iter().map(|&i| members[i].1).collect();
|
||||||
|
let given: Vec<Decimal> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||||
pub use periods::{
|
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::{
|
pub use reports::{
|
||||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
|
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
|
||||||
|
|||||||
@@ -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"`.
|
/// The three-letter code, e.g. `"USD"`.
|
||||||
pub fn code(&self) -> &str {
|
pub fn code(&self) -> &str {
|
||||||
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
|
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
|
||||||
|
|||||||
@@ -51,11 +51,30 @@ pub enum CloseTo {
|
|||||||
/// Members' capital accounts by share (shares sum to 1): the
|
/// Members' capital accounts by share (shares sum to 1): the
|
||||||
/// partnership convention, where each member's capital account carries
|
/// partnership convention, where each member's capital account carries
|
||||||
/// their allocated earnings and absorbs their distributions. Odd cents
|
/// their allocated earnings and absorbs their distributions. Odd cents
|
||||||
/// go by largest remainder ([`allocate`]), cumulatively: a run of
|
/// go by largest remainder, cumulatively from the first members close
|
||||||
/// member closes with the same members and shares gives each member,
|
/// with these members and shares ([`allocate_cumulative`]): each close
|
||||||
/// in total, their share of the run's total to the cent, so rounding
|
/// gives a member their share of everything closed to the members so
|
||||||
/// never piles up close after close (rules.md B-13; since 2026-10-13).
|
/// 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<MemberClose>),
|
Members(Vec<MemberClose>),
|
||||||
|
/// [`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<MemberClose>,
|
||||||
|
/// 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<MemberClose>),
|
||||||
/// [`CloseTo::Members`] rounding each close on its own (largest
|
/// [`CloseTo::Members`] rounding each close on its own (largest
|
||||||
/// remainder), the rule for closes journaled on 2026-10-13 before the
|
/// remainder), the rule for closes journaled on 2026-10-13 before the
|
||||||
/// cumulative one. Only replay of those journals produces it.
|
/// cumulative one. Only replay of those journals produces it.
|
||||||
@@ -66,6 +85,21 @@ pub enum CloseTo {
|
|||||||
MembersLastAbsorbs(Vec<MemberClose>),
|
MembersLastAbsorbs(Vec<MemberClose>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
/// One member in a partnership close.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct MemberClose {
|
pub struct MemberClose {
|
||||||
@@ -84,6 +118,8 @@ pub struct MemberClose {
|
|||||||
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
|
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
|
||||||
/// always sum to `amount` exactly, and no member is favored by position
|
/// 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.
|
/// (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(
|
pub fn allocate(
|
||||||
amount: rust_decimal::Decimal,
|
amount: rust_decimal::Decimal,
|
||||||
shares: &[rust_decimal::Decimal],
|
shares: &[rust_decimal::Decimal],
|
||||||
@@ -122,6 +158,104 @@ pub fn allocate_last_absorbs(
|
|||||||
parts
|
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<Vec<rust_decimal::Decimal>, 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<Decimal> = 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
|
/// Code of the Retained Earnings account that closing entries credit (or
|
||||||
/// debit), created on the first close if the chart lacks it.
|
/// debit), created on the first close if the chart lacks it.
|
||||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||||
@@ -145,13 +279,23 @@ pub struct Period {
|
|||||||
/// closed.
|
/// closed.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub close_seq: Option<u64>,
|
pub close_seq: Option<u64>,
|
||||||
/// 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)]
|
#[serde(default)]
|
||||||
pub member_close: Option<MemberCloseRecord>,
|
pub member_close: Option<MemberCloseRecord>,
|
||||||
|
/// 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<MembersClosed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cumulative members close ([`CloseTo::Members`]): who shared, and the
|
/// A run-rule members close ([`CloseTo::MembersRun`]): who shared, and the
|
||||||
/// net it closed. The next close with the same members continues the run.
|
/// net it closed. The next run-rule close with the same members continues
|
||||||
|
/// the run.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct MemberCloseRecord {
|
pub struct MemberCloseRecord {
|
||||||
/// (capital account, share), in the order given.
|
/// (capital account, share), in the order given.
|
||||||
@@ -160,6 +304,63 @@ pub struct MemberCloseRecord {
|
|||||||
pub net: rust_decimal::Decimal,
|
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<rust_decimal::Decimal>,
|
||||||
|
/// 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 {
|
impl Period {
|
||||||
/// True when `date` falls inside the period (inclusive).
|
/// True when `date` falls inside the period (inclusive).
|
||||||
pub fn contains(&self, date: NaiveDate) -> bool {
|
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||||
@@ -193,6 +394,101 @@ pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), Led
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{Currency, Ledger};
|
use crate::{Currency, Ledger};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
fn dec(s: &str) -> Decimal {
|
||||||
|
s.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decs(v: &[&str]) -> Vec<Decimal> {
|
||||||
|
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::<Decimal>(), 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::<Decimal>(), 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 {
|
fn period_ledger() -> Ledger {
|
||||||
let mut l = Ledger::new(Currency::USD);
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
|||||||
@@ -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
|
// The checker itself
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||||
//! produce byte-identical [`snapshot`] output.
|
//! produce byte-identical [`snapshot`] output.
|
||||||
|
|
||||||
use crate::periods::allocate;
|
|
||||||
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -842,32 +841,45 @@ pub fn cancelled_in_span(
|
|||||||
/// distributions (debits). Earnings are partnership earnings: net income
|
/// distributions (debits). Earnings are partnership earnings: net income
|
||||||
/// for the period, and earnings from before it not yet distributed (retained
|
/// for the period, and earnings from before it not yet distributed (retained
|
||||||
/// earnings plus unclosed income), are allocated by share. Allocation is to
|
/// 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
|
/// the currency's minor unit, odd units by largest remainder, the same
|
||||||
/// and the ending total equals balance-sheet equity.
|
/// running way the members closes split them, so every row's total is
|
||||||
|
/// exact and the ending total equals balance-sheet equity.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: each member's capital roll-forward for a partnership over a
|
/// GOAL: each member's capital roll-forward for a partnership over a
|
||||||
/// period: beginning, contributions, allocated net income, distributions,
|
/// period: beginning, contributions, allocated net income, distributions,
|
||||||
/// ending; with a total.
|
/// ending; with a total.
|
||||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule
|
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
|
||||||
/// M-2), B-11; R-9c.
|
/// §1.704-1(b)(2)(iv)(b); Form 1065 Schedule M-2; D-38), B-11;
|
||||||
|
/// R-9c.
|
||||||
/// INVARIANTS:
|
/// INVARIANTS:
|
||||||
/// 1. Each row MUST roll forward exactly: beginning + contributions +
|
/// 1. Each row MUST roll forward exactly: beginning + contributions +
|
||||||
/// income - distributions + other = ending.
|
/// income - distributions + other = ending.
|
||||||
/// 2. The total ending MUST equal balance-sheet equity at `end`.
|
/// 2. The total ending MUST equal balance-sheet equity at `end`.
|
||||||
/// 3. Undistributed earnings MUST be split by share, to the cent, by
|
/// 3. Undistributed earnings U MUST be split by share, to the base
|
||||||
/// largest remainder (DECISION, owner 2026-10-13), cumulatively as
|
/// currency's minor unit, by largest remainder (DECISION, owner
|
||||||
/// of each date: a member's beginning is their capital and share as
|
/// 2026-10-13), as of each date d, the way the next members close
|
||||||
/// of the day before `start`, their ending as of `end`.
|
/// 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
|
/// 3a. A member's ending on one statement MUST equal their beginning on
|
||||||
/// the next, exactly (a capital account is one running balance: Form
|
/// the next, exactly (a capital account is one running balance of
|
||||||
/// 1065 Schedule K-1 instructions, Item L, "beginning capital account
|
/// contributions, allocations and distributions: Treas. Reg.
|
||||||
/// and ending capital account ... based on the partnership's books").
|
/// §1.704-1(b)(2)(iv)(b)).
|
||||||
/// 3b. A member's income is the roll-forward's remainder: their share of
|
/// 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
|
/// the period's income, off by less than two minor units (under one
|
||||||
/// rounding at each end of the cumulative split); the members'
|
/// at each end of the cumulative split); the members' incomes MUST
|
||||||
/// incomes MUST sum to the period's net income. (Earnings a close has
|
/// sum to the period's net income.
|
||||||
/// already moved into capital were rounded by that close, B-13.)
|
/// 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".
|
/// 4. NEVER hide an unexplained change: it goes in "other".
|
||||||
/// 5. An entry dated `start` is period activity, not beginning capital.
|
/// 5. An entry dated `start` is period activity, not beginning capital.
|
||||||
/// 6. An entry and its reversal both dated in the span cancel
|
/// 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
|
// 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
|
// 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
|
// 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
|
// next one's beginning, to the minor unit (a capital account is one
|
||||||
// balance: Form 1065 Schedule K-1 instructions, Item L).
|
// running balance: Treas. Reg. §1.704-1(b)(2)(iv)(b)).
|
||||||
let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal {
|
let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal {
|
||||||
let mut m = Decimal::ZERO;
|
let mut m = Decimal::ZERO;
|
||||||
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
|
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))
|
.map_or(Decimal::ZERO, |d| capital_through(d, end))
|
||||||
})
|
})
|
||||||
.collect();
|
.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::<Decimal>(),
|
begin_bs.total_equity - capital.iter().sum::<Decimal>(),
|
||||||
&shares,
|
&ledger.members_regime(&key, Some(day_before)),
|
||||||
);
|
)?;
|
||||||
let end_alloc = allocate(
|
let end_alloc = unclosed_share(
|
||||||
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
|
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
|
||||||
&shares,
|
&end_regime,
|
||||||
);
|
)?;
|
||||||
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
|
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + 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,
|
end_bs.total_equity - begin_bs.total_equity - explained,
|
||||||
&shares,
|
¬hing,
|
||||||
);
|
places,
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut cols = Vec::with_capacity(members.len());
|
let mut cols = Vec::with_capacity(members.len());
|
||||||
for (i, m) in members.iter().enumerate() {
|
for (i, m) in members.iter().enumerate() {
|
||||||
|
|||||||
@@ -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, `"<code>.debit"` and
|
||||||
|
//! `"<code>.credit"` summing each account's lines on that side, every
|
||||||
|
//! line of the entry named), `balances` (`as_of`: each account named,
|
||||||
|
//! `"<code>"`, 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, `"<name>.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<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
setup: Setup,
|
||||||
|
events: Vec<Event>,
|
||||||
|
expect: Vec<Expect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Setup {
|
||||||
|
currency: String,
|
||||||
|
fiscal_year_start: String,
|
||||||
|
basis: String,
|
||||||
|
rounding: String,
|
||||||
|
accounts: Vec<AccountSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
members: Vec<MemberSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<LineSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
reverses: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
close: Option<CloseSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Expect {
|
||||||
|
query: String,
|
||||||
|
#[serde(default)]
|
||||||
|
from: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
to: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
as_of: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
entry: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
after: Option<usize>,
|
||||||
|
values: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn examples() -> Vec<PathBuf> {
|
||||||
|
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples");
|
||||||
|
let mut paths: Vec<PathBuf> = 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<MemberCapital> {
|
||||||
|
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<MemberClose> = 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<String, Decimal> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -171,4 +171,78 @@ proptest! {
|
|||||||
fn line_range_agrees(cents in arb_cents()) {
|
fn line_range_agrees(cents in arb_cents()) {
|
||||||
prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range());
|
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::<bool>(),
|
||||||
|
) {
|
||||||
|
let n = parts.len();
|
||||||
|
let whole: i64 = parts.iter().sum::<i64>() + 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<Decimal> = parts.iter().copied().map(dec).collect();
|
||||||
|
let ri = &rems[..n];
|
||||||
|
let rd: Vec<Decimal> = 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<Decimal> = 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::<Vec<_>>(), 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::<bool>(),
|
||||||
|
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<i64> = cuts.iter().map(|c| c % (big_w + 1)).collect();
|
||||||
|
points.push(0);
|
||||||
|
points.push(big_w);
|
||||||
|
points.sort_unstable();
|
||||||
|
let weights: Vec<i64> = 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<Decimal> = weights.iter().map(|&w| Decimal::new(w, scale)).collect();
|
||||||
|
// What earlier closes gave: anything summing to what they closed.
|
||||||
|
let mut given: Vec<i64> = spread[..n - 1].to_vec();
|
||||||
|
given.push(closed - given.iter().sum::<i64>());
|
||||||
|
let total = closed + net;
|
||||||
|
let got = ledger_core::periods::allocate_cumulative(
|
||||||
|
dec(total),
|
||||||
|
&shares,
|
||||||
|
&given.iter().copied().map(dec).collect::<Vec<_>>(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let magnitude = total.abs();
|
||||||
|
let mut parts: Vec<i64> = weights.iter().map(|w| magnitude * w / big_w).collect();
|
||||||
|
let rems: Vec<i64> = weights.iter().map(|w| magnitude * w % big_w).collect();
|
||||||
|
let against: Vec<i64> = given.iter().map(|&g| if total < 0 { -g } else { g }).collect();
|
||||||
|
kernel::apportion_cumulative(magnitude, &mut parts, &rems, 1, &against).unwrap();
|
||||||
|
let want: Vec<Decimal> = parts.iter().map(|&p| dec(if total < 0 { -p } else { p })).collect();
|
||||||
|
prop_assert_eq!(&got, &want);
|
||||||
|
prop_assert_eq!(got.iter().sum::<Decimal>(), 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -667,9 +667,13 @@ fn a_partnership_closes_into_members_capital() {
|
|||||||
cents("733.33")
|
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();
|
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[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!(
|
assert_eq!(
|
||||||
feb_me.total.ending,
|
feb_me.total.ending,
|
||||||
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
||||||
@@ -1595,6 +1599,14 @@ mod cumulative_closes {
|
|||||||
(cents("0.02"), cents("0.01")),
|
(cents("0.02"), cents("0.01")),
|
||||||
"cumulative: within a cent of half of 0.03 each"
|
"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! {
|
proptest::proptest! {
|
||||||
@@ -1645,11 +1657,34 @@ mod cumulative_closes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A run is closes with the same members and shares, back to back: a
|
/// Under the run rule of 2026-10-13 (CloseTo::MembersRun, journaled
|
||||||
/// close to anything else (or with other shares) ends it, and the next
|
/// "cumulative"; only replay makes it now), a run is closes with the
|
||||||
/// members close starts afresh.
|
/// 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]
|
#[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<MemberClose>) -> CloseTo,
|
||||||
|
) -> (Decimal, Decimal) {
|
||||||
let mut l = Ledger::new(Currency::USD);
|
let mut l = Ledger::new(Currency::USD);
|
||||||
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||||
let cash = add("1000", AccountType::Asset);
|
let cash = add("1000", AccountType::Asset);
|
||||||
@@ -1684,15 +1719,16 @@ mod cumulative_closes {
|
|||||||
l.close_period_to(p, to).unwrap();
|
l.close_period_to(p, to).unwrap();
|
||||||
};
|
};
|
||||||
// Month 1 to members (A gets the cent), month 2 to retained
|
// 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
|
// earnings, month 3 to members again.
|
||||||
// A gets that cent too.
|
|
||||||
cent(&mut l, 1);
|
cent(&mut l, 1);
|
||||||
close(&mut l, 1, &CloseTo::Members(half(&caps)));
|
close(&mut l, 1, &to(half(&caps)));
|
||||||
cent(&mut l, 2);
|
cent(&mut l, 2);
|
||||||
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
|
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
|
||||||
cent(&mut l, 3);
|
cent(&mut l, 3);
|
||||||
close(&mut l, 3, &CloseTo::Members(half(&caps)));
|
close(&mut l, 3, &to(half(&caps)));
|
||||||
assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02"));
|
(
|
||||||
assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0"));
|
l.account_balance(caps[0]).unwrap(),
|
||||||
|
l.account_balance(caps[1]).unwrap(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,11 +405,13 @@ pub struct CapitalLine {
|
|||||||
/// is empty, the liability accounts whose name says "due to" or
|
/// is empty, the liability accounts whose name says "due to" or
|
||||||
/// "owed to" (DECISION: a name is a guess; the setting is the books'
|
/// "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'
|
/// word). owed_to_member: this member's part of it by the members'
|
||||||
/// shares (ledger_core::periods::allocate over every member, so the
|
/// shares, to the base currency's minor unit (ledger_core::periods::
|
||||||
/// parts sum to the balance).
|
/// 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]]
|
/// 6. None when the party isn't a member, a [[company.members]]
|
||||||
/// account isn't in the chart, or k1_detail refuses (shares not
|
/// 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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct CapitalStatement {
|
pub struct CapitalStatement {
|
||||||
@@ -1746,7 +1748,13 @@ impl Books {
|
|||||||
.map(|&a| self.balance_on(a, span.1))
|
.map(|&a| self.balance_on(a, span.1))
|
||||||
.sum();
|
.sum();
|
||||||
let shares: Vec<Decimal> = members.iter().map(|x| x.share).collect();
|
let shares: Vec<Decimal> = 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 {
|
Some(CapitalStatement {
|
||||||
party,
|
party,
|
||||||
capital: m.capital,
|
capital: m.capital,
|
||||||
|
|||||||
@@ -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<Vec<Decimal>> {
|
||||||
|
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<Decimal> = 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))]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -5957,7 +5957,7 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.close_period",
|
name = "books.close_period",
|
||||||
title = "Close a 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = true,
|
destructive_hint = true,
|
||||||
@@ -5992,6 +5992,34 @@ impl BooksServer {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
// The members' split, from the closed ledger (rules.md B-13).
|
||||||
|
let split = |ledger: &Ledger| -> Option<MembersCloseOut> {
|
||||||
|
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 {
|
if !p.confirmed {
|
||||||
// Preview: run the real close on a copy of the ledger, so the
|
// Preview: run the real close on a copy of the ledger, so the
|
||||||
// preview is exactly what confirming would post.
|
// preview is exactly what confirming would post.
|
||||||
@@ -6004,6 +6032,7 @@ impl BooksServer {
|
|||||||
period_id: p.period_id,
|
period_id: p.period_id,
|
||||||
closing_entry: None,
|
closing_entry: None,
|
||||||
lines: describe(&scratch, closing),
|
lines: describe(&scratch, closing),
|
||||||
|
members: split(&scratch),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let closing = store
|
let closing = store
|
||||||
@@ -6015,13 +6044,14 @@ impl BooksServer {
|
|||||||
period_id: p.period_id,
|
period_id: p.period_id,
|
||||||
closing_entry: closing,
|
closing_entry: closing,
|
||||||
lines,
|
lines,
|
||||||
|
members: split(store.ledger()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.list_periods",
|
name = "books.list_periods",
|
||||||
title = "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)
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
)]
|
)]
|
||||||
async fn list_periods(&self) -> Result<Json<ListPeriodsOut>, String> {
|
async fn list_periods(&self) -> Result<Json<ListPeriodsOut>, String> {
|
||||||
@@ -6040,6 +6070,14 @@ impl BooksServer {
|
|||||||
PeriodStatus::Closed => "closed".to_string(),
|
PeriodStatus::Closed => "closed".to_string(),
|
||||||
},
|
},
|
||||||
closing_entry: p.closing_entry,
|
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();
|
.collect();
|
||||||
Ok(Json(ListPeriodsOut { periods }))
|
Ok(Json(ListPeriodsOut { periods }))
|
||||||
|
|||||||
@@ -2162,6 +2162,46 @@ pub struct ClosePeriodOut {
|
|||||||
pub closing_entry: Option<u64>,
|
pub closing_entry: Option<u64>,
|
||||||
/// Human-readable summary of the closing lines.
|
/// Human-readable summary of the closing lines.
|
||||||
pub lines: Vec<String>,
|
pub lines: Vec<String>,
|
||||||
|
/// For a close to the members: how it splits among them (the same in
|
||||||
|
/// the preview and once confirmed).
|
||||||
|
#[serde(default)]
|
||||||
|
pub members: Option<MembersCloseOut>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<MemberCloseOut>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`.
|
/// One period in `books.list_periods`.
|
||||||
@@ -2179,6 +2219,13 @@ pub struct PeriodOut {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
/// Closing entry id, if closed with one.
|
/// Closing entry id, if closed with one.
|
||||||
pub closing_entry: Option<u64>,
|
pub closing_entry: Option<u64>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of `books.list_periods`.
|
/// Output of `books.list_periods`.
|
||||||
|
|||||||
@@ -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<Value> {
|
||||||
|
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<Value> = 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]
|
#[test]
|
||||||
fn mcp_schedules_and_recurring_over_stdio() {
|
fn mcp_schedules_and_recurring_over_stdio() {
|
||||||
let dir = test_dir("schedules");
|
let dir = test_dir("schedules");
|
||||||
|
|||||||
@@ -26,8 +26,21 @@ use serde_json::{Value, json};
|
|||||||
/// The newest journal format this version reads and writes.
|
/// The newest journal format this version reads and writes.
|
||||||
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
|
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
|
||||||
|
|
||||||
/// Optional journal features this version reads (none yet).
|
/// Optional journal features this version reads. Each is a field or value
|
||||||
pub const KNOWN_FEATURES: &[&str] = &[];
|
/// 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.
|
/// The line type that declares the journal format.
|
||||||
pub const FORMAT_SET: &str = "journal.format_set";
|
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
|
/// Read a `journal.format_set` line, refusing a format or feature this
|
||||||
/// version doesn't read.
|
/// version doesn't read.
|
||||||
pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError> {
|
pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError> {
|
||||||
|
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<JournalFormat, StoreError> {
|
||||||
let p = &line.payload;
|
let p = &line.payload;
|
||||||
let format = p
|
let format = p
|
||||||
.get("format")
|
.get("format")
|
||||||
@@ -216,7 +235,7 @@ pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError>
|
|||||||
let unknown: Vec<&str> = features
|
let unknown: Vec<&str> = features
|
||||||
.iter()
|
.iter()
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.filter(|f| !KNOWN_FEATURES.contains(f))
|
.filter(|f| !known.contains(f))
|
||||||
.collect();
|
.collect();
|
||||||
if !unknown.is_empty() {
|
if !unknown.is_empty() {
|
||||||
return Err(newer(
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -583,6 +583,29 @@ impl Store {
|
|||||||
Ok(())
|
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
|
/// Change settings (`settings.set`): all or nothing, each value
|
||||||
/// canonical, the result whole (ledger_core::settings). Returns the
|
/// canonical, the result whole (ledger_core::settings). Returns the
|
||||||
/// changes made; none (journaling nothing) when every change is 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.
|
// ensure then no-ops and replay stays deterministic.
|
||||||
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
|
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 mut next = self.ledger.clone();
|
||||||
let closing_entry = next.close_period_to(id, to)?;
|
let closing_entry = next.close_period_to(id, to)?;
|
||||||
let entry_detail = match closing_entry {
|
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 } if sweep.is_empty() => json!("retained_earnings"),
|
||||||
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
|
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
|
||||||
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
|
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
|
||||||
CloseTo::Members(members)
|
_ => {
|
||||||
| CloseTo::MembersPerClose(members)
|
let members = to.members().expect("every other target is members");
|
||||||
| CloseTo::MembersLastAbsorbs(members) => {
|
|
||||||
let mut v = json!({
|
let mut v = json!({
|
||||||
"members": members
|
"members": members
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1835,7 +1862,18 @@ impl Store {
|
|||||||
// journaled before 2026-10-13 carry none (the last member
|
// journaled before 2026-10-13 carry none (the last member
|
||||||
// absorbed it).
|
// absorbed it).
|
||||||
match to {
|
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"),
|
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
|
// Deterministic: the close re-derives the closing entry from
|
||||||
// the replayed state and the journaled target (absent: Retained
|
// the replayed state and the journaled target (absent: Retained
|
||||||
// Earnings), so it must match the journal's id and every line.
|
// 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)?;
|
let got = ledger.close_period_to(id, &to)?;
|
||||||
if got != want {
|
if got != want {
|
||||||
return Err(StoreError::ReplayMismatch(format!(
|
return Err(StoreError::ReplayMismatch(format!(
|
||||||
@@ -5021,7 +5059,10 @@ fn tax_basis_from_journal(p: &Value) -> Result<ledger_core::TaxBasis, StoreError
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
/// 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<CloseTo, StoreError> {
|
||||||
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
|
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
|
||||||
let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
|
let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
|
||||||
v.as_array()
|
v.as_array()
|
||||||
@@ -5062,12 +5103,39 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
|||||||
.collect::<Result<_, StoreError>>()?;
|
.collect::<Result<_, StoreError>>()?;
|
||||||
// The rounding rule the close was made under: absent before
|
// The rounding rule the close was made under: absent before
|
||||||
// 2026-10-13, when the last member absorbed it; "largest_remainder"
|
// 2026-10-13, when the last member absorbed it; "largest_remainder"
|
||||||
// per close, then "cumulative" over a run of closes.
|
// per close, then "cumulative" over a run of closes, then (P3)
|
||||||
match v.get("rounding").and_then(|r| r.as_str()) {
|
// "cumulative_since" the close numbered since_seq.
|
||||||
None => Ok(CloseTo::MembersLastAbsorbs(members)),
|
let rounding = match v.get("rounding") {
|
||||||
Some("largest_remainder") => Ok(CloseTo::MembersPerClose(members)),
|
None | Some(Value::Null) => None,
|
||||||
Some("cumulative") => Ok(CloseTo::Members(members)),
|
Some(Value::String(r)) => Some(r.as_str()),
|
||||||
Some(_) => Err(corrupt("unknown rounding")),
|
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:?}"),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
|
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
|
||||||
# cargo run --bin expect -- OUT (expected.json beside each journal)
|
# cargo run --bin expect -- OUT (expected.json beside each journal)
|
||||||
# and copy each journal.jsonl, blobs/ and expected.json into old-books/.
|
# 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]
|
[package]
|
||||||
name = "writer"
|
name = "writer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -22,3 +25,5 @@ rust_decimal = "=1.43.0"
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
|
# main's crates (wb-main) keep their own workspace.
|
||||||
|
exclude = ["wb-main"]
|
||||||
@@ -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::<Decimal>().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)
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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"}
|
||||||
@@ -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<MemberClose> {
|
||||||
|
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<usize> = 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<Value> = 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<MemberCapital> {
|
||||||
|
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<Decimal> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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
|
/// Contract (rules.md B-13): each members close journals its rounding rule
|
||||||
/// and replays under it: "cumulative" for new closes, "largest_remainder"
|
/// and replays under it: "cumulative_since" with the close its count starts
|
||||||
/// for the per-close rule of earlier on 2026-10-13. Three one-cent months,
|
/// at for new closes (P3), "cumulative" for the run rule of 2026-10-13 to
|
||||||
/// 50/50: cumulative ends A 0.02 / B 0.01, per close A 0.03 / B 0.00.
|
/// 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]
|
#[test]
|
||||||
fn members_closes_replay_under_the_rule_they_were_journaled_with() {
|
fn members_closes_replay_under_the_rule_they_were_journaled_with() {
|
||||||
for (cumulative, rule, a, b) in [
|
use ledger_core::{CloseTo, MemberClose};
|
||||||
(true, "\"rounding\":\"cumulative\"", "0.02", "0.01"),
|
type Rule = fn(Vec<MemberClose>) -> CloseTo;
|
||||||
(false, "\"rounding\":\"largest_remainder\"", "0.03", "0.00"),
|
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 mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||||
let cash = store
|
let cash = store
|
||||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
.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
|
let rev = store
|
||||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let members: Vec<ledger_core::MemberClose> = caps
|
let members: Vec<MemberClose> = caps
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&capital| ledger_core::MemberClose {
|
.map(|&capital| MemberClose {
|
||||||
capital,
|
capital,
|
||||||
share: Decimal::new(5, 1),
|
share: Decimal::new(5, 1),
|
||||||
distributions: None,
|
distributions: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.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);
|
let cent = Money::new(Decimal::new(1, 2), Currency::USD);
|
||||||
for m in 1..=3u32 {
|
for m in 1..=3u32 {
|
||||||
let day = |d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
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
|
let p = store
|
||||||
.create_period(&format!("2026-{m:02}"), day(1), end)
|
.create_period(&format!("2026-{m:02}"), day(1), end)
|
||||||
.unwrap();
|
.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();
|
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);
|
drop(store);
|
||||||
let store = Store::open(&dir).unwrap();
|
let store = Store::open(&dir).unwrap();
|
||||||
store.verify().unwrap();
|
store.verify().unwrap();
|
||||||
let bal = |id| store.ledger().account_balance(id).unwrap();
|
let bal = |id| store.ledger().account_balance(id).unwrap();
|
||||||
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{rule}");
|
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{name}");
|
||||||
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{rule}");
|
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-9
@@ -1134,7 +1134,10 @@ posting.
|
|||||||
financing_accounts?, investing_accounts? }` → `{ content, content_encoding? }`.
|
financing_accounts?, investing_accounts? }` → `{ content, content_encoding? }`.
|
||||||
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
|
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
|
||||||
`cash_accounts`), `trial_balance`, `members_equity` (statement of
|
`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
|
`general_ledger` (by account: beginning balance, postings with running
|
||||||
balance and split, ending balance), `general_journal` (alias
|
balance and split, ending balance), `general_journal` (alias
|
||||||
`journal`: every entry, line by line, in date order).
|
`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 }`.
|
- `books.create_period { name, start, end }` → `{ period_id }`.
|
||||||
Dates are YYYY-MM-DD, inclusive.
|
Dates are YYYY-MM-DD, inclusive.
|
||||||
- `books.close_period { period_id, confirmed? }` → without confirmation:
|
- `books.close_period { period_id, confirmed? }` → without confirmation:
|
||||||
`{ status: "preview", lines: [...] }`: the exact closing entry (the close
|
`{ status: "preview", period_id, closing_entry: null, lines: [...],
|
||||||
run on a copy of the ledger), posting nothing; with `confirmed: true`:
|
members }`: the exact closing entry (the close run on a copy of the
|
||||||
posts it and marks the period closed. The entry zeroes revenue and
|
ledger), posting nothing; with `confirmed: true`: `{ status: "closed",
|
||||||
expense balances as of the period end; the net goes to `3999` Retained
|
period_id, closing_entry, lines, members }`, posting it and marking the
|
||||||
Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each
|
period closed. The entry zeroes revenue and expense balances as of the
|
||||||
member's capital account by share. Periods close in order. Closed periods
|
period end; the net goes to `3999` Retained Earnings, or, with
|
||||||
reject new entries.
|
`[[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,
|
- `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
|
## Tools — bank reconciliation
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -63,9 +63,11 @@ requirement conflict, the requirement wins.
|
|||||||
(loss), distributions (debits), ending — plus a total that equals
|
(loss), distributions (debits), ending — plus a total that equals
|
||||||
balance-sheet equity. Earnings not yet distributed (retained earnings and
|
balance-sheet equity. Earnings not yet distributed (retained earnings and
|
||||||
unclosed income) and the period's income are allocated by ownership share
|
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
|
to the currency's minor unit by largest remainder (the odd cent goes to
|
||||||
remainder, ties to the earlier member; the owner, 2026-10-13), so every
|
the largest remainder, ties to the earlier member; the owner, 2026-10-13),
|
||||||
row's total is exact. Any equity change the roll-forward doesn't explain is shown as
|
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.
|
"other", never hidden.
|
||||||
- **R-9d — Tax categories.** Revenue and expense accounts map to stable
|
- **R-9d — Tax categories.** Revenue and expense accounts map to stable
|
||||||
tax categories (`ledger_core::tax::CATEGORIES`: key, label, income /
|
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
|
end** (later-dated entries belong to later periods) and puts the net in
|
||||||
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
||||||
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
||||||
account by share, to the cent, odd cents by largest remainder: the
|
account by share, to the currency's minor unit, odd units by largest
|
||||||
partnership convention; closes journaled before 2026-10-13 replay with
|
remainder, cumulatively from the first members close with these members
|
||||||
the rule they were made under, the last member absorbing rounding), or `CloseTo::Account` (any equity account, e.g.
|
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
|
a sole proprietor's capital). Every target can sweep owner-pay equity
|
||||||
accounts — draws, distributions, dividends — into it: a member's
|
accounts — draws, distributions, dividends — into it: a member's
|
||||||
distributions account into their own capital. The members' equity
|
distributions account into their own capital. The members' equity
|
||||||
|
|||||||
+136
-30
@@ -206,34 +206,109 @@ closed period).
|
|||||||
|
|
||||||
## B-13 Partners share income by the partnership agreement; capital accounts carry it
|
## 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
|
- 26 U.S.C. §704(a) (`sources/irs-partnerships.md`): "A partner's
|
||||||
distributive share of income, gain, loss, deduction, or credit shall,
|
distributive share of income, gain, loss, deduction, or credit shall,
|
||||||
except as otherwise provided in this chapter, be determined by the
|
except as otherwise provided in this chapter, be determined by the
|
||||||
partnership agreement."
|
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
|
- 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
|
- **No authority** says how to round a share to the cent. Largest
|
||||||
remainder, ties to the earlier member: DECISION (the owner, 2026-10-13).
|
remainder, ties to the member listed first: DECISION (the owner,
|
||||||
Closes made before then replay with the last member absorbing it.
|
2026-10-13). Closes made before then replay with the last member
|
||||||
- Rounding never piles up: a run of members closes (same members and
|
absorbing it.
|
||||||
shares, back to back) splits cumulatively, each close giving a member
|
- **Members closes split cumulatively, counted from the company's first
|
||||||
their allocation of the run's total so far less their allocation before
|
close** (P3 defect 17; D-10). A *count* is the members closes with one
|
||||||
it, so each member's total from the run is within a cent of their exact
|
set of (capital account, share) pairs, from the first such close since
|
||||||
share (follows from the running capital account below; found
|
the last members close with another set: closes to retained earnings or
|
||||||
2026-10-13: closing each month on its own gave every tied cent to the
|
to one account are skipped (they allocate nothing to the members), and
|
||||||
earlier member). Journaled as `"rounding": "cumulative"`; closes
|
neither the members' order nor their distribution accounts matter. Each
|
||||||
journaled `"largest_remainder"` replay one at a time.
|
close gives member *i* allocate(C + net)*ᵢ* − given*ᵢ*: their split of
|
||||||
- A capital account is one running balance: Form 1065 Schedule K-1
|
everything the count has closed (C before it, plus its own net), to the
|
||||||
instructions, Item L: "The partnership must report your beginning
|
base currency's minor unit (cents, whole yen, thousandths of a dinar;
|
||||||
capital account and ending capital account for the year", "based on the
|
B-3; for a code ISO 4217 gives none, such as gold, the finest a line
|
||||||
partnership's books and records." So a statement of members' equity
|
carries, `kernel::MAX_LINE_SCALE` places, so every amount is still whole
|
||||||
splits undistributed earnings cumulatively, as of each date (beginning
|
units), less what the count's earlier closes gave them under whatever
|
||||||
as of the day before the period, ending as of its last day), never one
|
rule they were made. A tied odd unit goes to the member the count's
|
||||||
period at a time: a member's ending on one statement is their beginning
|
first close listed first, whatever order later closes list the members
|
||||||
on the next, to the cent, and a member's period income is the
|
in, so a reorder of the members moves no cent; the statement below
|
||||||
roll-forward's remainder (their share within a rounding cent).
|
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
|
- A correction undoes what it corrects (B-9): an entry and its reversal
|
||||||
both in a statement's span are neither contributions nor
|
both in a statement's span are neither contributions nor
|
||||||
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
|
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
|
members credited in one entry in proportion to their credits
|
||||||
(`k1` INVARIANTS 3, 4, 6).
|
(`k1` INVARIANTS 3, 4, 6).
|
||||||
|
|
||||||
**Enforced:** `CloseTo::Members`, `periods::allocate`,
|
**Enforced:** `CloseTo::Members`, `CloseTo::MembersCumulative`,
|
||||||
`kernel::apportion`, `members_equity`. **Checked:** Kani
|
`Ledger::members_regime`, `Regime::allocate`,
|
||||||
`apportion_is_exact`, `apportion_is_fair`; Lean `runPart_telescopes`,
|
`periods::allocate_cumulative`, `kernel::apportion_cumulative`,
|
||||||
`closePart_total`; properties `prop_split_*`,
|
`periods::allocate`, `kernel::apportion`, `members_equity`,
|
||||||
`each_members_capital_chains_across_statements`,
|
`Store::close_period_to` (the feature declaration), the store's
|
||||||
`each_member_stays_within_a_cent_of_their_share`;
|
`close_to_from_journal`. **Checked:** Kani `apportion_is_exact`,
|
||||||
`a_run_of_closes_never_piles_up_rounding`,
|
`apportion_is_fair`, `apportion_cumulative_is_exact`,
|
||||||
`members_closes_replay_under_the_rule_they_were_journaled_with`;
|
`cumulative_close_never_drifts_a_unit_at_halves`, `_at_tenths`,
|
||||||
`closes_journaled_under_the_old_rule_replay_with_it`.
|
`_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
|
## 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
|
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.
|
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`,
|
**Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`,
|
||||||
`gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`,
|
`gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`,
|
||||||
`JournalLine::parse`, `journal::read_journal_and_format`,
|
`JournalLine::parse`, `journal::read_journal_and_format`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Partnerships: shares of income and capital accounts
|
# Partnerships: shares of income and capital accounts
|
||||||
Governs `CloseTo::Members` and the members' equity statement. Partners' shares come from the partnership agreement (IRC §704(a)); 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)
|
## 26 U.S.C. §704(a)-(b)
|
||||||
Source: <https://www.law.cornell.edu/uscode/text/26/704>
|
Source: <https://www.law.cornell.edu/uscode/text/26/704>
|
||||||
@@ -24,3 +24,47 @@ SHA-256 of the retrieved file (`i1065.txt`): `364ec05f6a688447def50169604ec0aa97
|
|||||||
**Rounding on the return (not in the books)**
|
**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.
|
> 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: <https://www.ecfr.gov/api/renderer/v1/content/enhanced/current/title-26?part=1§ion=1.704-1>
|
||||||
|
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: <https://www.law.cornell.edu/uscode/text/26/706>
|
||||||
|
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: <https://www.ecfr.gov/api/renderer/v1/content/enhanced/current/title-26?part=1§ion=1.706-4>
|
||||||
|
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: <https://www.irs.gov/instructions/i1065sk1>
|
||||||
|
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.
|
||||||
+107
-2
@@ -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
|
`admin.rs`, `ledger-host` `commands.rs`, `ledger-cli`). The survivor lists
|
||||||
are kept with the run's output, outside the repository.
|
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)
|
## Benchmarks (baseline 2026-10-13)
|
||||||
|
|
||||||
No budget, by decision (the owner, 2026-10-13: the books do little work
|
No budget, by decision (the owner, 2026-10-13: the books do little work
|
||||||
@@ -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 |
|
| 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 |
|
| 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_...` |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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
|
Questions are settled from the sources, not by any one company's CPA
|
||||||
(wanderbooks is general software). Where the standards leave a real
|
(wanderbooks is general software). Where the standards leave a real
|
||||||
|
|||||||
+74
-1
@@ -142,10 +142,58 @@ sequence of commands, of any length**:
|
|||||||
of cumulative closes is their allocation of everything the run closed
|
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
|
(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
|
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
|
- Lean `runPart_telescopes`, `closePart_total` · property
|
||||||
`each_member_stays_within_a_cent_of_their_share`
|
`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
|
## What the proofs found
|
||||||
|
|
||||||
The engine already had 190+ tests, property tests, mutation testing,
|
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
|
for proof today: the format gate and the walk are I/O, and the model in
|
||||||
`verify/lean` stops at the ledger.
|
`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
|
## Running it
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+1
-1
@@ -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-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-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-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-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`. |
|
| D-13 | The first company timezone | (a) `company.timezone = America/Denver`. |
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ open Wanderbooks
|
|||||||
#print axioms plugLast_keeps
|
#print axioms plugLast_keeps
|
||||||
#print axioms runPart_telescopes
|
#print axioms runPart_telescopes
|
||||||
#print axioms closePart_total
|
#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.inv
|
||||||
#print axioms Reachable.books_net_zero
|
#print axioms Reachable.books_net_zero
|
||||||
#print axioms Reachable.trial_balance
|
#print axioms Reachable.trial_balance
|
||||||
@@ -35,5 +41,11 @@ open Wanderbooks
|
|||||||
#print axioms Apportion.ranks_perm
|
#print axioms Apportion.ranks_perm
|
||||||
#print axioms Apportion.apportion_sum
|
#print axioms Apportion.apportion_sum
|
||||||
#print axioms Apportion.apportion_sum_of_shortfall
|
#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 Step.record_changes_no_figure
|
||||||
#print axioms Reachable.records
|
#print axioms Reachable.records
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Wanderbooks.Closing
|
||||||
|
|
||||||
/-!
|
/-!
|
||||||
# Apportioning: the parts sum to the whole, for any number of parts
|
# 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
|
rw [Int.mul_comm] at this
|
||||||
exact this.symm
|
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
|
end Wanderbooks.Apportion
|
||||||
@@ -319,7 +319,8 @@ theorem plugLast_keeps (whole : Int) (ps : List Int) :
|
|||||||
|
|
||||||
/-! ## Cumulative member closes
|
/-! ## 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
|
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
|
their allocation of the total before it. `alloc` is any allocation of an
|
||||||
amount to members (member `i` gets `alloc x i`), such as
|
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]
|
rw [h, sums, sums]
|
||||||
omega
|
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
|
end Wanderbooks
|
||||||
Reference in new issue
Block a user