{-# LANGUAGE FlexibleContexts #-} {-| A 'Transaction' represents a movement of some commodity(ies) between two or more accounts. It consists of multiple account 'Posting's which balance to zero, a date, and optional extras like description, cleared status, and tags. -} {-# LANGUAGE OverloadedStrings, LambdaCase #-} module Hledger.Data.Transaction ( -- * Transaction nullsourcepos, nulltransaction, txnTieKnot, txnUntieKnot, -- * operations showAccountName, hasRealPostings, realPostings, assignmentPostings, virtualPostings, balancedVirtualPostings, transactionsPostings, isTransactionBalanced, -- nonzerobalanceerror, -- * date operations transactionDate2, -- * arithmetic transactionPostingBalances, balanceTransaction, balanceTransactionUpdate, -- * rendering showTransaction, showTransactionUnelided, showTransactionUnelidedOneLineAmounts, showPostingLine, showPostingLines, -- * GenericSourcePos sourceFilePath, sourceFirstLine, showGenericSourcePos, -- * tests easytests_Transaction ) where import Data.List import Control.Monad.Except import Control.Monad.Identity import Data.Maybe import Data.Text (Text) import qualified Data.Text as T import Data.Time.Calendar import Text.Printf import qualified Data.Map as Map import Hledger.Utils import Hledger.Data.Types import Hledger.Data.Dates import Hledger.Data.Posting import Hledger.Data.Amount sourceFilePath :: GenericSourcePos -> FilePath sourceFilePath = \case GenericSourcePos fp _ _ -> fp JournalSourcePos fp _ -> fp sourceFirstLine :: GenericSourcePos -> Int sourceFirstLine = \case GenericSourcePos _ line _ -> line JournalSourcePos _ (line, _) -> line showGenericSourcePos :: GenericSourcePos -> String showGenericSourcePos = \case GenericSourcePos fp line column -> show fp ++ " (line " ++ show line ++ ", column " ++ show column ++ ")" JournalSourcePos fp (line, line') -> show fp ++ " (lines " ++ show line ++ "-" ++ show line' ++ ")" nullsourcepos :: GenericSourcePos nullsourcepos = JournalSourcePos "" (1,1) nulltransaction :: Transaction nulltransaction = Transaction { tindex=0, tsourcepos=nullsourcepos, tdate=nulldate, tdate2=Nothing, tstatus=Unmarked, tcode="", tdescription="", tcomment="", ttags=[], tpostings=[], tpreceding_comment_lines="" } {-| Show a journal transaction, formatted for the print command. ledger 2.x's standard format looks like this: @ yyyy/mm/dd[ *][ CODE] description......... [ ; comment...............] account name 1..................... ...$amount1[ ; comment...............] account name 2..................... ..$-amount1[ ; comment...............] pcodewidth = no limit -- 10 -- mimicking ledger layout. pdescwidth = no limit -- 20 -- I don't remember what these mean, pacctwidth = 35 minimum, no maximum -- they were important at the time. pamtwidth = 11 pcommentwidth = no limit -- 22 @ -} showTransaction :: Transaction -> String showTransaction = showTransactionHelper True False showTransactionUnelided :: Transaction -> String showTransactionUnelided = showTransactionHelper False False showTransactionUnelidedOneLineAmounts :: Transaction -> String showTransactionUnelidedOneLineAmounts = showTransactionHelper False True -- cf showPosting showTransactionHelper :: Bool -> Bool -> Transaction -> String showTransactionHelper elide onelineamounts t = unlines $ [descriptionline] ++ newlinecomments ++ (postingsAsLines elide onelineamounts t (tpostings t)) ++ [""] where descriptionline = rstrip $ concat [date, status, code, desc, samelinecomment] date = showDate (tdate t) ++ maybe "" (("="++) . showDate) (tdate2 t) status | tstatus t == Cleared = " *" | tstatus t == Pending = " !" | otherwise = "" code = if T.length (tcode t) > 0 then printf " (%s)" $ T.unpack $ tcode t else "" desc = if null d then "" else " " ++ d where d = T.unpack $ tdescription t (samelinecomment, newlinecomments) = case renderCommentLines (tcomment t) of [] -> ("",[]) c:cs -> (c,cs) -- Render a transaction or posting's comment as indented, semicolon-prefixed comment lines. renderCommentLines :: Text -> [String] renderCommentLines t = case lines $ T.unpack t of ("":ls) -> "":map commentprefix ls ls -> map commentprefix ls where commentprefix = indent . ("; "++) -- -- Render a transaction or posting's comment as semicolon-prefixed comment lines - -- -- an inline (same-line) comment if it's a single line, otherwise multiple indented lines. -- commentLines' :: String -> (String, [String]) -- commentLines' s -- | null s = ("", []) -- | length ls == 1 = (prefix $ head ls, []) -- | otherwise = ("", (prefix $ head ls):(map prefix $ tail ls)) -- where -- ls = lines s -- prefix = indent . (";"++) postingsAsLines :: Bool -> Bool -> Transaction -> [Posting] -> [String] postingsAsLines elide onelineamounts t ps | elide && length ps > 1 && isTransactionBalanced Nothing t -- imprecise balanced check = (concatMap (postingAsLines False onelineamounts ps) $ init ps) ++ postingAsLines True onelineamounts ps (last ps) | otherwise = concatMap (postingAsLines False onelineamounts ps) ps postingAsLines :: Bool -> Bool -> [Posting] -> Posting -> [String] postingAsLines elideamount onelineamounts ps p = concat [ postingblock ++ newlinecomments | postingblock <- postingblocks] where postingblocks = [map rstrip $ lines $ concatTopPadded [statusandaccount, " ", amount, assertion, samelinecomment] | amount <- shownAmounts] assertion = maybe "" ((" = " ++) . showAmountWithZeroCommodity . fst) $ 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 minwidth = maximum $ map ((2+) . textWidth . T.pack . pacctstr) ps pstatusandacct p' = pstatusprefix p' ++ pacctstr p' pstatusprefix p' | null s = "" | otherwise = s ++ " " where s = show $ pstatus p' pacctstr p' = showAccountName Nothing (ptype p') (paccount p') -- currently prices are considered part of the amount string when right-aligning amounts shownAmounts | elideamount = [""] | onelineamounts = [fitString (Just amtwidth) Nothing False False $ showMixedAmountOneLine $ pamount p] | null (amounts $ pamount p) = [""] | otherwise = map (fitStringMulti (Just amtwidth) Nothing False False . showAmount ) . amounts $ pamount p where amtwidth = maximum $ 12 : map (strWidth . showMixedAmount . pamount) ps -- min. 12 for backwards compatibility (samelinecomment, newlinecomments) = case renderCommentLines (pcomment p) of [] -> ("",[]) c:cs -> (c,cs) -- used in balance assertion error showPostingLine p = indent $ if pstatus p == Cleared then "* " else "" ++ showAccountName Nothing (ptype p) (paccount p) ++ " " ++ showMixedAmountOneLine (pamount p) -- | Produce posting line with all comment lines associated with it showPostingLines :: Posting -> [String] showPostingLines p = postingAsLines False False ps p where ps | Just t <- ptransaction p = tpostings t | otherwise = [p] indent :: String -> String indent = (" "++) -- | Show an account name, clipped to the given width if any, and -- appropriately bracketed/parenthesised for the given posting type. showAccountName :: Maybe Int -> PostingType -> AccountName -> String showAccountName w = fmt where fmt RegularPosting = take w' . T.unpack fmt VirtualPosting = parenthesise . reverse . take (w'-2) . reverse . T.unpack fmt BalancedVirtualPosting = bracket . reverse . take (w'-2) . reverse . T.unpack w' = fromMaybe 999999 w parenthesise :: String -> String parenthesise s = "("++s++")" bracket :: String -> String bracket s = "["++s++"]" hasRealPostings :: Transaction -> Bool hasRealPostings = not . null . realPostings realPostings :: Transaction -> [Posting] realPostings = filter isReal . tpostings assignmentPostings :: Transaction -> [Posting] assignmentPostings = filter isAssignment . tpostings virtualPostings :: Transaction -> [Posting] virtualPostings = filter isVirtual . tpostings balancedVirtualPostings :: Transaction -> [Posting] balancedVirtualPostings = filter isBalancedVirtual . tpostings transactionsPostings :: [Transaction] -> [Posting] transactionsPostings = concat . map tpostings -- | Get the sums of a transaction's real, virtual, and balanced virtual postings. transactionPostingBalances :: Transaction -> (MixedAmount,MixedAmount,MixedAmount) transactionPostingBalances t = (sumPostings $ realPostings t ,sumPostings $ virtualPostings t ,sumPostings $ balancedVirtualPostings t) -- | Is this transaction balanced ? A balanced transaction's real -- (non-virtual) postings sum to 0, and any balanced virtual postings -- also sum to 0. isTransactionBalanced :: Maybe (Map.Map CommoditySymbol AmountStyle) -> Transaction -> Bool isTransactionBalanced styles t = -- isReallyZeroMixedAmountCost rsum && isReallyZeroMixedAmountCost bvsum isZeroMixedAmount rsum' && isZeroMixedAmount bvsum' where (rsum, _, bvsum) = transactionPostingBalances t rsum' = canonicalise $ costOfMixedAmount rsum bvsum' = canonicalise $ costOfMixedAmount bvsum canonicalise = maybe id canonicaliseMixedAmount styles -- | Ensure this transaction is balanced, possibly inferring a missing -- amount or conversion price(s), or return an error message. -- Balancing is affected by commodity display precisions, so those can -- (optionally) be provided. -- -- this fails for example, if there are several missing amounts -- (possibly with balance assignments) balanceTransaction :: Maybe (Map.Map CommoditySymbol AmountStyle) -> Transaction -> Either String Transaction balanceTransaction stylemap = runIdentity . runExceptT . balanceTransactionUpdate (\_ _ -> return ()) stylemap -- | More general version of 'balanceTransaction' that takes an update -- function balanceTransactionUpdate :: MonadError String m => (AccountName -> MixedAmount -> m ()) -- ^ update function -> Maybe (Map.Map CommoditySymbol AmountStyle) -> Transaction -> m Transaction balanceTransactionUpdate update mstyles t = finalize =<< inferBalancingAmount update (fromMaybe Map.empty mstyles) t where finalize t' = let t'' = inferBalancingPrices t' in if isTransactionBalanced mstyles t'' then return $ txnTieKnot t'' else throwError $ printerr $ nonzerobalanceerror t'' printerr s = intercalate "\n" [s, showTransactionUnelided t] nonzerobalanceerror :: Transaction -> String nonzerobalanceerror t = printf "could not balance this transaction (%s%s%s)" rmsg sep bvmsg where (rsum, _, bvsum) = transactionPostingBalances t rmsg | isReallyZeroMixedAmountCost rsum = "" | otherwise = "real postings are off by " ++ showMixedAmount (costOfMixedAmount rsum) bvmsg | isReallyZeroMixedAmountCost bvsum = "" | otherwise = "balanced virtual postings are off by " ++ showMixedAmount (costOfMixedAmount bvsum) sep = if not (null rmsg) && not (null bvmsg) then "; " else "" :: String -- | Infer up to one missing amount for this transactions's real postings, and -- likewise for its balanced virtual postings, if needed; or return an error -- message if we can't. -- -- We can infer a missing amount when there are multiple postings and exactly -- one of them is amountless. If the amounts had price(s) the inferred amount -- have the same price(s), and will be converted to the price commodity. inferBalancingAmount :: MonadError String m => (AccountName -> MixedAmount -> m ()) -- ^ update function -> Map.Map CommoditySymbol AmountStyle -- ^ standard amount styles -> Transaction -> m Transaction inferBalancingAmount update styles t@Transaction{tpostings=ps} | length amountlessrealps > 1 = throwError $ printerr "could not balance this transaction - can't have more than one real posting with no amount (remember to put 2 or more spaces before amounts)" | length amountlessbvps > 1 = throwError $ printerr "could not balance this transaction - can't have more than one balanced virtual posting with no amount (remember to put 2 or more spaces before amounts)" | otherwise = do postings <- mapM inferamount ps return t{tpostings=postings} where printerr s = intercalate "\n" [s, showTransactionUnelided t] (amountfulrealps, amountlessrealps) = partition hasAmount (realPostings t) realsum = sumStrict $ map pamount amountfulrealps (amountfulbvps, amountlessbvps) = partition hasAmount (balancedVirtualPostings t) bvsum = sumStrict $ map pamount amountfulbvps inferamount p@Posting{ptype=RegularPosting} | not (hasAmount p) = updateAmount p realsum inferamount p@Posting{ptype=BalancedVirtualPosting} | not (hasAmount p) = updateAmount p bvsum inferamount p = return p updateAmount p amt = update (paccount p) amt' >> return p { pamount=amt', porigin=Just $ originalPosting p } where -- Inferred amounts are converted to cost. -- Also, ensure the new amount has the standard style for its commodity -- (the main amount styling pass happened before this balancing pass). amt' = styleMixedAmount styles $ normaliseMixedAmount $ costOfMixedAmount (-amt) -- | Infer prices for this transaction's posting amounts, if needed to make -- the postings balance, and if possible. This is done once for the real -- postings and again (separately) for the balanced virtual postings. When -- it's not possible, the transaction is left unchanged. -- -- The simplest example is a transaction with two postings, each in a -- different commodity, with no prices specified. In this case we'll add a -- price to the first posting such that it can be converted to the commodity -- of the second posting (with -B), and such that the postings balance. -- -- In general, we can infer a conversion price when the sum of posting amounts -- contains exactly two different commodities and no explicit prices. Also -- all postings are expected to contain an explicit amount (no missing -- amounts) in a single commodity. Otherwise no price inferring is attempted. -- -- The transaction itself could contain more than two commodities, and/or -- prices, if they cancel out; what matters is that the sum of posting amounts -- contains exactly two commodities and zero prices. -- -- There can also be more than two postings in either of the commodities. -- -- We want to avoid excessive display of digits when the calculated price is -- an irrational number, while hopefully also ensuring the displayed numbers -- make sense if the user does a manual calculation. This is (mostly) achieved -- in two ways: -- -- - when there is only one posting in the "from" commodity, a total price -- (@@) is used, and all available decimal digits are shown -- -- - otherwise, a suitable averaged unit price (@) is applied to the relevant -- postings, with display precision equal to the summed display precisions -- of the two commodities being converted between, or 2, whichever is larger. -- -- (We don't always calculate a good-looking display precision for unit prices -- when the commodity display precisions are low, eg when a journal doesn't -- use any decimal places. The minimum of 2 helps make the prices shown by the -- print command a bit less surprising in this case. Could do better.) -- inferBalancingPrices :: Transaction -> Transaction inferBalancingPrices t@Transaction{tpostings=ps} = t{tpostings=ps'} where ps' = map (priceInferrerFor t BalancedVirtualPosting) $ map (priceInferrerFor t RegularPosting) $ ps -- | Generate a posting update function which assigns a suitable balancing -- price to the posting, if and as appropriate for the given transaction and -- posting type (real or balanced virtual). priceInferrerFor :: Transaction -> PostingType -> (Posting -> Posting) priceInferrerFor t pt = inferprice where postings = filter ((==pt).ptype) $ tpostings t pmixedamounts = map pamount postings pamounts = concatMap amounts pmixedamounts pcommodities = map acommodity pamounts sumamounts = amounts $ sumStrict pmixedamounts -- sum normalises to one amount per commodity & price sumcommodities = map acommodity sumamounts sumprices = filter (/=NoPrice) $ map aprice sumamounts caninferprices = length sumcommodities == 2 && null sumprices inferprice p@Posting{pamount=Mixed [a]} | caninferprices && ptype p == pt && acommodity a == fromcommodity = p{pamount=Mixed [a{aprice=conversionprice}], porigin=Just $ originalPosting p} where fromcommodity = head $ filter (`elem` sumcommodities) pcommodities -- these heads are ugly but should be safe conversionprice | fromcount==1 = TotalPrice $ abs toamount `withPrecision` maxprecision | otherwise = UnitPrice $ abs unitprice `withPrecision` unitprecision where fromcount = length $ filter ((==fromcommodity).acommodity) pamounts fromamount = head $ filter ((==fromcommodity).acommodity) sumamounts tocommodity = head $ filter (/=fromcommodity) sumcommodities toamount = head $ filter ((==tocommodity).acommodity) sumamounts unitprice = toamount `divideAmount` (aquantity fromamount) unitprecision = max 2 ((asprecision $ astyle $ toamount) + (asprecision $ astyle $ fromamount)) inferprice p = p -- Get a transaction's secondary date, defaulting to the primary date. transactionDate2 :: Transaction -> Day transactionDate2 t = fromMaybe (tdate t) $ tdate2 t -- | Ensure a transaction's postings refer back to it, so that eg -- relatedPostings works right. txnTieKnot :: Transaction -> Transaction txnTieKnot t@Transaction{tpostings=ps} = t' where t' = t{tpostings=map (postingSetTransaction t') ps} -- | Ensure a transaction's postings do not refer back to it, so that eg -- recursiveSize and GHCI's :sprint work right. txnUntieKnot :: Transaction -> Transaction txnUntieKnot t@Transaction{tpostings=ps} = t{tpostings=map (\p -> p{ptransaction=Nothing}) ps} -- | Set a posting's parent transaction. postingSetTransaction :: Transaction -> Posting -> Posting postingSetTransaction t p = p{ptransaction=Just t} -- tests easytests_Transaction = tests "Transaction" [ tests "showTransactionUnelided" [ showTransactionUnelided nulltransaction `is` "0000/01/01\n\n" ,showTransactionUnelided nulltransaction{ tdate=parsedate "2012/05/14", tdate2=Just $ parsedate "2012/05/15", tstatus=Unmarked, tcode="code", tdescription="desc", tcomment="tcomment1\ntcomment2\n", ttags=[("ttag1","val1")], tpostings=[ nullposting{ pstatus=Cleared, paccount="a", pamount=Mixed [usd 1, hrs 2], pcomment="\npcomment2\n", ptype=RegularPosting, ptags=[("ptag1","val1"),("ptag2","val2")] } ] } `is` unlines [ "2012/05/14=2012/05/15 (code) desc ; tcomment1", " ; tcomment2", " * a $1.00", " ; pcomment2", " * a 2.00h", " ; pcomment2", "" ] ] ,tests "postingAsLines" [ postingAsLines False False [posting] posting `is` [""] ,let p = posting{ pstatus=Cleared, paccount="a", pamount=Mixed [usd 1, hrs 2], pcomment="pcomment1\npcomment2\n tag3: val3 \n", ptype=RegularPosting, ptags=[("ptag1","val1"),("ptag2","val2")] } in postingAsLines False False [p] p `is` [ " * a $1.00 ; pcomment1", " ; pcomment2", " ; tag3: val3 ", " * a 2.00h ; pcomment1", " ; pcomment2", " ; tag3: val3 " ] ] ,do let inferTransaction :: Transaction -> Either String Transaction inferTransaction = runIdentity . runExceptT . inferBalancingAmount (\_ _ -> return ()) Map.empty tests "inferBalancingAmount" [ inferTransaction nulltransaction `is` Right nulltransaction ,inferTransaction nulltransaction{ tpostings=[ "a" `post` usd (-5), "b" `post` missingamt ]} `is` Right nulltransaction{ tpostings=[ "a" `post` usd (-5), "b" `post` usd 5 ]} ,inferTransaction nulltransaction{ tpostings=[ "a" `post` usd (-5), "b" `post` (eur 3 @@ usd 4), "c" `post` missingamt ]} `is` Right nulltransaction{ tpostings=[ "a" `post` usd (-5), "b" `post` (eur 3 @@ usd 4), "c" `post` usd 1 ]} ] ,tests "showTransaction" [ test "show a balanced transaction, eliding last amount" $ let t = Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "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} ] "" in showTransaction t `is` unlines ["2007/01/28 coopportunity" ," expenses:food:groceries $47.18" ," assets:checking" ,"" ] ] ,tests "showTransaction" [ test "show a balanced transaction, no eliding" $ (let t = Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "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} ] "" in showTransactionUnelided t) `is` (unlines ["2007/01/28 coopportunity" ," expenses:food:groceries $47.18" ," assets:checking $-47.18" ,"" ]) -- document some cases that arise in debug/testing: ,test "show an unbalanced transaction, should not elide" $ (showTransaction (txnTieKnot $ Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "coopportunity" "" [] [posting{paccount="expenses:food:groceries", pamount=Mixed [usd 47.18]} ,posting{paccount="assets:checking", pamount=Mixed [usd (-47.19)]} ] "")) `is` (unlines ["2007/01/28 coopportunity" ," expenses:food:groceries $47.18" ," assets:checking $-47.19" ,"" ]) ,test "show an unbalanced transaction with one posting, should not elide" $ (showTransaction (txnTieKnot $ Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "coopportunity" "" [] [posting{paccount="expenses:food:groceries", pamount=Mixed [usd 47.18]} ] "")) `is` (unlines ["2007/01/28 coopportunity" ," expenses:food:groceries $47.18" ,"" ]) ,test "show a transaction with one posting and a missing amount" $ (showTransaction (txnTieKnot $ Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "coopportunity" "" [] [posting{paccount="expenses:food:groceries", pamount=missingmixedamt} ] "")) `is` (unlines ["2007/01/28 coopportunity" ," expenses:food:groceries" ,"" ]) ,test "show a transaction with a priced commodityless amount" $ (showTransaction (txnTieKnot $ Transaction 0 nullsourcepos (parsedate "2010/01/01") Nothing Unmarked "" "x" "" [] [posting{paccount="a", pamount=Mixed [num 1 `at` (usd 2 `withPrecision` 0)]} ,posting{paccount="b", pamount= missingmixedamt} ] "")) `is` (unlines ["2010/01/01 x" ," a 1 @ $2" ," b" ,"" ]) ] ,tests "balanceTransaction" [ test "detect unbalanced entry, sign error" $ (expectLeft $ balanceTransaction Nothing (Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "test" "" [] [posting{paccount="a", pamount=Mixed [usd 1]} ,posting{paccount="b", pamount=Mixed [usd 1]} ] "")) ,test "detect unbalanced entry, multiple missing amounts" $ (expectLeft $ balanceTransaction Nothing (Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "test" "" [] [posting{paccount="a", pamount=missingmixedamt} ,posting{paccount="b", pamount=missingmixedamt} ] "")) ,test "one missing amount is inferred" $ (pamount . last . tpostings <$> balanceTransaction Nothing (Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "" "" [] [posting{paccount="a", pamount=Mixed [usd 1]} ,posting{paccount="b", pamount=missingmixedamt} ] "")) `is` Right (Mixed [usd (-1)]) ,test "conversion price is inferred" $ (pamount . head . tpostings <$> balanceTransaction Nothing (Transaction 0 nullsourcepos (parsedate "2007/01/28") Nothing Unmarked "" "" "" [] [posting{paccount="a", pamount=Mixed [usd 1.35]} ,posting{paccount="b", pamount=Mixed [eur (-1)]} ] "")) `is` Right (Mixed [usd 1.35 @@ (eur 1 `withPrecision` maxprecision)]) ,test "balanceTransaction balances based on cost if there are unit prices" $ expectRight $ balanceTransaction Nothing (Transaction 0 nullsourcepos (parsedate "2011/01/01") Nothing Unmarked "" "" "" [] [posting{paccount="a", pamount=Mixed [usd 1 `at` eur 2]} ,posting{paccount="a", pamount=Mixed [usd (-2) `at` eur 1]} ] "") ,test "balanceTransaction balances based on cost if there are total prices" $ expectRight $ balanceTransaction Nothing (Transaction 0 nullsourcepos (parsedate "2011/01/01") Nothing Unmarked "" "" "" [] [posting{paccount="a", pamount=Mixed [usd 1 @@ eur 1]} ,posting{paccount="a", pamount=Mixed [usd (-2) @@ eur 1]} ] "") ] ,tests "isTransactionBalanced" [ test "detect balanced" $ expect $ isTransactionBalanced Nothing $ Transaction 0 nullsourcepos (parsedate "2009/01/01") Nothing Unmarked "" "a" "" [] [posting{paccount="b", pamount=Mixed [usd 1.00]} ,posting{paccount="c", pamount=Mixed [usd (-1.00)]} ] "" ,test "detect unbalanced" $ expect $ not $ isTransactionBalanced Nothing $ Transaction 0 nullsourcepos (parsedate "2009/01/01") Nothing Unmarked "" "a" "" [] [posting{paccount="b", pamount=Mixed [usd 1.00]} ,posting{paccount="c", pamount=Mixed [usd (-1.01)]} ] "" ,test "detect unbalanced, one posting" $ expect $ not $ isTransactionBalanced Nothing $ Transaction 0 nullsourcepos (parsedate "2009/01/01") Nothing Unmarked "" "a" "" [] [posting{paccount="b", pamount=Mixed [usd 1.00]} ] "" ,test "one zero posting is considered balanced for now" $ expect $ isTransactionBalanced Nothing $ Transaction 0 nullsourcepos (parsedate "2009/01/01") Nothing Unmarked "" "a" "" [] [posting{paccount="b", pamount=Mixed [usd 0]} ] "" ,test "virtual postings don't need to balance" $ expect $ isTransactionBalanced Nothing $ Transaction 0 nullsourcepos (parsedate "2009/01/01") Nothing Unmarked "" "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} ] "" ,test "balanced virtual postings need to balance among themselves" $ expect $ not $ isTransactionBalanced Nothing $ Transaction 0 nullsourcepos (parsedate "2009/01/01") Nothing Unmarked "" "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} ] "" ,test "balanced virtual postings need to balance among themselves (2)" $ expect $ isTransactionBalanced Nothing $ Transaction 0 nullsourcepos (parsedate "2009/01/01") Nothing Unmarked "" "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} ] "" ] ]