mirror of
https://github.com/simonmichael/hledger.git
synced 2024-09-18 17:57:11 +03:00
finish refactoring balanceTransaction
This commit is contained in:
parent
4da22cd846
commit
5978a19b15
@ -184,7 +184,7 @@ amountWithCommodity c a = a{acommodity=c, aprice=NoPrice}
|
||||
|
||||
-- | Convert an amount to the commodity of its assigned price, if any. Notes:
|
||||
--
|
||||
-- - price amounts must be MixedAmounts with exactly one component Amount (or there will be a runtime error)
|
||||
-- - price amounts must be MixedAmounts with exactly one component Amount (or there will be a runtime error) XXX
|
||||
--
|
||||
-- - price amounts should be positive, though this is not currently enforced
|
||||
costOfAmount :: Amount -> Amount
|
||||
|
@ -260,25 +260,13 @@ isTransactionBalanced styles t =
|
||||
bvsum' = canonicalise $ costOfMixedAmount bvsum
|
||||
canonicalise = maybe id canonicaliseMixedAmount styles
|
||||
|
||||
-- XXX refactor
|
||||
-- | Ensure this transaction is balanced, possibly inferring a missing
|
||||
-- amount or conversion price, or return an error message.
|
||||
--
|
||||
-- Balancing is affected by commodity display precisions, so those may
|
||||
-- be provided.
|
||||
--
|
||||
-- We can infer a missing real amount when there are multiple real
|
||||
-- postings and exactly one of them is amountless (likewise for
|
||||
-- balanced virtual postings). Inferred amounts are converted to cost
|
||||
-- basis when possible.
|
||||
--
|
||||
-- We can infer a conversion price when all real amounts are specified
|
||||
-- and the sum of real postings' amounts is exactly two
|
||||
-- non-explicitly-priced amounts in different commodities (likewise
|
||||
-- for balanced virtual postings).
|
||||
-- amount or conversion price(s), or return an error message.
|
||||
-- Balancing is affected by commodity display precisions, so those can
|
||||
-- (optionally) be provided.
|
||||
balanceTransaction :: Maybe (Map.Map Commodity AmountStyle) -> Transaction -> Either String Transaction
|
||||
balanceTransaction styles t@Transaction{tpostings=ps} =
|
||||
case inferAmounts t of
|
||||
balanceTransaction styles t =
|
||||
case inferBalancingAmount t of
|
||||
Left err -> Left err
|
||||
Right tWithAmounts ->
|
||||
case isTransactionBalanced styles tWithAmountsAndPrices of
|
||||
@ -286,86 +274,28 @@ balanceTransaction styles t@Transaction{tpostings=ps} =
|
||||
True -> Right $ txnTieKnot tWithAmountsAndPrices
|
||||
|
||||
where
|
||||
tWithAmountsAndPrices = (if tWithAmounts==t then inferBalancingPrices else id) tWithAmounts -- XXX unneeded ?
|
||||
printerr s = intercalate "\n" [s, showTransactionUnelided t]
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
-- infer conversion prices for real postings if needed
|
||||
rmixedamountsinorder = map pamount $ realPostings tWithAmounts
|
||||
ramountsinorder = concatMap amounts rmixedamountsinorder
|
||||
rcommoditiesinorder = map acommodity ramountsinorder
|
||||
rsumamounts = amounts $ sum rmixedamountsinorder
|
||||
-- as it says above, we can infer a conversion price when
|
||||
tWithAmountsAndRealPrices =
|
||||
if tWithAmounts == t -- all real amounts were explicit (we didn't have to infer any)
|
||||
&& length rsumamounts == 2 -- and the sum of real amounts has exactly two commodities (assumption: summing mixed amounts normalises to one simple amount per commodity)
|
||||
&& all ((==NoPrice).aprice) rsumamounts -- and none of the amounts had explicit prices
|
||||
then tWithAmounts{tpostings=map inferprice ps}
|
||||
else tWithAmounts
|
||||
nonzerobalanceerror :: Transaction -> String
|
||||
nonzerobalanceerror t = printf "could not balance this transaction (%s%s%s)" rmsg sep bvmsg
|
||||
where
|
||||
inferprice p@Posting{pamount=Mixed [a@Amount{acommodity=c,aprice=NoPrice}], ptype=RegularPosting} -- assumption: a posting's mixed amount contains one simple amount
|
||||
= p{pamount=Mixed [a{aprice=conversionprice c}]}
|
||||
where
|
||||
conversionprice c | c == unpricedcommodity
|
||||
(rsum, _, bvsum) = transactionPostingBalances t
|
||||
rmsg | isReallyZeroMixedAmountCost rsum = ""
|
||||
| otherwise = "real postings are off by " ++ showMixedAmount (costOfMixedAmount rsum)
|
||||
bvmsg | isReallyZeroMixedAmountCost bvsum = ""
|
||||
| otherwise = "balanced virtual postings are off by " ++ showMixedAmount (costOfMixedAmount bvsum)
|
||||
sep = if not (null rmsg) && not (null bvmsg) then "; " else "" :: String
|
||||
|
||||
-- calculate a price that makes the postings balance, and give it "just enough"
|
||||
-- display precision that a manual calculation with the displayed numbers
|
||||
-- shows the transaction balancing.
|
||||
= if length ramountsinunpricedcommodity == 1
|
||||
|
||||
-- when there is only one posting in the target commodity,
|
||||
-- show a total price (@@) for more exact output. In this
|
||||
-- case show all available decimal digits, it shouldn't be too many.
|
||||
then TotalPrice $ abs targetcommodityamount `withPrecision` maxprecision
|
||||
|
||||
-- otherwise, calculate the average unit conversion price across all postings.
|
||||
-- Set the precision to the sum of the precisions of the commodities involved,
|
||||
-- which should be enough to make calculation look right while also preventing
|
||||
-- irrational numbers from printing excessive digits.
|
||||
else UnitPrice $ abs unitprice `withPrecision` sumofprecisions
|
||||
|
||||
| otherwise = NoPrice
|
||||
where
|
||||
unpricedcommodity = head $ filter (`elem` (map acommodity rsumamounts)) rcommoditiesinorder
|
||||
unpricedamount = head $ filter ((==unpricedcommodity).acommodity) rsumamounts
|
||||
targetcommodityamount = head $ filter ((/=unpricedcommodity).acommodity) rsumamounts
|
||||
ramountsinunpricedcommodity = filter ((==unpricedcommodity).acommodity) ramountsinorder
|
||||
unitprice = targetcommodityamount `divideAmount` (aquantity unpricedamount)
|
||||
sumofprecisions = (asprecision $ astyle $ targetcommodityamount) + (asprecision $ astyle $ unpricedamount)
|
||||
inferprice p = p
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
-- infer conversion prices for balanced virtual postings if needed. XXX duplicates the above
|
||||
bvmixedamountsinorder = map pamount $ balancedVirtualPostings tWithAmountsAndRealPrices
|
||||
bvamountsinorder = concatMap amounts bvmixedamountsinorder
|
||||
bvcommoditiesinorder = map acommodity bvamountsinorder
|
||||
bvsumamounts = amounts $ sum bvmixedamountsinorder
|
||||
tWithAmountsAndPrices =
|
||||
if length bvsumamounts == 2 && all ((==NoPrice).aprice) bvsumamounts && tWithAmounts==t -- XXX could check specifically for bv amount inferring
|
||||
then tWithAmountsAndRealPrices{tpostings=map inferprice ps}
|
||||
else tWithAmountsAndRealPrices
|
||||
where
|
||||
inferprice p@Posting{pamount=Mixed [a@Amount{acommodity=c,aprice=NoPrice}], ptype=BalancedVirtualPosting}
|
||||
= p{pamount=Mixed [a{aprice=conversionprice c}]}
|
||||
where
|
||||
conversionprice c | c == unpricedcommodity
|
||||
= if length bvamountsinunpricedcommodity == 1
|
||||
then TotalPrice $ abs targetcommodityamount `withPrecision` maxprecision
|
||||
else UnitPrice $ abs unitprice `withPrecision` sumofprecisions
|
||||
| otherwise = NoPrice
|
||||
where
|
||||
unpricedcommodity = head $ filter (`elem` (map acommodity bvsumamounts)) bvcommoditiesinorder
|
||||
unpricedamount = head $ filter ((==unpricedcommodity).acommodity) bvsumamounts
|
||||
targetcommodityamount = head $ filter ((/=unpricedcommodity).acommodity) bvsumamounts
|
||||
bvamountsinunpricedcommodity = filter ((==unpricedcommodity).acommodity) bvamountsinorder
|
||||
unitprice = targetcommodityamount `divideAmount` (aquantity unpricedamount)
|
||||
sumofprecisions = (asprecision $ astyle $ targetcommodityamount) + (asprecision $ astyle $ unpricedamount)
|
||||
inferprice p = p
|
||||
|
||||
-- | Infer up to one missing amount each for this transactions's real
|
||||
-- and balanced virtual postings, if needed, or return an error
|
||||
-- | Infer up to one missing amount for this transactions's real postings, and
|
||||
-- likewise for its balanced virtual postings, if needed; or return an error
|
||||
-- message if we can't.
|
||||
inferAmounts :: Transaction -> Either String Transaction
|
||||
inferAmounts t@Transaction{tpostings=ps}
|
||||
--
|
||||
-- We can infer a missing amount when there are multiple postings and exactly
|
||||
-- one of them is amountless. If the amounts had price(s) the inferred amount
|
||||
-- have the same price(s), and will be converted to the price commodity.
|
||||
--
|
||||
inferBalancingAmount :: Transaction -> Either String Transaction
|
||||
inferBalancingAmount t@Transaction{tpostings=ps}
|
||||
| length amountlessrealps > 1
|
||||
= Left $ printerr "could not balance this transaction - can't have more than one real posting with no amount (remember to put 2 or more spaces before amounts)"
|
||||
| length amountlessbvps > 1
|
||||
@ -380,15 +310,78 @@ inferAmounts t@Transaction{tpostings=ps}
|
||||
inferamount p@Posting{ptype=BalancedVirtualPosting} | not (hasAmount p) = p{pamount=costOfMixedAmount (-bvsum)}
|
||||
inferamount p = p
|
||||
|
||||
nonzerobalanceerror :: Transaction -> String
|
||||
nonzerobalanceerror t = printf "could not balance this transaction (%s%s%s)" rmsg sep bvmsg
|
||||
where
|
||||
(rsum, _, bvsum) = transactionPostingBalances t
|
||||
rmsg | isReallyZeroMixedAmountCost rsum = ""
|
||||
| otherwise = "real postings are off by " ++ showMixedAmount (costOfMixedAmount rsum)
|
||||
bvmsg | isReallyZeroMixedAmountCost bvsum = ""
|
||||
| otherwise = "balanced virtual postings are off by " ++ showMixedAmount (costOfMixedAmount bvsum)
|
||||
sep = if not (null rmsg) && not (null bvmsg) then "; " else "" :: String
|
||||
-- | Infer prices for this transaction's posting amounts, if needed to make
|
||||
-- the postings balance. This is done once for the real postings and again
|
||||
-- (separately) for the balanced virtual postings. When it's not possible, the
|
||||
-- transaction is left unchanged.
|
||||
--
|
||||
-- The simplest example is a transaction with two postings, each in a
|
||||
-- different commodity, with no prices specified. In this case we'll add a
|
||||
-- price to the first posting such that it can be converted to the commodity
|
||||
-- of the second posting (with -B), and such that the postings balance.
|
||||
--
|
||||
-- In general, we can infer a conversion price when the sum of posting amounts
|
||||
-- contains exactly two different commodities and no explicit prices. The
|
||||
-- transaction could contain additional commodities, and/or prices, if they
|
||||
-- cancel out; what matters is that the sum of posting amounts contains
|
||||
-- exactly two commodities and zero prices.
|
||||
--
|
||||
-- There can also be more than two postings in either of the commodities.
|
||||
--
|
||||
-- We want to avoid excessive display of digits when the calculated price is
|
||||
-- an irrational number, while also ensuring the displayed numbers balance if
|
||||
-- the user does a manual calculation. This is achieved in two ways:
|
||||
--
|
||||
-- - when there is only one posting in the "from" commodity, a total price
|
||||
-- (@@) is used, and all available decimal digits are shown
|
||||
--
|
||||
-- - otherwise, a suitable averaged unit price (@) is applied to the relevant
|
||||
-- postings, with a display precision that is the sum of the display
|
||||
-- precisions of the two commodities being converted between.
|
||||
--
|
||||
-- All postings are expected to contain an explicit amount (no missing
|
||||
-- amounts) in a single commodity. (The code used to avoid inferring prices
|
||||
-- when it had previously inferred a missing amount, but it seems harmless to
|
||||
-- do that.)
|
||||
--
|
||||
inferBalancingPrices :: Transaction -> Transaction
|
||||
inferBalancingPrices t@Transaction{tpostings=ps} = t{tpostings=ps'}
|
||||
where
|
||||
ps' = map (priceInferrerFor t BalancedVirtualPosting) $
|
||||
map (priceInferrerFor t RegularPosting) $
|
||||
ps
|
||||
|
||||
-- | Generate a posting update function which assigns a suitable balancing
|
||||
-- price to the posting, if and as appropriate for the given transaction and
|
||||
-- posting type (real or balanced virtual).
|
||||
priceInferrerFor :: Transaction -> PostingType -> (Posting -> Posting)
|
||||
priceInferrerFor t pt = inferprice
|
||||
where
|
||||
postings = filter ((==pt).ptype) $ tpostings t
|
||||
pmixedamounts = map pamount postings
|
||||
pamounts = concatMap amounts pmixedamounts
|
||||
pcommodities = map acommodity pamounts
|
||||
sumamounts = amounts $ sum pmixedamounts -- sum normalises to one amount per commodity & price
|
||||
sumcommodities = map acommodity sumamounts
|
||||
sumprices = filter (/=NoPrice) $ map aprice sumamounts
|
||||
caninferprices = length sumcommodities == 2 && null sumprices
|
||||
|
||||
inferprice p@Posting{pamount=Mixed [a]}
|
||||
| caninferprices && ptype p == pt && acommodity a == fromcommodity
|
||||
= p{pamount=Mixed [a{aprice=conversionprice}]}
|
||||
where
|
||||
fromcommodity = head $ filter (`elem` sumcommodities) pcommodities -- these heads are ugly but should be safe
|
||||
conversionprice
|
||||
| fromcount==1 = TotalPrice $ abs toamount `withPrecision` maxprecision
|
||||
| otherwise = UnitPrice $ abs unitprice `withPrecision` summedprecision
|
||||
where
|
||||
fromcount = length $ filter ((==fromcommodity).acommodity) pamounts
|
||||
fromamount = head $ filter ((==fromcommodity).acommodity) sumamounts
|
||||
tocommodity = head $ filter (/=fromcommodity) sumcommodities
|
||||
toamount = head $ filter ((==tocommodity).acommodity) sumamounts
|
||||
unitprice = toamount `divideAmount` (aquantity fromamount)
|
||||
summedprecision = (asprecision $ astyle $ toamount) + (asprecision $ astyle $ fromamount)
|
||||
inferprice p = p
|
||||
|
||||
-- Get a transaction's secondary date, defaulting to the primary date.
|
||||
transactionDate2 :: Transaction -> Day
|
||||
|
Loading…
Reference in New Issue
Block a user