mirror of
https://github.com/simonmichael/hledger.git
synced 2024-12-28 21:02:04 +03:00
412 lines
21 KiB
Haskell
412 lines
21 KiB
Haskell
{-|
|
|
|
|
A 'Transaction' consists of two or more related 'Posting's which balance
|
|
to zero, representing a movement of some commodity(ies) between accounts,
|
|
plus a date and optional metadata like description and cleared status.
|
|
|
|
-}
|
|
|
|
module Hledger.Data.Transaction
|
|
where
|
|
import Data.List
|
|
import Data.Maybe
|
|
import Test.HUnit
|
|
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
|
|
import Hledger.Data.Commodity
|
|
|
|
instance Show Transaction where show = showTransactionUnelided
|
|
|
|
instance Show ModifierTransaction where
|
|
show t = "= " ++ mtvalueexpr t ++ "\n" ++ unlines (map show (mtpostings t))
|
|
|
|
instance Show PeriodicTransaction where
|
|
show t = "~ " ++ ptperiodicexpr t ++ "\n" ++ unlines (map show (ptpostings t))
|
|
|
|
nulltransaction :: Transaction
|
|
nulltransaction = Transaction {
|
|
tdate=nulldate,
|
|
teffectivedate=Nothing,
|
|
tstatus=False,
|
|
tcode="",
|
|
tdescription="",
|
|
tcomment="",
|
|
tmetadata=[],
|
|
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 = showTransaction' True False
|
|
|
|
showTransactionUnelided :: Transaction -> String
|
|
showTransactionUnelided = showTransaction' False False
|
|
|
|
showTransactionForPrint :: Bool -> Transaction -> String
|
|
showTransactionForPrint effective = showTransaction' False effective
|
|
|
|
showTransaction' :: Bool -> Bool -> Transaction -> String
|
|
showTransaction' elide effective t =
|
|
unlines $ [description] ++ showpostings (tpostings t) ++ [""]
|
|
where
|
|
description = concat [date, status, code, desc, comment]
|
|
date | effective = showdate $ fromMaybe (tdate t) $ teffectivedate t
|
|
| otherwise = showdate (tdate t) ++ maybe "" showedate (teffectivedate t)
|
|
status = if tstatus t then " *" else ""
|
|
code = if length (tcode t) > 0 then printf " (%s)" $ tcode t else ""
|
|
desc = if null d then "" else " " ++ d where d = tdescription t
|
|
comment = if null c then "" else " ; " ++ c where c = tcomment t
|
|
showdate = printf "%-10s" . showDate
|
|
showedate = printf "=%s" . showdate
|
|
showpostings ps
|
|
| elide && length ps > 1 && isTransactionBalanced Nothing t -- imprecise balanced check
|
|
= map showposting (init ps) ++ [showpostingnoamt (last ps)]
|
|
| otherwise = map showposting ps
|
|
where
|
|
showpostingnoamt p = rstrip $ showacct p ++ " " ++ showcomment (pcomment p)
|
|
showposting p = concatTopPadded [showacct p
|
|
," "
|
|
,showamt (pamount p)
|
|
,showcomment (pcomment p)
|
|
]
|
|
showacct p = " " ++ showstatus p ++ printf (printf "%%-%ds" w) (showAccountName Nothing (ptype p) (paccount p))
|
|
where w = maximum $ map (length . paccount) ps
|
|
showstatus p = if pstatus p then "* " else ""
|
|
showamt =
|
|
padleft 12 . showMixedAmountOrZero
|
|
showcomment s = if null s then "" else " ; "++s
|
|
|
|
-- | 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'
|
|
fmt VirtualPosting = parenthesise . reverse . take (w'-2) . reverse
|
|
fmt BalancedVirtualPosting = bracket . reverse . take (w'-2) . reverse
|
|
w' = fromMaybe 999999 w
|
|
parenthesise s = "("++s++")"
|
|
bracket s = "["++s++"]"
|
|
|
|
hasRealPostings :: Transaction -> Bool
|
|
hasRealPostings = not . null . realPostings
|
|
|
|
realPostings :: Transaction -> [Posting]
|
|
realPostings = filter isReal . 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 String Commodity) -> Transaction -> Bool
|
|
isTransactionBalanced canonicalcommoditymap t =
|
|
-- isReallyZeroMixedAmountCost rsum && isReallyZeroMixedAmountCost bvsum
|
|
isZeroMixedAmount rsum' && isZeroMixedAmount bvsum'
|
|
where
|
|
(rsum, _, bvsum) = transactionPostingBalances t
|
|
rsum' = canonicaliseMixedAmountCommodity canonicalcommoditymap $ costOfMixedAmount rsum
|
|
bvsum' = canonicaliseMixedAmountCommodity canonicalcommoditymap $ costOfMixedAmount bvsum
|
|
|
|
-- | Ensure this transaction is balanced, possibly inferring a missing
|
|
-- amount or a conversion price first, or return an error message.
|
|
--
|
|
-- Balancing is affected by the provided commodities' display precisions.
|
|
--
|
|
-- We can infer an amount when there are multiple real postings and
|
|
-- exactly one of them is amountless; likewise for balanced virtual
|
|
-- postings. Inferred amounts are converted to cost basis when possible.
|
|
--
|
|
-- We can infer a price when all amounts were specified and the sum of
|
|
-- real postings' amounts is exactly two non-explicitly-priced amounts in
|
|
-- different commodities; likewise for balanced virtual postings.
|
|
balanceTransaction :: Maybe (Map.Map String Commodity) -> Transaction -> Either String Transaction
|
|
balanceTransaction canonicalcommoditymap t@Transaction{tpostings=ps}
|
|
| length rwithoutamounts > 1 || length bvwithoutamounts > 1
|
|
= Left $ printerr "could not balance this transaction (too many missing amounts)"
|
|
| not $ isTransactionBalanced canonicalcommoditymap t''' = Left $ printerr $ nonzerobalanceerror t'''
|
|
| otherwise = Right t'''
|
|
where
|
|
-- maybe infer missing amounts
|
|
(rwithamounts, rwithoutamounts) = partition hasAmount $ realPostings t
|
|
(bvwithamounts, bvwithoutamounts) = partition hasAmount $ balancedVirtualPostings t
|
|
ramounts = map pamount rwithamounts
|
|
bvamounts = map pamount bvwithamounts
|
|
t' = t{tpostings=map inferamount ps}
|
|
where
|
|
inferamount p | not (hasAmount p) && isReal p = p{pamount = (- sum ramounts)}
|
|
| not (hasAmount p) && isBalancedVirtual p = p{pamount = (- sum bvamounts)}
|
|
| otherwise = p
|
|
|
|
-- maybe infer conversion prices, for real postings
|
|
rmixedamountsinorder = map pamount $ realPostings t'
|
|
ramountsinorder = concatMap amounts rmixedamountsinorder
|
|
rcommoditiesinorder = map commodity ramountsinorder
|
|
rsumamounts = amounts $ sum rmixedamountsinorder
|
|
-- assumption: the sum of mixed amounts is normalised (one simple amount per commodity)
|
|
t'' = if length rsumamounts == 2 && all (isNothing.price) rsumamounts && t'==t
|
|
then t'{tpostings=map inferprice ps}
|
|
else t'
|
|
where
|
|
-- assumption: a posting's mixed amount contains one simple amount
|
|
inferprice p@Posting{pamount=Mixed [a@Amount{commodity=c,price=Nothing}], ptype=RegularPosting}
|
|
= p{pamount=Mixed [a{price=conversionprice c}]}
|
|
where
|
|
conversionprice c | c == unpricedcommodity
|
|
-- assign a balancing price. Use @@ for more exact output when possible.
|
|
= if length ramountsinunpricedcommodity == 1
|
|
then Just $ TotalPrice $ Mixed [setAmountPrecision maxprecision $ negate $ targetcommodityamount]
|
|
else Just $ UnitPrice $ Mixed [setAmountPrecision maxprecision $ negate $ targetcommodityamount `divideAmount` (quantity unpricedamount)]
|
|
| otherwise = Nothing
|
|
where
|
|
unpricedcommodity = head $ filter (`elem` (map commodity rsumamounts)) rcommoditiesinorder
|
|
unpricedamount = head $ filter ((==unpricedcommodity).commodity) rsumamounts
|
|
targetcommodityamount = head $ filter ((/=unpricedcommodity).commodity) rsumamounts
|
|
ramountsinunpricedcommodity = filter ((==unpricedcommodity).commodity) ramountsinorder
|
|
inferprice p = p
|
|
|
|
-- maybe infer prices for balanced virtual postings. Just duplicates the above for now.
|
|
bvmixedamountsinorder = map pamount $ balancedVirtualPostings t''
|
|
bvamountsinorder = concatMap amounts bvmixedamountsinorder
|
|
bvcommoditiesinorder = map commodity bvamountsinorder
|
|
bvsumamounts = amounts $ sum bvmixedamountsinorder
|
|
t''' = if length bvsumamounts == 2 && all (isNothing.price) bvsumamounts && t'==t -- XXX could check specifically for bv amount inferring
|
|
then t''{tpostings=map inferprice ps}
|
|
else t''
|
|
where
|
|
inferprice p@Posting{pamount=Mixed [a@Amount{commodity=c,price=Nothing}], ptype=BalancedVirtualPosting}
|
|
= p{pamount=Mixed [a{price=conversionprice c}]}
|
|
where
|
|
conversionprice c | c == unpricedcommodity
|
|
= if length bvamountsinunpricedcommodity == 1
|
|
then Just $ TotalPrice $ Mixed [setAmountPrecision maxprecision $ negate $ targetcommodityamount]
|
|
else Just $ UnitPrice $ Mixed [setAmountPrecision maxprecision $ negate $ targetcommodityamount `divideAmount` (quantity unpricedamount)]
|
|
| otherwise = Nothing
|
|
where
|
|
unpricedcommodity = head $ filter (`elem` (map commodity bvsumamounts)) bvcommoditiesinorder
|
|
unpricedamount = head $ filter ((==unpricedcommodity).commodity) bvsumamounts
|
|
targetcommodityamount = head $ filter ((/=unpricedcommodity).commodity) bvsumamounts
|
|
bvamountsinunpricedcommodity = filter ((==unpricedcommodity).commodity) bvamountsinorder
|
|
inferprice p = p
|
|
|
|
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 " ++ show (costOfMixedAmount rsum)
|
|
bvmsg | isReallyZeroMixedAmountCost bvsum = ""
|
|
| otherwise = "balanced virtual postings are off by " ++ show (costOfMixedAmount bvsum)
|
|
sep = if not (null rmsg) && not (null bvmsg) then "; " else ""
|
|
|
|
-- | Convert the primary date to either the actual or effective date.
|
|
journalTransactionWithDate :: WhichDate -> Transaction -> Transaction
|
|
journalTransactionWithDate ActualDate t = t
|
|
journalTransactionWithDate EffectiveDate t = txnTieKnot t{tdate=fromMaybe (tdate t) (teffectivedate t)}
|
|
|
|
-- | Ensure a transaction's postings refer back to it.
|
|
txnTieKnot :: Transaction -> Transaction
|
|
txnTieKnot t@Transaction{tpostings=ps} = t{tpostings=map (settxn t) ps}
|
|
|
|
-- | Set a posting's parent transaction.
|
|
settxn :: Transaction -> Posting -> Posting
|
|
settxn t p = p{ptransaction=Just t}
|
|
|
|
tests_Hledger_Data_Transaction = TestList [
|
|
"showTransaction" ~: do
|
|
assertEqual "show a balanced transaction, eliding last amount"
|
|
(unlines
|
|
["2007/01/28 coopportunity"
|
|
," expenses:food:groceries $47.18"
|
|
," assets:checking"
|
|
,""
|
|
])
|
|
(let t = Transaction (parsedate "2007/01/28") Nothing False "" "coopportunity" "" []
|
|
[Posting False "expenses:food:groceries" (Mixed [dollars 47.18]) "" RegularPosting [] (Just t)
|
|
,Posting False "assets:checking" (Mixed [dollars (-47.18)]) "" RegularPosting [] (Just t)
|
|
] ""
|
|
in showTransaction t)
|
|
|
|
,"showTransaction" ~: do
|
|
assertEqual "show a balanced transaction, no eliding"
|
|
(unlines
|
|
["2007/01/28 coopportunity"
|
|
," expenses:food:groceries $47.18"
|
|
," assets:checking $-47.18"
|
|
,""
|
|
])
|
|
(let t = Transaction (parsedate "2007/01/28") Nothing False "" "coopportunity" "" []
|
|
[Posting False "expenses:food:groceries" (Mixed [dollars 47.18]) "" RegularPosting [] (Just t)
|
|
,Posting False "assets:checking" (Mixed [dollars (-47.18)]) "" RegularPosting [] (Just t)
|
|
] ""
|
|
in showTransactionUnelided t)
|
|
|
|
-- document some cases that arise in debug/testing:
|
|
,"showTransaction" ~: do
|
|
assertEqual "show an unbalanced transaction, should not elide"
|
|
(unlines
|
|
["2007/01/28 coopportunity"
|
|
," expenses:food:groceries $47.18"
|
|
," assets:checking $-47.19"
|
|
,""
|
|
])
|
|
(showTransaction
|
|
(txnTieKnot $ Transaction (parsedate "2007/01/28") Nothing False "" "coopportunity" "" []
|
|
[Posting False "expenses:food:groceries" (Mixed [dollars 47.18]) "" RegularPosting [] Nothing
|
|
,Posting False "assets:checking" (Mixed [dollars (-47.19)]) "" RegularPosting [] Nothing
|
|
] ""))
|
|
|
|
,"showTransaction" ~: do
|
|
assertEqual "show an unbalanced transaction with one posting, should not elide"
|
|
(unlines
|
|
["2007/01/28 coopportunity"
|
|
," expenses:food:groceries $47.18"
|
|
,""
|
|
])
|
|
(showTransaction
|
|
(txnTieKnot $ Transaction (parsedate "2007/01/28") Nothing False "" "coopportunity" "" []
|
|
[Posting False "expenses:food:groceries" (Mixed [dollars 47.18]) "" RegularPosting [] Nothing
|
|
] ""))
|
|
|
|
,"showTransaction" ~: do
|
|
assertEqual "show a transaction with one posting and a missing amount"
|
|
(unlines
|
|
["2007/01/28 coopportunity"
|
|
," expenses:food:groceries "
|
|
,""
|
|
])
|
|
(showTransaction
|
|
(txnTieKnot $ Transaction (parsedate "2007/01/28") Nothing False "" "coopportunity" "" []
|
|
[Posting False "expenses:food:groceries" missingamt "" RegularPosting [] Nothing
|
|
] ""))
|
|
|
|
,"showTransaction" ~: do
|
|
assertEqual "show a transaction with a priced commodityless amount"
|
|
(unlines
|
|
["2010/01/01 x"
|
|
," a 1 @ $2"
|
|
," b "
|
|
,""
|
|
])
|
|
(showTransaction
|
|
(txnTieKnot $ Transaction (parsedate "2010/01/01") Nothing False "" "x" "" []
|
|
[Posting False "a" (Mixed [Amount unknown 1 (Just $ UnitPrice $ Mixed [Amount dollar{precision=0} 2 Nothing])]) "" RegularPosting [] Nothing
|
|
,Posting False "b" missingamt "" RegularPosting [] Nothing
|
|
] ""))
|
|
|
|
,"balanceTransaction" ~: do
|
|
assertBool "detect unbalanced entry, sign error"
|
|
(isLeft $ balanceTransaction Nothing
|
|
(Transaction (parsedate "2007/01/28") Nothing False "" "test" "" []
|
|
[Posting False "a" (Mixed [dollars 1]) "" RegularPosting [] Nothing,
|
|
Posting False "b" (Mixed [dollars 1]) "" RegularPosting [] Nothing
|
|
] ""))
|
|
assertBool "detect unbalanced entry, multiple missing amounts"
|
|
(isLeft $ balanceTransaction Nothing
|
|
(Transaction (parsedate "2007/01/28") Nothing False "" "test" "" []
|
|
[Posting False "a" missingamt "" RegularPosting [] Nothing,
|
|
Posting False "b" missingamt "" RegularPosting [] Nothing
|
|
] ""))
|
|
let e = balanceTransaction Nothing (Transaction (parsedate "2007/01/28") Nothing False "" "" "" []
|
|
[Posting False "a" (Mixed [dollars 1]) "" RegularPosting [] Nothing,
|
|
Posting False "b" missingamt "" RegularPosting [] Nothing
|
|
] "")
|
|
assertBool "balanceTransaction allows one missing amount" (isRight e)
|
|
assertEqual "balancing amount is inferred"
|
|
(Mixed [dollars (-1)])
|
|
(case e of
|
|
Right e' -> (pamount $ last $ tpostings e')
|
|
Left _ -> error' "should not happen")
|
|
let e = balanceTransaction Nothing (Transaction (parsedate "2011/01/01") Nothing False "" "" "" []
|
|
[Posting False "a" (Mixed [dollars 1.35]) "" RegularPosting [] Nothing,
|
|
Posting False "b" (Mixed [euros (-1)]) "" RegularPosting [] Nothing
|
|
] "")
|
|
assertBool "balanceTransaction can infer conversion price" (isRight e)
|
|
assertEqual "balancing conversion price is inferred"
|
|
(Mixed [Amount{commodity=dollar{precision=2},
|
|
quantity=1.35,
|
|
price=(Just $ TotalPrice $ Mixed [Amount{commodity=euro{precision=maxprecision},
|
|
quantity=1,
|
|
price=Nothing}])}])
|
|
(case e of
|
|
Right e' -> (pamount $ head $ tpostings e')
|
|
Left _ -> error' "should not happen")
|
|
|
|
,"isTransactionBalanced" ~: do
|
|
let t = Transaction (parsedate "2009/01/01") Nothing False "" "a" "" []
|
|
[Posting False "b" (Mixed [dollars 1.00]) "" RegularPosting [] (Just t)
|
|
,Posting False "c" (Mixed [dollars (-1.00)]) "" RegularPosting [] (Just t)
|
|
] ""
|
|
assertBool "detect balanced" (isTransactionBalanced Nothing t)
|
|
let t = Transaction (parsedate "2009/01/01") Nothing False "" "a" "" []
|
|
[Posting False "b" (Mixed [dollars 1.00]) "" RegularPosting [] (Just t)
|
|
,Posting False "c" (Mixed [dollars (-1.01)]) "" RegularPosting [] (Just t)
|
|
] ""
|
|
assertBool "detect unbalanced" (not $ isTransactionBalanced Nothing t)
|
|
let t = Transaction (parsedate "2009/01/01") Nothing False "" "a" "" []
|
|
[Posting False "b" (Mixed [dollars 1.00]) "" RegularPosting [] (Just t)
|
|
] ""
|
|
assertBool "detect unbalanced, one posting" (not $ isTransactionBalanced Nothing t)
|
|
let t = Transaction (parsedate "2009/01/01") Nothing False "" "a" "" []
|
|
[Posting False "b" (Mixed [dollars 0]) "" RegularPosting [] (Just t)
|
|
] ""
|
|
assertBool "one zero posting is considered balanced for now" (isTransactionBalanced Nothing t)
|
|
let t = Transaction (parsedate "2009/01/01") Nothing False "" "a" "" []
|
|
[Posting False "b" (Mixed [dollars 1.00]) "" RegularPosting [] (Just t)
|
|
,Posting False "c" (Mixed [dollars (-1.00)]) "" RegularPosting [] (Just t)
|
|
,Posting False "d" (Mixed [dollars 100]) "" VirtualPosting [] (Just t)
|
|
] ""
|
|
assertBool "virtual postings don't need to balance" (isTransactionBalanced Nothing t)
|
|
let t = Transaction (parsedate "2009/01/01") Nothing False "" "a" "" []
|
|
[Posting False "b" (Mixed [dollars 1.00]) "" RegularPosting [] (Just t)
|
|
,Posting False "c" (Mixed [dollars (-1.00)]) "" RegularPosting [] (Just t)
|
|
,Posting False "d" (Mixed [dollars 100]) "" BalancedVirtualPosting [] (Just t)
|
|
] ""
|
|
assertBool "balanced virtual postings need to balance among themselves" (not $ isTransactionBalanced Nothing t)
|
|
let t = Transaction (parsedate "2009/01/01") Nothing False "" "a" "" []
|
|
[Posting False "b" (Mixed [dollars 1.00]) "" RegularPosting [] (Just t)
|
|
,Posting False "c" (Mixed [dollars (-1.00)]) "" RegularPosting [] (Just t)
|
|
,Posting False "d" (Mixed [dollars 100]) "" BalancedVirtualPosting [] (Just t)
|
|
,Posting False "e" (Mixed [dollars (-100)]) "" BalancedVirtualPosting [] (Just t)
|
|
] ""
|
|
assertBool "balanced virtual postings need to balance among themselves (2)" (isTransactionBalanced Nothing t)
|
|
|
|
]
|