diff --git a/hledger-lib/Hledger/Data/Amount.hs b/hledger-lib/Hledger/Data/Amount.hs index a64876a1a37..ad414827f9b 100644 --- a/hledger-lib/Hledger/Data/Amount.hs +++ b/hledger-lib/Hledger/Data/Amount.hs @@ -166,7 +166,7 @@ instance Num Amount where -- | The empty simple amount. amount, nullamt :: Amount -amount = Amount{acommodity="", aquantity=0, aprice=NoPrice, astyle=amountstyle, amultiplier=False} +amount = Amount{acommodity="", aquantity=0, aprice=NoPrice, astyle=amountstyle} nullamt = amount -- | A temporary value for parsed transactions which had no amount specified. diff --git a/hledger-lib/Hledger/Data/Commodity.hs b/hledger-lib/Hledger/Data/Commodity.hs index 4e095c708c0..7e9e0e99f0b 100644 --- a/hledger-lib/Hledger/Data/Commodity.hs +++ b/hledger-lib/Hledger/Data/Commodity.hs @@ -12,7 +12,6 @@ are thousands separated by comma, significant decimal places and so on. module Hledger.Data.Commodity where -import Data.Char (isDigit) import Data.List import Data.Maybe (fromMaybe) #if !(MIN_VERSION_base(4,11,0)) @@ -26,13 +25,10 @@ import Hledger.Utils -- characters that may not be used in a non-quoted commodity symbol -nonsimplecommoditychars = "0123456789-+.@*;\n \"{}=" :: [Char] +nonsimplecommoditychars = "0123456789-+.@*;\n \"(){}=" :: [Char] isNonsimpleCommodityChar :: Char -> Bool -isNonsimpleCommodityChar c = isDigit c || c `textElem` otherChars - where - otherChars = "-+.@*;\n \"{}=" :: T.Text - textElem = T.any . (==) +isNonsimpleCommodityChar = flip elem nonsimplecommoditychars quoteCommoditySymbolIfNeeded s | T.any (isNonsimpleCommodityChar) s = "\"" <> s <> "\"" | otherwise = s diff --git a/hledger-lib/Hledger/Data/Journal.hs b/hledger-lib/Hledger/Data/Journal.hs index e6b776fc929..d63374bddef 100644 --- a/hledger-lib/Hledger/Data/Journal.hs +++ b/hledger-lib/Hledger/Data/Journal.hs @@ -570,14 +570,14 @@ journalCheckBalanceAssertions j = -- fails. checkBalanceAssertion :: Posting -> MixedAmount -> Either String () checkBalanceAssertion p@Posting{ pbalanceassertion = Just ass } bal = - foldl' fold (Right ()) amts + foldl' fold (Right ()) amt0 where fold (Right _) cass = checkBalanceAssertionCommodity p cass bal fold err _ = err - amt = baamount ass - amts = amt : if baexact ass - then map (\a -> a{ aquantity = 0 }) $ amounts $ filterMixedAmount (\a -> acommodity a /= assertedcomm) bal + amts = amounts $ baamount ass + amt0 = amts ++ if baexact ass + then map (\a -> a{ aquantity = 0 }) $ amounts $ filterMixedAmount (\a -> not $ elem (acommodity a) assertedcomms) bal else [] - assertedcomm = acommodity amt + assertedcomms = map acommodity amts checkBalanceAssertion _ _ = Right () checkBalanceAssertionCommodity :: Posting -> Amount -> MixedAmount -> Either String () @@ -759,14 +759,14 @@ checkInferAndRegisterAmounts (Right oldTx) = do let acc = paccount p case pbalanceassertion p of Just ba | baexact ba -> do - diff <- setMixedBalance acc $ Mixed [baamount ba] + diff <- setMixedBalance acc $ baamount ba fullPosting diff p Just ba | otherwise -> do old <- liftModifier $ \Env{ eBalances = bals } -> HT.lookup bals acc let amt = baamount ba - assertedcomm = acommodity amt + assertedcomms = map acommodity $ amounts amt diff <- setMixedBalance acc $ - Mixed [amt] + filterMixedAmount (\a -> acommodity a /= assertedcomm) (fromMaybe nullmixedamt old) + amt + filterMixedAmount (\a -> not $ acommodity a `elem` assertedcomms) (fromMaybe nullmixedamt old) fullPosting diff p Nothing -> return p fullPosting amt p = return p diff --git a/hledger-lib/Hledger/Data/Posting.hs b/hledger-lib/Hledger/Data/Posting.hs index 48921c8da59..17bcdb9212d 100644 --- a/hledger-lib/Hledger/Data/Posting.hs +++ b/hledger-lib/Hledger/Data/Posting.hs @@ -90,6 +90,7 @@ nullposting = Posting ,pcomment="" ,ptype=RegularPosting ,ptags=[] + ,pmultiplier=Nothing ,pbalanceassertion=Nothing ,ptransaction=Nothing ,porigin=Nothing @@ -104,7 +105,7 @@ nullsourcepos = JournalSourcePos "" (1,1) nullassertion, assertion :: BalanceAssertion nullassertion = BalanceAssertion - {baamount=nullamt + {baamount=nullmixedamt ,baexact=False ,baposition=nullsourcepos } diff --git a/hledger-lib/Hledger/Data/Transaction.hs b/hledger-lib/Hledger/Data/Transaction.hs index e9d4e467806..b5a32f4c8e3 100644 --- a/hledger-lib/Hledger/Data/Transaction.hs +++ b/hledger-lib/Hledger/Data/Transaction.hs @@ -222,7 +222,7 @@ postingAsLines elideamount onelineamounts pstoalignwith p = concat [ | postingblock <- postingblocks] where postingblocks = [map rstrip $ lines $ concatTopPadded [statusandaccount, " ", amount, assertion, samelinecomment] | amount <- shownAmounts] - assertion = maybe "" ((" = " ++) . showAmountWithZeroCommodity . baamount) $ pbalanceassertion p + assertion = maybe "" ((" = " ++) . showMixedAmountWithZeroCommodity . baamount) $ pbalanceassertion p statusandaccount = indent $ fitString (Just $ minwidth) Nothing False True $ pstatusandacct p where -- pad to the maximum account name width, plus 2 to leave room for status flags, to keep amounts aligned diff --git a/hledger-lib/Hledger/Data/TransactionModifier.hs b/hledger-lib/Hledger/Data/TransactionModifier.hs index 6eb9563388f..dfe5cea34d2 100644 --- a/hledger-lib/Hledger/Data/TransactionModifier.hs +++ b/hledger-lib/Hledger/Data/TransactionModifier.hs @@ -47,7 +47,7 @@ import Hledger.Utils.Debug -- 0000/01/01 -- ping $1.00 -- --- >>> putStr $ showTransaction $ transactionModifierToFunction (TransactionModifier "ping" ["pong" `post` amount{amultiplier=True, aquantity=3}]) nulltransaction{tpostings=["ping" `post` usd 2]} +-- >>> putStr $ showTransaction $ transactionModifierToFunction (TransactionModifier "ping" [nullposting{paccount="pong", pmultiplier=Just $ num 3}]) nulltransaction{tpostings=["ping" `post` usd 2]} -- 0000/01/01 -- ping $2.00 -- pong $6.00 @@ -86,33 +86,27 @@ tmPostingRuleToFunction pr = { pdate = pdate p , pdate2 = pdate2 p , pamount = amount' p + , pmultiplier = Nothing } where - amount' = case postingRuleMultiplier pr of + amount' = case pmultiplier pr of Nothing -> const $ pamount pr - Just n -> \p -> + Just n -> \p -> pamount pr + -- Multiply the old posting's amount by the posting rule's multiplier. let - pramount = dbg6 "pramount" $ head $ amounts $ 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 + -- Mixed as = dbg6 "multipliedamount" $ aquantity n `multiplyMixedAmount` mixedAmountTotalPriceToUnitPrice matchedamount -- Approach 2: multiply the total price (keeping it positive) as well as the quantity - Mixed as = dbg6 "multipliedamount" $ n `multiplyMixedAmountAndPrice` matchedamount + Mixed as = dbg6 "multipliedamount" $ aquantity n `multiplyMixedAmountAndPrice` matchedamount in - case acommodity pramount of + case acommodity n of "" -> Mixed as -- TODO multipliers with commodity symbols are not yet a documented feature. -- For now: in addition to multiplying the quantity, it also replaces the -- matched amount's commodity, display style, and price with those of the posting rule. - c -> Mixed [a{acommodity = c, astyle = astyle pramount, aprice = aprice pramount} | a <- as] - -postingRuleMultiplier :: TMPostingRule -> Maybe Quantity -postingRuleMultiplier p = - case amounts $ pamount p of - [a] | amultiplier a -> Just $ aquantity a - _ -> Nothing + c -> Mixed [a{acommodity = c, astyle = astyle n, aprice = aprice n} | a <- as] 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 be289925539..a585c78cdfc 100644 --- a/hledger-lib/Hledger/Data/Types.hs +++ b/hledger-lib/Hledger/Data/Types.hs @@ -204,9 +204,7 @@ data Amount = Amount { acommodity :: CommoditySymbol, aquantity :: Quantity, aprice :: Price, -- ^ the (fixed) price for this amount, if any - astyle :: AmountStyle, - amultiplier :: Bool -- ^ kludge: a flag marking this amount and posting as a multiplier - -- in a TMPostingRule. In a regular Posting, should always be false. + astyle :: AmountStyle } deriving (Eq,Ord,Typeable,Data,Generic,Show) instance NFData Amount @@ -240,7 +238,7 @@ instance Show Status where -- custom show.. bad idea.. don't do it.. -- | The amount to compare an account's balance to, to verify that the history -- leading to a given point is correct or to set the account to a known value. data BalanceAssertion = BalanceAssertion { - baamount :: Amount, -- ^ the expected value of a particular commodity + baamount :: MixedAmount, -- ^ the expected value of particular commodities baexact :: Bool, -- ^ whether the assertion is exclusive, and doesn't allow other commodities alongside 'baamount' baposition :: GenericSourcePos } deriving (Eq,Typeable,Data,Generic,Show) @@ -256,6 +254,7 @@ data Posting = Posting { pcomment :: Text, -- ^ this posting's comment lines, as a single non-indented multi-line string ptype :: PostingType, ptags :: [Tag], -- ^ tag names and values, extracted from the comment + pmultiplier :: Maybe Amount, -- ^ optional: the proportion of the base value to use in a 'TransactionModifier' pbalanceassertion :: Maybe BalanceAssertion, -- ^ optional: the expected balance in this commodity in the account after this posting ptransaction :: Maybe Transaction, -- ^ this posting's parent transaction (co-recursive types). -- Tying this knot gets tedious, Maybe makes it easier/optional. @@ -271,7 +270,7 @@ instance NFData Posting -- identity, to avoid recuring ad infinitum. -- XXX could check that it's Just or Nothing. instance Eq Posting where - (==) (Posting a1 b1 c1 d1 e1 f1 g1 h1 i1 _ _) (Posting a2 b2 c2 d2 e2 f2 g2 h2 i2 _ _) = a1==a2 && b1==b2 && c1==c2 && d1==d2 && e1==e2 && f1==f2 && g1==g2 && h1==h2 && i1==i2 + (==) (Posting a1 b1 c1 d1 e1 f1 g1 h1 i1 j1 _ _) (Posting a2 b2 c2 d2 e2 f2 g2 h2 i2 j2 _ _) = a1==a2 && b1==b2 && c1==c2 && d1==d2 && e1==e2 && f1==f2 && g1==g2 && h1==h2 && i1==i2 && j1==j2 -- | Posting's show instance elides the parent transaction so as not to recurse forever. instance Show Posting where @@ -284,6 +283,7 @@ instance Show Posting where ,("pcomment=" ++ show pcomment) ,("ptype=" ++ show ptype) ,("ptags=" ++ show ptags) + ,("pmultiplier=" ++ show pmultiplier) ,("pbalanceassertion=" ++ show pbalanceassertion) ,("ptransaction=" ++ show (const "" <$> ptransaction)) ,("porigin=" ++ show porigin) diff --git a/hledger-lib/Hledger/Read/Common.hs b/hledger-lib/Hledger/Read/Common.hs index 20f026fe35b..71601e0a6eb 100644 --- a/hledger-lib/Hledger/Read/Common.hs +++ b/hledger-lib/Hledger/Read/Common.hs @@ -71,11 +71,13 @@ module Hledger.Read.Common ( spaceandamountormissingp, amountp, amountp', + mamountp, mamountp', commoditysymbolp, priceamountp, balanceassertionp, fixedlotpricep, + modifierp, numberp, fromRawNumber, rawnumberp, @@ -604,21 +606,24 @@ spaceandamountormissingp = -- right, optional unit or total price, and optional (ignored) -- ledger-style balance assertion or fixed lot price declaration. amountp :: JournalParser m Amount -amountp = label "amount" $ do - amount <- amountwithoutpricep +amountp = label "amount" $ amountormultiplierp False + +amountormultiplierp :: Bool -> JournalParser m Amount +amountormultiplierp isMultiplier = do + amount <- amountwithoutpricep isMultiplier lift $ skipMany spacenonewline price <- priceamountp pure $ amount { aprice = price } -amountwithoutpricep :: JournalParser m Amount -amountwithoutpricep = do - (mult, sign) <- lift $ (,) <$> multiplierp <*> signp - leftsymbolamountp mult sign <|> rightornosymbolamountp mult sign +amountwithoutpricep :: Bool -> JournalParser m Amount +amountwithoutpricep isMultiplier = do + sign <- lift $ signp + leftsymbolamountp sign <|> rightornosymbolamountp sign where - leftsymbolamountp :: Bool -> (Decimal -> Decimal) -> JournalParser m Amount - leftsymbolamountp mult sign = label "amount" $ do + leftsymbolamountp :: (Decimal -> Decimal) -> JournalParser m Amount + leftsymbolamountp sign = label "amount" $ do c <- lift commoditysymbolp suggestedStyle <- getAmountStyle c commodityspaced <- lift $ skipMany' spacenonewline @@ -630,10 +635,10 @@ amountwithoutpricep = do let numRegion = (offBeforeNum, offAfterNum) (q,prec,mdec,mgrps) <- lift $ interpretNumber numRegion suggestedStyle ambiguousRawNum mExponent let s = amountstyle{ascommodityside=L, ascommodityspaced=commodityspaced, asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps} - return $ Amount c (sign (sign2 q)) NoPrice s mult + return $ Amount c (sign (sign2 q)) NoPrice s - rightornosymbolamountp :: Bool -> (Decimal -> Decimal) -> JournalParser m Amount - rightornosymbolamountp mult sign = label "amount" $ do + rightornosymbolamountp :: (Decimal -> Decimal) -> JournalParser m Amount + rightornosymbolamountp sign = label "amount" $ do offBeforeNum <- getOffset ambiguousRawNum <- lift rawnumberp mExponent <- lift $ optional $ try exponentp @@ -646,7 +651,7 @@ amountwithoutpricep = do suggestedStyle <- getAmountStyle c (q,prec,mdec,mgrps) <- lift $ interpretNumber numRegion suggestedStyle ambiguousRawNum mExponent let s = amountstyle{ascommodityside=R, ascommodityspaced=commodityspaced, asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps} - return $ Amount c (sign q) NoPrice s mult + return $ Amount c (sign q) NoPrice s -- no symbol amount Nothing -> do suggestedStyle <- getDefaultAmountStyle @@ -654,10 +659,10 @@ amountwithoutpricep = do -- if a default commodity has been set, apply it and its style to this amount -- (unless it's a multiplier in an automated posting) defcs <- getDefaultCommodityAndStyle - let (c,s) = case (mult, defcs) of + let (c,s) = case (isMultiplier, defcs) of (False, Just (defc,defs)) -> (defc, defs{asprecision=max (asprecision defs) prec}) _ -> ("", amountstyle{asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps}) - return $ Amount c (sign q) NoPrice s mult + return $ Amount c (sign q) NoPrice s -- For reducing code duplication. Doesn't parse anything. Has the type -- of a parser only in order to throw parse errors (for convenience). @@ -681,15 +686,84 @@ amountp' s = Right amt -> amt Left err -> error' $ show err -- XXX should throwError +-- | Parse a multi-commodity amount, comprising of multiple single amounts +-- joined as an arithmetic expression. +mamountp :: Bool -> JournalParser m MixedAmount +mamountp requireOp = label "mixed amount" $ do + pos <- getOffset + opc <- ( if requireOp + then id + else option '+' + ) $ do + c <- oneOf ("+-" :: String) + lift (skipMany spacenonewline) + pure c + paren <- option False $ try $ do + char '(' + lift (skipMany spacenonewline) + pure True + amount <- if paren + then do + inner <- mamountp False + lift (skipMany spacenonewline) + char ')' + pure inner + else do + inner <- amountp + pure $ Mixed [inner] + mult <- multiplierp pos + tail <- option nullmixedamt $ try $ do + lift (skipMany spacenonewline) + mamountp True + let op = case opc of + '-' -> negate + _ -> id + return $ multiplyMixedAmount mult (op amount) + tail + +multiplierp :: Int -> JournalParser m Quantity +multiplierp startOffset = do + lift (skipMany spacenonewline) + c <- optional $ try $ oneOf ("*/" :: String) + case c of + Nothing -> return 1 + Just c' -> do + lift (skipMany spacenonewline) + (m, _, _, _) <- lift $ numberp Nothing + endOffset <- getOffset + f <- if c' == '/' + then if m == 0 + then dividebyzeroerr startOffset endOffset + -- The "Decimal" docs recommend against using '(/)', but the + -- alternate interface provided might be more cumbersome than + -- necessary for this circumstance, as it would require + -- keeping track of multiple potential results. Maybe revisit + -- this as part of a larger patch focused on fair rounding? + else return (/ m) + else return (* m) + ms <- multiplierp startOffset + return $ f ms + +dividebyzeroerr :: Int -> Int -> JournalParser m a +dividebyzeroerr startOffset endOffset = customFailure $ parseErrorAtRegion startOffset endOffset "attempted division by 0" + -- | Parse a mixed amount from a string, or get an error. mamountp' :: String -> MixedAmount -mamountp' = Mixed . (:[]) . amountp' +mamountp' s = + case runParser (evalStateT (mamountp False <* eof) mempty) "" (T.pack s) of + Right amt -> amt + Left err -> error' $ show err -- XXX should throwError signp :: Num a => TextParser m (a -> a) signp = char '-' *> pure negate <|> char '+' *> pure id <|> pure id -multiplierp :: TextParser m Bool -multiplierp = option False $ char '*' *> pure True +-- | Parse a value used as a multiplier in a 'TransactionModifier' (a +-- @*@ character followed by a value following the rules of 'amountp', +-- except that it never takes the default commodity). +modifierp :: JournalParser m Amount +modifierp = label "modifier" $ do + char '*' + lift $ skipMany spacenonewline + amountormultiplierp True -- | This is like skipMany but it returns True if at least one element -- was skipped. This is helpful if you’re just using many to check if @@ -721,7 +795,7 @@ priceamountp = option NoPrice $ do priceConstructor <- char '@' *> pure TotalPrice <|> pure UnitPrice lift (skipMany spacenonewline) - priceAmount <- amountwithoutpricep "amount (as a price)" + priceAmount <- amountwithoutpricep False "amount (as a price)" pure $ priceConstructor priceAmount @@ -731,7 +805,7 @@ balanceassertionp = do char '=' exact <- optional $ try $ char '=' lift (skipMany spacenonewline) - a <- amountp "amount (for a balance assertion or assignment)" -- XXX should restrict to a simple amount + a <- mamountp False "amount (for a balance assertion or assignment)" -- XXX should restrict to a simple amount return BalanceAssertion { baamount = a , baexact = isJust exact @@ -1328,6 +1402,32 @@ tests_Common = tests "Common" [ } ] + ,tests "mamountp" [ + test "basic" $ expectParseEq (mamountp False) "$47.18" $ Mixed [usd 47.18] + ,test "operations" $ expectParseEq (mamountp False) "$6.00 + $5.00 - $4.00 / 2 * 3" $ Mixed [usd 5.00] + ,test "multiple commodities" $ expectParseEq (mamountp False) "$47.18+€20,59" $ Mixed [ + amount{ + acommodity="$" + ,aquantity=47.18 + ,astyle=amountstyle{asprecision=2, asdecimalpoint=Just '.'} + } + ,amount{ + acommodity="€" + ,aquantity=20.59 + ,astyle=amountstyle{asprecision=2, asdecimalpoint=Just ','} + } + ] + ,test "same commodity multiple times" $ expectParseEq (mamountp False) "$10 + $2 - $5-$2" $ Mixed [ + amount{ + acommodity="$" + ,aquantity=5 + ,astyle=amountstyle{asprecision=0, asdecimalpoint=Nothing} + } + ] + ,test "ledger-compatible expressions" $ expectParseEq (mamountp False) "($47.18 - $7.13)" $ Mixed [usd 40.05] + ,test "nested parentheses" $ expectParseEq (mamountp False) "($47.18 - ($20 + $7.13) + $5.05)" $ Mixed [usd 25.10] + ] + ,let p = lift (numberp Nothing) :: JournalParser IO (Quantity, Int, Maybe Char, Maybe DigitGroupStyle) in tests "numberp" [ test "." $ expectParseEq p "0" (0, 0, Nothing, Nothing) diff --git a/hledger-lib/Hledger/Read/CsvReader.hs b/hledger-lib/Hledger/Read/CsvReader.hs index 440ce8eaeb7..2f51f50b9e1 100644 --- a/hledger-lib/Hledger/Read/CsvReader.hs +++ b/hledger-lib/Hledger/Read/CsvReader.hs @@ -753,7 +753,7 @@ transactionFromCsvRecord sourcepos rules record = t ] } toAssertion (a, b) = assertion{ - baamount = a, + baamount = Mixed [a], baposition = b } diff --git a/hledger-lib/Hledger/Read/JournalReader.hs b/hledger-lib/Hledger/Read/JournalReader.hs index abbff181a50..fe9eec605d2 100644 --- a/hledger-lib/Hledger/Read/JournalReader.hs +++ b/hledger-lib/Hledger/Read/JournalReader.hs @@ -482,7 +482,7 @@ transactionmodifierp = do lift (skipMany spacenonewline) querytxt <- lift $ T.strip <$> descriptionp (_comment, _tags) <- lift transactioncommentp -- TODO apply these to modified txns ? - postings <- postingsp Nothing + postings <- postingsp Nothing True return $ TransactionModifier querytxt postings -- | Parse a periodic transaction @@ -543,7 +543,7 @@ periodictransactionp = do return (s,c,desc,(cmt,ts)) -- next lines; use same year determined above - postings <- postingsp (Just $ first3 $ toGregorian refdate) + postings <- postingsp (Just $ first3 $ toGregorian refdate) False return $ nullperiodictransaction{ ptperiodexpr=periodtxt @@ -570,7 +570,7 @@ transactionp = do description <- lift $ T.strip <$> descriptionp (comment, tags) <- lift transactioncommentp let year = first3 $ toGregorian date - postings <- postingsp (Just year) + postings <- postingsp (Just year) False endpos <- getSourcePos let sourcepos = journalSourcePos startpos endpos return $ txnTieKnot $ Transaction 0 sourcepos date edate status code description comment tags postings "" @@ -579,8 +579,8 @@ transactionp = do -- Parse the following whitespace-beginning lines as postings, posting -- tags, and/or comments (inferring year, if needed, from the given date). -postingsp :: Maybe Year -> JournalParser m [Posting] -postingsp mTransactionYear = many (postingp mTransactionYear) "postings" +postingsp :: Maybe Year -> Bool -> JournalParser m [Posting] +postingsp mTransactionYear allowCommodityMult = many (postingp mTransactionYear allowCommodityMult) "postings" -- linebeginningwithspaces :: JournalParser m String -- linebeginningwithspaces = do @@ -589,8 +589,8 @@ postingsp mTransactionYear = many (postingp mTransactionYear) "postings" -- cs <- lift restofline -- return $ sp ++ (c:cs) ++ "\n" -postingp :: Maybe Year -> JournalParser m Posting -postingp mTransactionYear = do +postingp :: Maybe Year -> Bool -> JournalParser m Posting +postingp mTransactionYear allowCommodityMult = do -- lift $ dbgparse 0 "postingp" (status, account) <- try $ do lift (skipSome spacenonewline) @@ -600,7 +600,15 @@ postingp mTransactionYear = do return (status, account) let (ptype, account') = (accountNamePostingType account, textUnbracket account) lift (skipMany spacenonewline) - amount <- option missingmixedamt $ Mixed . (:[]) <$> amountp + mult <- (if allowCommodityMult + then optional $ try modifierp + else return Nothing + ) + lift (skipMany spacenonewline) + let (defamt, requireOp) = if isJust mult + then (nullmixedamt, True) + else (missingmixedamt, False) + amount <- option defamt $ mamountp requireOp lift (skipMany spacenonewline) massertion <- optional $ balanceassertionp _ <- fixedlotpricep @@ -615,6 +623,7 @@ postingp mTransactionYear = do , pcomment=comment , ptype=ptype , ptags=tags + , pmultiplier=mult , pbalanceassertion=massertion } @@ -696,7 +705,7 @@ tests_JournalReader = tests "JournalReader" [ ] ,tests "postingp" [ - test "basic" $ expectParseEq (postingp Nothing) + test "basic" $ expectParseEq (postingp Nothing False) " expenses:food:dining $10.00 ; a: a a \n ; b: b b \n" posting{ paccount="expenses:food:dining", @@ -705,7 +714,7 @@ tests_JournalReader = tests "JournalReader" [ ptags=[("a","a a"), ("b","b b")] } - ,test "posting dates" $ expectParseEq (postingp Nothing) + ,test "posting dates" $ expectParseEq (postingp Nothing False) " a 1. ; date:2012/11/28, date2=2012/11/29,b:b\n" nullposting{ paccount="a" @@ -716,7 +725,7 @@ tests_JournalReader = tests "JournalReader" [ ,pdate2=Nothing -- Just $ fromGregorian 2012 11 29 } - ,test "posting dates bracket syntax" $ expectParseEq (postingp Nothing) + ,test "posting dates bracket syntax" $ expectParseEq (postingp Nothing False) " a 1. ; [2012/11/28=2012/11/29]\n" nullposting{ paccount="a" @@ -727,21 +736,28 @@ tests_JournalReader = tests "JournalReader" [ ,pdate2=Just $ fromGregorian 2012 11 29 } - ,test "quoted commodity symbol with digits" $ expectParse (postingp Nothing) " a 1 \"DE123\"\n" + ,test "quoted commodity symbol with digits" $ expectParse (postingp Nothing False) " a 1 \"DE123\"\n" - ,test "balance assertion and fixed lot price" $ expectParse (postingp Nothing) " a 1 \"DE123\" =$1 { =2.2 EUR} \n" + ,test "balance assertion and fixed lot price" $ expectParse (postingp Nothing False) " a 1 \"DE123\" =$1 { =2.2 EUR} \n" - ,test "balance assertion over entire contents of account" $ expectParse (postingp Nothing) " a $1 == $1\n" + ,test "balance assertion over entire contents of account" $ expectParse (postingp Nothing False) " a $1 == $1\n" ] ,tests "transactionmodifierp" [ - test "basic" $ expectParseEq transactionmodifierp + test "basic" $ expectParseEq transactionmodifierp "= (some value expr)\n some:postings 1.\n" nulltransactionmodifier { tmquerytxt = "(some value expr)" ,tmpostingrules = [nullposting{paccount="some:postings", pamount=Mixed[num 1]}] } + + ,test "multiplier" $ expectParseEq transactionmodifierp + "= (some value expr)\n some:postings *.33\n" + nulltransactionmodifier { + tmquerytxt = "(some value expr)" + ,tmpostingrules = [nullposting{paccount="some:postings", pmultiplier=Just $ (num 0.33) {astyle=amountstyle{asprecision=2}}}] + } ] ,tests "transactionp" [ diff --git a/hledger-lib/Hledger/Reports/MultiBalanceReports.hs b/hledger-lib/Hledger/Reports/MultiBalanceReports.hs index 0ee1bf24820..e443c6a2b26 100644 --- a/hledger-lib/Hledger/Reports/MultiBalanceReports.hs +++ b/hledger-lib/Hledger/Reports/MultiBalanceReports.hs @@ -314,7 +314,7 @@ tests_MultiBalanceReports = tests "MultiBalanceReports" [ (map showw aitems) `is` (map showw eitems) ((\(_, b, _) -> showMixedAmountDebug b) atotal) `is` (showMixedAmountDebug etotal) -- we only check the sum of the totals usd0 = usd 0 - amount0 = Amount {acommodity="$", aquantity=0, aprice=NoPrice, astyle=AmountStyle {ascommodityside = L, ascommodityspaced = False, asprecision = 2, asdecimalpoint = Just '.', asdigitgroups = Nothing}, amultiplier=False} + amount0 = amount {acommodity="$", aquantity=0, astyle=amountstyle {asprecision = 2}} in tests "multiBalanceReport" [ test "null journal" $ diff --git a/hledger/Hledger/Cli/Commands/Close.hs b/hledger/Hledger/Cli/Commands/Close.hs index 2613872c0db..aa7a94f309f 100755 --- a/hledger/Hledger/Cli/Commands/Close.hs +++ b/hledger/Hledger/Cli/Commands/Close.hs @@ -85,7 +85,7 @@ close CliOpts{rawopts_=rawopts, reportopts_=ropts} j = do balancingamt = negate $ sum $ map (\(_,_,_,b) -> normaliseMixedAmountSquashPricesForDisplay b) acctbals ps = [posting{paccount=a ,pamount=mixed [b] - ,pbalanceassertion=Just assertion{ baamount=b } + ,pbalanceassertion=Just assertion{ baamount=mixed [b] } } |(a,_,_,mb) <- acctbals ,b <- amounts $ normaliseMixedAmountSquashPricesForDisplay mb @@ -93,7 +93,7 @@ close CliOpts{rawopts_=rawopts, reportopts_=ropts} j = do ++ [posting{paccount="equity:opening balances", pamount=balancingamt}] nps = [posting{paccount=a ,pamount=mixed [negate b] - ,pbalanceassertion=Just assertion{ baamount=b{aquantity=0} } + ,pbalanceassertion=Just assertion{ baamount=mixed [b{aquantity=0}] } } |(a,_,_,mb) <- acctbals ,b <- amounts $ normaliseMixedAmountSquashPricesForDisplay mb diff --git a/hledger/Hledger/Cli/Commands/Rewrite.hs b/hledger/Hledger/Cli/Commands/Rewrite.hs index 216393bcd2b..dbb56ec2846 100755 --- a/hledger/Hledger/Cli/Commands/Rewrite.hs +++ b/hledger/Hledger/Cli/Commands/Rewrite.hs @@ -195,7 +195,7 @@ transactionModifierFromOpts CliOpts{rawopts_=rawopts,reportopts_=ropts} = ps = map (parseposting . stripquotes . T.pack) $ listofstringopt "add-posting" rawopts parseposting t = either (error' . errorBundlePretty) id ep where - ep = runIdentity (runJournalParser (postingp Nothing <* eof) t') + ep = runIdentity (runJournalParser (postingp Nothing True <* eof) t') t' = " " <> t <> "\n" -- inject space and newline for proper parsing printOrDiff :: RawOpts -> (CliOpts -> Journal -> Journal -> IO ()) diff --git a/site/doc/1.12/journal.md b/site/doc/1.12/journal.md index 982a9abef29..59342da2989 100644 --- a/site/doc/1.12/journal.md +++ b/site/doc/1.12/journal.md @@ -356,6 +356,48 @@ when an amountless posting is balanced using a price's commodity, or when -V is used.) If you find this causing problems, set the desired format with a commodity directive. +### Amount Expressions + +Amounts can be combined with the standard addition and subtraction operators, +with or without spaces around them. The values don't have to share the same +commodity, and different types of amount will simply be left existing alongside +each other: + +`$1 + $2` → `$3` +`$1-$2` → `-$1` +`$1 + £2` → `$1 + £2` + +Multiplication and division also work, although the value on the right side of +the symbol must *not* have any commodity associated with it (and, of course, +dividing by 0 doesn't work): + +`$2 * 3` → `$6` +`$5.00 / 0.5` → `$2.50` +`$1.00 * 3 / 4` → `$0.75` + +Do be aware that multiplication and division will round to the same number of +decimal places as other amounts of the same commodity display. If the length +isn't what you were expecting (if everything else in the journal simply uses +whole dollar amounts, for example), try inserting a [commodity +directive](#declaring-commodities) with the proper display. Also, hledger +doesn't try to use any fair rounding schemes, so don't rely on the product to +be exactly the same as your bank, to the cent. + +`$3 * 0.5` → `$1` (without anything else in the file) +`$1.00 / 2` → `$0.67` + +Finally, parentheses group amounts just like they do in any math problem. If +you need your files to be compatible with the original ledger program, you can +surround the entire expression with parentheses, but doing so isn't necessary +for hledger. + +`$1 + $2 * 3` → `$7` +`($1 + $2) * 3` → `$9` +`($1 + $2 * 3)` → `$7` + +Note, however, that any commodity symbols must be written alongside the amounts +they describe, not split by parentheses: `$(1 + 2)` is *not* able to be read. + ### Virtual Postings When you parenthesise the account name in a posting, we call that a @@ -1354,8 +1396,8 @@ posting-generating rule: The posting rules look just like normal postings, except the amount can be: -- a normal amount with a commodity symbol, eg `$2`. This will be used - as-is. +- a normal amount or amount expression with a commodity symbol, eg `$2`. This + will be used as-is. - a number, eg `2`. The commodity symbol (if any) from the matched posting will be added to this. - a numeric multiplier, eg `*2` (a star followed by a number N). The @@ -1364,6 +1406,10 @@ be: - a multiplier with a commodity symbol, eg `*$2` (a star, number N, and symbol S). The matched posting's amount will be multiplied by N, and its commodity symbol will be replaced with S. +- either of the three previous modifiers, followed by a plus or minus sign + and a normal amount or amount expression, eg `*2 + $1`. The matched + posting's amount will be multiplied or divided as before, and the rest of + the expression will be added to the result. Some examples: @@ -1377,12 +1423,20 @@ Some examples: assets:checking:gifts *-1 assets:checking *1 +; when I buy from a local-foods marketplace, they take a portion and pass the rest to the farmer += expenses:food:market + (expenses:local) *0.8 - $5 + 2017/12/1 - expenses:food $10 + expenses:food $10 assets:checking 2017/12/14 - expenses:gifts $20 + expenses:gifts $20 + assets:checking + +2017/12/22 + expenses:food:market $30 assets:checking ``` @@ -1398,6 +1452,11 @@ $ hledger print --auto assets:checking assets:checking:gifts -$20 assets:checking $20 + +2017/12/22 + expenses:food:market $30 + assets:checking + (expenses:local) $19 ``` Postings added by transaction modifiers participate in [transaction diff --git a/tests/journal/amount-expressions.test b/tests/journal/amount-expressions.test new file mode 100644 index 00000000000..263d30d988e --- /dev/null +++ b/tests/journal/amount-expressions.test @@ -0,0 +1,328 @@ +#!/usr/bin/env shelltest +# 1. Compatibiltiy with the example in Ledger docs +hledger -f - print +<<< +2017-03-10 * KFC + Expenses:Food ($10.00 + $20.00) + Assets:Cash +>>> +2017/03/10 * KFC + Expenses:Food $30.00 + Assets:Cash + +>>>2 +>>>=0 + +# 2. Expressions don't require parentheses +hledger -f - print +<<< +2017-03-10 * KFC + Expenses:Food $10.00 + $20.00 + Assets:Cash +>>> +2017/03/10 * KFC + Expenses:Food $30.00 + Assets:Cash + +>>>2 +>>>=0 + +# 3. Subtraction is distributive +hledger -f - print +<<< +2018-01-01 + a $10 - $5 + $2 + $3 + b $10 - ($5 + $2) + $7 + c +>>> +2018/01/01 + a $10 + b $10 + c + +>>>2 +>>>=0 + +# 4. Expressions consider the default commodity +hledger -f - print +<<< +D $1,000.00 + +2018-01-01 + a $10 - 5 + b +>>> +2018/01/01 + a $5.00 + b + +>>>2 +>>>=0 + +# 5. Expressions enable multi-commodity postings +hledger -f - print +<<< +2018-01-01 + a:usd $10 + a:coupon 10 OMD + b -($10 + 10 OMD) +>>> +2018/01/01 + a:usd $10 + a:coupon 10 OMD + b $-10 + b -10 OMD + +>>>2 +>>>=0 + +# 6. Expressions enable multi-commodity assertions +hledger -f - stats +<<< +2018-01-01 + a:usd $10 + a:coupon 10 OMD + b + +2018-01-02 + b 0 = -$10 - 10 OMD +>>> /Transactions/ +>>>2 +>>>=0 + +# 7. Default commodities are treated alongside their explicit counterpart +hledger -f - print +<<< +D $1,000.00 + +2018-01-01 + a $10 + 2 - 4 CAD + b +>>> +2018/01/01 + a $12.00 + a -4 CAD + b + +>>>2 +>>>=0 + +# 8. Auto-postings respect expressions +hledger -f - print --auto +<<< += a + c *-1 + $8 + d *1 - $8 + e *-1 + f *1 + g $8 + h -$8 + +2018-01-01 + a $5 + b +>>> +2018/01/01 + a $5 + c $3 + d $-3 + e $-5 + f $5 + g $8 + h $-8 + b + +>>>2 +>>>=0 + +# 9. Modifiers operate on all commodities in a posting +hledger -f - print --auto +<<< += a + (c) *-1 + $8 + +2018-01-01 + a $5 - 5 CAD + b +>>> +2018/01/01 + a $5 + a -5 CAD + (c) $3 + (c) 5 CAD + b + +>>>2 +>>>=0 + +# 10. Standard postings may not be headed by multipliers +hledger -f - print +<<< +2018-01-01 + a *-1 + $8 + b *1 - $8 +>>> +>>>2 /unexpected '*'/ +>>>=1 + +# 11. Auto-postings require an operator between multiplier and expression +# The error message could be a bit more helpful, but at least it mentions +# expecting a mixed amount +hledger -f - print --auto +<<< += a + c *-1 $8 + d *1 - $8 + +2018-01-01 + a $5 + b +>>> +>>>2 /unexpected '8'/ +>>>=1 + +# 12. Multiplication by plain values works as expected +hledger -f - print +<<< +2018-01-01 + a $1 * 2 + b -$2 * 0.5 + c $2 * -1 + d -$1 * -1 +>>> +2018/01/01 + a $2 + b $-1 + c $-2 + d $1 + +>>>2 +>>>=0 + +# 13. ... as does division +hledger -f - print +<<< +2018-01-01 + a $2.00 / 2 + b -$1.00 / 0.5 + c $2.00 / -1 + d -$3.00 / -1 +>>> +2018/01/01 + a $1.00 + b $-2.00 + c $-2.00 + d $3.00 + +>>>2 +>>>=0 + +# 14. Multiplication disallows commodities before the multiplier +hledger -f - print +<<< +2018-01-01 + a $1 * $2 + c +>>> +>>>2 /unexpected '\$'/ +>>>=1 + +# 15. ... and after +hledger -f - print +<<< +2018-01-01 + a $1 * 2 CAD + c +>>> +>>>2 /unexpected 'C'/ +>>>=1 + +# 16. Division prevents divide-by-zero errors +hledger -f - print +<<< +2018-01-01 + a $1 / 0 + b +>>> +>>>2 /division by 0/ +>>>=1 + +# 17. ... but multiplication just simplifies them +hledger -f - print +<<< +2018-01-01 + a $1 * 0 + b +>>> +2018/01/01 + a 0 + b + +>>>2 +>>>=0 + +# 18. Multiplication and division rounds values according to the multiplicand precision +hledger -f - print +<<< +2018-01-01 + a $3 * 0.5 + b $1 / 3 + c +>>> +2018/01/01 + a $2 + b 0 + c + +>>>2 +>>>=0 + +# 19. Commodity directives affect multiplication and division rounding +hledger -f - print +<<< +commodity $1,000.00 + +2018-01-01 + a $3 * 0.5 + b $1 / 3 + c +>>> +2018/01/01 + a $1.50 + b $0.33 + c + +>>>2 +>>>=0 + +# 20. Expressions respect order of operations +hledger -f - print +<<< +2018-01-01 + a $1 + $2 * 3 - $4 + b ($1 + $2) * 3 - $4 + c +>>> +2018/01/01 + a $3 + b $5 + c + +>>>2 +>>>=0 + +# 21. Multiplication and division work over multiple commodities +hledger -f - print +<<< +2018-01-01 + a ($1 + 2 CAD) * 3 + b +>>> +2018/01/01 + a $3 + a 6 CAD + b + +>>>2 +>>>=0 +