Skip to content

Commit bf26a09

Browse files
committed
imp: add: Verify balance assertions on each posting (#2355)
1 parent 8b027a4 commit bf26a09

File tree

4 files changed

+181
-18
lines changed

4 files changed

+181
-18
lines changed

hledger-lib/Hledger/Data/Balancing.hs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ module Hledger.Data.Balancing
1818
, isTransactionBalanced
1919
, balanceTransaction
2020
, balanceTransactionHelper
21+
-- * assertion validation
22+
, checkAssertions
2123
-- * journal balancing
2224
, journalBalanceTransactions
2325
-- * tests
@@ -146,6 +148,17 @@ transactionCheckBalanced BalancingOpts{commodity_styles_} t = errs
146148
isTransactionBalanced :: BalancingOpts -> Transaction -> Bool
147149
isTransactionBalanced bopts = null . transactionCheckBalanced bopts
148150

151+
-- | Verify that any assertions in this transaction hold
152+
-- when included in the larger journal.
153+
checkAssertions :: BalancingOpts -> Journal -> Transaction -> Either String Transaction
154+
checkAssertions bopts j t =
155+
if (ignore_assertions_ bopts) || noassertions t then Right t else do
156+
j' <- journalStyleAmounts j
157+
let newtxns = sortOn tdate (t : jtxns j')
158+
fmap (\_ -> t) $ journalBalanceTransactions defbalancingopts j'{jtxns = newtxns}
159+
where
160+
noassertions = all (isNothing . pbalanceassertion) . tpostings
161+
149162
-- | Balance this transaction, ensuring that its postings
150163
-- (and its balanced virtual postings) sum to 0,
151164
-- by inferring a missing amount or conversion price(s) if needed.
@@ -1072,6 +1085,32 @@ tests_Balancing =
10721085

10731086
]
10741087

1088+
,testGroup "checkAssertions" $ [
1089+
testCase "simple assertion on same day" $ do
1090+
assertRight $
1091+
checkAssertions defbalancingopts nulljournal{ jtxns = [
1092+
transaction (fromGregorian 2025 01 01) [ vpost' "a" (usd 1) Nothing ]
1093+
] } (transaction (fromGregorian 2025 01 01) [ vpost' "a" (usd 1) (balassert (usd 2)) ])
1094+
1095+
,testCase "inclusive assertions" $ do
1096+
assertRight $
1097+
checkAssertions defbalancingopts nulljournal{ jtxns = [
1098+
transaction (fromGregorian 2025 01 01) [ vpost' "a:a" (usd 1) Nothing ]
1099+
,transaction (fromGregorian 2025 01 02) [ vpost' "a:b" (usd 2) Nothing]
1100+
,transaction (fromGregorian 2025 01 02) [ vpost' "a:c" (usd 5) Nothing]
1101+
,transaction (fromGregorian 2025 01 03) [ vpost' "a:d" (eur 10) Nothing]
1102+
] } (transaction (fromGregorian 2025 01 04) [ vpost' "a" (usd 2) (balassertParInc (usd 10))])
1103+
1104+
,testCase "multicommodity assertion" $ do
1105+
assertRight $
1106+
checkAssertions defbalancingopts nulljournal{ jtxns = [
1107+
transaction (fromGregorian 2025 01 01) [ vpost' "a" (usd 1) Nothing ]
1108+
,transaction (fromGregorian 2025 01 02) [ vpost' "a:b" (eur 2) Nothing ]
1109+
,transaction (fromGregorian 2025 01 02) [ vpost' "a:c" (usd 5) Nothing ]
1110+
,transaction (fromGregorian 2025 01 03) [ vpost' "a:b" (eur (-2)) Nothing ]
1111+
] } (transaction (fromGregorian 2025 01 03) [ vpost' "a" (usd 2) (balassertTotInc (usd 8)) ])
1112+
]
1113+
10751114
,testGroup "commodityStylesFromAmounts" $ [
10761115

10771116
-- Journal similar to the one on #1091:

hledger/Hledger/Cli/Commands/Add.hs

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import Safe (headDef, headMay, atMay)
3636
import System.Console.CmdArgs.Explicit (flagNone)
3737
import System.Console.Haskeline (runInputT, defaultSettings, setComplete)
3838
import System.Console.Haskeline.Completion (CompletionFunc, completeWord, isFinished, noCompletion, simpleCompletion)
39-
import System.Console.Wizard (Wizard, defaultTo, line, output, retryMsg, linePrewritten, nonEmpty, parser, run)
39+
import System.Console.Wizard (Wizard, defaultTo, line, output, outputLn, retryMsg, linePrewritten, nonEmpty, parser, run)
4040
import System.Console.Wizard.Haskeline
4141
import System.IO ( stderr, hPutStr, hPutStrLn )
4242
import Text.Megaparsec
@@ -231,16 +231,33 @@ confirmedTransactionWizard prevInput es@EntryState{..} stack@(currentStage : _)
231231
confirmedTransactionWizard prevInput es{esPostings=init esPostings} (dropWhile notPrevAmountAndNotEnterDesc stack)
232232

233233
EnterAmountAndComment txnParams account -> amountAndCommentWizard prevInput es >>= \case
234-
Just (amt, comment) -> do
234+
Just (amt, assertion, (comment, tags, pdate1, pdate2)) -> do
235+
-- This check is necessary because we cons a ';' in the comment parser above,
236+
-- and we don't want to add an empty comment here if it wasn't given.
237+
let pcomment = if T.length comment == 1 then "" else comment
235238
let p = nullposting{paccount=T.pack $ stripbrackets account
236239
,pamount=mixedAmount amt
237-
,pcomment=comment
240+
,pcomment=pcomment
238241
,ptype=accountNamePostingType $ T.pack account
242+
,pbalanceassertion = assertion
243+
,pdate=pdate1
244+
,pdate2=pdate2
245+
,ptags=tags
239246
}
240247
amountAndCommentString = showAmount amt ++ T.unpack (if T.null comment then "" else " ;" <> comment)
241248
prevAmountAndCmnt' = replaceNthOrAppend (length esPostings) amountAndCommentString (prevAmountAndCmnt prevInput)
242249
es' = es{esPostings=esPostings++[p], esArgs=drop 1 esArgs}
243-
confirmedTransactionWizard prevInput{prevAmountAndCmnt=prevAmountAndCmnt'} es' (EnterNewPosting txnParams (Just posting) : stack)
250+
-- Include a dummy posting to balance the unfinished transation in assertion checking
251+
dummytxn = nulltransaction{tpostings = esPostings ++ [p, post "" missingamt]
252+
,tdate = txnDate txnParams
253+
,tdescription = txnDesc txnParams }
254+
validated = balanceTransaction defbalancingopts dummytxn >>= checkAssertions defbalancingopts esJournal
255+
case validated of
256+
Left err -> do
257+
liftIO (hPutStrLn stderr err)
258+
confirmedTransactionWizard prevInput es (EnterAmountAndComment txnParams account : stack)
259+
Right _ ->
260+
confirmedTransactionWizard prevInput{prevAmountAndCmnt=prevAmountAndCmnt'} es' (EnterNewPosting txnParams (Just posting) : stack)
244261
Nothing -> confirmedTransactionWizard prevInput es (drop 1 stack)
245262

246263
EndStage t -> do
@@ -324,7 +341,10 @@ accountWizard PrevInput{..} EntryState{..} = do
324341
| otherwise = Just t
325342
dbg' = id -- strace
326343

327-
amountAndCommentWizard PrevInput{..} EntryState{..} = do
344+
type Comment = (Text, [Tag], Maybe Day, Maybe Day)
345+
346+
amountAndCommentWizard :: PrevInput -> EntryState -> Wizard Haskeline (Maybe (Amount, Maybe BalanceAssertion, Comment))
347+
amountAndCommentWizard previnput@PrevInput{..} entrystate@EntryState{..} = do
328348
let pnum = length esPostings + 1
329349
(mhistoricalp,followedhistoricalsofar) =
330350
case esSimilarTransaction of
@@ -339,26 +359,36 @@ amountAndCommentWizard PrevInput{..} EntryState{..} = do
339359
| Just hp <- mhistoricalp, followedhistoricalsofar = showamt $ pamount hp
340360
| pnum > 1 && not (mixedAmountLooksZero balancingamt) = showamt balancingamtfirstcommodity
341361
| otherwise = ""
342-
retryMsg "A valid hledger amount is required. Eg: 1, $2, 3 EUR, \"4 red apples\"." $
343-
parser parseAmountAndComment $
362+
retryMsg "A valid hledger amount is required. Eg: 1, $2, 3 EUR, \"4 red apples\"." $
363+
parser' parseAmountAndComment $
344364
withCompletion (amountCompleter def) $
345365
defaultTo' def $
346366
nonEmpty $
347367
linePrewritten (green' $ printf "Amount %d%s: " pnum (showDefault def)) (fromMaybe "" $ prevAmountAndCmnt `atMay` length esPostings) ""
348368
where
349-
parseAmountAndComment s = if s == "<" then return Nothing else either (const Nothing) (return . Just) $
350-
runParser
351-
(evalStateT (amountandcommentp <* eof) nodefcommodityj)
352-
""
353-
(T.pack s)
369+
-- Custom parser that combines with Wizard to use IO via outputLn
370+
parser' f a = a >>= \input ->
371+
case f input of
372+
Left err -> do
373+
outputLn (customErrorBundlePretty err)
374+
amountAndCommentWizard previnput entrystate
375+
Right res -> pure res
376+
parseAmountAndComment s =
377+
if s == "<" then Right Nothing else
378+
Just <$> runParser
379+
(evalStateT (amountandcommentp <* eof) nodefcommodityj)
380+
""
381+
(T.pack s)
354382
nodefcommodityj = esJournal{jparsedefaultcommodity=Nothing}
355-
amountandcommentp :: JournalParser Identity (Amount, Text)
383+
amountandcommentp :: JournalParser Identity (Amount, Maybe BalanceAssertion, Comment)
356384
amountandcommentp = do
357385
a <- amountp
358386
lift skipNonNewlineSpaces
359-
c <- T.pack <$> fromMaybe "" `fmap` optional (char ';' >> many anySingle)
360-
-- eof
361-
return (a,c)
387+
assertion <- optional balanceassertionp
388+
com <- T.pack <$> fromMaybe "" `fmap` optional (char ';' >> many anySingle)
389+
case rtp (postingcommentp Nothing) (T.cons ';' com) of
390+
Left err -> fail $ customErrorBundlePretty err
391+
Right comment -> return $ (a, assertion, comment)
362392
balancingamt = maNegate . sumPostings $ filter isReal esPostings
363393
balancingamtfirstcommodity = mixed . take 1 $ amounts balancingamt
364394
showamt = wbUnpack . showMixedAmountB defaultFmt . mixedAmountSetPrecision

hledger/hledger.m4.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1803,6 +1803,12 @@ Eg a [commodity directive](#commodity-directive)
18031803
may limit the display precision, but this will not affect balance assertions.
18041804
Balance assertion failure messages show exact amounts.
18051805

1806+
### Assertions and hledger add
1807+
1808+
Balance assertions can be included in the amounts given in `add`.
1809+
All types of assertions are supported, and assertions can be used as
1810+
in a normal journal file.
1811+
18061812
## Posting comments
18071813

18081814
Text following `;`, at the end of a posting line,

hledger/test/add.test

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,96 @@ $ rm -f nosuch.journal; hledger -f nosuch.journal add; rm -f nosuch.journal
114114
> /Amount 3 \[-0.75\]:/
115115
>2 //
116116

117-
## 10. shouldn't add decimals if there aren't any
117+
## 10. Balance assertions with ==
118+
119+
<
120+
2025-05-01
121+
x
122+
a
123+
50 USD
124+
b
125+
-50 USD == 50 USD
126+
.
127+
$ rm -f nosuch.journal; hledger -f nosuch.journal add; rm -f nosuch.journal
128+
> //
129+
>2 /Balance assertion failed in b/
130+
131+
## 11. Balance assertions with =
132+
133+
<
134+
2025-05-01
135+
x
136+
a
137+
\$10
138+
a
139+
10 EUR
140+
a
141+
-10 EUR = 0 EUR
142+
a
143+
\$-10 = $0
144+
.
145+
$ rm -f nosuch.journal; hledger -f nosuch.journal add; rm -f nosuch.journal
146+
> /Save this transaction to the journal/
147+
>2 //
148+
149+
## 12. Assertions with subaccounts
150+
151+
<
152+
2025-05-01
153+
x
154+
a:b
155+
1000 JPY
156+
a
157+
-500 JPY ==* 500 JPY
158+
c
159+
-500 JPY
160+
.
161+
$ rm -f nosuch.journal; hledger -f nosuch.journal add; rm -f nosuch.journal
162+
> /Save this transaction to the journal/
163+
>2 //
164+
165+
## 13. Assertions with posting dates
166+
167+
<
168+
2025-05-01
169+
x
170+
a
171+
50 USD ; date:2025-05-10
172+
b
173+
-50 USD
174+
.
175+
y
176+
2025-05-05
177+
x2
178+
a
179+
10 USD == 10 USD
180+
c
181+
-10 USD
182+
.
183+
# Check the output with c to make sure we get to the final transaction display
184+
# (anything generic is also in the first transaction)
185+
$ rm -f nosuch.journal; hledger -f nosuch.journal add; rm -f nosuch.journal
186+
> /c[[:space:]]+-10 USD/
187+
>2 //
188+
189+
## 14. Multi-commodity subaccount assertions
190+
<
191+
2025-05-01
192+
x
193+
a:b
194+
50 EUR
195+
a:c
196+
500 MXN
197+
a
198+
-50 EUR =* 0 EUR
199+
a
200+
-500 MXN =* 0 MXN
201+
.
202+
$ rm -f nosuch.journal; hledger -f nosuch.journal add; rm -f nosuch.journal
203+
> /Save this transaction to the journal/
204+
>2 //
205+
206+
## 15. shouldn't add decimals if there aren't any
118207
## printf '\n\na\n1\nb\n' | hledger -f /dev/null add
119208
# <
120209
#
@@ -124,4 +213,3 @@ $ rm -f nosuch.journal; hledger -f nosuch.journal add; rm -f nosuch.journal
124213
# b
125214
# $ hledger -f /dev/null add
126215
# > /amount 2 \[-1\]/
127-

0 commit comments

Comments
 (0)