diff --git a/hledger-lib/Hledger/Data/Journal.hs b/hledger-lib/Hledger/Data/Journal.hs index 9dbdbc5b2..d5920fe22 100644 --- a/hledger-lib/Hledger/Data/Journal.hs +++ b/hledger-lib/Hledger/Data/Journal.hs @@ -733,13 +733,13 @@ inferFromAssignmentB p@Posting{paccount=acc} = -- checks the posting's balance assertion if any. Or if the posting has no -- amount, runs the supplied fallback action. addAmountAndCheckBalanceAssertionB :: - (Posting -> Balancing s Posting) -- ^ fallback action + (Posting -> Balancing s Posting) -- ^ fallback action XXX why ? -> Posting -> Balancing s Posting addAmountAndCheckBalanceAssertionB _ p | hasAmount p = do newAmt <- addToBalanceB (paccount p) (pamount p) assrt <- R.reader bsAssrt - lift $ when assrt $ ExceptT $ return $ checkBalanceAssertion p newAmt + when assrt $ checkBalanceAssertionB p newAmt return p addAmountAndCheckBalanceAssertionB fallback p = fallback p @@ -747,66 +747,81 @@ addAmountAndCheckBalanceAssertionB fallback p = fallback p -- return an error if the assertion is not satisfied. -- If the assertion is partial, unasserted commodities in the actual balance -- are ignored; if it is total, they will cause the assertion to fail. -checkBalanceAssertion :: Posting -> MixedAmount -> Either String () -checkBalanceAssertion p@Posting{pbalanceassertion=Just (BalanceAssertion{baamount,batotal})} actualbal = - foldl' f (Right ()) assertedamts - where - f (Right _) assertedamt = checkBalanceAssertionOneCommodity p assertedamt actualbal - f err _ = err - assertedamts = baamount : otheramts - where - assertedcomm = acommodity baamount - otheramts | batotal = map (\a -> a{ aquantity = 0 }) $ amounts $ filterMixedAmount (\a -> acommodity a /= assertedcomm) actualbal - | otherwise = [] -checkBalanceAssertion _ _ = Right () +checkBalanceAssertionB :: Posting -> MixedAmount -> Balancing s () +checkBalanceAssertionB p@Posting{pbalanceassertion=Just (BalanceAssertion{baamount,batotal})} actualbal = + forM_ assertedamts $ \amt -> checkBalanceAssertionOneCommodityB p amt actualbal + where + assertedamts = baamount : otheramts + where + assertedcomm = acommodity baamount + otheramts | batotal = map (\a -> a{aquantity=0}) $ amounts $ filterMixedAmount ((/=assertedcomm).acommodity) actualbal + | otherwise = [] +checkBalanceAssertionB _ _ = return () -- | Does this (single commodity) expected balance match the amount of that -- commodity in the given (multicommodity) actual balance ? If not, returns a -- balance assertion failure message based on the provided posting. To match, -- the amounts must be exactly equal (display precision is ignored here). -checkBalanceAssertionOneCommodity :: Posting -> Amount -> MixedAmount -> Either String () -checkBalanceAssertionOneCommodity p assertedamt actualbal - | pass = Right () - | otherwise = Left errmsg - where - assertedcomm = acommodity assertedamt - actualbalincommodity = fromMaybe nullamt $ find ((== assertedcomm) . acommodity) (amounts actualbal) - pass = - aquantity - -- traceWith (("asserted:"++).showAmountDebug) - assertedamt == - aquantity - -- traceWith (("actual:"++).showAmountDebug) - actualbalincommodity - errmsg = printf (unlines - [ "balance assertion: %s", - "\nassertion details:", - "date: %s", - "account: %s", - "commodity: %s", - -- "display precision: %d", - "calculated: %s", -- (at display precision: %s)", - "asserted: %s", -- (at display precision: %s)", - "difference: %s" - ]) - (case ptransaction p of - Nothing -> "?" -- shouldn't happen - Just t -> printf "%s\ntransaction:\n%s" - (showGenericSourcePos pos) - (chomp $ showTransaction t) - :: String - where - pos = baposition $ fromJust $ pbalanceassertion p - ) - (showDate $ postingDate p) - (T.unpack $ paccount p) -- XXX pack - assertedcomm - -- (asprecision $ astyle actualbalincommodity) -- should be the standard display precision I think - (show $ aquantity actualbalincommodity) - -- (showAmount actualbalincommodity) - (show $ aquantity assertedamt) - -- (showAmount assertedamt) - (show $ aquantity assertedamt - aquantity actualbalincommodity) +-- If the assertion is inclusive, the expected amount is compared with the account's +-- subaccount-inclusive balance; otherwise, with the subaccount-exclusive balance. +checkBalanceAssertionOneCommodityB :: Posting -> Amount -> MixedAmount -> Balancing s () +checkBalanceAssertionOneCommodityB p@Posting{paccount=assertedacct} assertedamt actualbal = do + -- sum the running balances of this account and any subaccounts seen so far + bals <- R.asks bsBalances + actualibal <- liftB $ const $ H.foldM + (\bal (acc, amt) -> return $ + if assertedacct==acc || assertedacct `isAccountNamePrefixOf` acc + then bal + amt + else bal) + 0 + bals + let + isinclusive = maybe False bainclusive $ pbalanceassertion p + actualbal' + | isinclusive = actualibal + | otherwise = actualbal + assertedcomm = acommodity assertedamt + actualbalincomm = headDef 0 $ amounts $ filterMixedAmountByCommodity assertedcomm actualbal' + pass = + aquantity + -- traceWith (("asserted:"++).showAmountDebug) + assertedamt == + aquantity + -- traceWith (("actual:"++).showAmountDebug) + actualbalincomm + + errmsg = printf (unlines + [ "balance assertion: %s", + "\nassertion details:", + "date: %s", + "account: %s%s", + "commodity: %s", + -- "display precision: %d", + "calculated: %s", -- (at display precision: %s)", + "asserted: %s", -- (at display precision: %s)", + "difference: %s" + ]) + (case ptransaction p of + Nothing -> "?" -- shouldn't happen + Just t -> printf "%s\ntransaction:\n%s" + (showGenericSourcePos pos) + (chomp $ showTransaction t) + :: String + where + pos = baposition $ fromJust $ pbalanceassertion p + ) + (showDate $ postingDate p) + (T.unpack $ paccount p) -- XXX pack + (if isinclusive then " (and subs)" else "" :: String) + assertedcomm + -- (asprecision $ astyle actualbalincommodity) -- should be the standard display precision I think + (show $ aquantity actualbalincomm) + -- (showAmount actualbalincommodity) + (show $ aquantity assertedamt) + -- (showAmount assertedamt) + (show $ aquantity assertedamt - aquantity actualbalincomm) + + when (not pass) $ throwError errmsg -- | Choose and apply a consistent display format to the posting -- amounts in each commodity. Each commodity's format is specified by diff --git a/hledger-lib/Hledger/Data/Posting.hs b/hledger-lib/Hledger/Data/Posting.hs index c82da2877..2c2594f84 100644 --- a/hledger-lib/Hledger/Data/Posting.hs +++ b/hledger-lib/Hledger/Data/Posting.hs @@ -105,6 +105,7 @@ nullassertion, assertion :: BalanceAssertion nullassertion = BalanceAssertion {baamount=nullamt ,batotal=False + ,bainclusive=False ,baposition=nullsourcepos } assertion = nullassertion diff --git a/hledger-lib/Hledger/Data/Transaction.hs b/hledger-lib/Hledger/Data/Transaction.hs index ebc856003..a1d5e5895 100644 --- a/hledger-lib/Hledger/Data/Transaction.hs +++ b/hledger-lib/Hledger/Data/Transaction.hs @@ -235,7 +235,7 @@ postingAsLines elideamount onelineamounts pstoalignwith p = concat [ | postingblock <- postingblocks] where postingblocks = [map rstrip $ lines $ concatTopPadded [statusandaccount, " ", amount, assertion, samelinecomment] | amount <- shownAmounts] - assertion = maybe "" ((" = " ++) . showAmountWithZeroCommodity . baamount) $ pbalanceassertion p + assertion = maybe "" ((' ':).showBalanceAssertion) $ pbalanceassertion p statusandaccount = indent $ fitString (Just $ minwidth) Nothing False True $ pstatusandacct p where -- pad to the maximum account name width, plus 2 to leave room for status flags, to keep amounts aligned @@ -259,6 +259,10 @@ postingAsLines elideamount onelineamounts pstoalignwith p = concat [ case renderCommentLines (pcomment p) of [] -> ("",[]) c:cs -> (c,cs) +-- | Render a balance assertion, as the =[=][*] symbol and expected amount. +showBalanceAssertion BalanceAssertion{..} = + "=" ++ ['=' | batotal] ++ ['*' | bainclusive] ++ " " ++ showAmountWithZeroCommodity baamount + -- | Render a posting, simply. Used in balance assertion errors. -- showPostingLine p = -- indent $ @@ -374,6 +378,9 @@ storeTransactionB t = liftB $ \bs -> -- by inferring a missing amount or conversion price(s) if needed. -- Or if balancing is not possible, because of unbalanced amounts or -- more than one missing amount, returns an error message. +-- Note this function may be unable to balance some transactions +-- that journalBalanceTransactions/balanceTransactionB can balance +-- (eg ones with balance assignments). -- Whether postings "sum to 0" depends on commodity display precisions, -- so those can optionally be provided. balanceTransaction :: diff --git a/hledger-lib/Hledger/Data/Types.hs b/hledger-lib/Hledger/Data/Types.hs index f693f3c80..f4b365eef 100644 --- a/hledger-lib/Hledger/Data/Types.hs +++ b/hledger-lib/Hledger/Data/Types.hs @@ -278,6 +278,7 @@ instance Show Status where -- custom show.. bad idea.. don't do it.. data BalanceAssertion = BalanceAssertion { baamount :: Amount, -- ^ the expected balance in a particular commodity batotal :: Bool, -- ^ disallow additional non-asserted commodities ? + bainclusive :: Bool, -- ^ include subaccounts when calculating the actual balance ? baposition :: GenericSourcePos -- ^ the assertion's file position, for error reporting } deriving (Eq,Typeable,Data,Generic,Show) diff --git a/hledger-lib/Hledger/Read/Common.hs b/hledger-lib/Hledger/Read/Common.hs index 47de5eb3a..14f9667a9 100644 --- a/hledger-lib/Hledger/Read/Common.hs +++ b/hledger-lib/Hledger/Read/Common.hs @@ -729,14 +729,16 @@ balanceassertionp = do sourcepos <- genericSourcePos <$> lift getSourcePos char '=' istotal <- fmap isJust $ optional $ try $ char '=' + isinclusive <- fmap isJust $ optional $ try $ char '*' lift (skipMany spacenonewline) -- this amount can have a price; balance assertions ignore it, -- but balance assignments will use it a <- amountp "amount (for a balance assertion or assignment)" return BalanceAssertion - { baamount = a - , batotal = istotal - , baposition = sourcepos + { baamount = a + , batotal = istotal + , bainclusive = isinclusive + , baposition = sourcepos } -- Parse a Ledger-style fixed lot price: {=PRICE} diff --git a/hledger-lib/hledger_journal.m4.md b/hledger-lib/hledger_journal.m4.md index 84f5ea959..f0234d90f 100644 --- a/hledger-lib/hledger_journal.m4.md +++ b/hledger-lib/hledger_journal.m4.md @@ -337,8 +337,8 @@ Virtual postings have some legitimate uses, but those are few. You can usually f hledger supports [Ledger-style balance assertions](http://ledger-cli.org/3.0/doc/ledger3.html#Balance-assertions) in journal files. -These look like `=EXPECTEDBALANCE` following a posting's amount. Eg in -this example we assert the expected dollar balance in accounts a and b after +These look like, for example, `= EXPECTEDBALANCE` following a posting's amount. +Eg here we assert the expected dollar balance in accounts a and b after each posting: ```journal @@ -355,7 +355,7 @@ After reading a journal file, hledger will check all balance assertions and report an error if any of them fail. Balance assertions can protect you from, eg, inadvertently disrupting reconciled balances while cleaning up old entries. You can disable them temporarily with -the `--ignore-assertions` flag, which can be useful for +the `-I/--ignore-assertions` flag, which can be useful for troubleshooting or for reading Ledger files. ### Assertions and ordering @@ -399,10 +399,10 @@ We could call this a "partial" balance assertion. To assert the balance of more than one commodity in an account, you can write multiple postings, each asserting one commodity's balance. -You can make a stronger kind of balance assertion, by writing a -double equals sign (`==EXPECTEDBALANCE`). -This "complete" balance assertion asserts the absence of other commodities -(or, that their balance is 0, which to hledger is equivalent.) +You can make a stronger "total" balance assertion by writing a +double equals sign (`== EXPECTEDBALANCE`). +This asserts that there are no other unasserted commodities in the account +(or, that their balance is 0). ``` {.journal} 2013/1/1 @@ -453,21 +453,16 @@ and because [balance *assignments*](#balance-assignments) do use them (see below ### Assertions and subaccounts -Balance assertions do not count the balance from subaccounts; they check -the posted account's exclusive balance. For example: +The balance assertions above (`=` and `==`) do not count the balance +from subaccounts; they check the account's exclusive balance only. +You can assert the balance including subaccounts by writing `=*` or `==*`, eg: + ```journal -1/1 - checking:fund 1 = 1 ; post to this subaccount, its balance is now 1 - checking 1 = 1 ; post to the parent account, its exclusive balance is now 1 - equity -``` -The balance report's flat mode shows these exclusive balances more clearly: -```shell -$ hledger bal checking --flat - 1 checking - 1 checking:fund --------------------- - 2 +2019/1/1 + equity:opening balances + checking:a 5 + checking:b 5 + checking 1 ==* 11 ``` ### Assertions and virtual postings diff --git a/tests/journal/balance-assertions.test b/tests/journal/balance-assertions.test index 368a82000..7024cfcd7 100755 --- a/tests/journal/balance-assertions.test +++ b/tests/journal/balance-assertions.test @@ -311,7 +311,7 @@ hledger -f - stats >>>2 >>>=0 -# 17. Exact assertions parse correctly +# 17. Total assertions (==) parse correctly hledger -f - stats <<< 2016/1/1 @@ -324,7 +324,7 @@ hledger -f - stats >>>2 >>>=0 -# 18. Exact assertions consider entire account +# 18. Total assertions consider entire multicommodity amount hledger -f - stats <<< 2016/1/1 @@ -340,7 +340,7 @@ hledger -f - stats >>>2 /balance assertion.*line 10, column 15/ >>>=1 -# 19. Mix different commodities and exact assignments +# 19. Mix different commodities and total assignments hledger -f - stats <<< 2016/1/1 @@ -440,3 +440,27 @@ commodity $1000.00 >>>2 /difference: 0\.0001/ >>>=1 + +# 26. Inclusive assertions include balances from subaccounts. +hledger -f- print +<<< +2019/1/1 + (a) X1 + (a) Y3 + (a:b) Y7 + (a) 0 =* X1 + (a) 0 =* Y10 + (a:b) 0 =* Y7 + (a:b) 0 ==* Y7 +>>> +2019/01/01 + (a) X1 + (a) Y3 + (a:b) Y7 + (a) 0 =* X1 + (a) 0 =* Y10 + (a:b) 0 =* Y7 + (a:b) 0 ==* Y7 + +>>>2 +>>>=0