Skip to content

Commit 2256d25

Browse files
committed
use a transaction's amount precisions when balancing it (#1479)
A surprising development in old behaviour: as a consequence of #931, print now shows amounts with all of their decimal places, so we had better balance transactions using all of those visible digits (so that hledger and a user will agree on whether it's balanced). So now when transaction balancing compares amounts to see if they look equal, it uses (for each commodity) the maximum precision seen in just that transaction's amounts - not the precision from the journal's commodity display styles. This makes it more localised - therefore simpler - and more robust, when print-ing transactions to be re-parsed by hledger (previously, print-ed transactions could be unparseable because they were dependent on commodity directives). However, the new behaviour can break existing journals, so we provide a `--balancing=exact|styled` option to select the new (default) or old balancing behaviour. (The old behaviour may not be *perfectly* replicated, but it's hopefully close enough to be unnoticeable.) This is intended as a temporary migration aid, hopefully to be removed eventually. In journalFinalise, applying commodity display styles to the journal's amounts is now done as a final step (after transaction balancing, not before), and only once (rather than twice when auto postings are enabled), and seems slightly more thorough (affecting some inferred amounts where it didn't before). As a consequence of this change, inferred unit transaction prices (which arise in a two-commodity transaction with 3+ postings, and can be seen with print -x) may in some cases be generated with a different (greater) precision than before. Specifically, it will now be the sum of the number of decimal places in the amounts being converted to and from. (Whereas before, it was.. something else.) Hopefully this will always be a suitable number of digits such that hledger's & users' calculation of balancedness will agree. Lib changes: Hledger.Data.Journal added: journalInferCommodityStyles journalInferAndApplyCommodityStyles removed: canonicalStyleFrom
1 parent 2fa60bb commit 2256d25

File tree

13 files changed

+309
-140
lines changed

13 files changed

+309
-140
lines changed

hledger-lib/Hledger/Data/Journal.hs

Lines changed: 72 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ module Hledger.Data.Journal (
2323
addTransaction,
2424
journalBalanceTransactions,
2525
journalInferMarketPricesFromTransactions,
26+
journalInferCommodityStyles,
2627
journalApplyCommodityStyles,
27-
commodityStylesFromAmounts,
28+
journalInferAndApplyCommodityStyles,
2829
journalCommodityStyles,
2930
journalToCost,
3031
journalReverse,
@@ -78,7 +79,6 @@ module Hledger.Data.Journal (
7879
journalEquityAccountQuery,
7980
journalCashAccountQuery,
8081
-- * Misc
81-
canonicalStyleFrom,
8282
nulljournal,
8383
journalCheckBalanceAssertions,
8484
journalNumberAndTieTransactions,
@@ -87,7 +87,7 @@ module Hledger.Data.Journal (
8787
journalApplyAliases,
8888
-- * Tests
8989
samplejournal,
90-
tests_Journal,
90+
tests_Journal
9191
)
9292
where
9393

@@ -101,7 +101,7 @@ import Data.Function ((&))
101101
import qualified Data.HashTable.Class as H (toList)
102102
import qualified Data.HashTable.ST.Cuckoo as H
103103
import Data.List (find, sortOn)
104-
import Data.List.Extra (groupSort, nubSort)
104+
import Data.List.Extra (nubSort)
105105
import qualified Data.Map as M
106106
import Data.Maybe (catMaybes, fromJust, fromMaybe, isJust, mapMaybe)
107107
#if !(MIN_VERSION_base(4,11,0))
@@ -627,7 +627,8 @@ journalModifyTransactions d j =
627627
-- | Check any balance assertions in the journal and return an error message
628628
-- if any of them fail (or if the transaction balancing they require fails).
629629
journalCheckBalanceAssertions :: Journal -> Maybe String
630-
journalCheckBalanceAssertions = either Just (const Nothing) . journalBalanceTransactions True
630+
journalCheckBalanceAssertions = either Just (const Nothing) . journalBalanceTransactions False True
631+
-- TODO: not using global display styles here, do we need to for BC ?
631632

632633
-- "Transaction balancing", including: inferring missing amounts,
633634
-- applying balance assignments, checking transaction balancedness,
@@ -722,18 +723,20 @@ updateTransactionB t = withRunningBalance $ \BalancingState{bsTransactions} ->
722723
-- and (optional) all balance assertions pass. Or return an error message
723724
-- (just the first error encountered).
724725
--
725-
-- Assumes journalInferCommodityStyles has been called, since those
726-
-- affect transaction balancing.
726+
-- Assumes the journal amounts' display styles still have the original number
727+
-- of decimal places that was parsed (ie, display styles have not yet been normalised),
728+
-- since this affects transaction balancing.
727729
--
728730
-- This does multiple things at once because amount inferring, balance
729731
-- assignments, balance assertions and posting dates are interdependent.
730-
journalBalanceTransactions :: Bool -> Journal -> Either String Journal
731-
journalBalanceTransactions assrt j' =
732+
--
733+
journalBalanceTransactions :: Bool -> Bool -> Journal -> Either String Journal
734+
journalBalanceTransactions usedisplaystyles assrt j' =
732735
let
733736
-- ensure transactions are numbered, so we can store them by number
734737
j@Journal{jtxns=ts} = journalNumberTransactions j'
735738
-- display precisions used in balanced checking
736-
styles = Just $ journalCommodityStyles j
739+
styles = if usedisplaystyles then Just $ journalCommodityStyles j else Nothing
737740
-- balance assignments will not be allowed on these
738741
txnmodifieraccts = S.fromList $ map paccount $ concatMap tmpostingrules $ jtxnmodifiers j
739742
in
@@ -965,25 +968,66 @@ checkBalanceAssignmentUnassignableAccountB p = do
965968

966969
--
967970

971+
-- | Get an ordered list of amounts in this journal which can
972+
-- influence canonical amount display styles. Those are, in
973+
-- the following order:
974+
--
975+
-- * amounts in market price (P) directives (in parse order)
976+
-- * posting amounts in transactions (in parse order)
977+
-- * the amount in the final default commodity (D) directive
978+
--
979+
-- Transaction price amounts (posting amounts' aprice field) are not included.
980+
--
981+
journalStyleInfluencingAmounts :: Journal -> [Amount]
982+
journalStyleInfluencingAmounts j =
983+
dbg7 "journalStyleInfluencingAmounts" $
984+
catMaybes $ concat [
985+
[mdefaultcommodityamt]
986+
,map (Just . pdamount) $ jpricedirectives j
987+
,map Just $ concatMap amounts $ map pamount $ journalPostings j
988+
]
989+
where
990+
-- D's amount style isn't actually stored as an amount, make it into one
991+
mdefaultcommodityamt =
992+
case jparsedefaultcommodity j of
993+
Just (symbol,style) -> Just nullamt{acommodity=symbol,astyle=style}
994+
Nothing -> Nothing
995+
996+
-- | Infer commodity display styles for each commodity (see commodityStylesFromAmounts)
997+
-- based on the amounts in this journal (see journalStyleInfluencingAmounts),
998+
-- and save those inferred styles in the journal.
999+
-- Can return an error message eg if inconsistent number formats are found.
1000+
journalInferCommodityStyles :: Journal -> Either String Journal
1001+
journalInferCommodityStyles j =
1002+
case commodityStylesFromAmounts $ journalStyleInfluencingAmounts j of
1003+
Left e -> Left e
1004+
Right cs -> Right j{jinferredcommodities = dbg7 "journalInferCommodityStyles" cs}
1005+
1006+
-- | Apply the given commodity display styles to the posting amounts in this journal.
1007+
journalApplyCommodityStyles :: M.Map CommoditySymbol AmountStyle -> Journal -> Journal
1008+
journalApplyCommodityStyles styles j@Journal{jtxns=ts, jpricedirectives=pds} =
1009+
j {jtxns=map fixtransaction ts
1010+
,jpricedirectives=map fixpricedirective pds
1011+
}
1012+
where
1013+
fixtransaction t@Transaction{tpostings=ps} = t{tpostings=map fixposting ps}
1014+
fixposting p = p{pamount=styleMixedAmount styles $ pamount p
1015+
,pbalanceassertion=fixbalanceassertion <$> pbalanceassertion p}
1016+
-- balance assertion/assignment amounts, and price amounts, are always displayed
1017+
-- (eg by print) at full precision
1018+
fixbalanceassertion ba = ba{baamount=styleAmountExceptPrecision styles $ baamount ba}
1019+
fixpricedirective pd@PriceDirective{pdamount=a} = pd{pdamount=styleAmountExceptPrecision styles a}
1020+
9681021
-- | Choose and apply a consistent display style to the posting
9691022
-- amounts in each commodity (see journalCommodityStyles).
9701023
-- Can return an error message eg if inconsistent number formats are found.
971-
journalApplyCommodityStyles :: Journal -> Either String Journal
972-
journalApplyCommodityStyles j@Journal{jtxns=ts, jpricedirectives=pds} =
1024+
journalInferAndApplyCommodityStyles :: Journal -> Either String Journal
1025+
journalInferAndApplyCommodityStyles j =
9731026
case journalInferCommodityStyles j of
9741027
Left e -> Left e
975-
Right j' -> Right j''
1028+
Right j' -> Right $ journalApplyCommodityStyles allstyles j'
9761029
where
977-
styles = journalCommodityStyles j'
978-
j'' = j'{jtxns=map fixtransaction ts
979-
,jpricedirectives=map fixpricedirective pds
980-
}
981-
fixtransaction t@Transaction{tpostings=ps} = t{tpostings=map fixposting ps}
982-
fixposting p = p{pamount=styleMixedAmount styles $ pamount p
983-
,pbalanceassertion=fixbalanceassertion <$> pbalanceassertion p}
984-
-- balance assertion amounts are always displayed (by print) at full precision, per docs
985-
fixbalanceassertion ba = ba{baamount=styleAmountExceptPrecision styles $ baamount ba}
986-
fixpricedirective pd@PriceDirective{pdamount=a} = pd{pdamount=styleAmountExceptPrecision styles a}
1030+
allstyles = journalCommodityStyles j'
9871031

9881032
-- | Get the canonical amount styles for this journal, whether (in order of precedence):
9891033
-- set globally in InputOpts,
@@ -1002,18 +1046,6 @@ journalCommodityStyles j =
10021046
defaultcommoditystyle = M.fromList $ catMaybes [jparsedefaultcommodity j]
10031047
inferredstyles = jinferredcommodities j
10041048

1005-
-- | Collect and save inferred amount styles for each commodity based on
1006-
-- the posting amounts in that commodity (excluding price amounts), ie:
1007-
-- "the format of the first amount, adjusted to the highest precision of all amounts".
1008-
-- Can return an error message eg if inconsistent number formats are found.
1009-
journalInferCommodityStyles :: Journal -> Either String Journal
1010-
journalInferCommodityStyles j =
1011-
case
1012-
commodityStylesFromAmounts $ journalStyleInfluencingAmounts j
1013-
of
1014-
Left e -> Left e
1015-
Right cs -> Right j{jinferredcommodities = dbg7 "journalInferCommodityStyles" cs}
1016-
10171049
-- -- | Apply this journal's historical price records to unpriced amounts where possible.
10181050
-- journalApplyPriceDirectives :: Journal -> Journal
10191051
-- journalApplyPriceDirectives j@Journal{jtxns=ts} = j{jtxns=map fixtransaction ts}
@@ -1242,7 +1274,7 @@ journalApplyAliases aliases j =
12421274
-- liabilities:debts $1
12431275
-- assets:bank:checking
12441276
--
1245-
Right samplejournal = journalBalanceTransactions False $
1277+
Right samplejournal = journalBalanceTransactions False False $
12461278
nulljournal
12471279
{jtxns = [
12481280
txnTieKnot $ Transaction {
@@ -1385,7 +1417,7 @@ tests_Journal = tests "Journal" [
13851417
,tests "journalBalanceTransactions" [
13861418

13871419
test "balance-assignment" $ do
1388-
let ej = journalBalanceTransactions True $
1420+
let ej = journalBalanceTransactions False True $
13891421
--2019/01/01
13901422
-- (a) = 1
13911423
nulljournal{ jtxns = [
@@ -1396,7 +1428,7 @@ tests_Journal = tests "Journal" [
13961428
(jtxns j & head & tpostings & head & pamount) @?= Mixed [num 1]
13971429

13981430
,test "same-day-1" $ do
1399-
assertRight $ journalBalanceTransactions True $
1431+
assertRight $ journalBalanceTransactions False True $
14001432
--2019/01/01
14011433
-- (a) = 1
14021434
--2019/01/01
@@ -1407,7 +1439,7 @@ tests_Journal = tests "Journal" [
14071439
]}
14081440

14091441
,test "same-day-2" $ do
1410-
assertRight $ journalBalanceTransactions True $
1442+
assertRight $ journalBalanceTransactions False True $
14111443
--2019/01/01
14121444
-- (a) 2 = 2
14131445
--2019/01/01
@@ -1425,7 +1457,7 @@ tests_Journal = tests "Journal" [
14251457
]}
14261458

14271459
,test "out-of-order" $ do
1428-
assertRight $ journalBalanceTransactions True $
1460+
assertRight $ journalBalanceTransactions False True $
14291461
--2019/1/2
14301462
-- (a) 1 = 2
14311463
--2019/1/1

hledger-lib/Hledger/Data/Transaction.hs

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import Hledger.Data.Amount
8282
import Hledger.Data.Valuation
8383
import Text.Tabular
8484
import Text.Tabular.AsciiWide
85+
import Control.Applicative ((<|>))
8586

8687
sourceFilePath :: GenericSourcePos -> FilePath
8788
sourceFilePath = \case
@@ -358,15 +359,31 @@ transactionsPostings = concatMap tpostings
358359
-- (Best effort; could be confused by postings with multicommodity amounts.)
359360
--
360361
-- 3. Does the amounts' sum appear non-zero when displayed ?
361-
-- (using the given display styles if provided)
362+
-- We have two ways of checking this:
363+
--
364+
-- Old way, supported for compatibility: if global display styles are provided,
365+
-- in each commodity, render the sum using the precision from the
366+
-- global display styles, and see if it looks like exactly zero.
367+
--
368+
-- New way, preferred: in each commodity, render the sum using the max precision
369+
-- that was used in this transaction's journal entry, and see if it looks
370+
-- like exactly zero.
362371
--
363372
transactionCheckBalanced :: Maybe (M.Map CommoditySymbol AmountStyle) -> Transaction -> [String]
364-
transactionCheckBalanced mstyles t = errs
373+
transactionCheckBalanced mglobalstyles t = errs
365374
where
366375
(rps, bvps) = (realPostings t, balancedVirtualPostings t)
367376

377+
-- For testing each commodity's zero sum, we'll render it with the number
378+
-- of decimal places specified by its display style, from either the
379+
-- provided global display styles, or local styles inferred from just
380+
-- this transaction.
381+
canonicalise = maybe id canonicaliseMixedAmount (mglobalstyles <|> mtxnstyles)
382+
where
383+
mtxnstyles = either (const Nothing) Just $ -- shouldn't get any error here, but if so just.. carry on, comparing uncanonicalised amounts XXX
384+
commodityStylesFromAmounts $ concatMap (amounts.pamount) $ rps ++ bvps
385+
368386
-- check for mixed signs, detecting nonzeros at display precision
369-
canonicalise = maybe id canonicaliseMixedAmount mstyles
370387
signsOk ps =
371388
case filter (not.mixedAmountLooksZero) $ map (canonicalise.mixedAmountCost.pamount) ps of
372389
nonzeros | length nonzeros >= 2
@@ -385,11 +402,11 @@ transactionCheckBalanced mstyles t = errs
385402
where
386403
rmsg
387404
| not rsignsok = "real postings all have the same sign"
388-
| not rsumok = "real postings' sum should be 0 but is: " ++ showMixedAmount rsumcost
405+
| not rsumok = "real postings' sum should be 0 but is: " ++ showMixedAmount (mixedAmountSetFullPrecision rsumcost)
389406
| otherwise = ""
390407
bvmsg
391408
| not bvsignsok = "balanced virtual postings all have the same sign"
392-
| not bvsumok = "balanced virtual postings' sum should be 0 but is: " ++ showMixedAmount bvsumcost
409+
| not bvsumok = "balanced virtual postings' sum should be 0 but is: " ++ showMixedAmount (mixedAmountSetFullPrecision bvsumcost)
393410
| otherwise = ""
394411

395412
-- | Legacy form of transactionCheckBalanced.
@@ -454,7 +471,7 @@ inferBalancingAmount ::
454471
M.Map CommoditySymbol AmountStyle -- ^ commodity display styles
455472
-> Transaction
456473
-> Either String (Transaction, [(AccountName, MixedAmount)])
457-
inferBalancingAmount styles t@Transaction{tpostings=ps}
474+
inferBalancingAmount _globalstyles t@Transaction{tpostings=ps}
458475
| length amountlessrealps > 1
459476
= Left $ transactionBalanceError t
460477
["can't have more than one real posting with no amount"
@@ -486,9 +503,7 @@ inferBalancingAmount styles t@Transaction{tpostings=ps}
486503
Just a -> (p{pamount=a', poriginal=Just $ originalPosting p}, Just a')
487504
where
488505
-- Inferred amounts are converted to cost.
489-
-- Also ensure the new amount has the standard style for its commodity
490-
-- (since the main amount styling pass happened before this balancing pass);
491-
a' = styleMixedAmount styles $ normaliseMixedAmount $ mixedAmountCost (-a)
506+
a' = normaliseMixedAmount $ mixedAmountCost (-a)
492507

493508
-- | Infer prices for this transaction's posting amounts, if needed to make
494509
-- the postings balance, and if possible. This is done once for the real
@@ -554,7 +569,11 @@ priceInferrerFor t pt = inferprice
554569
where
555570
fromcommodity = head $ filter (`elem` sumcommodities) pcommodities -- these heads are ugly but should be safe
556571
conversionprice
572+
-- Use a total price when we can, as it's more exact.
557573
| fromcount==1 = TotalPrice $ abs toamount `withPrecision` NaturalPrecision
574+
-- When there are multiple posting amounts to be converted,
575+
-- it's easiest to have them all use the same unit price.
576+
-- Floating-point error and rounding becomes an issue though.
558577
| otherwise = UnitPrice $ abs unitprice `withPrecision` unitprecision
559578
where
560579
fromcount = length $ filter ((==fromcommodity).acommodity) pamounts
@@ -564,9 +583,20 @@ priceInferrerFor t pt = inferprice
564583
toamount = head $ filter ((==tocommodity).acommodity) sumamounts
565584
toprecision = asprecision $ astyle toamount
566585
unitprice = (aquantity fromamount) `divideAmount` toamount
567-
-- Sum two display precisions, capping the result at the maximum bound
586+
-- The number of decimal places that will be shown for an
587+
-- inferred unit price. Often, the underlying Decimal will
588+
-- have the maximum number of decimal places (255). We
589+
-- don't want to show that many to the user; we'd prefer
590+
-- to show the minimum number of digits that makes the
591+
-- print-ed transaction appear balanced if you did the
592+
-- arithmetic by hand, and also makes the print-ed transaction
593+
-- parseable by hledger. How many decimal places is that ? I'm not sure.
594+
-- Currently we heuristically use 2 * the total number of decimal places
595+
-- from the amounts to be converted to and from (and at least 2, at most 255),
596+
-- which experimentally seems to be sufficient so far.
568597
unitprecision = case (fromprecision, toprecision) of
569-
(Precision a, Precision b) -> Precision $ if maxBound - a < b then maxBound else max 2 (a + b)
598+
(Precision a, Precision b) -> Precision $ if maxBound - a < b then maxBound else
599+
max 2 (2 * (a+b))
570600
_ -> NaturalPrecision
571601
inferprice p = p
572602

0 commit comments

Comments
 (0)