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
@@ -22,6 +22,8 @@ workspace = true
|
||||
[dev-dependencies]
|
||||
criterion = "0.8.2"
|
||||
proptest = "1"
|
||||
# bookspec examples are TOML (tests/bookspec.rs).
|
||||
toml = "1"
|
||||
|
||||
[[bench]]
|
||||
name = "books"
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
//! with the MacBook read as cash.)
|
||||
//! 4. Within one entry, the property (at most the members' credits in
|
||||
//! it) is shared among the members credited in proportion to their
|
||||
//! credits, to the cent, leftover cents by largest remainder
|
||||
//! credits, to the base currency's minor unit (cents, whole yen),
|
||||
//! leftover units by largest remainder
|
||||
//! (kernel::apportion, ties to the earlier member): each takes at
|
||||
//! most its own credit, and the split depends on the order the
|
||||
//! members are given only for a tied leftover cent (never on line
|
||||
@@ -167,6 +168,7 @@ pub fn k1_detail(
|
||||
));
|
||||
}
|
||||
let me = members_equity(ledger, start, end, members)?;
|
||||
let places = ledger.base_currency().allocation_places();
|
||||
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||
@@ -219,7 +221,7 @@ pub fn k1_detail(
|
||||
.iter()
|
||||
.map(|m| sum(e, Side::Credit, m.capital_account))
|
||||
.collect();
|
||||
let takes = share_property(pool, &credits)?;
|
||||
let takes = share_property(pool, &credits, places)?;
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let (credit, take) = (credits[i], takes[i]);
|
||||
if !(credit - take).is_zero() {
|
||||
@@ -274,11 +276,16 @@ pub fn k1_detail(
|
||||
|
||||
/// One entry's property shared among the members' credits in it
|
||||
/// (INVARIANT 4): at most their total credit, in proportion to each
|
||||
/// credit, rounded toward zero to the cent, the leftover cents by largest
|
||||
/// remainder. Each part is at most its own credit.
|
||||
/// credit, rounded toward zero to `places` (the base currency's
|
||||
/// Currency::allocation_places: cents, whole yen), the leftover units by
|
||||
/// largest remainder. Each part is at most its own credit.
|
||||
/// Refused when whole × credit is beyond exact arithmetic (credits near
|
||||
/// the line limit; P0 review round 3, S1).
|
||||
fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
|
||||
fn share_property(
|
||||
pool: Decimal,
|
||||
credits: &[Decimal],
|
||||
places: u32,
|
||||
) -> Result<Vec<Decimal>, LedgerError> {
|
||||
use rust_decimal::RoundingStrategy::ToZero;
|
||||
let too_large =
|
||||
|| LedgerError::TooLargeToCompute("a member's share of contributed property".into());
|
||||
@@ -291,7 +298,7 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, Le
|
||||
return Ok(vec![Decimal::ZERO; credits.len()]);
|
||||
}
|
||||
// whole × credit is exact; ÷ total is exact whenever the share is a
|
||||
// whole number of cents, so truncating never loses a cent it is owed.
|
||||
// whole number of units, so truncating never loses a unit it is owed.
|
||||
let exact: Vec<Decimal> = credits
|
||||
.iter()
|
||||
.map(|c| whole.checked_mul(*c).and_then(|x| x.checked_div(total)))
|
||||
@@ -299,11 +306,11 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, Le
|
||||
.ok_or_else(too_large)?;
|
||||
let mut parts: Vec<Decimal> = exact
|
||||
.iter()
|
||||
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
||||
.map(|x| x.round_dp_with_strategy(places, ToZero))
|
||||
.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))
|
||||
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
|
||||
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, places))
|
||||
.expect("each part is short by under a unit, so the shortfall is under a unit per member");
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
@@ -840,20 +847,20 @@ mod tests {
|
||||
flipped.reverse();
|
||||
assert_eq!(split(&bk, &flipped), vec![each, each]);
|
||||
assert_eq!(
|
||||
share_property(d("401"), &[d("750"), d("250")]).unwrap(),
|
||||
share_property(d("401"), &[d("750"), d("250")], 2).unwrap(),
|
||||
vec![d("300.75"), d("100.25")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("0.01"), &[d("5"), d("5")]).unwrap(),
|
||||
share_property(d("0.01"), &[d("5"), d("5")], 2).unwrap(),
|
||||
vec![d("0.01"), d("0.00")]
|
||||
);
|
||||
// More property than credit (part financed): at most the credits.
|
||||
assert_eq!(
|
||||
share_property(d("900"), &[d("300"), d("100")]).unwrap(),
|
||||
share_property(d("900"), &[d("300"), d("100")], 2).unwrap(),
|
||||
vec![d("300"), d("100")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("50"), &[d("0"), d("0")]).unwrap(),
|
||||
share_property(d("50"), &[d("0"), d("0")], 2).unwrap(),
|
||||
vec![d("0"), d("0")]
|
||||
);
|
||||
// Credits near the line limit: whole x credit is past exact
|
||||
@@ -861,18 +868,29 @@ mod tests {
|
||||
// round 3, S1); at a quarter of it, it still splits exactly.
|
||||
let near = d("900000000000000");
|
||||
assert!(matches!(
|
||||
share_property(near, &[near, near]),
|
||||
share_property(near, &[near, near], 2),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
share_property(near, &[Decimal::MAX, Decimal::MAX]),
|
||||
share_property(near, &[Decimal::MAX, Decimal::MAX], 2),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
let quarter = d("250000000000");
|
||||
assert_eq!(
|
||||
share_property(quarter, &[quarter, quarter]).unwrap(),
|
||||
share_property(quarter, &[quarter, quarter], 2).unwrap(),
|
||||
vec![d("125000000000"), d("125000000000")]
|
||||
);
|
||||
// To the base currency's minor unit (allocation_places): in whole
|
||||
// yen, 101 of property by credits of 51 each is 50.5 each, the
|
||||
// tied yen to the first; to cents it would be 50.50 each.
|
||||
assert_eq!(
|
||||
share_property(d("101"), &[d("51"), d("51")], 0).unwrap(),
|
||||
vec![d("51"), d("50")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("101"), &[d("51"), d("51")], 2).unwrap(),
|
||||
vec![d("50.50"), d("50.50")]
|
||||
);
|
||||
}
|
||||
|
||||
/// member_party doc: the party of that name with the member role only.
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
//! columns differ by the net of all movements (B-6);
|
||||
//! * a closing line zeroes the account it closes (B-11);
|
||||
//! * an allocation's parts sum to the whole, to the last unit, by largest
|
||||
//! remainder (B-13).
|
||||
//! remainder (B-13);
|
||||
//! * a cumulative allocation hands out exactly the new amount, and leaves
|
||||
//! each running total one allocation of everything allocated, so it never
|
||||
//! drifts a unit from its exact share (B-13).
|
||||
|
||||
use crate::{AccountType, Side};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -352,6 +355,60 @@ pub fn apportion<T: Amount>(whole: T, parts: &mut [T], remainders: &[T], unit: T
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Cumulative apportionment: what one close gives each part when the
|
||||
/// running `total` is apportioned afresh ([`apportion`]) and what earlier
|
||||
/// closes gave each part (`given`) is taken back. `parts` and
|
||||
/// `remainders` are the rounded-down shares of `total` and what rounding
|
||||
/// took from each, as for [`apportion`].
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: members' running totals that never drift a unit from their
|
||||
/// exact shares, whatever earlier closes did.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (shares: 26 U.S.C. §704(a); capital
|
||||
/// accounts carry the allocations: Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b); cent rounding: DECISION, owner
|
||||
/// 2026-10-13; counted from the first close: D-10, 2026-10-16).
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST make the parts sum to `total − Σ given` exactly.
|
||||
/// 2. MUST leave given[i] + parts[i] equal to what `apportion(total, …)`
|
||||
/// gives part i: a running total is one apportionment, so it carries
|
||||
/// one apportionment's rounding, never a sum of them.
|
||||
/// 3. On None, `parts` MUST be unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the lengths differ, the unit isn't positive, `total`, the
|
||||
/// unit, a part or a `given` is out of line range, or apportion
|
||||
/// refuses: SHOULD return None, leaving `parts` unchanged
|
||||
/// BECAUSE in range, the subtraction can't overflow, so a refusal is
|
||||
/// known before anything changes.
|
||||
/// 2. IF 0.03 was closed as A 0.03 / B 0 and the running total is now
|
||||
/// 0.04 (50/50, floors 0.02 / 0.02): SHOULD give A −0.01, B +0.02
|
||||
/// BECAUSE 0.04 apportions 0.02 each.
|
||||
/// ```
|
||||
///
|
||||
/// Allocation-free, with no bulk copy (docs/verification.md, "Trusting
|
||||
/// the checker"), so the model checker can prove it outright.
|
||||
pub fn apportion_cumulative<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -423,6 +480,51 @@ mod tests {
|
||||
assert_eq!(apportion(0, &mut none, &[], -1), None);
|
||||
}
|
||||
|
||||
/// apportion_cumulative scenario 1: mismatched lengths, a bad unit, an
|
||||
/// input out of line range, or a shortfall apportion refuses: None,
|
||||
/// and the parts untouched.
|
||||
#[test]
|
||||
fn apportion_cumulative_refuses_mismatched_or_out_of_range_inputs() {
|
||||
let big = (1i64 << 40) + 1;
|
||||
for (total, parts, rems, unit, given) in [
|
||||
(4i64, [2i64, 2], [0i64, 0], 1i64, &[0i64][..]),
|
||||
(4, [2, 2], [0, 0], 0, &[0, 0][..]),
|
||||
(4, [2, 2], [0, 0], big, &[0, 0][..]),
|
||||
(big, [2, 2], [0, 0], 1, &[0, 0][..]),
|
||||
(4, [big, 2], [0, 0], 1, &[0, 0][..]),
|
||||
(4, [2, 2], [0, 0], 1, &[big, 0][..]),
|
||||
// Three units short of two parts: not rounding.
|
||||
(7, [2, 2], [0, 0], 1, &[0, 0][..]),
|
||||
] {
|
||||
let mut p = parts;
|
||||
assert_eq!(
|
||||
apportion_cumulative(total, &mut p, &rems, unit, given),
|
||||
None,
|
||||
"{total} {parts:?} {unit} {given:?}"
|
||||
);
|
||||
assert_eq!(p, parts);
|
||||
}
|
||||
// Remainders that don't pair with the parts: apportion's refusal.
|
||||
let mut p = [2i64, 2];
|
||||
assert_eq!(apportion_cumulative(4, &mut p, &[0], 1, &[0, 0]), None);
|
||||
assert_eq!(p, [2, 2]);
|
||||
}
|
||||
|
||||
/// apportion_cumulative scenario 2 (cents, i64): three cents closed
|
||||
/// one at a time gave A all three; the fourth cent's close apportions
|
||||
/// the running 4 cents (2 / 2) and takes back what A and B had.
|
||||
#[test]
|
||||
fn apportion_cumulative_trues_up_a_legacy_split() {
|
||||
let mut parts = [2i64, 2];
|
||||
apportion_cumulative(4, &mut parts, &[0, 0], 1, &[3, 0]).unwrap();
|
||||
assert_eq!(parts, [-1, 2]);
|
||||
// A running total of 5: floors 2 / 2 with equal remainders, so the
|
||||
// tied unit goes to the first (3 / 2), less what each had (3 / 1).
|
||||
let mut parts = [2i64, 2];
|
||||
apportion_cumulative(5, &mut parts, &[5, 5], 1, &[3, 1]).unwrap();
|
||||
assert_eq!(parts, [0, 1]);
|
||||
}
|
||||
|
||||
/// With no parts, only a zero whole is fully apportioned.
|
||||
#[test]
|
||||
fn apportion_to_no_parts_accepts_only_zero() {
|
||||
|
||||
@@ -2127,6 +2127,7 @@ impl Ledger {
|
||||
closing_entry: None,
|
||||
close_seq: None,
|
||||
member_close: None,
|
||||
members_closed: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
@@ -2173,9 +2174,17 @@ impl Ledger {
|
||||
/// - `RetainedEarnings`: account `3999`, created if missing.
|
||||
/// - `Account`: one equity account (a sole proprietor's capital).
|
||||
/// - `Members`: each member's capital account gets their share of
|
||||
/// the net, to the cent (odd cents by largest remainder; closes
|
||||
/// journaled before 2026-10-13 replay with the last member
|
||||
/// absorbing them), and their own distribution account.
|
||||
/// everything closed to these members and shares so far, to the
|
||||
/// currency's minor unit (odd units by largest remainder, ties to
|
||||
/// the member listed first), less what earlier closes gave them
|
||||
/// (rules.md B-13; D-10), and their own distribution account. The
|
||||
/// count starts at the first members close, or at the first since
|
||||
/// the members or shares last changed (`Ledger::members_regime`);
|
||||
/// closes to anything else are skipped. A close that posts an
|
||||
/// entry may carry a one-time true-up of rounding by closes made
|
||||
/// under an earlier rule (the journaled `MembersRun`,
|
||||
/// `MembersPerClose` and `MembersLastAbsorbs`, which replay as
|
||||
/// they were made).
|
||||
/// 3. Marks the period closed.
|
||||
///
|
||||
/// Returns the closing entry's id, or `None` if there was nothing to
|
||||
@@ -2191,12 +2200,20 @@ impl Ledger {
|
||||
/// balance as of the period's last day.
|
||||
/// 2. The closing entry MUST balance, be dated the period's last day, and
|
||||
/// credit equity with exactly the net income through that day (split
|
||||
/// among members by share, to the cent, by largest remainder).
|
||||
/// among members by share, to the currency's minor unit, by largest
|
||||
/// remainder, cumulatively).
|
||||
/// 3. Entries dated after the period MUST stay untouched (their income
|
||||
/// belongs to their own period).
|
||||
/// 4. A sweep account (draws, distributions, dividends) MUST end at zero,
|
||||
/// its balance moved into its target.
|
||||
/// 5. NEVER post a closing entry when there is nothing to close.
|
||||
/// 6. After a members close that posts, each member's total from the
|
||||
/// count's closes MUST be one largest-remainder split of everything
|
||||
/// they closed: under one minor unit from their exact share. A
|
||||
/// members close that posts nothing credits no one (any true-up
|
||||
/// waits for the next close that posts).
|
||||
/// 7. NEVER changes a posted close: earlier closes replay under the
|
||||
/// rule they were journaled with.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the period is unknown or already closed: SHOULD refuse.
|
||||
/// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod).
|
||||
@@ -2205,6 +2222,13 @@ impl Ledger {
|
||||
/// member shares aren't positive and summing to 1: SHOULD refuse.
|
||||
/// 5. IF closing to retained earnings and 3999 exists but isn't a
|
||||
/// postable equity account: SHOULD refuse (ControlAccountMismatch).
|
||||
/// 6. IF a journaled MembersCumulative names a start other than the
|
||||
/// one the books derive: SHOULD refuse (InvalidMembers)
|
||||
/// BECAUSE replay would split differently from the journal.
|
||||
/// 7. IF 50/50 members closed −399.23 in January (A −199.62, B
|
||||
/// −199.61), February closed to retained earnings, and March closes
|
||||
/// −399.23 to the members: SHOULD give A −199.61, B −199.62, so
|
||||
/// each has −399.23 (the count skips February's close).
|
||||
/// ```
|
||||
pub fn close_period_to(
|
||||
&mut self,
|
||||
@@ -2223,11 +2247,13 @@ impl Ledger {
|
||||
self.preview_close_period_to(id, to)?;
|
||||
let period = self.periods.get(&id).expect("previewed").clone();
|
||||
let (closings, net) = self.income_closings(period.end);
|
||||
let close_seq = self.next_close_seq();
|
||||
|
||||
// (account, amount to credit; negative debits): the net to its
|
||||
// targets, and each sweep account's balance into its target.
|
||||
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
|
||||
let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new();
|
||||
let mut members_closed = None;
|
||||
let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
|
||||
// An equity balance (credit-normal) is −movement; zeroing it
|
||||
// moves exactly that much into the target.
|
||||
@@ -2259,30 +2285,27 @@ impl Ledger {
|
||||
sweep(*a, *into, self);
|
||||
}
|
||||
}
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
||||
let parts = match to {
|
||||
periods::CloseTo::MembersLastAbsorbs(_) => {
|
||||
periods::allocate_last_absorbs(net, &shares)
|
||||
}
|
||||
periods::CloseTo::MembersPerClose(_) => periods::allocate(net, &shares),
|
||||
_ => {
|
||||
// Cumulative: what the run gives each member with
|
||||
// this close, less what it gave them before.
|
||||
let before = self.member_close_run(&member_key(members));
|
||||
let after = periods::allocate(before + net, &shares);
|
||||
let prior = periods::allocate(before, &shares);
|
||||
after.iter().zip(&prior).map(|(a, p)| a - p).collect()
|
||||
}
|
||||
};
|
||||
for (m, part) in members.iter().zip(parts) {
|
||||
equity.push((m.capital, part));
|
||||
_ => {
|
||||
let members = to.members().expect("every other target is members");
|
||||
// The sweeps first: whether an entry posts decides whether
|
||||
// the parts may carry a true-up. Their lines still follow
|
||||
// the parts in the entry.
|
||||
for m in members {
|
||||
if let Some(d) = m.distributions {
|
||||
sweep(d, m.capital, self);
|
||||
}
|
||||
}
|
||||
let posts = !closings.is_empty() || !sweep_lines.is_empty();
|
||||
let (parts, rule) = self.members_split(to, net, posts, close_seq)?;
|
||||
for (m, part) in members.iter().zip(&parts) {
|
||||
equity.push((m.capital, *part));
|
||||
}
|
||||
members_closed = Some(periods::MembersClosed {
|
||||
members: member_key(members),
|
||||
net,
|
||||
parts,
|
||||
rule,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2314,26 +2337,201 @@ impl Ledger {
|
||||
Some(entry)
|
||||
};
|
||||
|
||||
let close_seq = self
|
||||
.periods
|
||||
.values()
|
||||
.filter(|p| p.status == PeriodStatus::Closed)
|
||||
.count() as u64
|
||||
+ 1;
|
||||
let p = self.periods.get_mut(&id).expect("period exists");
|
||||
p.status = PeriodStatus::Closed;
|
||||
p.closing_entry = closing_entry;
|
||||
p.close_seq = Some(close_seq);
|
||||
p.member_close = match to {
|
||||
periods::CloseTo::Members(members) => Some(periods::MemberCloseRecord {
|
||||
periods::CloseTo::MembersRun(members) => Some(periods::MemberCloseRecord {
|
||||
members: member_key(members),
|
||||
net,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
p.members_closed = members_closed;
|
||||
Ok(closing_entry)
|
||||
}
|
||||
|
||||
/// The number the next close takes ([`Period::close_seq`]): one more
|
||||
/// than the closes so far.
|
||||
fn next_close_seq(&self) -> u64 {
|
||||
self.periods
|
||||
.values()
|
||||
.filter(|p| p.status == PeriodStatus::Closed)
|
||||
.count() as u64
|
||||
+ 1
|
||||
}
|
||||
|
||||
/// What a members close (`to`, closing `net`; `posts` when its entry
|
||||
/// posts; numbered `close_seq`) credits each member, in `to`'s order,
|
||||
/// and the rule it is made under: the rule journaled for replayed
|
||||
/// closes, else the cumulative rule (rules.md B-13): each member's
|
||||
/// split of the count's running total, to the base currency's minor
|
||||
/// unit, less what the count's earlier closes gave them. A close that
|
||||
/// posts nothing credits nothing, so a true-up of earlier rounding
|
||||
/// rides only on an entry that posts (INVARIANT 5 stays true).
|
||||
fn members_split(
|
||||
&self,
|
||||
to: &periods::CloseTo,
|
||||
net: Decimal,
|
||||
posts: bool,
|
||||
close_seq: u64,
|
||||
) -> Result<(Vec<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
|
||||
/// these members and shares, that ends with the latest close: zero if
|
||||
/// the latest close was anything else (or there is none).
|
||||
@@ -2408,9 +2606,13 @@ impl Ledger {
|
||||
accounts.push(*into);
|
||||
accounts.extend(sweep);
|
||||
}
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
_ => {
|
||||
let members = to.members().expect("every other target is members");
|
||||
if let periods::CloseTo::MembersCumulative { since_seq: 0, .. } = to {
|
||||
return Err(LedgerError::InvalidMembers(
|
||||
"a count of members closes starts at close 1 or later, not 0".into(),
|
||||
));
|
||||
}
|
||||
let total: Decimal = members.iter().map(|m| m.share).sum();
|
||||
if members.is_empty()
|
||||
|| total != Decimal::ONE
|
||||
@@ -2467,7 +2669,9 @@ impl Ledger {
|
||||
/// [`Self::close_period_to`] would succeed — the period exists and is
|
||||
/// open, no draft entries are dated inside it, no later period is
|
||||
/// closed, the closing date isn't inside another closed period, and the
|
||||
/// target is valid — and returns the id the closing entry will receive,
|
||||
/// target is valid (for members: their split computes exactly, and a
|
||||
/// journaled start is the one the books derive) — and returns the id
|
||||
/// the closing entry will receive,
|
||||
/// or `None` when the close posts no entry. Stores call this before
|
||||
/// journaling so a failed close leaves no journal line behind.
|
||||
pub fn preview_close_period_to(
|
||||
@@ -2510,20 +2714,36 @@ impl Ledger {
|
||||
// An entry posts exactly when there is income to close or a sweep
|
||||
// account with a balance. Account creation (Retained Earnings) does
|
||||
// not consume entry ids.
|
||||
let (closings, _) = self.income_closings(period.end);
|
||||
let (closings, net) = self.income_closings(period.end);
|
||||
let sweeps: Vec<AccountId> = match to {
|
||||
periods::CloseTo::RetainedEarnings { sweep }
|
||||
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||||
periods::CloseTo::Members(members)
|
||||
| periods::CloseTo::MembersPerClose(members)
|
||||
| periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
members.iter().filter_map(|m| m.distributions).collect()
|
||||
}
|
||||
_ => to
|
||||
.members()
|
||||
.expect("every other target is members")
|
||||
.iter()
|
||||
.filter_map(|m| m.distributions)
|
||||
.collect(),
|
||||
};
|
||||
let any = !closings.is_empty()
|
||||
|| sweeps
|
||||
.iter()
|
||||
.any(|a| !self.movement_through(*a, period.end).is_zero());
|
||||
if to.members().is_some() {
|
||||
// The journaled start must be the derived one, and the split
|
||||
// must be computable: a close this refuses journals nothing.
|
||||
let (_, rule) = self.members_split(to, net, any, self.next_close_seq())?;
|
||||
if let (
|
||||
periods::CloseTo::MembersCumulative { since_seq, .. },
|
||||
periods::MemberRule::Cumulative { since_seq: derived },
|
||||
) = (to, rule)
|
||||
&& *since_seq != derived
|
||||
{
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"this close was journaled counting the members' closes from close {since_seq}, but the books count them from close {derived}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(any.then_some(self.next_entry_id))
|
||||
}
|
||||
|
||||
@@ -5267,7 +5487,80 @@ fn check_line_size(amount: Decimal) -> Result<(), LedgerError> {
|
||||
}
|
||||
|
||||
/// A members close's (capital account, share) list, in order: what a run
|
||||
/// of cumulative closes must share.
|
||||
/// of run-rule closes must share in that order, and a count of cumulative
|
||||
/// closes as a set ([`same_member_set`]).
|
||||
fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> {
|
||||
members.iter().map(|m| (m.capital, m.share)).collect()
|
||||
}
|
||||
|
||||
/// Two members closes' (capital account, share) lists name the same
|
||||
/// members with the same shares, in any order (rules.md B-13: a reorder of
|
||||
/// the members is not a change of members). Shares compare by value, so
|
||||
/// 0.5 is 0.50.
|
||||
fn same_member_set(a: &[(AccountId, Decimal)], b: &[(AccountId, Decimal)]) -> bool {
|
||||
let sorted = |v: &[(AccountId, Decimal)]| {
|
||||
let mut v = v.to_vec();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
a.len() == b.len() && sorted(a) == sorted(b)
|
||||
}
|
||||
|
||||
/// A members regime ([`Ledger::members_regime`]): the members closes with
|
||||
/// one set of members and shares since the last close with another.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Regime {
|
||||
/// The close the count starts at (its [`Period::close_seq`]); `None`
|
||||
/// when no members close with this set is counted yet.
|
||||
pub since_seq: Option<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 payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{
|
||||
CloseTo, MemberClose, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
CloseTo, MemberClose, MemberCloseRecord, MemberRule, MemberSplit, MembersClosed, Period,
|
||||
PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
};
|
||||
pub use reports::{
|
||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
|
||||
|
||||
@@ -57,6 +57,18 @@ impl Currency {
|
||||
}
|
||||
}
|
||||
|
||||
/// The decimal places a share of an amount in this currency is split
|
||||
/// to (members' shares of earnings, periods::allocate_cumulative): its
|
||||
/// minor units (cents, whole yen, thousandths of a dinar), or, for a
|
||||
/// code ISO 4217 gives none (gold, SDRs), the most a line may carry
|
||||
/// (kernel::MAX_LINE_SCALE). Every posted amount is then a whole number
|
||||
/// of the unit split to, so no member's running share is ever a unit
|
||||
/// from exact (rules.md B-13); split to cents, a finer amount's
|
||||
/// leftover fraction would all go to one member.
|
||||
pub fn allocation_places(&self) -> u32 {
|
||||
self.minor_units().unwrap_or(crate::kernel::MAX_LINE_SCALE)
|
||||
}
|
||||
|
||||
/// The three-letter code, e.g. `"USD"`.
|
||||
pub fn code(&self) -> &str {
|
||||
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
|
||||
|
||||
@@ -51,11 +51,30 @@ pub enum CloseTo {
|
||||
/// Members' capital accounts by share (shares sum to 1): the
|
||||
/// partnership convention, where each member's capital account carries
|
||||
/// their allocated earnings and absorbs their distributions. Odd cents
|
||||
/// go by largest remainder ([`allocate`]), cumulatively: a run of
|
||||
/// member closes with the same members and shares gives each member,
|
||||
/// in total, their share of the run's total to the cent, so rounding
|
||||
/// never piles up close after close (rules.md B-13; since 2026-10-13).
|
||||
/// go by largest remainder, cumulatively from the first members close
|
||||
/// with these members and shares ([`allocate_cumulative`]): each close
|
||||
/// gives a member their share of everything closed to the members so
|
||||
/// far, to the currency's minor unit, less what earlier closes gave
|
||||
/// them, so no member is ever a minor unit from their exact share
|
||||
/// (rules.md B-13; D-10). What new closes are made with; the books
|
||||
/// journal it as [`CloseTo::MembersCumulative`].
|
||||
Members(Vec<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
|
||||
/// remainder), the rule for closes journaled on 2026-10-13 before the
|
||||
/// cumulative one. Only replay of those journals produces it.
|
||||
@@ -66,6 +85,21 @@ pub enum CloseTo {
|
||||
MembersLastAbsorbs(Vec<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.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberClose {
|
||||
@@ -84,6 +118,8 @@ pub struct MemberClose {
|
||||
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
|
||||
/// always sum to `amount` exactly, and no member is favored by position
|
||||
/// (owner, 2026-10-13). A loss splits as the negation of the same gain.
|
||||
/// Closes journaled under the rules of 2026-10-13 replay with it, in cents
|
||||
/// whatever the currency; new closes split with [`allocate_cumulative`].
|
||||
pub fn allocate(
|
||||
amount: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
@@ -122,6 +158,104 @@ pub fn allocate_last_absorbs(
|
||||
parts
|
||||
}
|
||||
|
||||
/// What one members close gives each member: [`allocate`]'s split of the
|
||||
/// running `total` (everything closed to these members and shares,
|
||||
/// this close included), to `places` decimal places
|
||||
/// ([`crate::Currency::allocation_places`]) instead of cents, less what
|
||||
/// earlier closes gave each member (`given`, in the order of `shares`).
|
||||
/// Each member's running total is then one split of the running total
|
||||
/// (rules.md B-13; [`crate::kernel::apportion_cumulative`]). A loss is
|
||||
/// the negation of the same gain: it splits |total| against `given`
|
||||
/// negated, then negates.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: no member's running share of earnings is ever a minor unit from
|
||||
/// their exact share, whatever earlier closes gave them.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b); largest remainder: DECISION, owner
|
||||
/// 2026-10-13; from the first close: D-10); B-3 (the minor unit).
|
||||
/// INVARIANTS:
|
||||
/// 1. The parts MUST sum to total − Σ given exactly.
|
||||
/// 2. given[i] + part[i] MUST be member i's largest-remainder split of
|
||||
/// total to `places` (ties to the earlier member): one split.
|
||||
/// 3. So when total is a whole number of units of `places` (every posted
|
||||
/// line is at its currency's allocation_places: its minor unit, B-3,
|
||||
/// or for a currency without one the finest a line carries),
|
||||
/// |given[i] + part[i] − total × share[i]| is under one unit (Lean
|
||||
/// allocW_drift, cumulative_drift; Kani
|
||||
/// cumulative_close_never_drifts_a_unit_at_*).
|
||||
/// 4. NEVER rounds a product: if |total| × share isn't exact in Decimal
|
||||
/// (a share of many decimal places times a large total), refuse
|
||||
/// (TooLargeToCompute): a rounded product could rank the remainders
|
||||
/// wrong, and the bound above assumes exact ones (P3 review R-5).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF 101 yen splits 0.5 / 0.25 / 0.25 with nothing given: SHOULD be
|
||||
/// 51 / 25 / 25 (exact 50.5 / 25.25 / 25.25; the yen to the largest
|
||||
/// remainder), never fractions of a yen.
|
||||
/// 2. IF 0.03 was given as A 0.03 / B 0.00 (50/50) and the total is now
|
||||
/// 0.04: SHOULD be A −0.01, B +0.02.
|
||||
/// 3. IF the total is a loss: SHOULD be the negation of the same gain
|
||||
/// against the negated given.
|
||||
/// 4. IF the shares and given don't pair up, or places exceeds what a
|
||||
/// line may carry: SHOULD refuse.
|
||||
/// ```
|
||||
pub fn allocate_cumulative(
|
||||
total: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
given: &[rust_decimal::Decimal],
|
||||
places: u32,
|
||||
) -> Result<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
|
||||
/// debit), created on the first close if the chart lacks it.
|
||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||
@@ -145,13 +279,23 @@ pub struct Period {
|
||||
/// closed.
|
||||
#[serde(default)]
|
||||
pub close_seq: Option<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)]
|
||||
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
|
||||
/// net it closed. The next close with the same members continues the run.
|
||||
/// A run-rule members close ([`CloseTo::MembersRun`]): who shared, and the
|
||||
/// net it closed. The next run-rule close with the same members continues
|
||||
/// the run.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MemberCloseRecord {
|
||||
/// (capital account, share), in the order given.
|
||||
@@ -160,6 +304,63 @@ pub struct MemberCloseRecord {
|
||||
pub net: rust_decimal::Decimal,
|
||||
}
|
||||
|
||||
/// The rounding rule a members close was made under, as journaled.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MemberRule {
|
||||
/// The last member absorbed the rounding (closes before 2026-10-13;
|
||||
/// [`CloseTo::MembersLastAbsorbs`]).
|
||||
LastAbsorbs,
|
||||
/// Largest remainder, each close on its own (2026-10-13;
|
||||
/// [`CloseTo::MembersPerClose`]).
|
||||
PerClose,
|
||||
/// Cumulative over a back-to-back run (2026-10-13 to P3;
|
||||
/// [`CloseTo::MembersRun`]).
|
||||
Run,
|
||||
/// Cumulative from the members close numbered `since_seq`
|
||||
/// ([`CloseTo::Members`], [`CloseTo::MembersCumulative`]).
|
||||
Cumulative {
|
||||
/// The close the count starts at.
|
||||
since_seq: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A members close's split, under any rule ([`Period::members_closed`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MembersClosed {
|
||||
/// (capital account, share), in the close's order.
|
||||
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
|
||||
/// The net income this close allocated (negative for a loss).
|
||||
pub net: rust_decimal::Decimal,
|
||||
/// The income it credited each member's capital account (debited for
|
||||
/// a negative part), in the same order: their share of `net` plus any
|
||||
/// true-up. All zero when the close posted no entry.
|
||||
pub parts: Vec<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 {
|
||||
/// True when `date` falls inside the period (inclusive).
|
||||
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||
@@ -193,6 +394,101 @@ pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), Led
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Currency, Ledger};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn decs(v: &[&str]) -> Vec<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 {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||
//! produce byte-identical [`snapshot`] output.
|
||||
|
||||
use crate::periods::allocate;
|
||||
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -842,32 +841,45 @@ pub fn cancelled_in_span(
|
||||
/// distributions (debits). Earnings are partnership earnings: net income
|
||||
/// for the period, and earnings from before it not yet distributed (retained
|
||||
/// earnings plus unclosed income), are allocated by share. Allocation is to
|
||||
/// the cent, odd cents by largest remainder, so every row's total is exact
|
||||
/// and the ending total equals balance-sheet equity.
|
||||
/// the currency's minor unit, odd units by largest remainder, the same
|
||||
/// running way the members closes split them, so every row's total is
|
||||
/// exact and the ending total equals balance-sheet equity.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: each member's capital roll-forward for a partnership over a
|
||||
/// period: beginning, contributions, allocated net income, distributions,
|
||||
/// ending; with a total.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule
|
||||
/// M-2), B-11; R-9c.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b); Form 1065 Schedule M-2; D-38), B-11;
|
||||
/// R-9c.
|
||||
/// INVARIANTS:
|
||||
/// 1. Each row MUST roll forward exactly: beginning + contributions +
|
||||
/// income - distributions + other = ending.
|
||||
/// 2. The total ending MUST equal balance-sheet equity at `end`.
|
||||
/// 3. Undistributed earnings MUST be split by share, to the cent, by
|
||||
/// largest remainder (DECISION, owner 2026-10-13), cumulatively as
|
||||
/// of each date: a member's beginning is their capital and share as
|
||||
/// of the day before `start`, their ending as of `end`.
|
||||
/// 3. Undistributed earnings U MUST be split by share, to the base
|
||||
/// currency's minor unit, by largest remainder (DECISION, owner
|
||||
/// 2026-10-13), as of each date d, the way the next members close
|
||||
/// would split them: allocate(C + U) − allocate(C), where C is what
|
||||
/// the members regime's closes dated on or before d closed
|
||||
/// (Ledger::members_regime; D-38, P3 review R-1). A member's
|
||||
/// beginning is their capital and share as of the day before
|
||||
/// `start`, their ending as of `end`. Tied units, "other"'s
|
||||
/// included, go to the member the count's first close listed first
|
||||
/// (Regime::allocate; on a day before it, the count the next members
|
||||
/// close begins), so the order `members` lists them in moves no unit
|
||||
/// once a close has begun the count.
|
||||
/// 3a. A member's ending on one statement MUST equal their beginning on
|
||||
/// the next, exactly (a capital account is one running balance: Form
|
||||
/// 1065 Schedule K-1 instructions, Item L, "beginning capital account
|
||||
/// and ending capital account ... based on the partnership's books").
|
||||
/// the next, exactly (a capital account is one running balance of
|
||||
/// contributions, allocations and distributions: Treas. Reg.
|
||||
/// §1.704-1(b)(2)(iv)(b)).
|
||||
/// 3b. A member's income is the roll-forward's remainder: their share of
|
||||
/// the period's income, off by less than two cents (under one cent of
|
||||
/// rounding at each end of the cumulative split); the members'
|
||||
/// incomes MUST sum to the period's net income. (Earnings a close has
|
||||
/// already moved into capital were rounded by that close, B-13.)
|
||||
/// the period's income, off by less than two minor units (under one
|
||||
/// at each end of the cumulative split); the members' incomes MUST
|
||||
/// sum to the period's net income.
|
||||
/// 3c. A member's capital as of a date MUST NOT change when a period
|
||||
/// ending on or before it is later closed to the members, except by a
|
||||
/// true-up of rounding by closes made under an earlier rule, which
|
||||
/// shows only once a close posts it (D-38; R-1).
|
||||
/// 4. NEVER hide an unexplained change: it goes in "other".
|
||||
/// 5. An entry dated `start` is period activity, not beginning capital.
|
||||
/// 6. An entry and its reversal both dated in the span cancel
|
||||
@@ -981,8 +993,8 @@ pub fn members_equity(
|
||||
// earnings not yet distributed. Each member's share of it is allocated
|
||||
// cumulatively, as of a date, so a member's capital on a date never
|
||||
// depends on which statement shows it: one statement's ending is the
|
||||
// next one's beginning, to the cent (a capital account is one running
|
||||
// balance: Form 1065 Schedule K-1 instructions, Item L).
|
||||
// next one's beginning, to the minor unit (a capital account is one
|
||||
// running balance: Treas. Reg. §1.704-1(b)(2)(iv)(b)).
|
||||
let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal {
|
||||
let mut m = Decimal::ZERO;
|
||||
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
|
||||
@@ -1003,19 +1015,45 @@ pub fn members_equity(
|
||||
.map_or(Decimal::ZERO, |d| capital_through(d, end))
|
||||
})
|
||||
.collect();
|
||||
let begin_alloc = allocate(
|
||||
// As of a date, a member's share of the earnings not yet in capital is
|
||||
// what the next members close would give them of it: their split of
|
||||
// everything closed to these members and shares (the regime's closes
|
||||
// through that date) plus it, less their split of the closed part
|
||||
// alone. Closing a period moves earnings from here into capital
|
||||
// without changing the sum, so a member's capital on a date is the
|
||||
// same before and after its period closes (D-38); a true-up of
|
||||
// rounding by closes made under an earlier rule shows only once a
|
||||
// close posts it (P3 review R-1).
|
||||
let places = ledger.base_currency().allocation_places();
|
||||
let key: Vec<(crate::AccountId, Decimal)> = members
|
||||
.iter()
|
||||
.map(|m| (m.capital_account, m.share))
|
||||
.collect();
|
||||
let nothing = vec![Decimal::ZERO; members.len()];
|
||||
let unclosed_share = |undistributed: Decimal, regime: &crate::ledger::Regime| {
|
||||
let closed = regime.allocate(&key, regime.closed, ¬hing, places)?;
|
||||
regime.allocate(&key, regime.closed + undistributed, &closed, places)
|
||||
};
|
||||
let end_regime = ledger.members_regime(&key, Some(end));
|
||||
let begin_alloc = unclosed_share(
|
||||
begin_bs.total_equity - capital.iter().sum::<Decimal>(),
|
||||
&shares,
|
||||
);
|
||||
let end_alloc = allocate(
|
||||
&ledger.members_regime(&key, Some(day_before)),
|
||||
)?;
|
||||
let end_alloc = unclosed_share(
|
||||
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
|
||||
&shares,
|
||||
);
|
||||
&end_regime,
|
||||
)?;
|
||||
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,
|
||||
&shares,
|
||||
);
|
||||
¬hing,
|
||||
places,
|
||||
)?;
|
||||
|
||||
let mut cols = Vec::with_capacity(members.len());
|
||||
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()) {
|
||||
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")
|
||||
)
|
||||
);
|
||||
// February's 100.01 isn't closed yet: each member's share of it is
|
||||
// what February's close will give them (D-38): the running split of
|
||||
// 666.67 + 100.01 = 766.68 is 383.34 each, less January's 333.34 /
|
||||
// 333.33, so Alice 50.00 and Bob 50.01. Bob ends 733.33 − 100 + 50.01.
|
||||
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||||
assert_eq!(feb_me.members[0].beginning, cents("933.34"));
|
||||
assert_eq!(feb_me.members[1].ending, cents("683.33"));
|
||||
assert_eq!(feb_me.members[1].ending, cents("683.34"));
|
||||
assert_eq!(
|
||||
feb_me.total.ending,
|
||||
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
||||
@@ -1595,6 +1599,14 @@ mod cumulative_closes {
|
||||
(cents("0.02"), cents("0.01")),
|
||||
"cumulative: within a cent of half of 0.03 each"
|
||||
);
|
||||
// An unbroken run splits the same under the run rule of
|
||||
// 2026-10-13 (journaled "cumulative"), which still replays.
|
||||
let (l, caps) = three_cent_months(|c| CloseTo::MembersRun(half(c)));
|
||||
assert_eq!(
|
||||
(bal(&l, caps[0]), bal(&l, caps[1])),
|
||||
(cents("0.02"), cents("0.01")),
|
||||
"the run rule"
|
||||
);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
@@ -1645,11 +1657,34 @@ mod cumulative_closes {
|
||||
}
|
||||
}
|
||||
|
||||
/// A run is closes with the same members and shares, back to back: a
|
||||
/// close to anything else (or with other shares) ends it, and the next
|
||||
/// members close starts afresh.
|
||||
/// Under the run rule of 2026-10-13 (CloseTo::MembersRun, journaled
|
||||
/// "cumulative"; only replay makes it now), a run is closes with the
|
||||
/// same members and shares, back to back: a close to anything else ends
|
||||
/// it, and the next members close starts afresh, so a tied cent can go
|
||||
/// to the same member again (P3 defect 17). Closes journaled under it
|
||||
/// still replay that way.
|
||||
#[test]
|
||||
fn a_different_close_ends_the_run() {
|
||||
fn a_different_close_ends_a_legacy_run() {
|
||||
let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::MembersRun);
|
||||
assert_eq!((a, b), (cents("0.02"), cents("0")));
|
||||
}
|
||||
|
||||
/// The rule since P3 (rules.md B-13, D-10): a close to retained
|
||||
/// earnings allocates nothing to the members and is skipped, so March's
|
||||
/// members close continues January's count: the running 0.02 splits
|
||||
/// 0.01 / 0.01, and March gives B the cent.
|
||||
#[test]
|
||||
fn a_different_close_no_longer_restarts_the_count() {
|
||||
let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::Members);
|
||||
assert_eq!((a, b), (cents("0.01"), cents("0.01")));
|
||||
}
|
||||
|
||||
/// A cent in each of January, February and March, 50/50; January and
|
||||
/// March close to the members with `to`, February to retained
|
||||
/// earnings. The members' capital balances after March.
|
||||
fn cent_months_around_a_retained_earnings_close(
|
||||
to: fn(Vec<MemberClose>) -> CloseTo,
|
||||
) -> (Decimal, Decimal) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||
let cash = add("1000", AccountType::Asset);
|
||||
@@ -1684,15 +1719,16 @@ mod cumulative_closes {
|
||||
l.close_period_to(p, to).unwrap();
|
||||
};
|
||||
// Month 1 to members (A gets the cent), month 2 to retained
|
||||
// earnings (ends the run), month 3 to members again: a new run, so
|
||||
// A gets that cent too.
|
||||
// earnings, month 3 to members again.
|
||||
cent(&mut l, 1);
|
||||
close(&mut l, 1, &CloseTo::Members(half(&caps)));
|
||||
close(&mut l, 1, &to(half(&caps)));
|
||||
cent(&mut l, 2);
|
||||
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
|
||||
cent(&mut l, 3);
|
||||
close(&mut l, 3, &CloseTo::Members(half(&caps)));
|
||||
assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02"));
|
||||
assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0"));
|
||||
close(&mut l, 3, &to(half(&caps)));
|
||||
(
|
||||
l.account_balance(caps[0]).unwrap(),
|
||||
l.account_balance(caps[1]).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -405,11 +405,13 @@ pub struct CapitalLine {
|
||||
/// is empty, the liability accounts whose name says "due to" or
|
||||
/// "owed to" (DECISION: a name is a guess; the setting is the books'
|
||||
/// word). owed_to_member: this member's part of it by the members'
|
||||
/// shares (ledger_core::periods::allocate over every member, so the
|
||||
/// parts sum to the balance).
|
||||
/// shares, to the base currency's minor unit (ledger_core::periods::
|
||||
/// allocate_cumulative over every member with nothing given, ties to
|
||||
/// the member listed first, so the parts sum to the balance).
|
||||
/// 6. None when the party isn't a member, a [[company.members]]
|
||||
/// account isn't in the chart, or k1_detail refuses (shares not
|
||||
/// summing to 1, accounts not distinct equity accounts).
|
||||
/// summing to 1, accounts not distinct equity accounts), or the
|
||||
/// split of what is owed can't be computed exactly (TooLargeToCompute).
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CapitalStatement {
|
||||
@@ -1746,7 +1748,13 @@ impl Books {
|
||||
.map(|&a| self.balance_on(a, span.1))
|
||||
.sum();
|
||||
let shares: Vec<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 {
|
||||
party,
|
||||
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.
|
||||
- Never guess, and never look for the books' files: the tools are the evidence.
|
||||
- books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact.
|
||||
- A member's odd cent: closes, the statement of members' equity, the K-1 worksheet and the viewer split earnings among the members one running way. Each close gives a member their share of everything closed to the same members and shares so far, to the cent (odd cents by largest remainder, ties to the member listed first when that count began), less what earlier closes gave them, so no member is ever a cent from their exact share. Earnings not yet closed are split the way their close will split them, so a member's capital on a day never changes when its month is closed. books.close_period's members shows each member's part, running total and exact share; the first close that posts may carry a one-time correction of the rounding left by closes made under an earlier rule (a cent or so per member; each member's this_close shows it) (books.list_periods' close_rule names each close's rule). A change of members or shares starts a new count.
|
||||
@@ -5957,7 +5957,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.close_period",
|
||||
title = "Close a period",
|
||||
description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances as of that date into Retained Earnings, or, for a multi-member LLC (members in the settings), into each member's capital account by share. Periods close in order. Without confirmed=true, returns the exact closing entry it would post and closes nothing. With confirmed=true, posts it and marks the period closed. Closed periods reject new entries.",
|
||||
description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances into Retained Earnings or, for a multi-member LLC (members in the settings), into each member's capital account by share, cumulatively: each close gives a member their share of everything closed to the members so far, less what earlier closes gave them (odd cents by largest remainder), so no member is ever a cent off their exact share; a close may carry a one-time correction of earlier rounding. Periods close in order. Without confirmed=true, returns the exact closing entry and members' split, closing nothing; with confirmed=true, posts it and closes the period to new entries.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
@@ -5992,6 +5992,34 @@ impl BooksServer {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// The members' split, from the closed ledger (rules.md B-13).
|
||||
let split = |ledger: &Ledger| -> Option<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 {
|
||||
// Preview: run the real close on a copy of the ledger, so the
|
||||
// preview is exactly what confirming would post.
|
||||
@@ -6004,6 +6032,7 @@ impl BooksServer {
|
||||
period_id: p.period_id,
|
||||
closing_entry: None,
|
||||
lines: describe(&scratch, closing),
|
||||
members: split(&scratch),
|
||||
}));
|
||||
}
|
||||
let closing = store
|
||||
@@ -6015,13 +6044,14 @@ impl BooksServer {
|
||||
period_id: p.period_id,
|
||||
closing_entry: closing,
|
||||
lines,
|
||||
members: split(store.ledger()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.list_periods",
|
||||
title = "List periods",
|
||||
description = "List all accounting periods with their status.",
|
||||
description = "List all accounting periods with their status, and for a period closed to the members, the rounding rule it was closed under.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_periods(&self) -> Result<Json<ListPeriodsOut>, String> {
|
||||
@@ -6040,6 +6070,14 @@ impl BooksServer {
|
||||
PeriodStatus::Closed => "closed".to_string(),
|
||||
},
|
||||
closing_entry: p.closing_entry,
|
||||
close_rule: p.members_closed.as_ref().map(|m| match m.rule {
|
||||
ledger_core::MemberRule::Cumulative { since_seq } => {
|
||||
format!("members_cumulative_since:{since_seq}")
|
||||
}
|
||||
ledger_core::MemberRule::Run => "members_run".to_string(),
|
||||
ledger_core::MemberRule::PerClose => "members_per_close".to_string(),
|
||||
ledger_core::MemberRule::LastAbsorbs => "members_last_absorbs".to_string(),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(ListPeriodsOut { periods }))
|
||||
|
||||
@@ -2162,6 +2162,46 @@ pub struct ClosePeriodOut {
|
||||
pub closing_entry: Option<u64>,
|
||||
/// Human-readable summary of the closing lines.
|
||||
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`.
|
||||
@@ -2179,6 +2219,13 @@ pub struct PeriodOut {
|
||||
pub status: String,
|
||||
/// Closing entry id, if closed with one.
|
||||
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`.
|
||||
|
||||
@@ -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]
|
||||
fn mcp_schedules_and_recurring_over_stdio() {
|
||||
let dir = test_dir("schedules");
|
||||
|
||||
@@ -26,8 +26,21 @@ use serde_json::{Value, json};
|
||||
/// The newest journal format this version reads and writes.
|
||||
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
|
||||
|
||||
/// Optional journal features this version reads (none yet).
|
||||
pub const KNOWN_FEATURES: &[&str] = &[];
|
||||
/// Optional journal features this version reads. Each is a field or value
|
||||
/// on an event type an older version knows, which that version would
|
||||
/// misread silently; a writer declares the feature (`journal.format_set`)
|
||||
/// before its first such line, so the older version stops there instead
|
||||
/// (rules.md B-27). Books that never use one stay readable by versions
|
||||
/// before it.
|
||||
pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
/// "cumulative_since"` and `since_seq`, the count of members closes
|
||||
/// starting at the first since the members or shares last changed (P3
|
||||
/// defect 17, rules.md B-13). An older version would call the rounding
|
||||
/// unknown, or (before the format gate) replay the close under the run
|
||||
/// rule, which can split it differently.
|
||||
pub const MEMBERS_CUMULATIVE: &str = "members_cumulative";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
@@ -188,6 +201,12 @@ impl JournalFormat {
|
||||
/// Read a `journal.format_set` line, refusing a format or feature this
|
||||
/// version doesn't read.
|
||||
pub(crate) fn declared(line: &JournalLine) -> Result<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 format = p
|
||||
.get("format")
|
||||
@@ -216,7 +235,7 @@ pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError>
|
||||
let unknown: Vec<&str> = features
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|f| !KNOWN_FEATURES.contains(f))
|
||||
.filter(|f| !known.contains(f))
|
||||
.collect();
|
||||
if !unknown.is_empty() {
|
||||
return Err(newer(
|
||||
@@ -485,4 +504,30 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// P3 defect 17's feature (members_cumulative): this version reads it;
|
||||
/// a version before it (KNOWN_FEATURES empty, as through P2) stops at
|
||||
/// its declaration with NewerJournal, never replaying a close counted
|
||||
/// from its regime's start under the run rule.
|
||||
#[test]
|
||||
fn an_older_reader_stops_at_the_members_cumulative_feature() {
|
||||
assert!(KNOWN_FEATURES.contains(&MEMBERS_CUMULATIVE));
|
||||
let l = line(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": [MEMBERS_CUMULATIVE]}),
|
||||
);
|
||||
assert_eq!(
|
||||
declared(&l).unwrap(),
|
||||
JournalFormat {
|
||||
format: 2,
|
||||
features: vec![MEMBERS_CUMULATIVE.to_string()]
|
||||
}
|
||||
);
|
||||
match declared_knowing(&l, &[]) {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, "journal features members_cumulative")
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,6 +583,29 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Declare a journal feature (crate::format::KNOWN_FEATURES) before the
|
||||
/// first event that needs it: a field or value on a known event type
|
||||
/// that an older version would otherwise misread silently. Declared
|
||||
/// with the format in force (at least 2) and every feature already in
|
||||
/// force; nothing is journaled when it is already declared. Books that
|
||||
/// never use the feature stay readable by older versions.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
|
||||
/// instead of misreading a line it thinks it knows.
|
||||
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
|
||||
/// ```
|
||||
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
|
||||
if self.format.features.iter().any(|f| f == feature) {
|
||||
return Ok(());
|
||||
}
|
||||
let format = self.format.format.max(crate::meta::FORMAT);
|
||||
let mut features = self.format.features.clone();
|
||||
features.push(feature.to_string());
|
||||
let features: Vec<&str> = features.iter().map(String::as_str).collect();
|
||||
self.declare_journal_format(format, &features)
|
||||
}
|
||||
|
||||
/// Change settings (`settings.set`): all or nothing, each value
|
||||
/// canonical, the result whole (ledger_core::settings). Returns the
|
||||
/// changes made; none (journaling nothing) when every change is the
|
||||
@@ -1798,6 +1821,11 @@ impl Store {
|
||||
// ensure then no-ops and replay stays deterministic.
|
||||
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
|
||||
}
|
||||
if matches!(to, CloseTo::Members(_) | CloseTo::MembersCumulative { .. }) {
|
||||
// A close counted from its regime's start: an older version
|
||||
// would replay it under the run rule (format gate, B-27).
|
||||
self.ensure_feature(crate::format::MEMBERS_CUMULATIVE)?;
|
||||
}
|
||||
let mut next = self.ledger.clone();
|
||||
let closing_entry = next.close_period_to(id, to)?;
|
||||
let entry_detail = match closing_entry {
|
||||
@@ -1818,9 +1846,8 @@ impl Store {
|
||||
CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
|
||||
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
|
||||
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
|
||||
CloseTo::Members(members)
|
||||
| CloseTo::MembersPerClose(members)
|
||||
| CloseTo::MembersLastAbsorbs(members) => {
|
||||
_ => {
|
||||
let members = to.members().expect("every other target is members");
|
||||
let mut v = json!({
|
||||
"members": members
|
||||
.iter()
|
||||
@@ -1835,7 +1862,18 @@ impl Store {
|
||||
// journaled before 2026-10-13 carry none (the last member
|
||||
// absorbed it).
|
||||
match to {
|
||||
CloseTo::Members(_) => v["rounding"] = json!("cumulative"),
|
||||
CloseTo::Members(_) | CloseTo::MembersCumulative { .. } => {
|
||||
let rule = next
|
||||
.get_period(id)
|
||||
.and_then(|p| p.members_closed.as_ref())
|
||||
.map(|m| m.rule);
|
||||
let Some(ledger_core::MemberRule::Cumulative { since_seq }) = rule else {
|
||||
unreachable!("a members close records its cumulative rule")
|
||||
};
|
||||
v["rounding"] = json!("cumulative_since");
|
||||
v["since_seq"] = json!(since_seq);
|
||||
}
|
||||
CloseTo::MembersRun(_) => v["rounding"] = json!("cumulative"),
|
||||
CloseTo::MembersPerClose(_) => v["rounding"] = json!("largest_remainder"),
|
||||
_ => {}
|
||||
}
|
||||
@@ -4739,7 +4777,7 @@ pub(crate) fn replay_line_into_ledger(
|
||||
// Deterministic: the close re-derives the closing entry from
|
||||
// the replayed state and the journaled target (absent: Retained
|
||||
// Earnings), so it must match the journal's id and every line.
|
||||
let to = close_to_from_journal(p.get("close_to"))?;
|
||||
let to = close_to_from_journal(line, p.get("close_to"))?;
|
||||
let got = ledger.close_period_to(id, &to)?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
@@ -5021,7 +5059,10 @@ fn tax_basis_from_journal(p: &Value) -> Result<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 ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
|
||||
v.as_array()
|
||||
@@ -5062,12 +5103,39 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
||||
.collect::<Result<_, StoreError>>()?;
|
||||
// The rounding rule the close was made under: absent before
|
||||
// 2026-10-13, when the last member absorbed it; "largest_remainder"
|
||||
// per close, then "cumulative" over a run of closes.
|
||||
match v.get("rounding").and_then(|r| r.as_str()) {
|
||||
None => Ok(CloseTo::MembersLastAbsorbs(members)),
|
||||
Some("largest_remainder") => Ok(CloseTo::MembersPerClose(members)),
|
||||
Some("cumulative") => Ok(CloseTo::Members(members)),
|
||||
Some(_) => Err(corrupt("unknown rounding")),
|
||||
// per close, then "cumulative" over a run of closes, then (P3)
|
||||
// "cumulative_since" the close numbered since_seq.
|
||||
let rounding = match v.get("rounding") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(r)) => Some(r.as_str()),
|
||||
Some(other) => {
|
||||
return Err(crate::format::newer_content(
|
||||
line,
|
||||
format!("a members close's rounding {other}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let since_seq = v.get("since_seq");
|
||||
let older = |to: CloseTo| match since_seq {
|
||||
// Only "cumulative_since" counts from a close.
|
||||
Some(_) => Err(corrupt("since_seq beside another rounding")),
|
||||
None => Ok(to),
|
||||
};
|
||||
match rounding {
|
||||
None => older(CloseTo::MembersLastAbsorbs(members)),
|
||||
Some("largest_remainder") => older(CloseTo::MembersPerClose(members)),
|
||||
Some("cumulative") => older(CloseTo::MembersRun(members)),
|
||||
Some("cumulative_since") => Ok(CloseTo::MembersCumulative {
|
||||
members,
|
||||
since_seq: since_seq
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|&s| s >= 1)
|
||||
.ok_or_else(|| corrupt("since_seq"))?,
|
||||
}),
|
||||
Some(other) => Err(crate::format::newer_content(
|
||||
line,
|
||||
format!("a members close's rounding {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
|
||||
# cargo run --bin expect -- OUT (expected.json beside each journal)
|
||||
# and copy each journal.jsonl, blobs/ and expected.json into old-books/.
|
||||
# x_member_closes was written later, the same way, by main 00226a4c
|
||||
# (2026-10-20, before P3's odd cent): `cargo run --bin members -- OUT`
|
||||
# (members closes under every rounding rule main wrote; P3 review R-2).
|
||||
[package]
|
||||
name = "writer"
|
||||
version = "0.1.0"
|
||||
@@ -22,3 +25,5 @@ rust_decimal = "=1.43.0"
|
||||
serde_json = "1"
|
||||
|
||||
[workspace]
|
||||
# main's crates (wb-main) keep their own workspace.
|
||||
exclude = ["wb-main"]
|
||||
@@ -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
|
||||
/// and replays under it: "cumulative" for new closes, "largest_remainder"
|
||||
/// for the per-close rule of earlier on 2026-10-13. Three one-cent months,
|
||||
/// 50/50: cumulative ends A 0.02 / B 0.01, per close A 0.03 / B 0.00.
|
||||
/// and replays under it: "cumulative_since" with the close its count starts
|
||||
/// at for new closes (P3), "cumulative" for the run rule of 2026-10-13 to
|
||||
/// P3, "largest_remainder" for the per-close rule of earlier on 2026-10-13.
|
||||
/// Three one-cent months, 50/50: both cumulative rules end A 0.02 / B
|
||||
/// 0.01, per close A 0.03 / B 0.00. With February closed to retained
|
||||
/// earnings instead, the two cumulative rules differ: cumulative_since
|
||||
/// skips it (the running 0.02 splits A 0.01 / B 0.01), the run rule
|
||||
/// restarts at March (A gets both cents: A 0.02 / B 0.00).
|
||||
#[test]
|
||||
fn members_closes_replay_under_the_rule_they_were_journaled_with() {
|
||||
for (cumulative, rule, a, b) in [
|
||||
(true, "\"rounding\":\"cumulative\"", "0.02", "0.01"),
|
||||
(false, "\"rounding\":\"largest_remainder\"", "0.03", "0.00"),
|
||||
use ledger_core::{CloseTo, MemberClose};
|
||||
type Rule = fn(Vec<MemberClose>) -> CloseTo;
|
||||
let since: Rule = CloseTo::Members;
|
||||
let run: Rule = CloseTo::MembersRun;
|
||||
let per: Rule = CloseTo::MembersPerClose;
|
||||
for (name, to, re_february, rule, a, b) in [
|
||||
(
|
||||
"since",
|
||||
since,
|
||||
false,
|
||||
"\"rounding\":\"cumulative_since\"",
|
||||
"0.02",
|
||||
"0.01",
|
||||
),
|
||||
(
|
||||
"run",
|
||||
run,
|
||||
false,
|
||||
"\"rounding\":\"cumulative\"",
|
||||
"0.02",
|
||||
"0.01",
|
||||
),
|
||||
(
|
||||
"per",
|
||||
per,
|
||||
false,
|
||||
"\"rounding\":\"largest_remainder\"",
|
||||
"0.03",
|
||||
"0.00",
|
||||
),
|
||||
(
|
||||
"since-re",
|
||||
since,
|
||||
true,
|
||||
"\"rounding\":\"cumulative_since\"",
|
||||
"0.01",
|
||||
"0.01",
|
||||
),
|
||||
(
|
||||
"run-re",
|
||||
run,
|
||||
true,
|
||||
"\"rounding\":\"cumulative\"",
|
||||
"0.02",
|
||||
"0.00",
|
||||
),
|
||||
] {
|
||||
let dir = test_dir(&format!("close-rule-{cumulative}"));
|
||||
let dir = test_dir(&format!("close-rule-{name}"));
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
@@ -2112,19 +2160,14 @@ fn members_closes_replay_under_the_rule_they_were_journaled_with() {
|
||||
let rev = store
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let members: Vec<ledger_core::MemberClose> = caps
|
||||
let members: Vec<MemberClose> = caps
|
||||
.iter()
|
||||
.map(|&capital| ledger_core::MemberClose {
|
||||
.map(|&capital| MemberClose {
|
||||
capital,
|
||||
share: Decimal::new(5, 1),
|
||||
distributions: None,
|
||||
})
|
||||
.collect();
|
||||
let to = if cumulative {
|
||||
ledger_core::CloseTo::Members(members)
|
||||
} else {
|
||||
ledger_core::CloseTo::MembersPerClose(members)
|
||||
};
|
||||
let cent = Money::new(Decimal::new(1, 2), Currency::USD);
|
||||
for m in 1..=3u32 {
|
||||
let day = |d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||
@@ -2139,16 +2182,26 @@ fn members_closes_replay_under_the_rule_they_were_journaled_with() {
|
||||
let p = store
|
||||
.create_period(&format!("2026-{m:02}"), day(1), end)
|
||||
.unwrap();
|
||||
store.close_period_to(p, &to).unwrap();
|
||||
if re_february && m == 2 {
|
||||
store.close_period(p).unwrap();
|
||||
} else {
|
||||
store.close_period_to(p, &to(members.clone())).unwrap();
|
||||
}
|
||||
}
|
||||
let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
assert_eq!(journal.matches(rule).count(), 3, "{rule}");
|
||||
let closes = if re_february { 2 } else { 3 };
|
||||
assert_eq!(journal.matches(rule).count(), closes, "{name}");
|
||||
if rule.contains("cumulative_since") {
|
||||
// Every count starts at the first close: February's close to
|
||||
// retained earnings is skipped, not a restart.
|
||||
assert_eq!(journal.matches("\"since_seq\":1").count(), closes, "{name}");
|
||||
}
|
||||
drop(store);
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
let bal = |id| store.ledger().account_balance(id).unwrap();
|
||||
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{rule}");
|
||||
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{rule}");
|
||||
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{name}");
|
||||
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user