From 74f61a5c7c4de97dc6ad68fa3bcb4de980b6802b Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Thu, 1 Oct 2026 18:31:57 +1000 Subject: [PATCH 1/2] fix: preserve other commodities in partial inclusive assignments --- hledger-lib/Hledger/Data/Balancing.hs | 96 +- hledger/CHANGES.md | 15 + hledger/hledger.m4.md | 6 + ...balance-assignments-partial-inclusive.test | 823 ++++++++++++++++++ 4 files changed, 931 insertions(+), 9 deletions(-) create mode 100644 hledger/test/journal/balance-assignments-partial-inclusive.test diff --git a/hledger-lib/Hledger/Data/Balancing.hs b/hledger-lib/Hledger/Data/Balancing.hs index e4c6d8eae97..e9234eb0294 100644 --- a/hledger-lib/Hledger/Data/Balancing.hs +++ b/hledger-lib/Hledger/Data/Balancing.hs @@ -628,6 +628,15 @@ getRunningBalanceB :: AccountName -> Balancing s MixedAmount getRunningBalanceB acc = withRunningBalance $ \BalancingState{bsBalances} -> do fromMaybe nullmixedamt <$> H.lookup bsBalances acc +-- | Get this account's current inclusive running balance, including all subaccounts. +getInclusiveRunningBalanceB :: AccountName -> Balancing s MixedAmount +getInclusiveRunningBalanceB parent = withRunningBalance $ \BalancingState{bsBalances} -> + H.foldM + (\ibal (acc, amt) -> return $ + if parent==acc || parent `isAccountNamePrefixOf` acc then maPlus ibal amt else ibal) + nullmixedamt + bsBalances + -- | Add this amount to this account's exclusive running balance. -- Returns the new running balance. addToRunningBalanceB :: AccountName -> MixedAmount -> Balancing s MixedAmount @@ -825,7 +834,8 @@ addOrAssignAmountAndCheckAssertionB (i,p@Posting{paccount=acc, pamount=amt, pbal then return $ mixedAmount baamount -- a partial balance assignment (=, one commodity) else do - oldbalothercommodities <- filterMixedAmount ((acommodity baamount /=) . acommodity) <$> getRunningBalanceB acc + oldbal <- (if bainclusive then getInclusiveRunningBalanceB else getRunningBalanceB) acc + let oldbalothercommodities = filterMixedAmount ((acommodity baamount /=) . acommodity) oldbal return $ maAddAmount oldbalothercommodities baamount diff <- (if bainclusive then setInclusiveRunningBalanceB else setRunningBalanceB) acc newbal let p' = p{pamount=filterMixedAmount (not . amountIsZero) diff, poriginal=Just $ originalPosting p} @@ -885,14 +895,7 @@ checkBalanceAssertionOneCommodityB p@Posting{paccount=assertedacct} assertedcomm -- let styled = maybe id styleAmounts mstyles actualbal' <- if isinclusive - then - -- sum the running balances of this account and any of its subaccounts seen so far - withRunningBalance $ \BalancingState{bsBalances} -> - H.foldM - (\ibal (acc, amt) -> return $ - if assertedacct==acc || assertedacct `isAccountNamePrefixOf` acc then maPlus ibal amt else ibal) - nullmixedamt - bsBalances + then getInclusiveRunningBalanceB assertedacct else return actualbal let assertedcomm = acommodity assertedcommbal @@ -1290,6 +1293,81 @@ tests_Balancing = case ej of Right j -> (jtxns j & headErr & tpostings & headErr & pamount & amountsRaw) @?= [num 1] -- PARTIAL headErrs succeed because non-null txns & postings lists given Left _ -> error' "balance-assignment test: shouldn't happen" + ,testGroup "balance-assignment operators" [ + testCase name $ do + let opening acc amt = transaction (fromGregorian 2026 01 01) + [post acc amt, post "equity" missingamt] + assignment = transaction (fromGregorian 2026 01 02) + [post' "assets" missingamt (assertion (usd 20)), post "equity" missingamt] + ej = journalBalanceTransactions defbalancingopts nulljournal{jtxns = + [ opening "assets" (eur 7) + , opening "assets" (usd 3) + , opening "assets:cash" (eur 10) + , opening "assets:cash" (usd 12) + , assignment + ]} + assertRight ej + case ej of + Right j -> do + let ps = tpostings $ headErr $ reverse $ jtxns j + map pamount ps @?= [expected, maNegate expected] + map paccount ps @?= ["assets", "equity"] + Left _ -> assertFailure "balance-assignment operators: journal did not balance" + | (name, assertion, expected) <- + [ ("partial exclusive", balassert, mixed [usd 17]) + , ("total exclusive", balassertTot, mixed [eur (-7), usd 17]) + , ("partial inclusive", balassertParInc, mixed [usd 5]) + , ("total inclusive", balassertTotInc, mixed [eur (-17), usd 5]) + ] + ] + + ,testGroup "partial inclusive assignments preserve raw amounts" [ + testCase name $ do + let ps = [vpost acc amt | (acc, amt) <- holdings] ++ + [ vpost "assets:cash" (usd 12) + , vpost' "assets" missingamt (balassertParInc (usd 20)) + , vpost' "assets" missingamt (balassertParInc (usd 20)) + ] + ej = journalBalanceTransactions defbalancingopts nulljournal{jtxns = + [transaction (fromGregorian 2026 01 01) ps]} + expected = [(acc, [amt]) | (acc, amt) <- holdings] ++ + [("assets:cash", [usd 12]), ("assets", [usd 8]), ("assets", [])] + assertRight ej + case ej of + Right j -> map (\p -> (paccount p, amountsRaw $ pamount p)) (journalPostings j) @?= expected + Left _ -> assertFailure "partial inclusive assignment: journal did not balance" + | (name, holdings) <- + [ ("positive other commodity", [("assets:cash", eur 10)]) + , ("negative other commodity", [("assets:cash", eur (-10))]) + , ("zero other commodity", [("assets:cash", eur 0)]) + , ("direct and nested balances", + [("assets", eur 7), ("assets:cash", eur 3), ("assets:cash:coins", eur 7)]) + , ("unit cost", [("assets:cash", (eur 10){acost=Just $ UnitCost (usd 2)})]) + , ("total cost", [("assets:cash", (eur 10){acost=Just $ TotalCost (usd 20)})]) + , ("matching unit costs on parent and child", + [ ("assets", (eur 7){acost=Just $ UnitCost (usd 2)}) + , ("assets:cash", (eur 10){acost=Just $ UnitCost (usd 2)}) + ]) + , ("opposite quantities with different unit costs", + [ ("assets:cash", (eur 10){acost=Just $ UnitCost (usd 2)}) + , ("assets:cash", (eur (-10)){acost=Just $ UnitCost (usd 3)}) + ]) + ] + ] + + ,testCase "partial inclusive assignment retains costs on its target" $ do + let target = (usd 20){acost=Just $ UnitCost (eur 2)} + ej = journalBalanceTransactions defbalancingopts nulljournal{jtxns = + [transaction (fromGregorian 2026 01 01) + [ vpost "assets:cash" (eur 10) + , vpost "assets:cash" (usd 12) + , vpost' "assets" missingamt (balassertParInc target) + ]]} + assertRight ej + case ej of + Right j -> (pamount $ headErr $ reverse $ journalPostings j) @?= mixed [usd (-12), target] + Left _ -> assertFailure "cost-bearing inclusive assignment: journal did not balance" + ,testCase "same-day-1" $ do assertRight $ journalBalanceTransactions defbalancingopts $ --2019/01/01 diff --git a/hledger/CHANGES.md b/hledger/CHANGES.md index 003a2298d49..ff812725b6e 100644 --- a/hledger/CHANGES.md +++ b/hledger/CHANGES.md @@ -10,6 +10,21 @@ User-visible changes in the hledger command line tool and library. +# Unreleased + +## Breaking changes + +- Partial inclusive balance assignments (`=*`) now preserve other commodities + in the balance including subaccounts. Previously, they could generate parent + postings that offset other commodity balances held in subaccounts. (#2093) + Re-reading affected journals can change inferred postings, counterpart amounts, + later assignments and reports. Review amountless `=*` postings on accounts + with subaccounts holding other commodities. Use `==*` when you intend to set + other inclusive commodity balances to zero; this does not generally reproduce + the previous behaviour when the parent held those commodities directly. + Make adjustments and their counterparts explicit when you need to preserve + exact historical amounts. + # 1.99.4 2026-09-10 ## Breaking changes diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index 852057fcb9b..de25cb376bb 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -2663,6 +2663,12 @@ The calculated amount depends on the account's balance in the commodity at that (which depends on the previously-dated postings of the commodity to that account since the last balance assertion or assignment). +As with balance assertions, `=` and `=*` assign only the specified commodity, +preserving balances in other commodities. `=*` includes subaccount balances; +`==` and `==*` also set other commodity balances to zero in their respective scopes. +An inclusive assignment adjusts the parent account's posting amount, leaving +existing subaccount postings unchanged. + Downsides: using balance assignments makes your journal less explicit; to know the exact amount posted, you have to run hledger or do the calculations yourself, instead of just reading it. Also balance diff --git a/hledger/test/journal/balance-assignments-partial-inclusive.test b/hledger/test/journal/balance-assignments-partial-inclusive.test new file mode 100644 index 00000000000..e098aa51e64 --- /dev/null +++ b/hledger/test/journal/balance-assignments-partial-inclusive.test @@ -0,0 +1,823 @@ +# Partial inclusive balance assignments preserve other commodities (#2093). + +# = uses the correct balance scope +< +2026-01-01 + assets 7 EUR + assets 3 USD + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets = 20 USD + equity + +2026-01-03 + assets 0 EUR = 7 EUR + assets 0 USD = 20 USD + assets 0 EUR =* 17 EUR + assets 0 USD =* 32 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -17 EUR + equity 0 USD = -32 USD + +$ hledger -f- check + +# == uses the correct balance scope +< +2026-01-01 + assets 7 EUR + assets 3 USD + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets == 20 USD + equity + +2026-01-03 + assets 0 EUR = 0 EUR + assets 0 USD = 20 USD + assets 0 EUR =* 10 EUR + assets 0 USD =* 32 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -10 EUR + equity 0 USD = -32 USD + +$ hledger -f- check + +# =* uses the correct balance scope +< +2026-01-01 + assets 7 EUR + assets 3 USD + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 20 USD + equity + +2026-01-03 + assets 0 EUR = 7 EUR + assets 0 USD = 8 USD + assets 0 EUR =* 17 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -17 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# ==* uses the correct balance scope +< +2026-01-01 + assets 7 EUR + assets 3 USD + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets ==* 20 USD + equity + +2026-01-03 + assets 0 EUR = -10 EUR + assets 0 USD = 8 USD + assets 0 EUR =* 0 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 0 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Partial inclusive: EUR 10, USD 12 to 20, repeated +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 20 USD + assets =* 20 USD + equity + +2026-01-03 + assets =* 20 USD + equity + +2026-01-04 + assets 0 EUR =* 10 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -10 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Partial inclusive: EUR 10, USD 12 to 5, repeated +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 5 USD + assets =* 5 USD + equity + +2026-01-03 + assets =* 5 USD + equity + +2026-01-04 + assets 0 EUR =* 10 EUR + assets 0 USD =* 5 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -10 EUR + equity 0 USD = -5 USD + +$ hledger -f- check + +# Partial inclusive: EUR 10, USD 12 to 12, repeated +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 12 USD + assets =* 12 USD + equity + +2026-01-03 + assets =* 12 USD + equity + +2026-01-04 + assets 0 EUR =* 10 EUR + assets 0 USD =* 12 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -10 EUR + equity 0 USD = -12 USD + +$ hledger -f- check + +# Partial inclusive: EUR 10, USD 12 to 0, repeated +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 0 USD + assets =* 0 USD + equity + +2026-01-03 + assets =* 0 USD + equity + +2026-01-04 + assets 0 EUR =* 10 EUR + assets 0 USD =* 0 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -10 EUR + equity 0 USD = 0 USD + +$ hledger -f- check + +# Partial inclusive: EUR 10, USD -12 to -20, repeated +< +2026-01-01 + assets:cash 10 EUR + assets:cash -12 USD + equity + +2026-01-02 + assets =* -20 USD + assets =* -20 USD + equity + +2026-01-03 + assets =* -20 USD + equity + +2026-01-04 + assets 0 EUR =* 10 EUR + assets 0 USD =* -20 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = -12 USD + equity 0 EUR = -10 EUR + equity 0 USD = 20 USD + +$ hledger -f- check + +# Partial inclusive: EUR 10, USD -12 to 20, repeated +< +2026-01-01 + assets:cash 10 EUR + assets:cash -12 USD + equity + +2026-01-02 + assets =* 20 USD + assets =* 20 USD + equity + +2026-01-03 + assets =* 20 USD + equity + +2026-01-04 + assets 0 EUR =* 10 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = -12 USD + equity 0 EUR = -10 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Partial inclusive: EUR -10, USD 12 to 20, repeated +< +2026-01-01 + assets:cash -10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 20 USD + assets =* 20 USD + equity + +2026-01-03 + assets =* 20 USD + equity + +2026-01-04 + assets 0 EUR =* -10 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = -10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 10 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Partial inclusive: EUR -10, USD 12 to 5, repeated +< +2026-01-01 + assets:cash -10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 5 USD + assets =* 5 USD + equity + +2026-01-03 + assets =* 5 USD + equity + +2026-01-04 + assets 0 EUR =* -10 EUR + assets 0 USD =* 5 USD + assets:cash 0 EUR = -10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 10 EUR + equity 0 USD = -5 USD + +$ hledger -f- check + +# Partial inclusive: EUR -10, USD 12 to 12, repeated +< +2026-01-01 + assets:cash -10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 12 USD + assets =* 12 USD + equity + +2026-01-03 + assets =* 12 USD + equity + +2026-01-04 + assets 0 EUR =* -10 EUR + assets 0 USD =* 12 USD + assets:cash 0 EUR = -10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 10 EUR + equity 0 USD = -12 USD + +$ hledger -f- check + +# Partial inclusive: EUR -10, USD 12 to 0, repeated +< +2026-01-01 + assets:cash -10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 0 USD + assets =* 0 USD + equity + +2026-01-03 + assets =* 0 USD + equity + +2026-01-04 + assets 0 EUR =* -10 EUR + assets 0 USD =* 0 USD + assets:cash 0 EUR = -10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 10 EUR + equity 0 USD = 0 USD + +$ hledger -f- check + +# Partial inclusive: EUR -10, USD -12 to -20, repeated +< +2026-01-01 + assets:cash -10 EUR + assets:cash -12 USD + equity + +2026-01-02 + assets =* -20 USD + assets =* -20 USD + equity + +2026-01-03 + assets =* -20 USD + equity + +2026-01-04 + assets 0 EUR =* -10 EUR + assets 0 USD =* -20 USD + assets:cash 0 EUR = -10 EUR + assets:cash 0 USD = -12 USD + equity 0 EUR = 10 EUR + equity 0 USD = 20 USD + +$ hledger -f- check + +# Partial inclusive: EUR -10, USD -12 to 20, repeated +< +2026-01-01 + assets:cash -10 EUR + assets:cash -12 USD + equity + +2026-01-02 + assets =* 20 USD + assets =* 20 USD + equity + +2026-01-03 + assets =* 20 USD + equity + +2026-01-04 + assets 0 EUR =* -10 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = -10 EUR + assets:cash 0 USD = -12 USD + equity 0 EUR = 10 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Partial inclusive: EUR 0, USD 12 to 20, repeated +< +2026-01-01 + assets:cash 0 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 20 USD + assets =* 20 USD + equity + +2026-01-03 + assets =* 20 USD + equity + +2026-01-04 + assets 0 EUR =* 0 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = 0 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 0 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Partial inclusive: EUR 0, USD 12 to 5, repeated +< +2026-01-01 + assets:cash 0 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 5 USD + assets =* 5 USD + equity + +2026-01-03 + assets =* 5 USD + equity + +2026-01-04 + assets 0 EUR =* 0 EUR + assets 0 USD =* 5 USD + assets:cash 0 EUR = 0 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 0 EUR + equity 0 USD = -5 USD + +$ hledger -f- check + +# Partial inclusive: EUR 0, USD 12 to 12, repeated +< +2026-01-01 + assets:cash 0 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 12 USD + assets =* 12 USD + equity + +2026-01-03 + assets =* 12 USD + equity + +2026-01-04 + assets 0 EUR =* 0 EUR + assets 0 USD =* 12 USD + assets:cash 0 EUR = 0 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 0 EUR + equity 0 USD = -12 USD + +$ hledger -f- check + +# Partial inclusive: EUR 0, USD 12 to 0, repeated +< +2026-01-01 + assets:cash 0 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 0 USD + assets =* 0 USD + equity + +2026-01-03 + assets =* 0 USD + equity + +2026-01-04 + assets 0 EUR =* 0 EUR + assets 0 USD =* 0 USD + assets:cash 0 EUR = 0 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 0 EUR + equity 0 USD = 0 USD + +$ hledger -f- check + +# Partial inclusive: EUR 0, USD -12 to -20, repeated +< +2026-01-01 + assets:cash 0 EUR + assets:cash -12 USD + equity + +2026-01-02 + assets =* -20 USD + assets =* -20 USD + equity + +2026-01-03 + assets =* -20 USD + equity + +2026-01-04 + assets 0 EUR =* 0 EUR + assets 0 USD =* -20 USD + assets:cash 0 EUR = 0 EUR + assets:cash 0 USD = -12 USD + equity 0 EUR = 0 EUR + equity 0 USD = 20 USD + +$ hledger -f- check + +# Partial inclusive: EUR 0, USD -12 to 20, repeated +< +2026-01-01 + assets:cash 0 EUR + assets:cash -12 USD + equity + +2026-01-02 + assets =* 20 USD + assets =* 20 USD + equity + +2026-01-03 + assets =* 20 USD + equity + +2026-01-04 + assets 0 EUR =* 0 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = 0 EUR + assets:cash 0 USD = -12 USD + equity 0 EUR = 0 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Nested accounts, siblings and similar names are counted correctly +< +2026-01-01 + assets 7 EUR + assets 3 USD + assets:cash 3 EUR + assets:cash:coins 7 EUR + assets:savings 5 USD + assets:savings:usd 7 USD + assets-other 99 EUR + assets2 99 USD + liabilities:assets 99 USD + equity + +2026-01-02 + assets =* 20 USD + equity + +2026-01-03 + assets 0 EUR =* 17 EUR + assets 0 USD =* 20 USD + assets 0 EUR = 7 EUR + assets 0 USD = 8 USD + assets-other 0 EUR = 99 EUR + assets2 0 USD = 99 USD + liabilities:assets 0 USD = 99 USD + +$ hledger -f- check + +# Changing USD, EUR and USD targets, same transaction: False +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 20 USD + equity + +2026-01-03 + assets =* 15 EUR + assets =* 22 USD + equity + +2026-01-04 + assets 0 EUR =* 15 EUR + assets 0 USD =* 22 USD + assets 0 EUR = 5 EUR + assets 0 USD = 10 USD + equity 0 EUR = -15 EUR + equity 0 USD = -22 USD + +$ hledger -f- check + +# Changing USD, EUR and USD targets, same transaction: True +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets =* 20 USD + assets =* 15 EUR + assets =* 22 USD + equity + +2026-01-04 + assets 0 EUR =* 15 EUR + assets 0 USD =* 22 USD + assets 0 EUR = 5 EUR + assets 0 USD = 10 USD + equity 0 EUR = -15 EUR + equity 0 USD = -22 USD + +$ hledger -f- check + +# Dates determine assignment state, while same-day and posting order are retained +< +2026-01-02 + assets:cash 10 EUR + assets:cash 12 USD + assets =* 20 USD + equity + +2026-01-04 + assets:cash 2 EUR + equity + +2026-01-03 + assets =* 20 USD + equity + +2026-01-01 + assets:cash 5 EUR + equity + +2026-01-05 + assets 0 EUR =* 17 EUR + assets 0 USD =* 20 USD + assets:cash 0 EUR = 17 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = -17 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Explicit zero remains a partial inclusive assertion +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets 0 USD =* 12 USD + equity = 0 GBP + +2026-01-03 + assets 0 EUR =* 10 EUR + assets 0 USD =* 12 USD + equity 0 EUR = -10 EUR + equity 0 USD = -12 USD + +$ hledger -f- check + +# Partial inclusive virtual assignments preserve other commodities +< +2026-01-01 + (assets:cash) 10 EUR + (assets:cash) 12 USD + (equity) -10 EUR + (equity) -12 USD + +2026-01-02 + (assets) =* 20 USD + (assets) =* 20 USD + (equity) -8 USD + +2026-01-03 + (assets) 0 EUR =* 10 EUR + (assets) 0 USD =* 20 USD + (equity) 0 EUR = -10 EUR + (equity) 0 USD = -20 USD + +$ hledger -f- check + +# Partial inclusive balanced virtual assignments preserve other commodities +< +2026-01-01 + [assets:cash] 10 EUR + [assets:cash] 12 USD + [equity] -10 EUR + [equity] -12 USD + +2026-01-02 + [assets] =* 20 USD + [assets] =* 20 USD + [equity] -8 USD + +2026-01-03 + [assets] 0 EUR =* 10 EUR + [assets] 0 USD =* 20 USD + [equity] 0 EUR = -10 EUR + [equity] 0 USD = -20 USD + +$ hledger -f- check + +# Real assignments include virtual postings in the existing running-balance scope +< +2026-01-01 + assets:cash 10 EUR + equity -10 EUR + (assets:cash) 12 USD + (equity) -12 USD + +2026-01-02 + assets =* 20 USD + equity + +2026-01-03 + assets 0 USD = 8 USD + assets 0 EUR =* 10 EUR + assets 0 USD =* 20 USD + equity 0 EUR = -10 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Leaf assignments preserve other commodities +< +2026-01-01 + assets:cash 10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets:cash =* 20 USD + assets:cash =* 20 USD + equity + +2026-01-03 + assets:cash 0 EUR = 10 EUR + assets:cash 0 USD = 20 USD + equity 0 EUR = -10 EUR + equity 0 USD = -20 USD + +$ hledger -f- check + +# Fractional amounts retain their exact quantities despite display precision +< +commodity EUR 1.00 +commodity USD 1.00 + +2026-01-01 + assets:cash 0.006 EUR + assets:cash 0.006 USD + equity + +2026-01-02 + assets =* 0.012 USD + equity + +2026-01-03 + assets 0 EUR =* 0.006 EUR + assets 0 USD =* 0.012 USD + +$ hledger -f- check + +# Total inclusive zero assignments still remove other currencies +< +2026-01-01 + assets 7 EUR + assets:cash -10 EUR + assets:cash 12 USD + equity + +2026-01-02 + assets ==* 0 USD + equity + +2026-01-03 + assets 0 EUR =* 0 EUR + assets 0 USD =* 0 USD + assets 0 EUR = 10 EUR + assets 0 USD = -12 USD + assets:cash 0 EUR = -10 EUR + assets:cash 0 USD = 12 USD + equity 0 EUR = 0 EUR + equity 0 USD = 0 USD + +$ hledger -f- check From 2c7ce1f365dc9386f4122a96d7ad935ce01ba4f1 Mon Sep 17 00:00:00 2001 From: Ryan Duguid Date: Thu, 8 Oct 2026 19:10:47 +1100 Subject: [PATCH 2/2] fix: preserve alias amounts exactly in partial inclusive assignments Convert the account's own balance and its subaccounts' total separately, as setInclusiveRunningBalanceB does, so commodities the assignment keeps cancel exactly. Converting the combined total left a residue when both held amounts in a commodity alias with a quantity. Inclusive assertion checking keeps upstream's code. --- hledger-lib/Hledger/Data/Balancing.hs | 29 +++++--- ...balance-assignments-partial-inclusive.test | 66 +++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/hledger-lib/Hledger/Data/Balancing.hs b/hledger-lib/Hledger/Data/Balancing.hs index d3d76450b2a..e0e5885c97f 100644 --- a/hledger-lib/Hledger/Data/Balancing.hs +++ b/hledger-lib/Hledger/Data/Balancing.hs @@ -678,14 +678,16 @@ getRunningBalanceB :: AccountName -> Balancing s MixedAmount getRunningBalanceB acc = withRunningBalance $ \BalancingState{bsBalances, bsAliases} -> do maConvertAliases bsAliases . fromMaybe nullmixedamt <$> H.lookup bsBalances acc --- | Get this account's current inclusive running balance, including all subaccounts. -getInclusiveRunningBalanceB :: AccountName -> Balancing s MixedAmount -getInclusiveRunningBalanceB parent = withRunningBalance $ \BalancingState{bsBalances, bsAliases} -> - maConvertAliases bsAliases <$> H.foldM - (\ibal (acc, amt) -> return $ - if parent==acc || parent `isAccountNamePrefixOf` acc then maPlus ibal amt else ibal) - nullmixedamt - bsBalances +-- | Get this account's current inclusive running balance, including all subaccounts, +-- converting the account's own balance and its subaccounts' total separately, as +-- setInclusiveRunningBalanceB does. Commodities that a partial inclusive assignment +-- preserves from this then cancel exactly when the new balance is set. +getInclusiveRunningBalanceForAssignmentB :: AccountName -> Balancing s MixedAmount +getInclusiveRunningBalanceForAssignmentB acc = withRunningBalance $ \BalancingState{bsBalances, bsAliases} -> do + ebal <- fromMaybe nullmixedamt <$> H.lookup bsBalances acc + allebals <- H.toList bsBalances + let subsibal = maSum . map snd $ filter ((acc `isAccountNamePrefixOf`).fst) allebals + return $ maConvertAliases bsAliases ebal `maPlus` maConvertAliases bsAliases subsibal -- | Add this amount to this account's exclusive running balance. -- Returns the new running balance. @@ -907,7 +909,7 @@ addOrAssignAmountAndCheckAssertionB (i,p@Posting{paccount=acc, pbalanceassertion then return $ mixedAmount baamount -- a partial balance assignment (=, one commodity) else do - oldbal <- (if bainclusive then getInclusiveRunningBalanceB else getRunningBalanceB) acc + oldbal <- (if bainclusive then getInclusiveRunningBalanceForAssignmentB else getRunningBalanceB) acc let oldbalothercommodities = filterMixedAmount ((acommodity baamount /=) . acommodity) oldbal return $ maAddAmount oldbalothercommodities baamount diff <- (if bainclusive then setInclusiveRunningBalanceB else setRunningBalanceB) acc newbal @@ -971,7 +973,14 @@ checkBalanceAssertionOneCommodityB p@Posting{paccount=assertedacct} assertedcomm -- let styled = maybe id styleAmounts mstyles actualbal' <- if isinclusive - then getInclusiveRunningBalanceB assertedacct + then + -- sum the running balances of this account and any of its subaccounts seen so far + withRunningBalance $ \BalancingState{bsBalances, bsAliases} -> + maConvertAliases bsAliases <$> H.foldM + (\ibal (acc, amt) -> return $ + if assertedacct==acc || assertedacct `isAccountNamePrefixOf` acc then maPlus ibal amt else ibal) + nullmixedamt + bsBalances else return actualbal let assertedcomm = acommodity assertedcommbal diff --git a/hledger/test/journal/balance-assignments-partial-inclusive.test b/hledger/test/journal/balance-assignments-partial-inclusive.test index e00a55984dd..9377289aa74 100644 --- a/hledger/test/journal/balance-assignments-partial-inclusive.test +++ b/hledger/test/journal/balance-assignments-partial-inclusive.test @@ -842,3 +842,69 @@ commodity h 1.00 ; alias: 60 min (b) 0 EUR = 0 EUR $ hledger -f- check + +# Other commodities held in an alias by both the account and its subaccounts are preserved exactly +< +commodity h 1.00 ; alias: 60 min + +2026-01-01 + (p) 10 min + (p:c) 10 min + +2026-01-02 + (p) =* 1 EUR + +$ hledger -f- print -x date:2026-01-02 +2026-01-02 + (p) 1 EUR =* 1 EUR + +>= + +# Assigned amounts written in an alias are converted +< +commodity h 1.00 ; alias: 60 min + +2026-01-01 + (b:x) 10 min + (b:x) 10 min + (b:x) 10 min + (b:y) 5 EUR + +2026-01-02 + (b) =* 30 min + +2026-01-03 + (b) =* 60 min + +2026-01-04 + (b) 0 h =* 1 h + (b) 0 EUR =* 5 EUR + (b) 0 h = 0.5 h + (b) 0 EUR = 0 EUR + +$ hledger -f- check + +# Postings inferred from assignments written in an alias +< +commodity h 1.00 ; alias: 60 min + +2026-01-01 + (b:x) 10 min + (b:x) 10 min + (b:x) 10 min + (b:y) 5 EUR + +2026-01-02 + (b) =* 30 min + +2026-01-03 + (b) =* 60 min + +$ hledger -f- print -x date:2026-01-02- +2026-01-02 + (b) 0 =* h 0.5 + +2026-01-03 + (b) h 0.50 =* h 1 + +>=