555 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Haskell
		
	
	
	
	
	
			
		
		
	
	
			555 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Haskell
		
	
	
	
	
	
| {-|
 | |
| 
 | |
| 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 MultiWayIf        #-}
 | |
| {-# LANGUAGE NamedFieldPuns    #-}
 | |
| {-# LANGUAGE OverloadedStrings #-}
 | |
| 
 | |
| module Hledger.Data.Transaction
 | |
| ( -- * Transaction
 | |
|   nulltransaction
 | |
| , transaction
 | |
| , txnTieKnot
 | |
| , txnUntieKnot
 | |
|   -- * operations
 | |
| , hasRealPostings
 | |
| , realPostings
 | |
| , assignmentPostings
 | |
| , virtualPostings
 | |
| , balancedVirtualPostings
 | |
| , transactionsPostings
 | |
| , transactionTransformPostings
 | |
| , transactionApplyValuation
 | |
| , transactionToCost
 | |
| , transactionAddInferredEquityPostings
 | |
| , transactionAddPricesFromEquity
 | |
| , transactionApplyAliases
 | |
| , transactionMapPostings
 | |
| , transactionMapPostingAmounts
 | |
|   -- nonzerobalanceerror
 | |
|   -- * date operations
 | |
| , transactionDate2
 | |
| , transactionDateOrDate2
 | |
|   -- * transaction description parts
 | |
| , transactionPayee
 | |
| , transactionNote
 | |
|   -- payeeAndNoteFromDescription
 | |
|   -- * rendering
 | |
| , showTransaction
 | |
| , showTransactionOneLineAmounts
 | |
| , showTransactionLineFirstPart
 | |
| , transactionFile
 | |
|   -- * transaction errors
 | |
| , annotateErrorWithTransaction
 | |
|   -- * tests
 | |
| , tests_Transaction
 | |
| ) where
 | |
| 
 | |
| import Control.Monad.Trans.State (StateT(..), evalStateT)
 | |
| import Data.Bifunctor (first)
 | |
| import Data.Foldable (foldrM)
 | |
| import Data.Maybe (fromMaybe, isJust, mapMaybe)
 | |
| import Data.Semigroup (Endo(..))
 | |
| import Data.Text (Text)
 | |
| import qualified Data.Map as M
 | |
| import qualified Data.Text as T
 | |
| import qualified Data.Text.Lazy as TL
 | |
| import qualified Data.Text.Lazy.Builder as TB
 | |
| import Data.Time.Calendar (Day, fromGregorian)
 | |
| 
 | |
| import Hledger.Utils
 | |
| import Hledger.Data.Types
 | |
| import Hledger.Data.Dates
 | |
| import Hledger.Data.Posting
 | |
| import Hledger.Data.Amount
 | |
| import Hledger.Data.Valuation
 | |
| 
 | |
| 
 | |
| nulltransaction :: Transaction
 | |
| nulltransaction = Transaction {
 | |
|                     tindex=0,
 | |
|                     tsourcepos=nullsourcepos,
 | |
|                     tdate=nulldate,
 | |
|                     tdate2=Nothing,
 | |
|                     tstatus=Unmarked,
 | |
|                     tcode="",
 | |
|                     tdescription="",
 | |
|                     tcomment="",
 | |
|                     ttags=[],
 | |
|                     tpostings=[],
 | |
|                     tprecedingcomment=""
 | |
|                   }
 | |
| 
 | |
| -- | Make a simple transaction with the given date and postings.
 | |
| transaction :: Day -> [Posting] -> Transaction
 | |
| transaction day ps = txnTieKnot $ nulltransaction{tdate=day, tpostings=ps}
 | |
| 
 | |
| transactionPayee :: Transaction -> Text
 | |
| transactionPayee = fst . payeeAndNoteFromDescription . tdescription
 | |
| 
 | |
| transactionNote :: Transaction -> Text
 | |
| transactionNote = snd . payeeAndNoteFromDescription . tdescription
 | |
| 
 | |
| -- | Parse a transaction's description into payee and note (aka narration) fields,
 | |
| -- assuming a convention of separating these with | (like Beancount).
 | |
| -- Ie, everything up to the first | is the payee, everything after it is the note.
 | |
| -- When there's no |, payee == note == description.
 | |
| payeeAndNoteFromDescription :: Text -> (Text,Text)
 | |
| payeeAndNoteFromDescription t
 | |
|   | T.null n = (t, t)
 | |
|   | otherwise = (T.strip p, T.strip $ T.drop 1 n)
 | |
|   where
 | |
|     (p, n) = T.span (/= '|') t
 | |
| 
 | |
| {-|
 | |
| Render a journal transaction as text similar to the style of Ledger's print command.
 | |
| 
 | |
| Adapted from Ledger 2.x and 3.x standard format:
 | |
| 
 | |
| @
 | |
| 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
 | |
| @
 | |
| 
 | |
| The output will be parseable journal syntax.
 | |
| To facilitate this, postings with explicit multi-commodity amounts
 | |
| are displayed as multiple similar postings, one per commodity.
 | |
| (Normally does not happen with this function).
 | |
| -}
 | |
| showTransaction :: Transaction -> Text
 | |
| showTransaction = TL.toStrict . TB.toLazyText . showTransactionHelper False
 | |
| 
 | |
| -- | Like showTransaction, but explicit multi-commodity amounts
 | |
| -- are shown on one line, comma-separated. In this case the output will
 | |
| -- not be parseable journal syntax.
 | |
| showTransactionOneLineAmounts :: Transaction -> Text
 | |
| showTransactionOneLineAmounts = TL.toStrict . TB.toLazyText . showTransactionHelper True
 | |
| 
 | |
| -- | Helper for showTransaction*.
 | |
| showTransactionHelper :: Bool -> Transaction -> TB.Builder
 | |
| showTransactionHelper onelineamounts t =
 | |
|       TB.fromText descriptionline <> newline
 | |
|     <> foldMap ((<> newline) . TB.fromText) newlinecomments
 | |
|     <> foldMap ((<> newline) . TB.fromText) (postingsAsLines onelineamounts $ tpostings t)
 | |
|     <> newline
 | |
|   where
 | |
|     descriptionline = T.stripEnd $ showTransactionLineFirstPart t <> T.concat [desc, samelinecomment]
 | |
|     desc = if T.null d then "" else " " <> d where d = tdescription t
 | |
|     (samelinecomment, newlinecomments) =
 | |
|       case renderCommentLines (tcomment t) of []   -> ("",[])
 | |
|                                               c:cs -> (c,cs)
 | |
|     newline = TB.singleton '\n'
 | |
| 
 | |
| -- Useful when rendering error messages.
 | |
| showTransactionLineFirstPart t = T.concat [date, status, code]
 | |
|   where
 | |
|     date = showDate (tdate t) <> maybe "" (("="<>) . showDate) (tdate2 t)
 | |
|     status | tstatus t == Cleared = " *"
 | |
|            | tstatus t == Pending = " !"
 | |
|            | otherwise            = ""
 | |
|     code = if T.null (tcode t) then "" else wrap " (" ")" $ tcode t
 | |
| 
 | |
| hasRealPostings :: Transaction -> Bool
 | |
| hasRealPostings = not . null . realPostings
 | |
| 
 | |
| realPostings :: Transaction -> [Posting]
 | |
| realPostings = filter isReal . tpostings
 | |
| 
 | |
| assignmentPostings :: Transaction -> [Posting]
 | |
| assignmentPostings = filter hasBalanceAssignment . tpostings
 | |
| 
 | |
| virtualPostings :: Transaction -> [Posting]
 | |
| virtualPostings = filter isVirtual . tpostings
 | |
| 
 | |
| balancedVirtualPostings :: Transaction -> [Posting]
 | |
| balancedVirtualPostings = filter isBalancedVirtual . tpostings
 | |
| 
 | |
| transactionsPostings :: [Transaction] -> [Posting]
 | |
| transactionsPostings = concatMap tpostings
 | |
| 
 | |
| -- Get a transaction's secondary date, or the primary date if there is none.
 | |
| transactionDate2 :: Transaction -> Day
 | |
| transactionDate2 t = fromMaybe (tdate t) $ tdate2 t
 | |
| 
 | |
| -- Get a transaction's primary or secondary date, as specified.
 | |
| transactionDateOrDate2 :: WhichDate -> Transaction -> Day
 | |
| transactionDateOrDate2 PrimaryDate   = tdate
 | |
| transactionDateOrDate2 SecondaryDate = transactionDate2
 | |
| 
 | |
| -- | 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}
 | |
| 
 | |
| -- | Apply a transform function to this transaction's amounts.
 | |
| transactionTransformPostings :: (Posting -> Posting) -> Transaction -> Transaction
 | |
| transactionTransformPostings f t@Transaction{tpostings=ps} = t{tpostings=map f ps}
 | |
| 
 | |
| -- | Apply a specified valuation to this transaction's amounts, using
 | |
| -- the provided price oracle, commodity styles, and reference dates.
 | |
| -- See amountApplyValuation.
 | |
| transactionApplyValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Day -> Day -> ValuationType -> Transaction -> Transaction
 | |
| transactionApplyValuation priceoracle styles periodlast today v =
 | |
|   transactionTransformPostings (postingApplyValuation priceoracle styles periodlast today v)
 | |
| 
 | |
| -- | Maybe convert this 'Transaction's amounts to cost and apply the
 | |
| -- appropriate amount styles.
 | |
| transactionToCost :: M.Map CommoditySymbol AmountStyle -> ConversionOp -> Transaction -> Transaction
 | |
| transactionToCost styles cost t = t{tpostings = mapMaybe (postingToCost styles cost) $ tpostings t}
 | |
| 
 | |
| -- | Add inferred equity postings to a 'Transaction' using transaction prices.
 | |
| transactionAddInferredEquityPostings :: AccountName -> Transaction -> Transaction
 | |
| transactionAddInferredEquityPostings equityAcct t =
 | |
|     t{tpostings=concatMap (postingAddInferredEquityPostings equityAcct) $ tpostings t}
 | |
| 
 | |
| -- | Add inferred transaction prices from equity postings. For every adjacent
 | |
| -- pair of conversion postings, it will first search the postings with
 | |
| -- transaction prices to see if any match. If so, it will tag it as matched.
 | |
| -- If no postings with transaction prices match, it will then search the
 | |
| -- postings without transaction prices, and will match the first such posting
 | |
| -- which matches one of the conversion amounts. If it finds a match, it will
 | |
| -- add a transaction price and then tag it.
 | |
| type IdxPosting = (Int, Posting)
 | |
| transactionAddPricesFromEquity :: M.Map AccountName AccountType -> Transaction -> Either String Transaction
 | |
| transactionAddPricesFromEquity acctTypes t = first (annotateErrorWithTransaction t . T.unpack) $ do
 | |
|     (conversionPairs, stateps) <- partitionPs npostings
 | |
|     f <- transformIndexedPostingsF addPricesToPostings conversionPairs stateps
 | |
|     return t{tpostings = map (snd . f) npostings}
 | |
|   where
 | |
|     -- Include indices for postings
 | |
|     npostings = zip [0..] $ tpostings t
 | |
|     transformIndexedPostingsF f = evalStateT . fmap (appEndo . foldMap Endo) . traverse f
 | |
| 
 | |
|     -- Sort postings into pairs of conversion postings, transaction price postings, and other postings
 | |
|     partitionPs = fmap fst . foldrM select (([], ([], [])), Nothing)
 | |
|     select np@(_, p) ((cs, others@(ps, os)), Nothing)
 | |
|       | isConversion p = Right ((cs, others),      Just np)
 | |
|       | hasPrice p     = Right ((cs, (np:ps, os)), Nothing)
 | |
|       | otherwise      = Right ((cs, (ps, np:os)), Nothing)
 | |
|     select np@(_, p) ((cs, others), Just lst)
 | |
|       | isConversion p = Right (((lst, np):cs, others), Nothing)
 | |
|       | otherwise      = Left "Conversion postings must occur in adjacent pairs"
 | |
| 
 | |
|     -- Given a pair of indexed conversion postings, and a state consisting of lists of
 | |
|     -- priced and unpriced non-conversion postings, create a function which adds transaction
 | |
|     -- prices to the posting which matches the conversion postings if necessary, and tags
 | |
|     -- the conversion and matched postings. Then update the state by removing the matched
 | |
|     -- postings. If there are no matching postings or too much ambiguity, return an error
 | |
|     -- string annotated with the conversion postings.
 | |
|     addPricesToPostings :: (IdxPosting, IdxPosting)
 | |
|                         -> StateT ([IdxPosting], [IdxPosting]) (Either Text) (IdxPosting -> IdxPosting)
 | |
|     addPricesToPostings ((n1, cp1), (n2, cp2)) = StateT $ \(priceps, otherps) -> do
 | |
|         -- Get the two conversion posting amounts, if possible
 | |
|         ca1 <- postingAmountNoPrice cp1
 | |
|         ca2 <- postingAmountNoPrice cp2
 | |
|         let -- The function to add transaction prices and tag postings in the indexed list of postings
 | |
|             transformPostingF np pricep (n,p) =
 | |
|               (n, if | n == np            -> pricep `postingAddTags` [("_price-matched","")]
 | |
|                      | n == n1 || n == n2 -> p      `postingAddTags` [("_conversion-matched","")]
 | |
|                      | otherwise          -> p)
 | |
|             -- All priced postings which match the conversion posting pair
 | |
|             matchingPricePs = mapMaybe (mapM $ pricedPostingIfMatchesBothAmounts ca1 ca2) priceps
 | |
|             -- All other postings which match at least one of the conversion posting pair
 | |
|             matchingOtherPs = mapMaybe (mapM $ addPriceIfMatchesOneAmount ca1 ca2) otherps
 | |
| 
 | |
|         -- Annotate any errors with the conversion posting pair
 | |
|         first (annotateWithPostings [cp1, cp2]) $
 | |
|             if -- If a single transaction price posting matches the conversion postings,
 | |
|                -- delete it from the list of priced postings in the state, delete the
 | |
|                -- first matching unpriced posting from the list of non-priced postings
 | |
|                -- in the state, and return the transformation function with the new state.
 | |
|                | [(np, (pricep, _))] <- matchingPricePs
 | |
|                , Just newpriceps <- deleteIdx np priceps
 | |
|                    -> Right (transformPostingF np pricep, (newpriceps, otherps))
 | |
|                -- If no transaction price postings match the conversion postings, but some
 | |
|                -- of the unpriced postings match, check that the first such posting has a
 | |
|                -- different amount from all the others, and if so add a transaction price to
 | |
|                -- it, then delete it from the list of non-priced postings in the state, and
 | |
|                -- return the transformation function with the new state.
 | |
|                | [] <- matchingPricePs
 | |
|                , (np, (pricep, amt)):nps <- matchingOtherPs
 | |
|                , not $ any (amountMatches amt . snd . snd) nps
 | |
|                , Just newotherps <- deleteIdx np otherps
 | |
|                    -> Right (transformPostingF np pricep, (priceps, newotherps))
 | |
|                -- Otherwise it's too ambiguous to make a guess, so return an error.
 | |
|                | otherwise -> Left "There is not a unique posting which matches the conversion posting pair:"
 | |
| 
 | |
|     -- If a posting with transaction price matches both the conversion amounts, return it along
 | |
|     -- with the matching amount which must be present in another non-conversion posting.
 | |
|     pricedPostingIfMatchesBothAmounts :: Amount -> Amount -> Posting -> Maybe (Posting, Amount)
 | |
|     pricedPostingIfMatchesBothAmounts a1 a2 p = do
 | |
|         a@Amount{aprice=Just _} <- postingSingleAmount p
 | |
|         if | amountMatches (-a1) a && amountMatches a2 (amountCost a) -> Just (p, -a2)
 | |
|            | amountMatches (-a2) a && amountMatches a1 (amountCost a) -> Just (p, -a1)
 | |
|            | otherwise -> Nothing
 | |
| 
 | |
|     -- Add a transaction price to a posting if it matches (negative) one of the
 | |
|     -- supplied conversion amounts, adding the other amount as the price
 | |
|     addPriceIfMatchesOneAmount :: Amount -> Amount -> Posting -> Maybe (Posting, Amount)
 | |
|     addPriceIfMatchesOneAmount a1 a2 p = do
 | |
|         a <- postingSingleAmount p
 | |
|         let newp price = p{pamount = mixedAmount a{aprice = Just $ TotalPrice price}}
 | |
|         if | amountMatches (-a1) a -> Just (newp a2, a2)
 | |
|            | amountMatches (-a2) a -> Just (newp a1, a1)
 | |
|            | otherwise             -> Nothing
 | |
| 
 | |
|     hasPrice p = isJust $ aprice =<< postingSingleAmount p
 | |
|     postingAmountNoPrice p = case postingSingleAmount p of
 | |
|         Just a@Amount{aprice=Nothing} -> Right a
 | |
|         _ -> Left $ annotateWithPostings [p] "The posting must only have a single amount with no transaction price"
 | |
|     postingSingleAmount p = case amountsRaw (pamount p) of
 | |
|         [a] -> Just a
 | |
|         _   -> Nothing
 | |
| 
 | |
|     amountMatches a b = acommodity a == acommodity b && aquantity a == aquantity b
 | |
|     isConversion p = M.lookup (paccount p) acctTypes == Just Conversion
 | |
| 
 | |
|     -- Delete a posting from the indexed list of postings based on either its
 | |
|     -- index or its posting amount.
 | |
|     -- Note: traversing the whole list to delete a single match is generally not efficient,
 | |
|     -- but given that a transaction probably doesn't have more than four postings, it should
 | |
|     -- still be more efficient than using a Map or another data structure. Even monster
 | |
|     -- transactions with up to 10 postings, which are generally not a good
 | |
|     -- idea, are still too small for there to be an advantage.
 | |
|     deleteIdx n = deleteUniqueMatch ((n==) . fst)
 | |
|     deleteUniqueMatch p (x:xs) | p x       = if any p xs then Nothing else Just xs
 | |
|                                | otherwise = (x:) <$> deleteUniqueMatch p xs
 | |
|     deleteUniqueMatch _ []                 = Nothing
 | |
|     annotateWithPostings xs str = T.unlines $ str : postingsAsLines False xs
 | |
| 
 | |
| -- | Apply some account aliases to all posting account names in the transaction, as described by accountNameApplyAliases.
 | |
| -- This can fail due to a bad replacement pattern in a regular expression alias.
 | |
| transactionApplyAliases :: [AccountAlias] -> Transaction -> Either RegexError Transaction
 | |
| transactionApplyAliases aliases t =
 | |
|   case mapM (postingApplyAliases aliases) $ tpostings t of
 | |
|     Right ps -> Right $ txnTieKnot $ t{tpostings=ps}
 | |
|     Left err -> Left err
 | |
| 
 | |
| -- | Apply a transformation to a transaction's postings.
 | |
| 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 :: (MixedAmount -> MixedAmount) -> Transaction -> Transaction
 | |
| transactionMapPostingAmounts f  = transactionMapPostings (postingTransformAmount f)
 | |
| 
 | |
| -- | The file path from which this transaction was parsed.
 | |
| transactionFile :: Transaction -> FilePath
 | |
| transactionFile Transaction{tsourcepos} = sourceName $ fst tsourcepos
 | |
| 
 | |
| -- Add transaction information to an error message.
 | |
| annotateErrorWithTransaction :: Transaction -> String -> String
 | |
| annotateErrorWithTransaction t s =
 | |
|   unlines [ sourcePosPairPretty $ tsourcepos t, s
 | |
|           , T.unpack . T.stripEnd $ showTransaction t
 | |
|           ]
 | |
| 
 | |
| -- tests
 | |
| 
 | |
| tests_Transaction :: TestTree
 | |
| tests_Transaction =
 | |
|   testGroup "Transaction" [
 | |
| 
 | |
|       testGroup "showPostingLines" [
 | |
|           testCase "null posting" $ showPostingLines nullposting @?= ["                   0"]
 | |
|         , testCase "non-null posting" $
 | |
|            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 showPostingLines p @?=
 | |
|               [ "    * a         $1.00  ; pcomment1"
 | |
|               , "    ; pcomment2"
 | |
|               , "    ;   tag3: val3  "
 | |
|               , "    * a         2.00h  ; pcomment1"
 | |
|               , "    ; pcomment2"
 | |
|               , "    ;   tag3: val3  "
 | |
|               ]
 | |
|         ]
 | |
| 
 | |
|     , let
 | |
|         -- one implicit amount
 | |
|         timp = nulltransaction {tpostings = ["a" `post` usd 1, "b" `post` missingamt]}
 | |
|         -- explicit amounts, balanced
 | |
|         texp = nulltransaction {tpostings = ["a" `post` usd 1, "b" `post` usd (-1)]}
 | |
|         -- explicit amount, only one posting
 | |
|         texp1 = nulltransaction {tpostings = ["(a)" `post` usd 1]}
 | |
|         -- explicit amounts, two commodities, explicit balancing price
 | |
|         texp2 = nulltransaction {tpostings = ["a" `post` usd 1, "b" `post` (hrs (-1) `at` usd 1)]}
 | |
|         -- explicit amounts, two commodities, implicit balancing price
 | |
|         texp2b = nulltransaction {tpostings = ["a" `post` usd 1, "b" `post` hrs (-1)]}
 | |
|         -- one missing amount, not the last one
 | |
|         t3 = nulltransaction {tpostings = ["a" `post` usd 1, "b" `post` missingamt, "c" `post` usd (-1)]}
 | |
|         -- unbalanced amounts when precision is limited (#931)
 | |
|         -- t4 = nulltransaction {tpostings = ["a" `post` usd (-0.01), "b" `post` usd (0.005), "c" `post` usd (0.005)]}
 | |
|       in testGroup "postingsAsLines" [
 | |
|               testCase "null-transaction" $ postingsAsLines False (tpostings nulltransaction) @?= []
 | |
|             , testCase "implicit-amount" $ postingsAsLines False (tpostings timp) @?=
 | |
|                   [ "    a           $1.00"
 | |
|                   , "    b" -- implicit amount remains implicit
 | |
|                   ]
 | |
|             , testCase "explicit-amounts" $ postingsAsLines False (tpostings texp) @?=
 | |
|                   [ "    a           $1.00"
 | |
|                   , "    b          $-1.00"
 | |
|                   ]
 | |
|             , testCase "one-explicit-amount" $ postingsAsLines False (tpostings texp1) @?=
 | |
|                   [ "    (a)           $1.00"
 | |
|                   ]
 | |
|             , testCase "explicit-amounts-two-commodities" $ postingsAsLines False (tpostings texp2) @?=
 | |
|                   [ "    a             $1.00"
 | |
|                   , "    b    -1.00h @ $1.00"
 | |
|                   ]
 | |
|             , testCase "explicit-amounts-not-explicitly-balanced" $ postingsAsLines False (tpostings texp2b) @?=
 | |
|                   [ "    a           $1.00"
 | |
|                   , "    b          -1.00h"
 | |
|                   ]
 | |
|             , testCase "implicit-amount-not-last" $ postingsAsLines False (tpostings t3) @?=
 | |
|                   ["    a           $1.00", "    b", "    c          $-1.00"]
 | |
|             -- , testCase "ensure-visibly-balanced" $
 | |
|             --    in postingsAsLines False (tpostings t4) @?=
 | |
|             --       ["    a          $-0.01", "    b           $0.005", "    c           $0.005"]
 | |
| 
 | |
|             ]
 | |
| 
 | |
|     , testGroup "showTransaction" [
 | |
|           testCase "null transaction" $ showTransaction nulltransaction @?= "0000-01-01\n\n"
 | |
|         , testCase "non-null transaction" $ showTransaction
 | |
|             nulltransaction
 | |
|               { tdate = fromGregorian 2012 05 14
 | |
|               , tdate2 = Just $ fromGregorian 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")]
 | |
|                       }
 | |
|                   ]
 | |
|               } @?=
 | |
|           T.unlines
 | |
|             [ "2012-05-14=2012-05-15 (code) desc  ; tcomment1"
 | |
|             , "    ; tcomment2"
 | |
|             , "    * a         $1.00"
 | |
|             , "    ; pcomment2"
 | |
|             , "    * a         2.00h"
 | |
|             , "    ; pcomment2"
 | |
|             , ""
 | |
|             ]
 | |
|         , testCase "show a balanced transaction" $
 | |
|           (let t =
 | |
|                  Transaction
 | |
|                    0
 | |
|                    ""
 | |
|                    nullsourcepos
 | |
|                    (fromGregorian 2007 01 28)
 | |
|                    Nothing
 | |
|                    Unmarked
 | |
|                    ""
 | |
|                    "coopportunity"
 | |
|                    ""
 | |
|                    []
 | |
|                    [ 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
 | |
|              [ "2007-01-28 coopportunity"
 | |
|              , "    expenses:food:groceries          $47.18"
 | |
|              , "    assets:checking                 $-47.18"
 | |
|              , ""
 | |
|              ])
 | |
|         , testCase "show an unbalanced transaction, should not elide" $
 | |
|           (showTransaction
 | |
|              (txnTieKnot $
 | |
|               Transaction
 | |
|                 0
 | |
|                 ""
 | |
|                 nullsourcepos
 | |
|                 (fromGregorian 2007 01 28)
 | |
|                 Nothing
 | |
|                 Unmarked
 | |
|                 ""
 | |
|                 "coopportunity"
 | |
|                 ""
 | |
|                 []
 | |
|                 [ 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"
 | |
|              , "    expenses:food:groceries          $47.18"
 | |
|              , "    assets:checking                 $-47.19"
 | |
|              , ""
 | |
|              ])
 | |
|         , testCase "show a transaction with one posting and a missing amount" $
 | |
|           (showTransaction
 | |
|              (txnTieKnot $
 | |
|               Transaction
 | |
|                 0
 | |
|                 ""
 | |
|                 nullsourcepos
 | |
|                 (fromGregorian 2007 01 28)
 | |
|                 Nothing
 | |
|                 Unmarked
 | |
|                 ""
 | |
|                 "coopportunity"
 | |
|                 ""
 | |
|                 []
 | |
|                 [posting {paccount = "expenses:food:groceries", pamount = missingmixedamt}])) @?=
 | |
|           (T.unlines ["2007-01-28 coopportunity", "    expenses:food:groceries", ""])
 | |
|         , testCase "show a transaction with a priced commodityless amount" $
 | |
|           (showTransaction
 | |
|              (txnTieKnot $
 | |
|               Transaction
 | |
|                 0
 | |
|                 ""
 | |
|                 nullsourcepos
 | |
|                 (fromGregorian 2010 01 01)
 | |
|                 Nothing
 | |
|                 Unmarked
 | |
|                 ""
 | |
|                 "x"
 | |
|                 ""
 | |
|                 []
 | |
|                 [ 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", ""])
 | |
|         ]
 | |
|     ]
 |