diff --git a/bin/hledger-smooth.hs b/bin/hledger-smooth.hs index 6978878d6..c61c965c5 100755 --- a/bin/hledger-smooth.hs +++ b/bin/hledger-smooth.hs @@ -110,11 +110,11 @@ splitPosting acct dates p@Posting{paccount,pamount} [d] -> (d, []) [] -> error' "splitPosting ran out of dates, should not happen (maybe sort your transactions by date)" days = initSafe [start..end] - amt = (genericLength days) `divideMixedAmount` pamount + amt = (fromIntegral $ length days) `divideMixedAmount` pamount -- give one of the postings an exact balancing amount to ensure the transaction is balanced -- lastamt = pamount - ptrace (amt `multiplyMixedAmount` (fromIntegral $ length days)) lastamt = missingmixedamt - daysamts = zip days (take (length days - 1) (repeat amt) ++ [lastamt]) + daysamts = zip days (replicate (length days - 1) amt ++ [lastamt]) ps' = [postingSetDate (Just d) p{pamount=a} | (d,a) <- daysamts ] -- | Set a posting's (primary) date, as if it had been parsed from the journal entry: diff --git a/hledger-lib/Hledger/Data.hs b/hledger-lib/Hledger/Data.hs index 80aaaae06..839a7175f 100644 --- a/hledger-lib/Hledger/Data.hs +++ b/hledger-lib/Hledger/Data.hs @@ -47,7 +47,7 @@ import Hledger.Data.StringFormat import Hledger.Data.Timeclock import Hledger.Data.Transaction import Hledger.Data.TransactionModifier -import Hledger.Data.Types +import Hledger.Data.Types hiding (MixedAmountKey, Mixed) import Hledger.Data.Valuation import Hledger.Utils.Test diff --git a/hledger-lib/Hledger/Data/Amount.hs b/hledger-lib/Hledger/Data/Amount.hs index 06cb1f517..320a86c0c 100644 --- a/hledger-lib/Hledger/Data/Amount.hs +++ b/hledger-lib/Hledger/Data/Amount.hs @@ -101,6 +101,7 @@ module Hledger.Data.Amount ( maAddAmount, maAddAmounts, amounts, + amountsRaw, filterMixedAmount, filterMixedAmountByCommodity, mapMixedAmount, @@ -152,10 +153,8 @@ import Data.Foldable (toList) import Data.List (find, foldl', intercalate, intersperse, mapAccumL, partition) import Data.List.NonEmpty (NonEmpty(..), nonEmpty) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) -#if !(MIN_VERSION_base(4,11,0)) +import Data.Maybe (fromMaybe, isNothing) import Data.Semigroup (Semigroup(..)) -#endif import qualified Data.Text as T import qualified Data.Text.Lazy.Builder as TB import Data.Word (Word8) @@ -589,48 +588,54 @@ canonicaliseAmount styles a@Amount{acommodity=c, astyle=s} = a{astyle=s'} instance Semigroup MixedAmount where (<>) = maPlus + sconcat = maSum + stimes n = multiplyMixedAmount (fromIntegral n) instance Monoid MixedAmount where mempty = nullmixedamt + mconcat = maSum #if !(MIN_VERSION_base(4,11,0)) mappend = (<>) #endif instance Num MixedAmount where - fromInteger i = Mixed [fromInteger i] - negate = maNegate - (+) = maPlus - (*) = error' "error, mixed amounts do not support multiplication" -- PARTIAL: - abs = error' "error, mixed amounts do not support abs" - signum = error' "error, mixed amounts do not support signum" + fromInteger = mixedAmount . fromInteger + negate = maNegate + (+) = maPlus + (*) = error' "error, mixed amounts do not support multiplication" -- PARTIAL: + abs = error' "error, mixed amounts do not support abs" + signum = error' "error, mixed amounts do not support signum" --- | Get a mixed amount's component amounts. -amounts :: MixedAmount -> [Amount] -amounts (Mixed as) = as +-- | Calculate the key used to store an Amount within a MixedAmount. +amountKey :: Amount -> MixedAmountKey +amountKey amt@Amount{acommodity=c} = case aprice amt of + Nothing -> MixedAmountKeyNoPrice c + Just (TotalPrice p) -> MixedAmountKeyTotalPrice c (acommodity p) + Just (UnitPrice p) -> MixedAmountKeyUnitPrice c (acommodity p) (aquantity p) -- | The empty mixed amount. nullmixedamt :: MixedAmount -nullmixedamt = Mixed [] +nullmixedamt = Mixed mempty -- | A temporary value for parsed transactions which had no amount specified. missingmixedamt :: MixedAmount missingmixedamt = mixedAmount missingamt --- | Convert amounts in various commodities into a normalised MixedAmount. -mixed :: [Amount] -> MixedAmount -mixed = normaliseMixedAmount . Mixed +-- | Convert amounts in various commodities into a mixed amount. +mixed :: Foldable t => t Amount -> MixedAmount +mixed = maAddAmounts nullmixedamt -- | Create a MixedAmount from a single Amount. mixedAmount :: Amount -> MixedAmount -mixedAmount = Mixed . pure +mixedAmount a = Mixed $ M.singleton (amountKey a) a -- | Add an Amount to a MixedAmount, normalising the result. maAddAmount :: MixedAmount -> Amount -> MixedAmount -maAddAmount (Mixed as) a = normaliseMixedAmount . Mixed $ a : as +maAddAmount (Mixed ma) a = Mixed $ M.insertWith sumSimilarAmountsUsingFirstPrice (amountKey a) a ma -- | Add a collection of Amounts to a MixedAmount, normalising the result. -maAddAmounts :: MixedAmount -> [Amount] -> MixedAmount -maAddAmounts (Mixed as) bs = bs `seq` normaliseMixedAmount . Mixed $ bs ++ as +maAddAmounts :: Foldable t => MixedAmount -> t Amount -> MixedAmount +maAddAmounts = foldl' maAddAmount -- | Negate mixed amount's quantities (and total prices, if any). maNegate :: MixedAmount -> MixedAmount @@ -638,7 +643,7 @@ maNegate = transformMixedAmount negate -- | Sum two MixedAmount. maPlus :: MixedAmount -> MixedAmount -> MixedAmount -maPlus (Mixed as) (Mixed bs) = normaliseMixedAmount . Mixed $ as ++ bs +maPlus (Mixed as) (Mixed bs) = Mixed $ M.unionWith sumSimilarAmountsUsingFirstPrice as bs -- | Subtract a MixedAmount from another. maMinus :: MixedAmount -> MixedAmount -> MixedAmount @@ -658,7 +663,7 @@ multiplyMixedAmount n = transformMixedAmount (*n) -- | Apply a function to a mixed amount's quantities (and its total prices, if it has any). transformMixedAmount :: (Quantity -> Quantity) -> MixedAmount -> MixedAmount -transformMixedAmount f = mapMixedAmount (transformAmount f) +transformMixedAmount f = mapMixedAmountUnsafe (transformAmount f) -- | Calculate the average of some mixed amounts. averageMixedAmounts :: [MixedAmount] -> MixedAmount @@ -699,7 +704,7 @@ maIsZero = mixedAmountIsZero maIsNonZero :: MixedAmount -> Bool maIsNonZero = not . mixedAmountIsZero --- | Simplify a mixed amount's component amounts: +-- | Get a mixed amount's component amounts. -- -- * amounts in the same commodity are combined unless they have different prices or total prices -- @@ -711,34 +716,35 @@ maIsNonZero = not . mixedAmountIsZero -- -- * the special "missing" mixed amount remains unchanged -- -normaliseMixedAmount :: MixedAmount -> MixedAmount -normaliseMixedAmount = normaliseHelper False - -normaliseHelper :: Bool -> MixedAmount -> MixedAmount -normaliseHelper squashprices (Mixed as) - | missingkey `M.member` amtMap = missingmixedamt -- missingamt should always be alone, but detect it even if not - | M.null nonzeros = Mixed [newzero] - | otherwise = Mixed $ toList nonzeros +amounts :: MixedAmount -> [Amount] +amounts (Mixed ma) + | missingkey `M.member` ma = [missingamt] -- missingamt should always be alone, but detect it even if not + | M.null nonzeros = [newzero] + | otherwise = toList nonzeros where newzero = fromMaybe nullamt $ find (not . T.null . acommodity) zeros - (zeros, nonzeros) = M.partition amountIsZero amtMap - amtMap = foldr (\a -> M.insertWith sumSimilarAmountsUsingFirstPrice (key a) a) mempty as - key Amount{acommodity=c,aprice=p} = (c, if squashprices then Nothing else priceKey <$> p) - where - priceKey (UnitPrice x) = (acommodity x, Just $ aquantity x) - priceKey (TotalPrice x) = (acommodity x, Nothing) - missingkey = key missingamt + (zeros, nonzeros) = M.partition amountIsZero ma + missingkey = amountKey missingamt --- | Like normaliseMixedAmount, but combine each commodity's amounts --- into just one by throwing away all prices except the first. This is --- only used as a rendering helper, and could show a misleading price. +-- | Get a mixed amount's component amounts without normalising zero and missing +-- amounts. This is used for JSON serialisation, so the order is important. In +-- particular, we want the Amounts given in the order of the MixedAmountKeys, +-- i.e. lexicographically first by commodity, then by price commodity, then by +-- unit price from most negative to most positive. +amountsRaw :: MixedAmount -> [Amount] +amountsRaw (Mixed ma) = toList ma + +normaliseMixedAmount :: MixedAmount -> MixedAmount +normaliseMixedAmount = id -- XXX Remove + +-- | Strip prices from a MixedAmount. normaliseMixedAmountSquashPricesForDisplay :: MixedAmount -> MixedAmount -normaliseMixedAmountSquashPricesForDisplay = normaliseHelper True +normaliseMixedAmountSquashPricesForDisplay = mixedAmountStripPrices -- XXX Remove -- | Unify a MixedAmount to a single commodity value if possible. --- Like normaliseMixedAmount, this consolidates amounts of the same commodity --- and discards zero amounts; but this one insists on simplifying to --- a single commodity, and will return Nothing if this is not possible. +-- This consolidates amounts of the same commodity and discards zero +-- amounts; but this one insists on simplifying to a single commodity, +-- and will return Nothing if this is not possible. unifyMixedAmount :: MixedAmount -> Maybe Amount unifyMixedAmount = foldM combine 0 . amounts where @@ -768,22 +774,27 @@ sumSimilarAmountsUsingFirstPrice a b = (a + b){aprice=p} -- | Filter a mixed amount's component amounts by a predicate. filterMixedAmount :: (Amount -> Bool) -> MixedAmount -> MixedAmount -filterMixedAmount p (Mixed as) = Mixed $ filter p as +filterMixedAmount p (Mixed ma) = Mixed $ M.filter p ma -- | Return an unnormalised MixedAmount containing exactly one Amount -- with the specified commodity and the quantity of that commodity -- found in the original. NB if Amount's quantity is zero it will be -- discarded next time the MixedAmount gets normalised. filterMixedAmountByCommodity :: CommoditySymbol -> MixedAmount -> MixedAmount -filterMixedAmountByCommodity c (Mixed as) = Mixed as' - where - as' = case filter ((==c) . acommodity) as of - [] -> [nullamt{acommodity=c}] - as'' -> [sum as''] +filterMixedAmountByCommodity c (Mixed ma) + | M.null ma' = mixedAmount nullamt{acommodity=c} + | otherwise = Mixed ma' + where ma' = M.filter ((c==) . acommodity) ma -- | Apply a transform to a mixed amount's component 'Amount's. mapMixedAmount :: (Amount -> Amount) -> MixedAmount -> MixedAmount -mapMixedAmount f (Mixed as) = Mixed $ map f as +mapMixedAmount f (Mixed ma) = mixed . map f $ toList ma + +-- | Apply a transform to a mixed amount's component 'Amount's, which does not +-- affect the key of the amount (i.e. doesn't change the commodity, price +-- commodity, or unit price amount). This condition is not checked. +mapMixedAmountUnsafe :: (Amount -> Amount) -> MixedAmount -> MixedAmount +mapMixedAmountUnsafe f (Mixed ma) = Mixed $ M.map f ma -- Use M.map instead of fmap to maintain strictness -- | Convert all component amounts to cost/selling price where -- possible (see amountCost). @@ -795,17 +806,17 @@ mixedAmountCost = mapMixedAmount amountCost -- -- For now, use this when cross-commodity zero equality is important. -- mixedAmountEquals :: MixedAmount -> MixedAmount -> Bool -- mixedAmountEquals a b = amounts a' == amounts b' || (mixedAmountLooksZero a' && mixedAmountLooksZero b') --- where a' = normaliseMixedAmountSquashPricesForDisplay a --- b' = normaliseMixedAmountSquashPricesForDisplay b +-- where a' = mixedAmountStripPrices a +-- b' = mixedAmountStripPrices b -- | Given a map of standard commodity display styles, apply the -- appropriate one to each individual amount. styleMixedAmount :: M.Map CommoditySymbol AmountStyle -> MixedAmount -> MixedAmount -styleMixedAmount styles = mapMixedAmount (styleAmount styles) +styleMixedAmount styles = mapMixedAmountUnsafe (styleAmount styles) -- | Reset each individual amount's display style to the default. mixedAmountUnstyled :: MixedAmount -> MixedAmount -mixedAmountUnstyled = mapMixedAmount amountUnstyled +mixedAmountUnstyled = mapMixedAmountUnsafe amountUnstyled -- | Get the string representation of a mixed amount, after -- normalising it to one amount per commodity. Assumes amounts have @@ -871,8 +882,8 @@ showMixedAmountDebug m | m == missingmixedamt = "(missing)" -- - If displayed on multiple lines, any Amounts longer than the -- maximum width will be elided. showMixedAmountB :: AmountDisplayOpts -> MixedAmount -> WideBuilder -showMixedAmountB opts = showAmountsB opts . amounts - . (if displayPrice opts then id else mixedAmountStripPrices) . normaliseMixedAmountSquashPricesForDisplay +showMixedAmountB opts = + showAmountsB opts . amounts . if displayPrice opts then id else mixedAmountStripPrices data AmountDisplay = AmountDisplay { adBuilder :: !WideBuilder -- ^ String representation of the Amount @@ -916,20 +927,22 @@ ltraceamount s = traceWith (((s ++ ": ") ++).showMixedAmount) -- | Set the display precision in the amount's commodities. mixedAmountSetPrecision :: AmountPrecision -> MixedAmount -> MixedAmount -mixedAmountSetPrecision p = mapMixedAmount (amountSetPrecision p) +mixedAmountSetPrecision p = mapMixedAmountUnsafe (amountSetPrecision p) -- | In each component amount, increase the display precision sufficiently -- to render it exactly (showing all significant decimal digits). mixedAmountSetFullPrecision :: MixedAmount -> MixedAmount -mixedAmountSetFullPrecision = mapMixedAmount amountSetFullPrecision +mixedAmountSetFullPrecision = mapMixedAmountUnsafe amountSetFullPrecision --- | Strip all prices from a MixedAmount. +-- | Remove all prices from a MixedAmount. mixedAmountStripPrices :: MixedAmount -> MixedAmount -mixedAmountStripPrices = mapMixedAmount amountStripPrices +mixedAmountStripPrices (Mixed ma) = + foldl' (\m a -> maAddAmount m a{aprice=Nothing}) (Mixed noPrices) withPrices + where (noPrices, withPrices) = M.partition (isNothing . aprice) ma -- | Canonicalise a mixed amount's display styles using the provided commodity style map. canonicaliseMixedAmount :: M.Map CommoditySymbol AmountStyle -> MixedAmount -> MixedAmount -canonicaliseMixedAmount styles = mapMixedAmount (canonicaliseAmount styles) +canonicaliseMixedAmount styles = mapMixedAmountUnsafe (canonicaliseAmount styles) -- | Replace each component amount's TotalPrice, if it has one, with an equivalent UnitPrice. -- Has no effect on amounts without one. @@ -982,14 +995,14 @@ tests_Amount = tests "Amount" [ ,usd (-1) `withPrecision` Precision 3 ,usd (-0.25) ]) - @?= Mixed [usd 0 `withPrecision` Precision 3] + @?= mixedAmount (usd 0 `withPrecision` Precision 3) ,test "adding mixed amounts with total prices" $ do maSum (map mixedAmount [usd 1 @@ eur 1 ,usd (-2) @@ eur 1 ]) - @?= Mixed [usd (-1) @@ eur 2 ] + @?= mixedAmount (usd (-1) @@ eur 2) ,test "showMixedAmount" $ do showMixedAmount (mixedAmount (usd 1)) @?= "$1.00" @@ -1003,22 +1016,22 @@ tests_Amount = tests "Amount" [ showMixedAmountWithoutPrice False (mixedAmount (a)) @?= "$1.00" showMixedAmountWithoutPrice False (mixed [a, -a]) @?= "0" - ,tests "normaliseMixedAmount" [ + ,tests "amounts" [ test "a missing amount overrides any other amounts" $ - amounts (normaliseMixedAmount $ mixed [usd 1, missingamt]) @?= [missingamt] + amounts (mixed [usd 1, missingamt]) @?= [missingamt] ,test "unpriced same-commodity amounts are combined" $ - amounts (normaliseMixedAmount $ mixed [usd 0, usd 2]) @?= [usd 2] + amounts (mixed [usd 0, usd 2]) @?= [usd 2] ,test "amounts with same unit price are combined" $ - amounts (normaliseMixedAmount $ mixed [usd 1 `at` eur 1, usd 1 `at` eur 1]) @?= [usd 2 `at` eur 1] + amounts (mixed [usd 1 `at` eur 1, usd 1 `at` eur 1]) @?= [usd 2 `at` eur 1] ,test "amounts with different unit prices are not combined" $ - amounts (normaliseMixedAmount $ mixed [usd 1 `at` eur 1, usd 1 `at` eur 2]) @?= [usd 1 `at` eur 1, usd 1 `at` eur 2] + amounts (mixed [usd 1 `at` eur 1, usd 1 `at` eur 2]) @?= [usd 1 `at` eur 1, usd 1 `at` eur 2] ,test "amounts with total prices are combined" $ - amounts (normaliseMixedAmount $ mixed [usd 1 @@ eur 1, usd 1 @@ eur 1]) @?= [usd 2 @@ eur 2] + amounts (mixed [usd 1 @@ eur 1, usd 1 @@ eur 1]) @?= [usd 2 @@ eur 2] ] - ,test "normaliseMixedAmountSquashPricesForDisplay" $ do - amounts (normaliseMixedAmountSquashPricesForDisplay nullmixedamt) @?= [nullamt] - assertBool "" $ mixedAmountLooksZero $ normaliseMixedAmountSquashPricesForDisplay + ,test "mixedAmountStripPrices" $ do + amounts (mixedAmountStripPrices nullmixedamt) @?= [nullamt] + assertBool "" $ mixedAmountLooksZero $ mixedAmountStripPrices (mixed [usd 10 ,usd 10 @@ eur 7 ,usd (-10) diff --git a/hledger-lib/Hledger/Data/Journal.hs b/hledger-lib/Hledger/Data/Journal.hs index 53df742df..d10cce4a6 100644 --- a/hledger-lib/Hledger/Data/Journal.hs +++ b/hledger-lib/Hledger/Data/Journal.hs @@ -547,8 +547,8 @@ journalMapPostings :: (Posting -> Posting) -> Journal -> Journal journalMapPostings f j@Journal{jtxns=ts} = j{jtxns=map (transactionMapPostings f) ts} -- | Apply a transformation to a journal's posting amounts. -journalMapPostingAmounts :: (Amount -> Amount) -> Journal -> Journal -journalMapPostingAmounts f = journalMapPostings (postingTransformAmount (mapMixedAmount f)) +journalMapPostingAmounts :: (MixedAmount -> MixedAmount) -> Journal -> Journal +journalMapPostingAmounts f = journalMapPostings (postingTransformAmount f) {- ------------------------------------------------------------------------------- @@ -929,7 +929,7 @@ addOrAssignAmountAndCheckAssertionB p@Posting{paccount=acc, pamount=amt, pbalanc -- need to see the balance as it stands after each individual posting. addAmountAndCheckAssertionB :: Posting -> Balancing s Posting addAmountAndCheckAssertionB p | hasAmount p = do - newbal <- addToRunningBalanceB (paccount p) (pamount p) + newbal <- addToRunningBalanceB (paccount p) $ pamount p whenM (R.reader bsAssrt) $ checkBalanceAssertionB p newbal return p addAmountAndCheckAssertionB p = return p @@ -940,13 +940,12 @@ addAmountAndCheckAssertionB p = return p -- are ignored; if it is total, they will cause the assertion to fail. checkBalanceAssertionB :: Posting -> MixedAmount -> Balancing s () checkBalanceAssertionB p@Posting{pbalanceassertion=Just (BalanceAssertion{baamount,batotal})} actualbal = - forM_ assertedamts $ \amt -> checkBalanceAssertionOneCommodityB p amt actualbal + forM_ (baamount : otheramts) $ \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 = [] + assertedcomm = acommodity baamount + otheramts | batotal = map (\a -> a{aquantity=0}) . amountsRaw + $ filterMixedAmount ((/=assertedcomm).acommodity) actualbal + | otherwise = [] checkBalanceAssertionB _ _ = return () -- | Does this (single commodity) expected balance match the amount of that @@ -971,7 +970,7 @@ checkBalanceAssertionOneCommodityB p@Posting{paccount=assertedacct} assertedamt else return actualbal let assertedcomm = acommodity assertedamt - actualbalincomm = headDef 0 $ amounts $ filterMixedAmountByCommodity assertedcomm $ actualbal' + actualbalincomm = headDef nullamt . amountsRaw . filterMixedAmountByCommodity assertedcomm $ actualbal' pass = aquantity -- traceWith (("asserted:"++).showAmountDebug) @@ -1181,16 +1180,16 @@ journalInferMarketPricesFromTransactions j = -- first commodity amount is considered. postingInferredmarketPrice :: Posting -> Maybe MarketPrice postingInferredmarketPrice p@Posting{pamount} = - -- convert any total prices to unit prices - case amounts $ mixedAmountTotalPriceToUnitPrice pamount of - Amount{acommodity=fromcomm, aprice = Just (UnitPrice Amount{acommodity=tocomm, aquantity=rate})}:_ -> - Just MarketPrice { - mpdate = postingDate p - ,mpfrom = fromcomm - ,mpto = tocomm - ,mprate = rate - } - _ -> Nothing + -- convert any total prices to unit prices + case amountsRaw $ mixedAmountTotalPriceToUnitPrice pamount of + Amount{acommodity=fromcomm, aprice = Just (UnitPrice Amount{acommodity=tocomm, aquantity=rate})}:_ -> + Just MarketPrice { + mpdate = postingDate p + ,mpfrom = fromcomm + ,mpto = tocomm + ,mprate = rate + } + _ -> Nothing -- | Convert all this journal's amounts to cost using the transaction prices, if any. -- The journal's commodity styles are applied to the resulting amounts. @@ -1229,12 +1228,12 @@ journalToCost j@Journal{jtxns=ts} = j{jtxns=map (transactionToCost styles) ts} -- Transaction price amounts (posting amounts' aprice field) are not included. -- journalStyleInfluencingAmounts :: Journal -> [Amount] -journalStyleInfluencingAmounts j = +journalStyleInfluencingAmounts j = dbg7 "journalStyleInfluencingAmounts" $ catMaybes $ concat [ [mdefaultcommodityamt] ,map (Just . pdamount) $ jpricedirectives j - ,map Just $ concatMap amounts $ map pamount $ journalPostings j + ,map Just . concatMap (amountsRaw . pamount) $ journalPostings j ] where -- D's amount style isn't actually stored as an amount, make it into one @@ -1561,7 +1560,7 @@ tests_Journal = tests "Journal" [ ]} assertRight ej let Right j = ej - (jtxns j & head & tpostings & head & pamount) @?= mixedAmount (num 1) + (jtxns j & head & tpostings & head & pamount & amountsRaw) @?= [num 1] ,test "same-day-1" $ do assertRight $ journalBalanceTransactions True $ diff --git a/hledger-lib/Hledger/Data/Json.hs b/hledger-lib/Hledger/Data/Json.hs index 6272cffb4..312760e42 100644 --- a/hledger-lib/Hledger/Data/Json.hs +++ b/hledger-lib/Hledger/Data/Json.hs @@ -50,6 +50,7 @@ import GHC.Generics (Generic) import System.Time (ClockTime) import Hledger.Data.Types +import Hledger.Data.Amount (amountsRaw, mixed) -- To JSON @@ -105,7 +106,11 @@ instance ToJSON AmountPrecision where instance ToJSON Side instance ToJSON DigitGroupStyle -instance ToJSON MixedAmount + +instance ToJSON MixedAmount where + toJSON = toJSON . amountsRaw + toEncoding = toEncoding . amountsRaw + instance ToJSON BalanceAssertion instance ToJSON AmountPrice instance ToJSON MarketPrice @@ -188,7 +193,10 @@ instance FromJSON AmountPrecision where instance FromJSON Side instance FromJSON DigitGroupStyle -instance FromJSON MixedAmount + +instance FromJSON MixedAmount where + parseJSON = fmap (mixed :: [Amount] -> MixedAmount) . parseJSON + instance FromJSON BalanceAssertion instance FromJSON AmountPrice instance FromJSON MarketPrice diff --git a/hledger-lib/Hledger/Data/Posting.hs b/hledger-lib/Hledger/Data/Posting.hs index b980b6744..778e6c967 100644 --- a/hledger-lib/Hledger/Data/Posting.hs +++ b/hledger-lib/Hledger/Data/Posting.hs @@ -115,7 +115,7 @@ posting = nullposting -- | Make a posting to an account. post :: AccountName -> Amount -> Posting -post acc amt = posting {paccount=acc, pamount=Mixed [amt]} +post acc amt = posting {paccount=acc, pamount=mixedAmount amt} -- | Make a virtual (unbalanced) posting to an account. vpost :: AccountName -> Amount -> Posting @@ -123,7 +123,7 @@ vpost acc amt = (post acc amt){ptype=VirtualPosting} -- | Make a posting to an account, maybe with a balance assertion. post' :: AccountName -> Amount -> Maybe BalanceAssertion -> Posting -post' acc amt ass = posting {paccount=acc, pamount=Mixed [amt], pbalanceassertion=ass} +post' acc amt ass = posting {paccount=acc, pamount=mixedAmount amt, pbalanceassertion=ass} -- | Make a virtual (unbalanced) posting to an account, maybe with a balance assertion. vpost' :: AccountName -> Amount -> Maybe BalanceAssertion -> Posting @@ -197,10 +197,11 @@ hasBalanceAssignment p = not (hasAmount p) && isJust (pbalanceassertion p) accountNamesFromPostings :: [Posting] -> [AccountName] accountNamesFromPostings = nubSort . map paccount +-- | Sum all amounts from a list of postings. sumPostings :: [Posting] -> MixedAmount sumPostings = foldl' (\amt p -> maPlus amt $ pamount p) nullmixedamt --- | Remove all prices of a posting +-- | Strip all prices from a Posting. postingStripPrices :: Posting -> Posting postingStripPrices = postingTransformAmount mixedAmountStripPrices diff --git a/hledger-lib/Hledger/Data/Transaction.hs b/hledger-lib/Hledger/Data/Transaction.hs index e2a7dbaa2..afef68e96 100644 --- a/hledger-lib/Hledger/Data/Transaction.hs +++ b/hledger-lib/Hledger/Data/Transaction.hs @@ -65,7 +65,7 @@ where import Data.Default (def) import Data.List (intercalate, partition) import Data.List.Extra (nubSort) -import Data.Maybe (fromMaybe, mapMaybe) +import Data.Maybe (fromMaybe, isJust, mapMaybe) #if !(MIN_VERSION_base(4,11,0)) import Data.Semigroup ((<>)) #endif @@ -283,9 +283,8 @@ postingAsLines elideamount onelineamounts acctwidth amtwidth p = -- amtwidth and thisamtwidth, make sure thisamtwidth does not depend on -- amtwidth at all. shownAmounts - | elideamount || null (amounts $ pamount p) = [mempty] - | otherwise = showAmountsLinesB displayopts . amounts $ pamount p - where displayopts = noColour{displayOneLine=onelineamounts} + | elideamount = [mempty] + | otherwise = showAmountsLinesB noColour{displayOneLine=onelineamounts} . amounts $ pamount p thisamtwidth = maximumDef 0 $ map wbWidth shownAmounts (samelinecomment, newlinecomments) = @@ -554,35 +553,35 @@ priceInferrerFor :: Transaction -> PostingType -> (Posting -> Posting) priceInferrerFor t pt = inferprice where postings = filter ((==pt).ptype) $ tpostings t - pamounts = concatMap (amounts . pamount) postings - pcommodities = map acommodity pamounts - sumamounts = amounts $ sumPostings postings -- sum normalises to one amount per commodity & price + pmixedamounts = map pamount postings + pcommodities = map acommodity $ concatMap amountsRaw pmixedamounts + sumamounts = amounts $ maSum pmixedamounts -- sum normalises to one amount per commodity & price sumcommodities = map acommodity sumamounts - sumprices = filter (/=Nothing) $ map aprice sumamounts + sumprices = filter isJust $ map aprice sumamounts caninferprices = length sumcommodities == 2 && null sumprices - inferprice p@Posting{pamount=Mixed [a]} - | caninferprices && ptype p == pt && acommodity a == fromcommodity - = p{pamount=mixedAmount $ a{aprice=Just conversionprice}, poriginal=Just $ originalPosting p} - where - fromcommodity = head $ filter (`elem` sumcommodities) pcommodities -- these heads are ugly but should be safe - totalpricesign = if aquantity a < 0 then negate else id - conversionprice - | fromcount==1 = TotalPrice $ totalpricesign (abs toamount) `withPrecision` NaturalPrecision - | otherwise = UnitPrice $ abs unitprice `withPrecision` unitprecision - where - fromcount = length $ filter ((==fromcommodity).acommodity) pamounts - fromamount = head $ filter ((==fromcommodity).acommodity) sumamounts - fromprecision = asprecision $ astyle fromamount - tocommodity = head $ filter (/=fromcommodity) sumcommodities - toamount = head $ filter ((==tocommodity).acommodity) sumamounts - toprecision = asprecision $ astyle toamount - unitprice = (aquantity fromamount) `divideAmount` toamount - -- Sum two display precisions, capping the result at the maximum bound - unitprecision = case (fromprecision, toprecision) of - (Precision a, Precision b) -> Precision $ if maxBound - a < b then maxBound else max 2 (a + b) - _ -> NaturalPrecision - inferprice p = p + inferprice p@Posting{pamount=amt} = case amountsRaw amt of + [a] | caninferprices && ptype p == pt && acommodity a == fromcommodity + -> p{ pamount=mixedAmount a{aprice=Just conversionprice} + , poriginal=Just $ originalPosting p} + where + fromcommodity = head $ filter (`elem` sumcommodities) pcommodities -- these heads are ugly but should be safe + totalpricesign = if aquantity a < 0 then negate else id + conversionprice = case filter (==fromcommodity) pcommodities of + [_] -> TotalPrice $ totalpricesign (abs toamount) `withPrecision` NaturalPrecision + _ -> UnitPrice $ abs unitprice `withPrecision` unitprecision + where + fromamount = head $ filter ((==fromcommodity).acommodity) sumamounts + fromprecision = asprecision $ astyle fromamount + tocommodity = head $ filter (/=fromcommodity) sumcommodities + toamount = head $ filter ((==tocommodity).acommodity) sumamounts + toprecision = asprecision $ astyle toamount + unitprice = aquantity fromamount `divideAmount` toamount + -- Sum two display precisions, capping the result at the maximum bound + unitprecision = case (fromprecision, toprecision) of + (Precision a, Precision b) -> Precision $ if maxBound - a < b then maxBound else max 2 (a + b) + _ -> NaturalPrecision + _ -> p -- Get a transaction's secondary date, defaulting to the primary date. transactionDate2 :: Transaction -> Day @@ -638,8 +637,8 @@ transactionMapPostings :: (Posting -> Posting) -> Transaction -> Transaction transactionMapPostings f t@Transaction{tpostings=ps} = t{tpostings=map f ps} -- | Apply a transformation to a transaction's posting amounts. -transactionMapPostingAmounts :: (Amount -> Amount) -> Transaction -> Transaction -transactionMapPostingAmounts f = transactionMapPostings (postingTransformAmount (mapMixedAmount f)) +transactionMapPostingAmounts :: (MixedAmount -> MixedAmount) -> Transaction -> Transaction +transactionMapPostingAmounts f = transactionMapPostings (postingTransformAmount f) -- | The file path from which this transaction was parsed. transactionFile :: Transaction -> FilePath @@ -655,13 +654,13 @@ tests_Transaction = tests "Transaction" [ tests "showPostingLines" [ - test "null posting" $ showPostingLines posting @?= [""] + test "null posting" $ showPostingLines nullposting @?= [" 0"] , test "non-null posting" $ let p = posting { pstatus = Cleared , paccount = "a" - , pamount = Mixed [usd 1, hrs 2] + , pamount = mixed [usd 1, hrs 2] , pcomment = "pcomment1\npcomment2\n tag3: val3 \n" , ptype = RegularPosting , ptags = [("ptag1", "val1"), ("ptag2", "val2")] @@ -742,7 +741,7 @@ tests_Transaction = [ nullposting { pstatus = Cleared , paccount = "a" - , pamount = Mixed [usd 1, hrs 2] + , pamount = mixed [usd 1, hrs 2] , pcomment = "\npcomment2\n" , ptype = RegularPosting , ptags = [("ptag1", "val1"), ("ptag2", "val2")] @@ -771,8 +770,8 @@ tests_Transaction = "coopportunity" "" [] - [ posting {paccount = "expenses:food:groceries", pamount = Mixed [usd 47.18], ptransaction = Just t} - , posting {paccount = "assets:checking", pamount = Mixed [usd (-47.18)], ptransaction = Just t} + [ posting {paccount = "expenses:food:groceries", pamount = mixedAmount (usd 47.18), ptransaction = Just t} + , posting {paccount = "assets:checking", pamount = mixedAmount (usd (-47.18)), ptransaction = Just t} ] in showTransaction t) @?= (T.unlines @@ -795,8 +794,8 @@ tests_Transaction = "coopportunity" "" [] - [ posting {paccount = "expenses:food:groceries", pamount = Mixed [usd 47.18]} - , posting {paccount = "assets:checking", pamount = Mixed [usd (-47.19)]} + [ posting {paccount = "expenses:food:groceries", pamount = mixedAmount (usd 47.18)} + , posting {paccount = "assets:checking", pamount = mixedAmount (usd (-47.19))} ])) @?= (T.unlines [ "2007-01-28 coopportunity" @@ -834,7 +833,7 @@ tests_Transaction = "x" "" [] - [ posting {paccount = "a", pamount = Mixed [num 1 `at` (usd 2 `withPrecision` Precision 0)]} + [ posting {paccount = "a", pamount = mixedAmount $ num 1 `at` (usd 2 `withPrecision` Precision 0)} , posting {paccount = "b", pamount = missingmixedamt} ])) @?= (T.unlines ["2010-01-01 x", " a 1 @ $2", " b", ""]) @@ -855,7 +854,7 @@ tests_Transaction = "test" "" [] - [posting {paccount = "a", pamount = Mixed [usd 1]}, posting {paccount = "b", pamount = Mixed [usd 1]}])) + [posting {paccount = "a", pamount = mixedAmount (usd 1)}, posting {paccount = "b", pamount = mixedAmount (usd 1)}])) ,test "detect unbalanced entry, multiple missing amounts" $ assertLeft $ balanceTransaction @@ -889,8 +888,8 @@ tests_Transaction = "" "" [] - [posting {paccount = "a", pamount = Mixed [usd 1]}, posting {paccount = "b", pamount = missingmixedamt}])) @?= - Right (Mixed [usd (-1)]) + [posting {paccount = "a", pamount = mixedAmount (usd 1)}, posting {paccount = "b", pamount = missingmixedamt}])) @?= + Right (mixedAmount $ usd (-1)) ,test "conversion price is inferred" $ (pamount . head . tpostings <$> balanceTransaction @@ -906,10 +905,10 @@ tests_Transaction = "" "" [] - [ posting {paccount = "a", pamount = Mixed [usd 1.35]} - , posting {paccount = "b", pamount = Mixed [eur (-1)]} + [ posting {paccount = "a", pamount = mixedAmount (usd 1.35)} + , posting {paccount = "b", pamount = mixedAmount (eur (-1))} ])) @?= - Right (Mixed [usd 1.35 @@ (eur 1 `withPrecision` NaturalPrecision)]) + Right (mixedAmount $ usd 1.35 @@ (eur 1 `withPrecision` NaturalPrecision)) ,test "balanceTransaction balances based on cost if there are unit prices" $ assertRight $ balanceTransaction @@ -925,8 +924,8 @@ tests_Transaction = "" "" [] - [ posting {paccount = "a", pamount = Mixed [usd 1 `at` eur 2]} - , posting {paccount = "a", pamount = Mixed [usd (-2) `at` eur 1]} + [ posting {paccount = "a", pamount = mixedAmount $ usd 1 `at` eur 2} + , posting {paccount = "a", pamount = mixedAmount $ usd (-2) `at` eur 1} ]) ,test "balanceTransaction balances based on cost if there are total prices" $ assertRight $ @@ -943,8 +942,8 @@ tests_Transaction = "" "" [] - [ posting {paccount = "a", pamount = Mixed [usd 1 @@ eur 1]} - , posting {paccount = "a", pamount = Mixed [usd (-2) @@ eur (-1)]} + [ posting {paccount = "a", pamount = mixedAmount $ usd 1 @@ eur 1} + , posting {paccount = "a", pamount = mixedAmount $ usd (-2) @@ eur (-1)} ]) ] , tests "isTransactionBalanced" [ @@ -962,8 +961,8 @@ tests_Transaction = "a" "" [] - [ posting {paccount = "b", pamount = Mixed [usd 1.00]} - , posting {paccount = "c", pamount = Mixed [usd (-1.00)]} + [ posting {paccount = "b", pamount = mixedAmount (usd 1.00)} + , posting {paccount = "c", pamount = mixedAmount (usd (-1.00))} ] ,test "detect unbalanced" $ assertBool "" $ @@ -980,8 +979,8 @@ tests_Transaction = "a" "" [] - [ posting {paccount = "b", pamount = Mixed [usd 1.00]} - , posting {paccount = "c", pamount = Mixed [usd (-1.01)]} + [ posting {paccount = "b", pamount = mixedAmount (usd 1.00)} + , posting {paccount = "c", pamount = mixedAmount (usd (-1.01))} ] ,test "detect unbalanced, one posting" $ assertBool "" $ @@ -998,7 +997,7 @@ tests_Transaction = "a" "" [] - [posting {paccount = "b", pamount = Mixed [usd 1.00]}] + [posting {paccount = "b", pamount = mixedAmount (usd 1.00)}] ,test "one zero posting is considered balanced for now" $ assertBool "" $ isTransactionBalanced Nothing $ @@ -1013,7 +1012,7 @@ tests_Transaction = "a" "" [] - [posting {paccount = "b", pamount = Mixed [usd 0]}] + [posting {paccount = "b", pamount = mixedAmount (usd 0)}] ,test "virtual postings don't need to balance" $ assertBool "" $ isTransactionBalanced Nothing $ @@ -1028,9 +1027,9 @@ tests_Transaction = "a" "" [] - [ posting {paccount = "b", pamount = Mixed [usd 1.00]} - , posting {paccount = "c", pamount = Mixed [usd (-1.00)]} - , posting {paccount = "d", pamount = Mixed [usd 100], ptype = VirtualPosting} + [ posting {paccount = "b", pamount = mixedAmount (usd 1.00)} + , posting {paccount = "c", pamount = mixedAmount (usd (-1.00))} + , posting {paccount = "d", pamount = mixedAmount (usd 100), ptype = VirtualPosting} ] ,test "balanced virtual postings need to balance among themselves" $ assertBool "" $ @@ -1047,9 +1046,9 @@ tests_Transaction = "a" "" [] - [ posting {paccount = "b", pamount = Mixed [usd 1.00]} - , posting {paccount = "c", pamount = Mixed [usd (-1.00)]} - , posting {paccount = "d", pamount = Mixed [usd 100], ptype = BalancedVirtualPosting} + [ posting {paccount = "b", pamount = mixedAmount (usd 1.00)} + , posting {paccount = "c", pamount = mixedAmount (usd (-1.00))} + , posting {paccount = "d", pamount = mixedAmount (usd 100), ptype = BalancedVirtualPosting} ] ,test "balanced virtual postings need to balance among themselves (2)" $ assertBool "" $ @@ -1065,10 +1064,10 @@ tests_Transaction = "a" "" [] - [ posting {paccount = "b", pamount = Mixed [usd 1.00]} - , posting {paccount = "c", pamount = Mixed [usd (-1.00)]} - , posting {paccount = "d", pamount = Mixed [usd 100], ptype = BalancedVirtualPosting} - , posting {paccount = "3", pamount = Mixed [usd (-100)], ptype = BalancedVirtualPosting} + [ posting {paccount = "b", pamount = mixedAmount (usd 1.00)} + , posting {paccount = "c", pamount = mixedAmount (usd (-1.00))} + , posting {paccount = "d", pamount = mixedAmount (usd 100), ptype = BalancedVirtualPosting} + , posting {paccount = "3", pamount = mixedAmount (usd (-100)), ptype = BalancedVirtualPosting} ] ] ] diff --git a/hledger-lib/Hledger/Data/TransactionModifier.hs b/hledger-lib/Hledger/Data/TransactionModifier.hs index 2a493a4a1..bb74a25ea 100644 --- a/hledger-lib/Hledger/Data/TransactionModifier.hs +++ b/hledger-lib/Hledger/Data/TransactionModifier.hs @@ -114,13 +114,13 @@ tmPostingRuleToFunction querytxt pr = Just n -> \p -> -- Multiply the old posting's amount by the posting rule's multiplier. let - pramount = dbg6 "pramount" $ head $ amounts $ pamount pr + pramount = dbg6 "pramount" . head . amountsRaw $ pamount pr matchedamount = dbg6 "matchedamount" $ pamount p -- Handle a matched amount with a total price carefully so as to keep the transaction balanced (#928). -- Approach 1: convert to a unit price and increase the display precision slightly -- Mixed as = dbg6 "multipliedamount" $ n `multiplyMixedAmount` mixedAmountTotalPriceToUnitPrice matchedamount -- Approach 2: multiply the total price (keeping it positive) as well as the quantity - as = dbg6 "multipliedamount" $ n `multiplyMixedAmount` matchedamount + as = dbg6 "multipliedamount" $ multiplyMixedAmount n matchedamount in case acommodity pramount of "" -> as @@ -130,10 +130,9 @@ tmPostingRuleToFunction querytxt pr = c -> mapMixedAmount (\a -> a{acommodity = c, astyle = astyle pramount, aprice = aprice pramount}) as postingRuleMultiplier :: TMPostingRule -> Maybe Quantity -postingRuleMultiplier p = - case amounts $ pamount p of - [a] | aismultiplier a -> Just $ aquantity a - _ -> Nothing +postingRuleMultiplier p = case amountsRaw $ pamount p of + [a] | aismultiplier a -> Just $ aquantity a + _ -> Nothing renderPostingCommentDates :: Posting -> Posting renderPostingCommentDates p = p { pcomment = comment' } diff --git a/hledger-lib/Hledger/Data/Types.hs b/hledger-lib/Hledger/Data/Types.hs index 1afac7ebc..1d0bfce94 100644 --- a/hledger-lib/Hledger/Data/Types.hs +++ b/hledger-lib/Hledger/Data/Types.hs @@ -16,12 +16,13 @@ For more detailed documentation on each type, see the corresponding modules. -} +{-# LANGUAGE CPP #-} -- {-# LANGUAGE DeriveAnyClass #-} -- https://hackage.haskell.org/package/deepseq-1.4.4.0/docs/Control-DeepSeq.html#v:rnf -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE FlexibleInstances #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE RecordWildCards #-} -{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeSynonymInstances #-} module Hledger.Data.Types @@ -38,6 +39,10 @@ import Text.Blaze (ToMarkup(..)) --You will eventually need all the values stored. --The stored values don't represent large virtual data structures to be lazily computed. import qualified Data.Map as M +import Data.Ord (comparing) +#if !(MIN_VERSION_base(4,11,0)) +import Data.Semigroup ((<>)) +#endif import Data.Text (Text) -- import qualified Data.Text as T import Data.Time.Calendar @@ -230,7 +235,38 @@ data Amount = Amount { aprice :: !(Maybe AmountPrice) -- ^ the (fixed, transaction-specific) price for this amount, if any } deriving (Eq,Ord,Generic,Show) -newtype MixedAmount = Mixed [Amount] deriving (Eq,Ord,Generic,Show) +newtype MixedAmount = Mixed (M.Map MixedAmountKey Amount) deriving (Eq,Ord,Generic,Show) + +-- | Stores the CommoditySymbol of the Amount, along with the CommoditySymbol of +-- the price, and its unit price if being used. +data MixedAmountKey + = MixedAmountKeyNoPrice !CommoditySymbol + | MixedAmountKeyTotalPrice !CommoditySymbol !CommoditySymbol + | MixedAmountKeyUnitPrice !CommoditySymbol !CommoditySymbol !Quantity + deriving (Eq,Generic,Show) + +-- | We don't auto-derive the Ord instance because it would give an undesired ordering. +-- We want the keys to be sorted lexicographically: +-- (1) By the primary commodity of the amount. +-- (2) By the commodity of the price, with no price being first. +-- (3) By the unit price, from most negative to most positive, with total prices +-- before unit prices. +-- For example, we would like the ordering to give +-- MixedAmountKeyNoPrice "X" < MixedAmountKeyTotalPrice "X" "Z" < MixedAmountKeyNoPrice "Y" +instance Ord MixedAmountKey where + compare = comparing commodity <> comparing pCommodity <> comparing pPrice + where + commodity (MixedAmountKeyNoPrice c) = c + commodity (MixedAmountKeyTotalPrice c _) = c + commodity (MixedAmountKeyUnitPrice c _ _) = c + + pCommodity (MixedAmountKeyNoPrice _) = Nothing + pCommodity (MixedAmountKeyTotalPrice _ pc) = Just pc + pCommodity (MixedAmountKeyUnitPrice _ pc _) = Just pc + + pPrice (MixedAmountKeyNoPrice _) = Nothing + pPrice (MixedAmountKeyTotalPrice _ _) = Nothing + pPrice (MixedAmountKeyUnitPrice _ _ q) = Just q data PostingType = RegularPosting | VirtualPosting | BalancedVirtualPosting deriving (Eq,Show,Generic) diff --git a/hledger-lib/Hledger/Data/Valuation.hs b/hledger-lib/Hledger/Data/Valuation.hs index 28110dbf9..c95b79130 100644 --- a/hledger-lib/Hledger/Data/Valuation.hs +++ b/hledger-lib/Hledger/Data/Valuation.hs @@ -17,8 +17,9 @@ module Hledger.Data.Valuation ( ,ValuationType(..) ,PriceOracle ,journalPriceOracle - -- ,amountApplyValuation - -- ,amountValueAtDate + ,amountApplyCostValuation + ,amountApplyValuation + ,amountValueAtDate ,mixedAmountApplyCostValuation ,mixedAmountApplyValuation ,mixedAmountValueAtDate @@ -105,12 +106,7 @@ priceDirectiveToMarketPrice PriceDirective{..} = -- See amountApplyValuation and amountCost. mixedAmountApplyCostValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Day -> Day -> Day -> Costing -> Maybe ValuationType -> MixedAmount -> MixedAmount mixedAmountApplyCostValuation priceoracle styles periodlast today postingdate cost v = - valuation . costing - where - valuation = maybe id (mixedAmountApplyValuation priceoracle styles periodlast today postingdate) v - costing = case cost of - Cost -> styleMixedAmount styles . mixedAmountCost - NoCost -> id + mapMixedAmount (amountApplyCostValuation priceoracle styles periodlast today postingdate cost v) -- | Apply a specified valuation to this mixed amount, using the -- provided price oracle, commodity styles, and reference dates. @@ -119,6 +115,19 @@ mixedAmountApplyValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> mixedAmountApplyValuation priceoracle styles periodlast today postingdate v = mapMixedAmount (amountApplyValuation priceoracle styles periodlast today postingdate v) +-- | Apply a specified costing and valuation to this Amount, +-- using the provided price oracle, commodity styles, and reference dates. +-- Costing is done first if requested, and after that any valuation. +-- See amountApplyValuation and amountCost. +amountApplyCostValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Day -> Day -> Day -> Costing -> Maybe ValuationType -> Amount -> Amount +amountApplyCostValuation priceoracle styles periodlast today postingdate cost v = + valuation . costing + where + valuation = maybe id (amountApplyValuation priceoracle styles periodlast today postingdate) v + costing = case cost of + Cost -> styleAmount styles . amountCost + NoCost -> id + -- | Apply a specified valuation to this amount, using the provided -- price oracle, reference dates, and whether this is for a -- multiperiod report or not. Also fix up its display style using the diff --git a/hledger-lib/Hledger/Query.hs b/hledger-lib/Hledger/Query.hs index 94f62a028..bf7a9e3ef 100644 --- a/hledger-lib/Hledger/Query.hs +++ b/hledger-lib/Hledger/Query.hs @@ -78,7 +78,7 @@ import Text.Megaparsec.Char (char, string) import Hledger.Utils hiding (words') import Hledger.Data.Types import Hledger.Data.AccountName -import Hledger.Data.Amount (nullamt, usd) +import Hledger.Data.Amount (amountsRaw, mixedAmount, nullamt, usd) import Hledger.Data.Dates import Hledger.Data.Posting import Hledger.Data.Transaction @@ -562,8 +562,9 @@ matchesAccount (Tag _ _) _ = False matchesAccount _ _ = True matchesMixedAmount :: Query -> MixedAmount -> Bool -matchesMixedAmount q (Mixed []) = q `matchesAmount` nullamt -matchesMixedAmount q (Mixed as) = any (q `matchesAmount`) as +matchesMixedAmount q ma = case amountsRaw ma of + [] -> q `matchesAmount` nullamt + as -> any (q `matchesAmount`) as matchesCommodity :: Query -> CommoditySymbol -> Bool matchesCommodity (Sym r) = regexMatchText r @@ -614,8 +615,8 @@ matchesPosting (Date2 span) p = span `spanContainsDate` postingDate2 p matchesPosting (StatusQ s) p = postingStatus p == s matchesPosting (Real v) p = v == isReal p matchesPosting q@(Depth _) Posting{paccount=a} = q `matchesAccount` a -matchesPosting q@(Amt _ _) Posting{pamount=amt} = q `matchesMixedAmount` amt -matchesPosting (Sym r) Posting{pamount=Mixed as} = any (matchesCommodity (Sym r)) $ map acommodity as +matchesPosting q@(Amt _ _) Posting{pamount=as} = q `matchesMixedAmount` as +matchesPosting (Sym r) Posting{pamount=as} = any (matchesCommodity (Sym r)) . map acommodity $ amountsRaw as matchesPosting (Tag n v) p = case (reString n, v) of ("payee", Just v) -> maybe False (regexMatchText v . transactionPayee) $ ptransaction p ("note", Just v) -> maybe False (regexMatchText v . transactionNote) $ ptransaction p @@ -811,10 +812,10 @@ tests_Query = tests "Query" [ ,test "a tag match on a posting also sees inherited tags" $ assertBool "" $ (Tag (toRegex' "txntag") Nothing) `matchesPosting` nullposting{ptransaction=Just nulltransaction{ttags=[("txntag","")]}} ,test "cur:" $ do let toSym = either id (const $ error' "No query opts") . either error' id . parseQueryTerm (fromGregorian 2000 01 01) . ("cur:"<>) - assertBool "" $ not $ toSym "$" `matchesPosting` nullposting{pamount=Mixed [usd 1]} -- becomes "^$$", ie testing for null symbol - assertBool "" $ (toSym "\\$") `matchesPosting` nullposting{pamount=Mixed [usd 1]} -- have to quote $ for regexpr - assertBool "" $ (toSym "shekels") `matchesPosting` nullposting{pamount=Mixed [nullamt{acommodity="shekels"}]} - assertBool "" $ not $ (toSym "shek") `matchesPosting` nullposting{pamount=Mixed [nullamt{acommodity="shekels"}]} + assertBool "" $ not $ toSym "$" `matchesPosting` nullposting{pamount=mixedAmount $ usd 1} -- becomes "^$$", ie testing for null symbol + assertBool "" $ (toSym "\\$") `matchesPosting` nullposting{pamount=mixedAmount $ usd 1} -- have to quote $ for regexpr + assertBool "" $ (toSym "shekels") `matchesPosting` nullposting{pamount=mixedAmount nullamt{acommodity="shekels"}} + assertBool "" $ not $ (toSym "shek") `matchesPosting` nullposting{pamount=mixedAmount nullamt{acommodity="shekels"}} ] ,test "matchesTransaction" $ do diff --git a/hledger-lib/Hledger/Read/Common.hs b/hledger-lib/Hledger/Read/Common.hs index 7e0944381..d4243b93c 100644 --- a/hledger-lib/Hledger/Read/Common.hs +++ b/hledger-lib/Hledger/Read/Common.hs @@ -407,7 +407,7 @@ journalCheckAccountsDeclared j = sequence_ $ map checkacct $ journalPostings j -- | Check that all the commodities used in this journal's postings have been declared -- by commodity directives, returning an error message otherwise. journalCheckCommoditiesDeclared :: Journal -> Either String () -journalCheckCommoditiesDeclared j = +journalCheckCommoditiesDeclared j = sequence_ $ map checkcommodities $ journalPostings j where checkcommodities Posting{..} = @@ -423,7 +423,7 @@ journalCheckCommoditiesDeclared j = where mfirstundeclaredcomm = find (`M.notMember` jcommodities j) . map acommodity $ - (maybe id ((:) . baamount) pbalanceassertion) (filter (/= missingamt) $ amounts pamount) + (maybe id ((:) . baamount) pbalanceassertion) . filter (/= missingamt) $ amountsRaw pamount setYear :: Year -> JournalParser m () diff --git a/hledger-lib/Hledger/Read/JournalReader.hs b/hledger-lib/Hledger/Read/JournalReader.hs index 1b5810ccb..e61dadc3c 100644 --- a/hledger-lib/Hledger/Read/JournalReader.hs +++ b/hledger-lib/Hledger/Read/JournalReader.hs @@ -711,7 +711,7 @@ postingp mTransactionYear = do return (status, account) let (ptype, account') = (accountNamePostingType account, textUnbracket account) lift skipNonNewlineSpaces - amount <- option missingmixedamt $ mixedAmount <$> amountp + amount <- optional amountp lift skipNonNewlineSpaces massertion <- optional balanceassertionp lift skipNonNewlineSpaces @@ -721,7 +721,7 @@ postingp mTransactionYear = do , pdate2=mdate2 , pstatus=status , paccount=account' - , pamount=amount + , pamount=maybe missingmixedamt mixedAmount amount , pcomment=comment , ptype=ptype , ptags=tags @@ -823,7 +823,7 @@ tests_JournalReader = tests "JournalReader" [ " expenses:food:dining $10.00 ; a: a a \n ; b: b b \n" posting{ paccount="expenses:food:dining", - pamount=Mixed [usd 10], + pamount=mixedAmount (usd 10), pcomment="a: a a\nb: b b\n", ptags=[("a","a a"), ("b","b b")] } @@ -832,7 +832,7 @@ tests_JournalReader = tests "JournalReader" [ " a 1. ; date:2012/11/28, date2=2012/11/29,b:b\n" nullposting{ paccount="a" - ,pamount=Mixed [num 1] + ,pamount=mixedAmount (num 1) ,pcomment="date:2012/11/28, date2=2012/11/29,b:b\n" ,ptags=[("date", "2012/11/28"), ("date2=2012/11/29,b", "b")] -- TODO tag name parsed too greedily ,pdate=Just $ fromGregorian 2012 11 28 @@ -843,7 +843,7 @@ tests_JournalReader = tests "JournalReader" [ " a 1. ; [2012/11/28=2012/11/29]\n" nullposting{ paccount="a" - ,pamount=Mixed [num 1] + ,pamount=mixedAmount (num 1) ,pcomment="[2012/11/28=2012/11/29]\n" ,ptags=[] ,pdate= Just $ fromGregorian 2012 11 28 @@ -872,7 +872,7 @@ tests_JournalReader = tests "JournalReader" [ "= (some value expr)\n some:postings 1.\n" nulltransactionmodifier { tmquerytxt = "(some value expr)" - ,tmpostingrules = [nullposting{paccount="some:postings", pamount=Mixed[num 1]}] + ,tmpostingrules = [nullposting{paccount="some:postings", pamount=mixedAmount (num 1)}] } ] @@ -905,7 +905,7 @@ tests_JournalReader = tests "JournalReader" [ pdate=Nothing, pstatus=Cleared, paccount="a", - pamount=Mixed [usd 1], + pamount=mixedAmount (usd 1), pcomment="pcomment1\npcomment2\nptag1: val1\nptag2: val2\n", ptype=RegularPosting, ptags=[("ptag1","val1"),("ptag2","val2")], diff --git a/hledger-lib/Hledger/Read/TimedotReader.hs b/hledger-lib/Hledger/Read/TimedotReader.hs index 2e17f4100..047b699ee 100644 --- a/hledger-lib/Hledger/Read/TimedotReader.hs +++ b/hledger-lib/Hledger/Read/TimedotReader.hs @@ -182,7 +182,7 @@ entryp = do tstatus = Cleared, tpostings = [ nullposting{paccount=a - ,pamount=Mixed [amountSetPrecision (Precision 2) $ num hours] -- don't assume hours; do set precision to 2 + ,pamount=mixedAmount . amountSetPrecision (Precision 2) $ num hours -- don't assume hours; do set precision to 2 ,ptype=VirtualPosting ,ptransaction=Just t } diff --git a/hledger-lib/Hledger/Reports/BalanceReport.hs b/hledger-lib/Hledger/Reports/BalanceReport.hs index 6cf73520b..00b61eff1 100644 --- a/hledger-lib/Hledger/Reports/BalanceReport.hs +++ b/hledger-lib/Hledger/Reports/BalanceReport.hs @@ -90,7 +90,7 @@ Right samplejournal2 = tcomment="", ttags=[], tpostings= - [posting {paccount="assets:bank:checking", pamount=Mixed [usd 1]} + [posting {paccount="assets:bank:checking", pamount=mixedAmount (usd 1)} ,posting {paccount="income:salary", pamount=missingmixedamt} ], tprecedingcomment="" diff --git a/hledger-lib/Hledger/Reports/PostingsReport.hs b/hledger-lib/Hledger/Reports/PostingsReport.hs index 974442252..25eb91ecc 100644 --- a/hledger-lib/Hledger/Reports/PostingsReport.hs +++ b/hledger-lib/Hledger/Reports/PostingsReport.hs @@ -170,7 +170,7 @@ postingsReportItems ((p,menddate):ps) (pprev,menddateprev) wd d b runningcalcfn isdifferentdate = case wd of PrimaryDate -> postingDate p /= postingDate pprev SecondaryDate -> postingDate2 p /= postingDate2 pprev p' = p{paccount= clipOrEllipsifyAccountName d $ paccount p} - b' = runningcalcfn itemnum b (pamount p) + b' = runningcalcfn itemnum b $ pamount p -- | Generate one postings report line item, containing the posting, -- the current running balance, and optionally the posting date and/or @@ -231,7 +231,7 @@ summarisePostingsInDateSpan (DateSpan b e) wd mdepth showempty ps isclipped a = maybe True (accountNameLevel a >=) mdepth negatePostingAmount :: Posting -> Posting -negatePostingAmount p = p { pamount = maNegate $ pamount p } +negatePostingAmount = postingTransformAmount negate -- tests diff --git a/hledger-ui/Hledger/UI/TransactionScreen.hs b/hledger-ui/Hledger/UI/TransactionScreen.hs index 09ff044e8..7303df1b8 100644 --- a/hledger-ui/Hledger/UI/TransactionScreen.hs +++ b/hledger-ui/Hledger/UI/TransactionScreen.hs @@ -72,7 +72,7 @@ tsDraw UIState{aopts=UIOpts{cliopts_=copts@CliOpts{reportspec_=rspec@ReportSpec{ _ -> [maincontent] where -- as with print, show amounts with all of their decimal places - t = transactionMapPostingAmounts amountSetFullPrecision t' + t = transactionMapPostingAmounts mixedAmountSetFullPrecision t' maincontent = Widget Greedy Greedy $ do let prices = journalPriceOracle (infer_value_ ropts) j diff --git a/hledger-web/Hledger/Web/Widget/AddForm.hs b/hledger-web/Hledger/Web/Widget/AddForm.hs index d484b4d0e..49efcc06d 100644 --- a/hledger-web/Hledger/Web/Widget/AddForm.hs +++ b/hledger-web/Hledger/Web/Widget/AddForm.hs @@ -158,7 +158,7 @@ validatePostings acctRes amtRes = let zipRow (Left e) (Left e') = Left (Just e, Just e') zipRow (Left e) (Right _) = Left (Just e, Nothing) zipRow (Right _) (Left e) = Left (Nothing, Just e) - zipRow (Right acct) (Right amt) = Right (nullposting {paccount = acct, pamount = Mixed [amt]}) + zipRow (Right acct) (Right amt) = Right (nullposting {paccount = acct, pamount = mixedAmount amt}) errorToFormMsg = first (("Invalid value: " <>) . T.pack . foldl (\s a -> s <> parseErrorTextPretty a) "" . diff --git a/hledger/Hledger/Cli/Commands/Add.hs b/hledger/Hledger/Cli/Commands/Add.hs index e643cce92..9cdf54cb0 100644 --- a/hledger/Hledger/Cli/Commands/Add.hs +++ b/hledger/Hledger/Cli/Commands/Add.hs @@ -233,7 +233,7 @@ confirmedTransactionWizard prevInput es@EntryState{..} stack@(currentStage : _) EnterAmountAndComment txnParams account -> amountAndCommentWizard prevInput es >>= \case Just (amount, comment) -> do let posting = nullposting{paccount=T.pack $ stripbrackets account - ,pamount=Mixed [amount] + ,pamount=mixedAmount amount ,pcomment=comment ,ptype=accountNamePostingType $ T.pack account } diff --git a/hledger/Hledger/Cli/Commands/Close.hs b/hledger/Hledger/Cli/Commands/Close.hs index 49e5a9a0d..49f2129fa 100755 --- a/hledger/Hledger/Cli/Commands/Close.hs +++ b/hledger/Hledger/Cli/Commands/Close.hs @@ -98,7 +98,7 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do closingps = concat [ [posting{paccount = a - ,pamount = mixed [precise $ negate b] + ,pamount = mixedAmount . precise $ negate b -- after each commodity's last posting, assert 0 balance (#1035) -- balance assertion amounts are unpriced (#824) ,pbalanceassertion = @@ -108,11 +108,11 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do } ] -- maybe an interleaved posting transferring this balance to equity - ++ [posting{paccount=closingacct, pamount=Mixed [precise b]} | interleaved] + ++ [posting{paccount=closingacct, pamount=mixedAmount $ precise b} | interleaved] | -- get the balances for each commodity and transaction price (a,_,_,mb) <- acctbals - , let bs = amounts $ normaliseMixedAmount mb + , let bs = amounts mb -- mark the last balance in each commodity with True , let bs' = concat [reverse $ zip (reverse bs) (True : repeat False) | bs <- groupBy ((==) `on` acommodity) bs] @@ -121,21 +121,21 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do -- or a final multicommodity posting transferring all balances to equity -- (print will show this as multiple single-commodity postings) - ++ [posting{paccount=closingacct, pamount=if explicit then mapMixedAmount precise totalamt else missingmixedamt} | not interleaved] + ++ [posting{paccount=closingacct, pamount=if explicit then mixedAmountSetFullPrecision totalamt else missingmixedamt} | not interleaved] -- the opening transaction openingtxn = nulltransaction{tdate=openingdate, tdescription=openingdesc, tpostings=openingps} openingps = concat [ [posting{paccount = a - ,pamount = mixed [precise b] + ,pamount = mixedAmount $ precise b ,pbalanceassertion = case mcommoditysum of Just s -> Just nullassertion{baamount=precise s{aprice=Nothing}} Nothing -> Nothing } ] - ++ [posting{paccount=openingacct, pamount=Mixed [precise $ negate b]} | interleaved] + ++ [posting{paccount=openingacct, pamount=mixedAmount . precise $ negate b} | interleaved] | (a,_,_,mb) <- acctbals , let bs = amounts $ normaliseMixedAmount mb @@ -145,7 +145,7 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do , let commoditysum = (sum bs)] , (b, mcommoditysum) <- bs' ] - ++ [posting{paccount=openingacct, pamount=if explicit then mapMixedAmount precise (maNegate totalamt) else missingmixedamt} | not interleaved] + ++ [posting{paccount=openingacct, pamount=if explicit then mixedAmountSetFullPrecision (maNegate totalamt) else missingmixedamt} | not interleaved] -- print them when closing . T.putStr $ showTransaction closingtxn diff --git a/hledger/Hledger/Cli/Commands/Prices.hs b/hledger/Hledger/Cli/Commands/Prices.hs index 4528a00a5..b71a05906 100755 --- a/hledger/Hledger/Cli/Commands/Prices.hs +++ b/hledger/Hledger/Cli/Commands/Prices.hs @@ -33,7 +33,7 @@ prices opts j = do ps = filter (matchesPosting q) $ allPostings j mprices = jpricedirectives j cprices = map (stylePriceDirectiveExceptPrecision styles) $ concatMap postingsPriceDirectivesFromCosts ps - icprices = map (stylePriceDirectiveExceptPrecision styles) $ concatMap postingsPriceDirectivesFromCosts $ mapAmount invertPrice ps + icprices = map (stylePriceDirectiveExceptPrecision styles) $ concatMap postingsPriceDirectivesFromCosts $ map (postingTransformAmount $ mapMixedAmount invertPrice) ps allprices = mprices ++ ifBoolOpt "costs" cprices ++ ifBoolOpt "inverted-costs" icprices mapM_ (T.putStrLn . showPriceDirective) $ sortOn pddate $ @@ -71,8 +71,8 @@ invertPrice a = pa' = pa { aquantity = abs $ aquantity a, acommodity = acommodity a, aprice = Nothing, astyle = astyle a } postingsPriceDirectivesFromCosts :: Posting -> [PriceDirective] -postingsPriceDirectivesFromCosts p = mapMaybe (amountPriceDirectiveFromCost date) . amounts $ pamount p where - date = fromMaybe (tdate . fromJust $ ptransaction p) $ pdate p +postingsPriceDirectivesFromCosts p = mapMaybe (amountPriceDirectiveFromCost date) . amountsRaw $ pamount p + where date = fromMaybe (tdate . fromJust $ ptransaction p) $ pdate p amountPriceDirectiveFromCost :: Day -> Amount -> Maybe PriceDirective amountPriceDirectiveFromCost d a = @@ -92,8 +92,3 @@ stylePriceDirectiveExceptPrecision styles pd@PriceDirective{pdamount=a} = allPostings :: Journal -> [Posting] allPostings = concatMap tpostings . jtxns - -mapAmount :: (Amount -> Amount) -> [Posting] -> [Posting] -mapAmount f = map pf where - pf p = p { pamount = mf (pamount p) } - mf = mixed . map f . amounts diff --git a/hledger/Hledger/Cli/Commands/Print.hs b/hledger/Hledger/Cli/Commands/Print.hs index 78c8319e2..6f3c99090 100644 --- a/hledger/Hledger/Cli/Commands/Print.hs +++ b/hledger/Hledger/Cli/Commands/Print.hs @@ -59,7 +59,7 @@ print' opts j = do -- that. For now we try to reverse it by increasing all amounts' decimal places -- sufficiently to show the amount exactly. The displayed amounts may have minor -- differences from the originals, such as trailing zeroes added. - let j' = journalMapPostingAmounts amountSetFullPrecision j + let j' = journalMapPostingAmounts mixedAmountSetFullPrecision j case maybestringopt "match" $ rawopts_ opts of Nothing -> printEntries opts j' Just desc -> printMatch opts j' $ T.pack $ dbg1 "finding best match for description" desc @@ -181,7 +181,7 @@ postingToCSV p = let credit = if q < 0 then showamt $ negate a_ else "" in let debit = if q >= 0 then showamt a_ else "" in [account, amount, c, credit, debit, status, comment]) - . amounts $ pamount p + . amounts $ pamount p where status = T.pack . show $ pstatus p account = showAccountName Nothing (ptype p) (paccount p) diff --git a/hledger/Hledger/Cli/Commands/Register.hs b/hledger/Hledger/Cli/Commands/Register.hs index e318ed621..e3834612c 100644 --- a/hledger/Hledger/Cli/Commands/Register.hs +++ b/hledger/Hledger/Cli/Commands/Register.hs @@ -190,7 +190,7 @@ postingsReportItemAsText opts preferredamtwidth preferredbalwidth (mdate, mendda BalancedVirtualPosting -> (wrap "[" "]", acctwidth-2) VirtualPosting -> (wrap "(" ")", acctwidth-2) _ -> (id,acctwidth) - amt = showAmountsLinesB dopts . (\x -> if null x then [nullamt] else x) . amounts $ pamount p + amt = showAmountsLinesB dopts . (\x -> if null x then [nullamt] else x) . amountsRaw $ pamount p bal = showAmountsLinesB dopts $ amounts b -- Since postingsReport strips prices from all Amounts when not used, we can display prices. dopts = oneLine{displayColour=color_, displayPrice=True} diff --git a/hledger/Hledger/Cli/Commands/Stats.hs b/hledger/Hledger/Cli/Commands/Stats.hs index 3ebe5f45a..fbf90c53c 100644 --- a/hledger/Hledger/Cli/Commands/Stats.hs +++ b/hledger/Hledger/Cli/Commands/Stats.hs @@ -82,7 +82,7 @@ showLedgerStats l today span = path = journalFilePath j ts = sortOn tdate $ filter (spanContainsDate span . tdate) $ jtxns j as = nub $ map paccount $ concatMap tpostings ts - cs = either error' Map.keys $ commodityStylesFromAmounts $ concatMap (amounts . pamount) $ concatMap tpostings ts -- PARTIAL: + cs = either error' Map.keys . commodityStylesFromAmounts . concatMap (amountsRaw . pamount) $ concatMap tpostings ts -- PARTIAL: lastdate | null ts = Nothing | otherwise = Just $ tdate $ last ts lastelapsed = fmap (diffDays today) lastdate