packages feed

hledger-lib 0.13 → 0.14

raw patch · 19 files changed

+927/−301 lines, 19 filesdep +bytestringPVP ok

version bump matches the API change (PVP)

Dependencies added: bytestring

API changes (from Hackage documentation)

- Hledger.Data.Amount: amountop :: (Double -> Double -> Double) -> Amount -> Amount -> Amount
- Hledger.Data.Amount: amountopPreservingHighestPrecision :: (Double -> Double -> Double) -> Amount -> Amount -> Amount
- Hledger.Data.Amount: convertAmountTo :: Commodity -> Amount -> Amount
- Hledger.Data.Amount: convertMixedAmountTo :: Commodity -> MixedAmount -> Amount
- Hledger.Data.Amount: instance Ord Amount
- Hledger.Data.Amount: instance Ord MixedAmount
- Hledger.Data.Amount: isNegativeAmount :: Amount -> Bool
- Hledger.Data.Amount: isReallyZeroAmount :: Amount -> Bool
- Hledger.Data.Amount: isReallyZeroMixedAmount :: MixedAmount -> Bool
- Hledger.Data.Amount: isZeroAmount :: Amount -> Bool
- Hledger.Data.Amount: mixedAmountEquals :: MixedAmount -> MixedAmount -> Bool
- Hledger.Data.Amount: normaliseMixedAmountIgnoringPrice :: MixedAmount -> MixedAmount
- Hledger.Data.Amount: normaliseMixedAmountPreservingHighestPrecision :: MixedAmount -> MixedAmount
- Hledger.Data.Amount: showAmount :: Amount -> String
- Hledger.Data.Amount: showAmount' :: Amount -> String
- Hledger.Data.Amount: showAmountWithPrecision :: Int -> Amount -> String
- Hledger.Data.Amount: showAmountWithoutPrice :: Amount -> String
- Hledger.Data.Amount: showAmountWithoutPriceOrCommodity :: Amount -> String
- Hledger.Data.Amount: sumAmountsPreservingHighestPrecision :: [Amount] -> Amount
- Hledger.Data.Dates: periodexprinterval :: GenParser Char st Interval
- Hledger.Data.Types: Daily :: Interval
- Hledger.Data.Types: Monthly :: Interval
- Hledger.Data.Types: Quarterly :: Interval
- Hledger.Data.Types: Weekly :: Interval
- Hledger.Data.Types: Yearly :: Interval
- Hledger.Data.Types: comma :: Commodity -> Bool
- Hledger.Read.JournalReader: tests_JournalReader :: Test
- Hledger.Read.TimelogReader: tests_TimelogReader :: Test
+ Hledger.Data.Amount: convertMixedAmountToSimilarCommodity :: Commodity -> MixedAmount -> Amount
+ Hledger.Data.Amount: divideAmount :: Amount -> Double -> Amount
+ Hledger.Data.Amount: divideMixedAmount :: MixedAmount -> Double -> MixedAmount
+ Hledger.Data.Amount: maxprecision :: Int
+ Hledger.Data.Amount: maxprecisionwithpoint :: Int
+ Hledger.Data.Amount: setAmountPrecision :: Int -> Amount -> Amount
+ Hledger.Data.Amount: tests_Hledger_Data_Amount :: Test
+ Hledger.Data.Dates: getCurrentYear :: IO Integer
+ Hledger.Data.Dates: reportinginterval :: GenParser Char st Interval
+ Hledger.Data.Types: DayOfMonth :: Int -> Interval
+ Hledger.Data.Types: DayOfWeek :: Int -> Interval
+ Hledger.Data.Types: Days :: Int -> Interval
+ Hledger.Data.Types: Months :: Int -> Interval
+ Hledger.Data.Types: Quarters :: Int -> Interval
+ Hledger.Data.Types: TotalPrice :: MixedAmount -> Price
+ Hledger.Data.Types: UnitPrice :: MixedAmount -> Price
+ Hledger.Data.Types: Weeks :: Int -> Interval
+ Hledger.Data.Types: Years :: Int -> Interval
+ Hledger.Data.Types: data Price
+ Hledger.Data.Types: decimalpoint :: Commodity -> Char
+ Hledger.Data.Types: instance Eq Price
+ Hledger.Data.Types: instance Ord Amount
+ Hledger.Data.Types: instance Ord MixedAmount
+ Hledger.Data.Types: instance Ord Price
+ Hledger.Data.Types: separator :: Commodity -> Char
+ Hledger.Data.Types: separatorpositions :: Commodity -> [Int]
+ Hledger.Data.UTF8: appendFile :: FilePath -> String -> IO ()
+ Hledger.Data.UTF8: getContents :: IO String
+ Hledger.Data.UTF8: hGetContents :: Handle -> IO String
+ Hledger.Data.UTF8: hPutStr :: Handle -> String -> IO ()
+ Hledger.Data.UTF8: hPutStrLn :: Handle -> String -> IO ()
+ Hledger.Data.UTF8: putStr :: String -> IO ()
+ Hledger.Data.UTF8: putStrLn :: String -> IO ()
+ Hledger.Data.UTF8: readFile :: FilePath -> IO String
+ Hledger.Data.UTF8: writeFile :: FilePath -> String -> IO ()
+ Hledger.Data.Utils: applyN :: Int -> (a -> a) -> a -> a
+ Hledger.Read.JournalReader: tests_Hledger_Read_JournalReader :: Test
+ Hledger.Read.TimelogReader: tests_Hledger_Read_TimelogReader :: Test
- Hledger.Data.Types: Amount :: Commodity -> Double -> Maybe MixedAmount -> Amount
+ Hledger.Data.Types: Amount :: Commodity -> Double -> Maybe Price -> Amount
- Hledger.Data.Types: Commodity :: String -> Side -> Bool -> Bool -> Int -> Commodity
+ Hledger.Data.Types: Commodity :: String -> Side -> Bool -> Int -> Char -> Char -> [Int] -> Commodity
- Hledger.Data.Types: price :: Amount -> Maybe MixedAmount
+ Hledger.Data.Types: price :: Amount -> Maybe Price

Files

Hledger/Data.hs view
@@ -2,7 +2,8 @@  The Hledger.Data library allows parsing and querying of C++ ledger-style journal files.  It generally provides a compatible subset of C++ ledger's-functionality.  This package re-exports all the Hledger.Data.* modules.+functionality.  This package re-exports all the Hledger.Data.* modules+(except UTF8, which requires an explicit import.)  -} @@ -37,16 +38,16 @@  tests_Hledger_Data = TestList     [-    --  Hledger.Data.Account.tests_Account-    -- ,Hledger.Data.AccountName.tests_AccountName-     Hledger.Data.Amount.tests_Amount-    -- ,Hledger.Data.Commodity.tests_Commodity-    ,Hledger.Data.Dates.tests_Dates-    ,Hledger.Data.Transaction.tests_Transaction-    -- ,Hledger.Data.Hledger.Data.tests_Hledger.Data-    -- ,Hledger.Data.Journal.tests_Journal-    -- ,Hledger.Data.Posting.tests_Posting-    ,Hledger.Data.TimeLog.tests_TimeLog-    -- ,Hledger.Data.Types.tests_Types-    -- ,Hledger.Data.Utils.tests_Utils+     tests_Hledger_Data_Account+    ,tests_Hledger_Data_AccountName+    ,tests_Hledger_Data_Amount+    ,tests_Hledger_Data_Commodity+    ,tests_Hledger_Data_Dates+    ,tests_Hledger_Data_Journal+    ,tests_Hledger_Data_Ledger+    ,tests_Hledger_Data_Posting+    ,tests_Hledger_Data_TimeLog+    ,tests_Hledger_Data_Transaction+    -- ,tests_Hledger_Data_Types+    -- ,tests_Hledger_Data_Utils     ]
Hledger/Data/Account.hs view
@@ -25,3 +25,6 @@  nullacct = Account "" [] nullmixedamt +tests_Hledger_Data_Account = TestList [+ ]+
Hledger/Data/AccountName.hs view
@@ -173,3 +173,29 @@ clipAccountName :: Int -> AccountName -> AccountName clipAccountName n = accountNameFromComponents . take n . accountNameComponents +tests_Hledger_Data_AccountName = TestList+ [+  "accountNameTreeFrom" ~: do+    accountNameTreeFrom ["a"]       `is` Node "top" [Node "a" []]+    accountNameTreeFrom ["a","b"]   `is` Node "top" [Node "a" [], Node "b" []]+    accountNameTreeFrom ["a","a:b"] `is` Node "top" [Node "a" [Node "a:b" []]]+    accountNameTreeFrom ["a:b:c"]   `is` Node "top" [Node "a" [Node "a:b" [Node "a:b:c" []]]]++  ,"expandAccountNames" ~:+    expandAccountNames ["assets:cash","assets:checking","expenses:vacation"] `is`+     ["assets","assets:cash","assets:checking","expenses","expenses:vacation"]++  ,"isAccountNamePrefixOf" ~: do+    "assets" `isAccountNamePrefixOf` "assets" `is` False+    "assets" `isAccountNamePrefixOf` "assets:bank" `is` True+    "assets" `isAccountNamePrefixOf` "assets:bank:checking" `is` True+    "my assets" `isAccountNamePrefixOf` "assets:bank" `is` False++  ,"isSubAccountNameOf" ~: do+    "assets" `isSubAccountNameOf` "assets" `is` False+    "assets:bank" `isSubAccountNameOf` "assets" `is` True+    "assets:bank:checking" `isSubAccountNameOf` "assets" `is` False+    "assets:bank" `isSubAccountNameOf` "my assets" `is` False++ ]+
Hledger/Data/Amount.hs view
@@ -38,7 +38,40 @@  -} -module Hledger.Data.Amount+-- XXX due for review/rewrite++module Hledger.Data.Amount (+                            amounts,+                            canonicaliseAmount,+                            canonicaliseMixedAmount,+                            convertMixedAmountToSimilarCommodity,+                            costOfAmount,+                            costOfMixedAmount,+                            divideAmount,+                            divideMixedAmount,+                            isNegativeMixedAmount,+                            isReallyZeroMixedAmountCost,+                            isZeroMixedAmount,+                            maxprecision,+                            maxprecisionwithpoint,+                            missingamt,+                            normaliseMixedAmount,+                            nullamt,+                            nullmixedamt,+                            punctuatethousands,+                            setAmountPrecision,+                            setMixedAmountPrecision,+                            showAmountDebug,+                            showMixedAmount,+                            showMixedAmountDebug,+                            showMixedAmountOrZero,+                            showMixedAmountOrZeroWithoutPrice,+                            showMixedAmountWithoutPrice,+                            showMixedAmountWithPrecision,+                            sumMixedAmountsPreservingHighestPrecision,+                            tests_Hledger_Data_Amount+                            -- Hledger.Data.Amount.tests_Hledger_Data_Amount+                           ) where import qualified Data.Map as Map import Data.Map (findWithDefault)@@ -56,67 +89,49 @@     abs (Amount c q p) = Amount c (abs q) p     signum (Amount c q p) = Amount c (signum q) p     fromInteger i = Amount (comm "") (fromInteger i) Nothing-    (+) = amountop (+)-    (-) = amountop (-)-    (*) = amountop (*)--instance Ord Amount where-    compare (Amount ac aq ap) (Amount bc bq bp) = compare (ac,aq,ap) (bc,bq,bp)+    (+) = similarAmountsOp (+)+    (-) = similarAmountsOp (-)+    (*) = similarAmountsOp (*)  instance Num MixedAmount where     fromInteger i = Mixed [Amount (comm "") (fromInteger i) Nothing]     negate (Mixed as) = Mixed $ map negateAmountPreservingPrice as+        where negateAmountPreservingPrice a = (-a){price=price a}     (+) (Mixed as) (Mixed bs) = normaliseMixedAmount $ Mixed $ as ++ bs     (*)    = error' "programming error, mixed amounts do not support multiplication"     abs    = error' "programming error, mixed amounts do not support abs"     signum = error' "programming error, mixed amounts do not support signum" -instance Ord MixedAmount where-    compare (Mixed as) (Mixed bs) = compare as bs--negateAmountPreservingPrice a = (-a){price=price a}---- | Apply a binary arithmetic operator to two amounts, converting to the--- second one's commodity (and display precision), discarding any price--- information. (Using the second commodity is best since sum and other--- folds start with a no-commodity amount.)-amountop :: (Double -> Double -> Double) -> Amount -> Amount -> Amount-amountop op a@(Amount _ _ _) (Amount bc bq _) = -    Amount bc (quantity (convertAmountTo bc a) `op` bq) Nothing+-- | Apply a binary arithmetic operator to two amounts, after converting+-- the first to the commodity (and display precision) of the second in a+-- simplistic way. This should be used only for two amounts in the same+-- commodity, since the conversion rate is assumed to be 1.+-- NB preserving the second commodity is preferred since sum and other+-- folds start with the no-commodity zero amount.+similarAmountsOp :: (Double -> Double -> Double) -> Amount -> Amount -> Amount+similarAmountsOp op a (Amount bc bq _) =+    Amount bc (quantity (convertAmountToSimilarCommodity bc a) `op` bq) Nothing --- | Convert an amount to the specified commodity using the appropriate--- exchange rate (which is currently always 1).-convertAmountTo :: Commodity -> Amount -> Amount-convertAmountTo c2 (Amount c1 q _) = Amount c2 (q * conversionRate c1 c2) Nothing+-- | Convert an amount to the specified commodity, assuming an exchange rate of 1.+convertAmountToSimilarCommodity :: Commodity -> Amount -> Amount+convertAmountToSimilarCommodity c (Amount _ q _) = Amount c q Nothing --- | Convert mixed amount to the specified commodity-convertMixedAmountTo :: Commodity -> MixedAmount -> Amount-convertMixedAmountTo c2 (Mixed ams) = Amount c2 total Nothing+-- | Convert a mixed amount to the specified commodity, assuming an exchange rate of 1.+convertMixedAmountToSimilarCommodity :: Commodity -> MixedAmount -> Amount+convertMixedAmountToSimilarCommodity c (Mixed as) = Amount c total Nothing     where-    total = sum . map (quantity . convertAmountTo c2) $ ams+      total = sum $ map (quantity . convertAmountToSimilarCommodity c) as --- | Convert an amount to the commodity of its saved price, if any.+-- | Convert an amount to the commodity of its saved price, if any.  Notes:+-- - price amounts must be MixedAmounts with exactly one component Amount (or there will be a runtime error)+-- - price amounts should be positive, though this is not currently enforced costOfAmount :: Amount -> Amount-costOfAmount a@(Amount _ _ Nothing) = a-costOfAmount (Amount _ q (Just price))-    | isZeroMixedAmount price = nullamt-    | otherwise = Amount pc (pq*q) Nothing-    where (Amount pc pq _) = head $ amounts price---- | Get the string representation of an amount, based on its commodity's--- display settings.-showAmount :: Amount -> String-showAmount (Amount (Commodity {symbol="AUTO"}) _ _) = "" -- can appear in an error message-showAmount a@(Amount (Commodity {symbol=sym,side=side,spaced=spaced}) _ pri) =-    case side of-      L -> printf "%s%s%s%s" sym' space quantity price-      R -> printf "%s%s%s%s" quantity space sym' price-    where-      sym' = quoteCommoditySymbolIfNeeded sym-      space = if (spaced && not (null sym')) then " " else ""-      quantity = showAmount' a-      price = case pri of (Just pamt) -> " @ " ++ showMixedAmount pamt-                          Nothing -> ""+costOfAmount a@(Amount _ q price) =+    case price of+      Nothing -> a+      Just (UnitPrice  (Mixed [Amount pc pq Nothing])) -> Amount pc (pq*q) Nothing+      Just (TotalPrice (Mixed [Amount pc pq Nothing])) -> Amount pc (pq*signum q) Nothing+      _ -> error' "costOfAmount: Malformed price encountered, programmer error"  -- | Get the string representation of an amount, based on its commodity's -- display settings except using the specified precision.@@ -129,7 +144,7 @@ -- | Get the unambiguous string representation of an amount, for debugging. showAmountDebug :: Amount -> String showAmountDebug (Amount c q pri) = printf "Amount {commodity = %s, quantity = %s, price = %s}"-                                   (show c) (show q) (maybe "" showMixedAmountDebug pri)+                                   (show c) (show q) (maybe "" showPriceDebug pri)  -- | Get the string representation of an amount, without any \@ price. showAmountWithoutPrice :: Amount -> String@@ -139,19 +154,69 @@ showAmountWithoutPriceOrCommodity :: Amount -> String showAmountWithoutPriceOrCommodity a@Amount{commodity=c} = showAmount a{commodity=c{symbol=""}, price=Nothing} +showPrice :: Price -> String+showPrice (UnitPrice pa)  = " @ "  ++ showMixedAmount pa+showPrice (TotalPrice pa) = " @@ " ++ showMixedAmount pa++showPriceDebug :: Price -> String+showPriceDebug (UnitPrice pa)  = " @ "  ++ showMixedAmountDebug pa+showPriceDebug (TotalPrice pa) = " @@ " ++ showMixedAmountDebug pa++-- | Get the string representation of an amount, based on its commodity's+-- display settings. Amounts which look like zero are rendered without sign or commodity.+showAmount :: Amount -> String+showAmount (Amount (Commodity {symbol="AUTO"}) _ _) = "" -- can appear in an error message+showAmount a@(Amount (Commodity {symbol=sym,side=side,spaced=spaced}) _ pri) =+    case side of+      L -> printf "%s%s%s%s" sym' space quantity' price+      R -> printf "%s%s%s%s" quantity' space sym' price+    where+      quantity = showamountquantity a+      displayingzero = null $ filter (`elem` "123456789") $ quantity+      (quantity',sym') | displayingzero = ("0","")+                       | otherwise      = (quantity,quoteCommoditySymbolIfNeeded sym)+      space = if (not (null sym') && spaced) then " " else ""+      price = maybe "" showPrice pri+ -- | Get the string representation of the number part of of an amount,--- using the display precision from its commodity.-showAmount' :: Amount -> String-showAmount' (Amount (Commodity {comma=comma,precision=p}) q _) = addthousandsseparators $ qstr-  where-    addthousandsseparators = if comma then punctuatethousands else id-    qstr | p == maxprecision && isint q = printf "%d" (round q::Integer)-         | p == maxprecision            = printf "%f" q+-- using the display settings from its commodity.+showamountquantity :: Amount -> String+showamountquantity (Amount (Commodity {decimalpoint=d,precision=p,separator=s,separatorpositions=spos}) q _) =+    punctuatenumber d s spos $ qstr+    where+    -- isint n = fromIntegral (round n) == n+    qstr -- p == maxprecision && isint q = printf "%d" (round q::Integer)+         | p == maxprecisionwithpoint    = printf "%f" q+         | p == maxprecision             = chopdotzero $ printf "%f" q          | otherwise                    = printf ("%."++show p++"f") q-    isint n = fromIntegral (round n) == n -maxprecision = 999999+chopdotzero str = reverse $ case reverse str of+                              '0':'.':s -> s+                              s         -> s +-- | A special precision value meaning show all available digits.+maxprecision = 999998+-- | Similar, forces display of a decimal point.+maxprecisionwithpoint = 999999++-- | Replace a number string's decimal point with the specified character,+-- and add the specified digit group separators.+punctuatenumber :: Char -> Char -> [Int] -> String -> String+punctuatenumber dec sep grps str = sign ++ reverse (addseps sep (extend grps) (reverse int)) ++ frac''+    where+      (sign,num) = break isDigit str+      (int,frac) = break (=='.') num+      frac' = dropWhile (=='.') frac+      frac'' | null frac' = ""+             | otherwise  = dec:frac'+      extend [] = []+      extend gs = init gs ++ repeat (last gs)+      addseps _ [] str = str+      addseps sep (g:gs) str+          | length str <= g = str+          | otherwise = let (s,rest) = splitAt g str+                        in s ++ [sep] ++ addseps sep gs rest+ -- | Add thousands-separating commas to a decimal number string punctuatethousands :: String -> String punctuatethousands s =@@ -181,8 +246,7 @@ amounts :: MixedAmount -> [Amount] amounts (Mixed as) = as --- | Does this mixed amount appear to be zero - empty, or--- containing only simple amounts which appear to be zero ?+-- | Does this mixed amount appear to be zero when displayed with its given precision ? isZeroMixedAmount :: MixedAmount -> Bool isZeroMixedAmount = all isZeroAmount . amounts . normaliseMixedAmount @@ -202,14 +266,14 @@ isReallyZeroMixedAmountCost :: MixedAmount -> Bool isReallyZeroMixedAmountCost = isReallyZeroMixedAmount . costOfMixedAmount --- | MixedAmount derives Eq in Types.hs, but that doesn't know that we--- want $0 = EUR0 = 0. Yet we don't want to drag all this code in there.--- When zero equality is important, use this, for now; should be used--- everywhere.-mixedAmountEquals :: MixedAmount -> MixedAmount -> Bool-mixedAmountEquals a b = amounts a' == amounts b' || (isZeroMixedAmount a' && isZeroMixedAmount b')-    where a' = normaliseMixedAmount a-          b' = normaliseMixedAmount b+-- -- | MixedAmount derives Eq in Types.hs, but that doesn't know that we+-- -- want $0 = EUR0 = 0. Yet we don't want to drag all this code in there.+-- -- When zero equality is important, use this, for now; should be used+-- -- everywhere.+-- mixedAmountEquals :: MixedAmount -> MixedAmount -> Bool+-- mixedAmountEquals a b = amounts a' == amounts b' || (isZeroMixedAmount a' && isZeroMixedAmount b')+--     where a' = normaliseMixedAmount a+--           b' = normaliseMixedAmount b  -- | Get the string representation of a mixed amount, showing each of -- its component amounts. NB a mixed amount can have an empty amounts@@ -256,21 +320,19 @@     | isZeroMixedAmount a = "0"     | otherwise = showMixedAmountWithoutPrice a --- | Simplify a mixed amount by combining any component amounts which have--- the same commodity and the same price. Also removes zero amounts,--- or adds a single zero amount if there are no amounts at all.+-- | Simplify a mixed amount by removing redundancy in its component amounts, as follows:+-- 1. sum amounts which have the same commodity (ignoring their price)+-- 2. remove zero amounts+-- 3. if there are no amounts at all, add a single zero amount normaliseMixedAmount :: MixedAmount -> MixedAmount normaliseMixedAmount (Mixed as) = Mixed as''     where -      as'' = map sumSamePricedAmountsPreservingPrice $ group $ sort as'-      sort = sortBy cmpsymbolandprice-      cmpsymbolandprice a1 a2 = compare (sym a1,price a1) (sym a2,price a2)-      group = groupBy samesymbolandprice -      samesymbolandprice a1 a2 = (sym a1 == sym a2) && (price a1 == price a2)+      as'' = if null nonzeros then [nullamt] else nonzeros+      (_,nonzeros) = partition (\a -> isReallyZeroAmount a && Mixed [a] /= missingamt) as'+      as' = map sumSamePricedAmountsPreservingPrice $ group $ sort as+      sort = sortBy (\a1 a2 -> compare (sym a1) (sym a2))+      group = groupBy (\a1 a2 -> sym a1 == sym a2)       sym = symbol . commodity-      as' | null nonzeros = [head $ zeros ++ [nullamt]]-          | otherwise = nonzeros-      (zeros,nonzeros) = partition isReallyZeroAmount as  -- | Set a mixed amount's commodity to the canonicalised commodity from -- the provided commodity map.@@ -338,7 +400,7 @@ amountopPreservingHighestPrecision op a@(Amount ac@Commodity{precision=ap} _ _) (Amount bc@Commodity{precision=bp} bq _) =      Amount c q Nothing     where-      q = quantity (convertAmountTo bc a) `op` bq+      q = quantity (convertAmountToSimilarCommodity bc a) `op` bq       c = if ap > bp then ac else bc -- @@ -347,6 +409,14 @@ costOfMixedAmount :: MixedAmount -> MixedAmount costOfMixedAmount (Mixed as) = Mixed $ map costOfAmount as +-- | Divide a mixed amount's quantities by some constant.+divideMixedAmount :: MixedAmount -> Double -> MixedAmount+divideMixedAmount (Mixed as) d = Mixed $ map (flip divideAmount d) as++-- | Divide an amount's quantity by some constant.+divideAmount :: Amount -> Double -> Amount+divideAmount a@Amount{quantity=q} d = a{quantity=q/d}+ -- | The empty simple amount. nullamt :: Amount nullamt = Amount unknown 0 Nothing@@ -357,13 +427,16 @@  -- | A temporary value for parsed transactions which had no amount specified. missingamt :: MixedAmount-missingamt = Mixed [Amount Commodity {symbol="AUTO",side=L,spaced=False,comma=False,precision=0} 0 Nothing]+missingamt = Mixed [Amount unknown{symbol="AUTO"} 0 Nothing]  -tests_Amount = TestList [+tests_Hledger_Data_Amount = TestList [ -   "showMixedAmount" ~: do-     showMixedAmount (Mixed [Amount dollar 0 Nothing]) `is` "$0.00"+   "showAmount" ~: do+     showAmount (dollars 0 + pounds 0) `is` "0"++  ,"showMixedAmount" ~: do+     showMixedAmount (Mixed [Amount dollar 0 Nothing]) `is` "0"      showMixedAmount (Mixed []) `is` "0"      showMixedAmount missingamt `is` "" @@ -380,6 +453,10 @@     (a1 + a3) `is` Amount (comm "$") 0 Nothing     (a2 + a3) `is` Amount (comm "$") (-2.46) Nothing     (a3 + a3) `is` Amount (comm "$") (-2.46) Nothing+    -- arithmetic with different commodities currently assumes conversion rate 1:+    let a4 = euros (-1.23)+    assertBool "" $ isZeroAmount (a1 + a4)+     sum [a2,a3] `is` Amount (comm "$") (-2.46) Nothing     sum [a3,a3] `is` Amount (comm "$") (-2.46) Nothing     sum [a1,a2,a3,-a3] `is` Amount (comm "$") 0 Nothing@@ -393,7 +470,26 @@              [Amount dollar 1.25 Nothing,               Amount dollar0 (-1) Nothing,               Amount dollar (-0.25) Nothing])-      `is` Mixed [Amount dollar 0 Nothing]+      `is` Mixed [Amount unknown 0 Nothing] +  ,"normaliseMixedAmount" ~: do+     normaliseMixedAmount (Mixed []) `is` Mixed [nullamt]+     assertBool "" $ isZeroMixedAmount $ normaliseMixedAmount (Mixed [Amount {commodity=dollar, quantity=10,    price=Nothing}+                                                                     ,Amount {commodity=dollar, quantity=10,    price=Just (TotalPrice (Mixed [Amount {commodity=euro, quantity=7, price=Nothing}]))}+                                                                     ,Amount {commodity=dollar, quantity=(-10), price=Nothing}+                                                                     ,Amount {commodity=dollar, quantity=(-10), price=Just (TotalPrice (Mixed [Amount {commodity=euro, quantity=7, price=Nothing}]))}+                                                                     ])++  ,"punctuatethousands 1" ~: punctuatethousands "" `is` ""++  ,"punctuatethousands 2" ~: punctuatethousands "1234567.8901" `is` "1,234,567.8901"++  ,"punctuatethousands 3" ~: punctuatethousands "-100" `is` "-100"++  ,"costOfAmount" ~: do+    costOfAmount (euros 1) `is` euros 1+    costOfAmount (euros 2){price=Just $ UnitPrice $ Mixed [dollars 2]} `is` dollars 4+    costOfAmount (euros 1){price=Just $ TotalPrice $ Mixed [dollars 2]} `is` dollars 2+    costOfAmount (euros (-1)){price=Just $ TotalPrice $ Mixed [dollars 2]} `is` dollars (-2)    ]
Hledger/Data/Commodity.hs view
@@ -21,18 +21,18 @@  -- convenient amount and commodity constructors, for tests etc. -unknown = Commodity {symbol="",   side=L,spaced=False,comma=False,precision=0}-dollar  = Commodity {symbol="$",  side=L,spaced=False,comma=False,precision=2}-euro    = Commodity {symbol="EUR",side=L,spaced=False,comma=False,precision=2}-pound   = Commodity {symbol="£",  side=L,spaced=False,comma=False,precision=2}-hour    = Commodity {symbol="h",  side=R,spaced=False,comma=False,precision=1}+unknown = Commodity {symbol="", side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]}+dollar  = Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=2,separator=',',separatorpositions=[]}+euro    = Commodity {symbol="€",side=L,spaced=False,decimalpoint='.',precision=2,separator=',',separatorpositions=[]}+pound   = Commodity {symbol="£",side=L,spaced=False,decimalpoint='.',precision=2,separator=',',separatorpositions=[]}+hour    = Commodity {symbol="h",side=R,spaced=False,decimalpoint='.',precision=1,separator=',',separatorpositions=[]}  dollars n = Amount dollar n Nothing-euros n   = Amount euro n Nothing-pounds n  = Amount pound n Nothing-hours n   = Amount hour n Nothing+euros n   = Amount euro   n Nothing+pounds n  = Amount pound  n Nothing+hours n   = Amount hour   n Nothing -defaultcommodities = [dollar,  euro,  pound, hour, unknown]+defaultcommodities = [dollar, euro, pound, hour, unknown]  -- | Look up one of the hard-coded default commodities. For use in tests. comm :: String -> Commodity@@ -57,3 +57,7 @@     commoditymap = Map.fromList [(s, commoditieswithsymbol s) | s <- symbols]     commoditieswithsymbol s = filter ((s==) . symbol) cs     symbols = nub $ map symbol cs++tests_Hledger_Data_Commodity = TestList [+ ]+
Hledger/Data/Dates.hs view
@@ -18,6 +18,8 @@  -} +-- XXX fromGregorian silently clips bad dates, use fromGregorianValid instead ?+ module Hledger.Data.Dates where @@ -33,38 +35,52 @@ showDate :: Day -> String showDate = formatTime defaultTimeLocale "%C%y/%m/%d" +-- | Get the current local date. getCurrentDay :: IO Day getCurrentDay = do     t <- getZonedTime     return $ localDay (zonedTimeToLocalTime t) +-- | Get the current local year.+getCurrentYear :: IO Integer+getCurrentYear = do+  (y,_,_) <- toGregorian `fmap` getCurrentDay+  return y+ elapsedSeconds :: Fractional a => UTCTime -> UTCTime -> a elapsedSeconds t1 = realToFrac . diffUTCTime t1  -- | Split a DateSpan into one or more consecutive spans at the specified interval. splitSpan :: Interval -> DateSpan -> [DateSpan] splitSpan _ (DateSpan Nothing Nothing) = [DateSpan Nothing Nothing]-splitSpan NoInterval s = [s]-splitSpan Daily s      = splitspan startofday     nextday     s-splitSpan Weekly s     = splitspan startofweek    nextweek    s-splitSpan Monthly s    = splitspan startofmonth   nextmonth   s-splitSpan Quarterly s  = splitspan startofquarter nextquarter s-splitSpan Yearly s     = splitspan startofyear    nextyear    s+splitSpan NoInterval     s = [s]+splitSpan (Days n)       s = splitspan startofday     (applyN n nextday)     s+splitSpan (Weeks n)      s = splitspan startofweek    (applyN n nextweek)    s+splitSpan (Months n)     s = splitspan startofmonth   (applyN n nextmonth)   s+splitSpan (Quarters n)   s = splitspan startofquarter (applyN n nextquarter) s+splitSpan (Years n)      s = splitspan startofyear    (applyN n nextyear)    s+splitSpan (DayOfMonth n) s = splitspan (nthdayofmonthcontaining n) (applyN (n-1) nextday . nextmonth) s+splitSpan (DayOfWeek n)  s = splitspan (nthdayofweekcontaining n)  (applyN (n-1) nextday . nextweek)  s+-- splitSpan (WeekOfYear n)    s = splitspan startofweek    (applyN n nextweek)    s+-- splitSpan (MonthOfYear n)   s = splitspan startofmonth   (applyN n nextmonth)   s+-- splitSpan (QuarterOfYear n) s = splitspan startofquarter (applyN n nextquarter) s +-- Split the given span using the provided helper functions:+-- start is applied to the span's start date to get the first sub-span's start date+-- next is applied to a sub-span's start date to get the next sub-span's start date splitspan :: (Day -> Day) -> (Day -> Day) -> DateSpan -> [DateSpan] splitspan _ _ (DateSpan Nothing Nothing) = []-splitspan start next (DateSpan Nothing (Just e)) = [DateSpan (Just $ start e) (Just $ next $ start e)]-splitspan start next (DateSpan (Just b) Nothing) = [DateSpan (Just $ start b) (Just $ next $ start b)]-splitspan start next span@(DateSpan (Just b) (Just e))-    | b == e = [span]+splitspan start next (DateSpan Nothing (Just e)) = splitspan start next (DateSpan (Just $ start e) (Just $ next $ start e))+splitspan start next (DateSpan (Just s) Nothing) = splitspan start next (DateSpan (Just $ start s) (Just $ next $ start s))+splitspan start next span@(DateSpan (Just s) (Just e))+    | s == e = [span]     | otherwise = splitspan' start next span     where-      splitspan' start next (DateSpan (Just b) (Just e))-          | b >= e = []-          | otherwise = DateSpan (Just s) (Just n)-                        : splitspan' start next (DateSpan (Just n) (Just e))-          where s = start b-                n = next s+      splitspan' start next (DateSpan (Just s) (Just e))+          | s >= e = []+          | otherwise = DateSpan (Just subs) (Just sube) : splitspan' start next (DateSpan (Just sube) (Just e))+          where subs = start s+                sube = next subs       splitspan' _ _ _ = error' "won't happen, avoids warnings"  -- | Count the days in a DateSpan, or if it is open-ended return Nothing.@@ -211,6 +227,18 @@ nextyear = startofyear . addGregorianYearsClip 1 startofyear day = fromGregorian y 1 1 where (y,_,_) = toGregorian day +nthdayofmonthcontaining n d | d1 >= d    = d1+                            | otherwise = d2+    where d1 = addDays (fromIntegral n-1) s+          d2 = addDays (fromIntegral n-1) $ nextmonth s+          s = startofmonth d++nthdayofweekcontaining n d | d1 >= d    = d1+                           | otherwise = d2+    where d1 = addDays (fromIntegral n-1) s+          d2 = addDays (fromIntegral n-1) $ nextweek s+          s = startofweek d+ ---------------------------------------------------------------------- -- parsing @@ -398,7 +426,7 @@ intervalanddateperiodexpr :: Day -> GenParser Char st (Interval, DateSpan) intervalanddateperiodexpr rdate = do   many spacenonewline-  i <- periodexprinterval+  i <- reportinginterval   many spacenonewline   s <- periodexprdatespan rdate   return (i,s)@@ -406,7 +434,7 @@ intervalperiodexpr :: GenParser Char st (Interval, DateSpan) intervalperiodexpr = do   many spacenonewline-  i <- periodexprinterval+  i <- reportinginterval   return (i, DateSpan Nothing Nothing)  dateperiodexpr :: Day -> GenParser Char st (Interval, DateSpan)@@ -415,19 +443,65 @@   s <- periodexprdatespan rdate   return (NoInterval, s) -periodexprinterval :: GenParser Char st Interval-periodexprinterval = -    choice $ map try [-                tryinterval "day" "daily" Daily,-                tryinterval "week" "weekly" Weekly,-                tryinterval "month" "monthly" Monthly,-                tryinterval "quarter" "quarterly" Quarterly,-                tryinterval "year" "yearly" Yearly-               ]+-- Parse a reporting interval.+reportinginterval :: GenParser Char st Interval+reportinginterval = choice' [+                       tryinterval "day"     "daily"     Days,+                       tryinterval "week"    "weekly"    Weeks,+                       tryinterval "month"   "monthly"   Months,+                       tryinterval "quarter" "quarterly" Quarters,+                       tryinterval "year"    "yearly"    Years,+                       do string "biweekly"+                          return $ Weeks 2,+                       do string "bimonthly"+                          return $ Months 2,+                       do string "every"+                          many spacenonewline+                          n <- fmap read $ many1 digit+                          thsuffix+                          many spacenonewline+                          string "day"+                          many spacenonewline+                          string "of"+                          many spacenonewline+                          string "week"+                          return $ DayOfWeek n,+                       do string "every"+                          many spacenonewline+                          n <- fmap read $ many1 digit+                          thsuffix+                          many spacenonewline+                          string "day"+                          optional $ do+                            many spacenonewline+                            string "of"+                            many spacenonewline+                            string "month"+                          return $ DayOfMonth n+                    ]     where-      tryinterval s1 s2 v = -          choice [try (string $ "every "++s1), try (string s2)] >> return v +      thsuffix = choice' $ map string ["st","nd","rd","th"]++      -- Parse any of several variants of a basic interval, eg "daily", "every day", "every N days".+      tryinterval :: String -> String -> (Int -> Interval) -> GenParser Char st Interval+      tryinterval singular compact intcons =+          choice' [+           do string compact+              return $ intcons 1,+           do string "every"+              many spacenonewline+              string singular+              return $ intcons 1,+           do string "every"+              many spacenonewline+              n <- fmap read $ many1 digit+              many spacenonewline+              string plural+              return $ intcons n+           ]+          where plural = singular ++ "s"+ periodexprdatespan :: Day -> GenParser Char st DateSpan periodexprdatespan rdate = choice $ map try [                             doubledatespan rdate,@@ -470,23 +544,100 @@  nulldate = parsedate "1900/01/01" -tests_Dates = TestList [+tests_Hledger_Data_Dates = TestList+ [ -   "splitSpan" ~: do+   "parsedate" ~: do+    let date1 = parsedate "2008/11/26"+    parsedate "2008/02/03" `is` parsetimewith "%Y/%m/%d" "2008/02/03" date1+    parsedate "2008-02-03" `is` parsetimewith "%Y/%m/%d" "2008/02/03" date1++  ,"period expressions" ~: do+    let todaysdate = parsedate "2008/11/26"+    let str `gives` result = show (parsewith (periodexpr todaysdate) str) `is` ("Right " ++ result)+    "from aug to oct"           `gives` "(NoInterval,DateSpan (Just 2008-08-01) (Just 2008-10-01))"+    "aug to oct"                `gives` "(NoInterval,DateSpan (Just 2008-08-01) (Just 2008-10-01))"+    "every 3 days in aug"       `gives` "(Days 3,DateSpan (Just 2008-08-01) (Just 2008-09-01))"+    "daily from aug"            `gives` "(Days 1,DateSpan (Just 2008-08-01) Nothing)"+    "every week to 2009"        `gives` "(Weeks 1,DateSpan Nothing (Just 2009-01-01))"++  ,"splitSpan" ~: do     let gives (interval, span) = (splitSpan interval span `is`)     (NoInterval,mkdatespan "2008/01/01" "2009/01/01") `gives`      [mkdatespan "2008/01/01" "2009/01/01"]-    (Quarterly,mkdatespan "2008/01/01" "2009/01/01") `gives`+    (Quarters 1,mkdatespan "2008/01/01" "2009/01/01") `gives`      [mkdatespan "2008/01/01" "2008/04/01"      ,mkdatespan "2008/04/01" "2008/07/01"      ,mkdatespan "2008/07/01" "2008/10/01"      ,mkdatespan "2008/10/01" "2009/01/01"      ]-    (Quarterly,nulldatespan) `gives`+    (Quarters 1,nulldatespan) `gives`      [nulldatespan]-    (Daily,mkdatespan "2008/01/01" "2008/01/01") `gives`+    (Days 1,mkdatespan "2008/01/01" "2008/01/01") `gives`      [mkdatespan "2008/01/01" "2008/01/01"]-    (Quarterly,mkdatespan "2008/01/01" "2008/01/01") `gives`+    (Quarters 1,mkdatespan "2008/01/01" "2008/01/01") `gives`      [mkdatespan "2008/01/01" "2008/01/01"]+    (Months 1,mkdatespan "2008/01/01" "2008/04/01") `gives`+     [mkdatespan "2008/01/01" "2008/02/01"+     ,mkdatespan "2008/02/01" "2008/03/01"+     ,mkdatespan "2008/03/01" "2008/04/01"+     ]+    (Months 2,mkdatespan "2008/01/01" "2008/04/01") `gives`+     [mkdatespan "2008/01/01" "2008/03/01"+     ,mkdatespan "2008/03/01" "2008/05/01"+     ]+    (Weeks 1,mkdatespan "2008/01/01" "2008/01/15") `gives`+     [mkdatespan "2007/12/31" "2008/01/07"+     ,mkdatespan "2008/01/07" "2008/01/14"+     ,mkdatespan "2008/01/14" "2008/01/21"+     ]+    (Weeks 2,mkdatespan "2008/01/01" "2008/01/15") `gives`+     [mkdatespan "2007/12/31" "2008/01/14"+     ,mkdatespan "2008/01/14" "2008/01/28"+     ]+    (DayOfMonth 2,mkdatespan "2008/01/01" "2008/04/01") `gives`+     [mkdatespan "2008/01/02" "2008/02/02"+     ,mkdatespan "2008/02/02" "2008/03/02"+     ,mkdatespan "2008/03/02" "2008/04/02"+     ]+    (DayOfWeek 2,mkdatespan "2011/01/01" "2011/01/15") `gives`+     [mkdatespan "2011/01/04" "2011/01/11"+     ,mkdatespan "2011/01/11" "2011/01/18"+     ] -    ]+  ,"fixSmartDateStr" ~: do+    let gives = is . fixSmartDateStr (parsedate "2008/11/26")+    "1999-12-02"   `gives` "1999/12/02"+    "1999.12.02"   `gives` "1999/12/02"+    "1999/3/2"     `gives` "1999/03/02"+    "19990302"     `gives` "1999/03/02"+    "2008/2"       `gives` "2008/02/01"+    "0020/2"       `gives` "0020/02/01"+    "1000"         `gives` "1000/01/01"+    "4/2"          `gives` "2008/04/02"+    "2"            `gives` "2008/11/02"+    "January"      `gives` "2008/01/01"+    "feb"          `gives` "2008/02/01"+    "today"        `gives` "2008/11/26"+    "yesterday"    `gives` "2008/11/25"+    "tomorrow"     `gives` "2008/11/27"+    "this day"     `gives` "2008/11/26"+    "last day"     `gives` "2008/11/25"+    "next day"     `gives` "2008/11/27"+    "this week"    `gives` "2008/11/24" -- last monday+    "last week"    `gives` "2008/11/17" -- previous monday+    "next week"    `gives` "2008/12/01" -- next monday+    "this month"   `gives` "2008/11/01"+    "last month"   `gives` "2008/10/01"+    "next month"   `gives` "2008/12/01"+    "this quarter" `gives` "2008/10/01"+    "last quarter" `gives` "2008/07/01"+    "next quarter" `gives` "2009/01/01"+    "this year"    `gives` "2008/01/01"+    "last year"    `gives` "2007/01/01"+    "next year"    `gives` "2009/01/01"+--     "last wed"     `gives` "2008/11/19"+--     "next friday"  `gives` "2008/11/28"+--     "next january" `gives` "2009/01/01"++ ]
Hledger/Data/Journal.hs view
@@ -268,7 +268,7 @@         fixmixedamount (Mixed as) = Mixed $ map fixamount as         fixamount = fixprice         fixprice a@Amount{price=Just _} = a-        fixprice a@Amount{commodity=c} = a{price=journalHistoricalPriceFor j d c}+        fixprice a@Amount{commodity=c} = a{price=maybe Nothing (Just . UnitPrice) $ journalHistoricalPriceFor j d c}  -- | Get the price for a commodity on the specified day from the price database, if known. -- Does only one lookup step, ie will not look up the price of a price.@@ -307,8 +307,10 @@  -- | Get this amount's commodity and any commodities referenced in its price. amountCommodities :: Amount -> [Commodity]-amountCommodities Amount{commodity=c,price=Nothing} = [c]-amountCommodities Amount{commodity=c,price=Just ma} = c:(concatMap amountCommodities $ amounts ma)+amountCommodities Amount{commodity=c,price=p} =+    case p of Nothing -> [c]+              Just (UnitPrice ma)  -> c:(concatMap amountCommodities $ amounts ma)+              Just (TotalPrice ma) -> c:(concatMap amountCommodities $ amounts ma)  -- | Get all this journal's amounts, in the order parsed. journalAmounts :: Journal -> [MixedAmount]@@ -387,3 +389,7 @@       sortedps = sortBy (comparing paccount) ps       groupedps = groupBy (\p1 p2 -> paccount p1 == paccount p2) sortedps       m' = Map.fromList [(paccount $ head g, g) | g <- groupedps]++tests_Hledger_Data_Journal = TestList [+ ]+
Hledger/Data/Ledger.hs view
@@ -122,3 +122,8 @@  ledgeramounts :: Ledger -> [MixedAmount] ledgeramounts = journalAmounts . journal++tests_Hledger_Data_Ledger = TestList+ [+ ]+
Hledger/Data/Posting.hs view
@@ -74,8 +74,14 @@ postingDate :: Posting -> Day postingDate p = maybe nulldate tdate $ ptransaction p +-- |Is this posting cleared? If this posting was individually marked+-- as cleared, returns True. Otherwise, return the parent+-- transaction's cleared status or, if there is no parent+-- transaction, return False. postingCleared :: Posting -> Bool-postingCleared p = maybe False tstatus $ ptransaction p+postingCleared p = if pstatus p+                    then True+                    else maybe False tstatus $ ptransaction p  -- | Does this posting fall within the given date span ? isPostingInDateSpan :: DateSpan -> Posting -> Bool@@ -90,4 +96,7 @@ postingsDateSpan [] = DateSpan Nothing Nothing postingsDateSpan ps = DateSpan (Just $ postingDate $ head ps') (Just $ addDays 1 $ postingDate $ last ps')     where ps' = sortBy (comparing postingDate) ps++tests_Hledger_Data_Posting = TestList [+ ] 
Hledger/Data/TimeLog.hs view
@@ -90,7 +90,7 @@       ps       = [Posting{pstatus=False,paccount=acctname,pamount=amount,                           pcomment="",ptype=VirtualPosting,pmetadata=[],ptransaction=Just t}] -tests_TimeLog = TestList [+tests_Hledger_Data_TimeLog = TestList [     "timeLogEntriesToTransactions" ~: do      today <- getCurrentDay
Hledger/Data/Transaction.hs view
@@ -15,7 +15,7 @@ import Hledger.Data.Dates import Hledger.Data.Posting import Hledger.Data.Amount-import Hledger.Data.Commodity (dollars, dollar, unknown)+import Hledger.Data.Commodity  instance Show Transaction where show = showTransactionUnelided @@ -72,8 +72,8 @@            | 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 = ' ' : tdescription t-      comment = if null com then "" else "  ; " ++ com where com = tcomment t+      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@@ -133,29 +133,87 @@       rsum'  = canonicaliseMixedAmount canonicalcommoditymap $ costOfMixedAmount rsum       bvsum' = canonicaliseMixedAmount canonicalcommoditymap $ costOfMixedAmount bvsum --- | Ensure that this entry is balanced, possibly auto-filling a missing--- amount first. We can auto-fill if there is just one non-virtual--- transaction without an amount. The auto-filled balance will be--- converted to cost basis if possible. If the entry can not be balanced,--- return an error message instead.+-- | 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'+    | not $ isTransactionBalanced canonicalcommoditymap t''' = Left $ printerr $ nonzerobalanceerror t'''+    | otherwise = Right t'''     where-      rps = filter isReal ps-      bvps = filter isBalancedVirtual ps-      (rwithamounts, rwithoutamounts) = partition hasAmount rps-      (bvwithamounts, bvwithoutamounts) = partition hasAmount bvps-      t' = t{tpostings=map balance ps}+      -- 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 -            balance p | not (hasAmount p) && isReal p-                          = p{pamount = (-(sum $ map pamount rwithamounts))}-                      | not (hasAmount p) && isBalancedVirtual p-                          = p{pamount = (-(sum $ map pamount bvwithamounts))}-                      | otherwise = p+            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@@ -172,7 +230,6 @@ 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@@ -182,7 +239,7 @@ settxn :: Transaction -> Posting -> Posting settxn t p = p{ptransaction=Just t} -tests_Transaction = TestList [+tests_Hledger_Data_Transaction = TestList [   "showTransaction" ~: do      assertEqual "show a balanced transaction, eliding last amount"        (unlines@@ -260,8 +317,85 @@         ])        (showTransaction         (txnTieKnot $ Transaction (parsedate "2010/01/01") Nothing False "" "x" "" []-         [Posting False "a" (Mixed [Amount unknown 1 (Just $ Mixed [Amount dollar{precision=0} 2 Nothing])]) "" RegularPosting [] Nothing+         [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)    ]
Hledger/Data/Types.hs view
@@ -46,7 +46,10 @@  data DateSpan = DateSpan (Maybe Day) (Maybe Day) deriving (Eq,Show,Ord) -data Interval = NoInterval | Daily | Weekly | Monthly | Quarterly | Yearly +data Interval = NoInterval+              | Days Int | Weeks Int | Months Int | Quarters Int | Years Int+              | DayOfMonth Int | DayOfWeek Int+              -- WeekOfYear Int | MonthOfYear Int | QuarterOfYear Int                 deriving (Eq,Show,Ord)  type AccountName = String@@ -54,21 +57,30 @@ data Side = L | R deriving (Eq,Show,Read,Ord)  data Commodity = Commodity {-      symbol :: String,  -- ^ the commodity's symbol+      symbol :: String,            -- ^ the commodity's symbol       -- display preferences for amounts of this commodity-      side :: Side,      -- ^ should the symbol appear on the left or the right-      spaced :: Bool,    -- ^ should there be a space between symbol and quantity-      comma :: Bool,     -- ^ should thousands be comma-separated-      precision :: Int   -- ^ number of decimal places to display-    } deriving (Eq,Show,Read,Ord)+      side :: Side,                -- ^ should the symbol appear on the left or the right+      spaced :: Bool,              -- ^ should there be a space between symbol and quantity+      precision :: Int,            -- ^ number of decimal places to display+      -- XXX these three might be better belonging to Journal+      decimalpoint :: Char,        -- ^ character to use as decimal point+      separator :: Char,           -- ^ character to use for separating digit groups (eg thousands)+      separatorpositions :: [Int]  -- ^ positions of separators, counting leftward from decimal point+    } deriving (Eq,Ord,Show,Read) +-- | An amount's price may be written as \@ unit price or \@\@ total price.+-- Note although Price has a MixedAmount, it should hold only+-- single-commodity amounts, cf costOfAmount.+data Price = UnitPrice MixedAmount | TotalPrice MixedAmount+             deriving (Eq,Ord)+ data Amount = Amount {       commodity :: Commodity,       quantity :: Double,-      price :: Maybe MixedAmount  -- ^ unit price/conversion rate for this amount at posting time-    } deriving (Eq)+      price :: Maybe Price  -- ^ the price for this amount at posting time+    } deriving (Eq,Ord) -newtype MixedAmount = Mixed [Amount] deriving (Eq)+newtype MixedAmount = Mixed [Amount] deriving (Eq,Ord)  data PostingType = RegularPosting | VirtualPosting | BalancedVirtualPosting                    deriving (Eq,Show)
+ Hledger/Data/UTF8.hs view
@@ -0,0 +1,83 @@+{-+From pandoc, slightly extended.++----------------------------------------------------------------------+Copyright (C) 2010 John MacFarlane <jgm@berkeley.edu>++This program is free software; you can redistribute it and/or modify+it under the terms of the GNU General Public License as published by+the Free Software Foundation; either version 2 of the License, or+(at your option) any later version.++This program is distributed in the hope that it will be useful,+but WITHOUT ANY WARRANTY; without even the implied warranty of+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the+GNU General Public License for more details.++You should have received a copy of the GNU General Public License+along with this program; if not, write to the Free Software+Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA+-}++{- |+   Module      : Text.Pandoc.UTF8+   Copyright   : Copyright (C) 2010 John MacFarlane+   License     : GNU GPL, version 2 or above ++   Maintainer  : John MacFarlane <jgm@berkeley.edu>+   Stability   : alpha+   Portability : portable++UTF-8 aware string IO functions that will work with GHC 6.10 or 6.12.+-}+module Hledger.Data.UTF8 ( readFile+                         , writeFile+                         , appendFile+                         , getContents+                         , hGetContents+                         , putStr+                         , putStrLn+                         , hPutStr+                         , hPutStrLn+                         )++where+import qualified Data.ByteString.Lazy as B+import Data.ByteString.Lazy.UTF8 (toString, fromString)+import Prelude hiding (readFile, writeFile, appendFile, getContents, putStr, putStrLn)+import System.IO (Handle)+import Control.Monad (liftM)++bom :: B.ByteString+bom = B.pack [0xEF, 0xBB, 0xBF]++stripBOM :: B.ByteString -> B.ByteString+stripBOM s | bom `B.isPrefixOf` s = B.drop 3 s+stripBOM s = s++readFile :: FilePath -> IO String+readFile = liftM (toString . stripBOM) . B.readFile++writeFile :: FilePath -> String -> IO ()+writeFile f = B.writeFile f . fromString++appendFile :: FilePath -> String -> IO ()+appendFile f = B.appendFile f . fromString++getContents :: IO String+getContents = liftM (toString . stripBOM) B.getContents++hGetContents :: Handle -> IO String+hGetContents h = liftM (toString . stripBOM) (B.hGetContents h)++putStr :: String -> IO ()+putStr = B.putStr . fromString++putStrLn :: String -> IO ()+putStrLn = B.putStrLn . fromString++hPutStr :: Handle -> String -> IO ()+hPutStr h = B.hPutStr h . fromString++hPutStrLn :: Handle -> String -> IO ()+hPutStrLn h s = hPutStr h (s ++ "\n")
Hledger/Data/Utils.hs view
@@ -19,6 +19,7 @@ module Data.Time.LocalTime, module Debug.Trace, module Hledger.Data.Utils,+-- module Hledger.Data.UTF8, module Text.Printf, module Text.RegexPR, module Test.HUnit,@@ -36,10 +37,9 @@ import Data.Time.Calendar import Data.Time.LocalTime import Debug.Trace-#if __GLASGOW_HASKELL__ <= 610-import Prelude hiding (readFile,putStr,print)-import System.IO.UTF8-#endif+-- needs to be done in each module I think+-- import Prelude hiding (readFile,writeFile,getContents,putStr,putStrLn)+-- import Hledger.Data.UTF8 import Test.HUnit import Text.Printf import Text.RegexPR@@ -371,3 +371,6 @@ -- --                                return (homeDirectory pw ++ path) -- tildeExpand xs           =  return xs +-- | Apply a function the specified number of times. Possibly uses O(n) stack ?+applyN :: Int -> (a -> a) -> a -> a+applyN n f = (!! n) . iterate f
Hledger/Read.hs view
@@ -16,13 +16,6 @@        myTimelog, ) where-import Hledger.Data.Dates (getCurrentDay)-import Hledger.Data.Types (Journal(..), Reader(..))-import Hledger.Data.Journal (nullctx)-import Hledger.Data.Utils-import Hledger.Read.JournalReader as JournalReader-import Hledger.Read.TimelogReader as TimelogReader- import Control.Monad.Error import Data.Either (partitionEithers) import Safe (headDef)@@ -30,18 +23,22 @@ import System.Environment (getEnv) import System.FilePath ((</>)) import System.IO (IOMode(..), withFile, stderr)-#if __GLASGOW_HASKELL__ <= 610++import Hledger.Data.Dates (getCurrentDay)+import Hledger.Data.Types (Journal(..), Reader(..))+import Hledger.Data.Journal (nullctx)+import Hledger.Data.Utils import Prelude hiding (getContents)-import System.IO.UTF8 (getContents, hGetContents)-#else-import System.IO (hGetContents)-#endif+import Hledger.Data.UTF8 (getContents, hGetContents)+import Hledger.Read.JournalReader as JournalReader+import Hledger.Read.TimelogReader as TimelogReader  -journalenvvar           = "LEDGER"-timelogenvvar          = "TIMELOG"-journaldefaultfilename  = ".journal"-timelogdefaultfilename = ".timelog"+journalenvvar           = "LEDGER_FILE"+journalenvvar2          = "LEDGER"+timelogenvvar           = "TIMELOG"+journaldefaultfilename  = ".hledger.journal"+timelogdefaultfilename = ".hledger.timelog"  -- Here are the available readers. The first is the default, used for unknown data formats. readers :: [Reader]@@ -111,13 +108,21 @@ readJournal :: Maybe String -> String -> IO (Either String Journal) readJournal format s = journalFromPathAndString format "(string)" s --- | Get the user's default journal file path.+-- | Get the user's journal file path. Like ledger, we look first for the+-- LEDGER_FILE environment variable, and if that does not exist, for the+-- legacy LEDGER environment variable. If neither is set, or the value is+-- blank, return the default journal file path, which is+-- ".hledger.journal" in the users's home directory, or if we cannot+-- determine that, in the current directory. myJournalPath :: IO String-myJournalPath =-    getEnv journalenvvar `catch`-               (\_ -> do+myJournalPath = do+  s <- envJournalPath+  if null s then defaultJournalPath else return s+    where+      envJournalPath = getEnv journalenvvar `catch` (\_ -> getEnv journalenvvar2 `catch` (\_ -> return ""))+      defaultJournalPath = do                   home <- getHomeDirectory `catch` (\_ -> return "")-                  return $ home </> journaldefaultfilename)+                  return $ home </> journaldefaultfilename    -- | Get the user's default timelog file path. myTimelogPath :: IO String@@ -137,12 +142,12 @@  tests_Hledger_Read = TestList   [+   tests_Hledger_Read_JournalReader,+   tests_Hledger_Read_TimelogReader,     "journalFile" ~: do     assertBool "journalFile should parse an empty file" (isRight $ parseWithCtx nullctx JournalReader.journalFile "")     jE <- readJournal Nothing "" -- don't know how to get it from journalFile     either error' (assertBool "journalFile parsing an empty file should give an empty journal" . null . jtxns) jE -  ,JournalReader.tests_JournalReader-  ,TimelogReader.tests_TimelogReader   ]
Hledger/Read/JournalReader.hs view
@@ -104,28 +104,27 @@ -}  module Hledger.Read.JournalReader (-       tests_JournalReader,-       reader,-       journalFile,+       emptyLine,        journalAddFile,-       someamount,+       journalFile,        ledgeraccountname,+       ledgerdatetime,+       ledgerDefaultYear,        ledgerExclamationDirective,        ledgerHistoricalPrice,-       ledgerDefaultYear,-       emptyLine,-       ledgerdatetime,+       reader,+       someamount,+       tests_Hledger_Read_JournalReader ) where import Control.Monad.Error (ErrorT(..), throwError, catchError) import Data.List.Split (wordsBy)+import Safe (headDef) import Text.ParserCombinators.Parsec hiding (parse)-#if __GLASGOW_HASKELL__ <= 610-import Prelude hiding (readFile, putStr, putStrLn, print, getContents)-import System.IO.UTF8-#endif  import Hledger.Data+import Prelude hiding (readFile)+import Hledger.Data.UTF8 (readFile) import Hledger.Read.Utils  @@ -318,8 +317,7 @@   restofline   return $ return id --- | Try to parse a ledger entry. If we successfully parse an entry,--- check it can be balanced, and fail if not.+-- | Parse a (possibly unbalanced) ledger transaction. ledgerTransaction :: GenParser Char JournalContext Transaction ledgerTransaction = do   date <- ledgerdate <?> "transaction"@@ -332,12 +330,7 @@       ) <?> "description and/or comment"   md <- try ledgermetadata <|> return []   postings <- ledgerpostings-  let t = txnTieKnot $ Transaction date edate status code description comment md postings ""-  -- case balanceTransaction Nothing t of-  --   Right t' -> return t'-  --   Left err -> fail err-  -- check it later, after we have worked out commodity display precisions-  return t+  return $ txnTieKnot $ Transaction date edate status code description comment md postings ""  ledgerdate :: GenParser Char JournalContext Day ledgerdate = do@@ -346,20 +339,15 @@   -- pos <- getPosition   datestr <- many1 $ choice' [digit, datesepchar]   let dateparts = wordsBy (`elem` datesepchars) datestr-  case dateparts of-    [y,m,d] -> do-               failIfInvalidYear y-               failIfInvalidMonth m-               failIfInvalidDay d-               return $ fromGregorian (read y) (read m) (read d)-    [m,d]   -> do-               y <- getYear-               case y of Nothing -> fail "partial date found, but no default year specified"-                         Just y' -> do failIfInvalidYear $ show y'-                                       failIfInvalidMonth m-                                       failIfInvalidDay d-                                       return $ fromGregorian y' (read m) (read d)-    _       -> fail $ "bad date: " ++ datestr+  currentyear <- getYear+  let [y,m,d] = case (dateparts,currentyear) of+                  ([m,d],Just y)  -> [show y,m,d]+                  ([_,_],Nothing) -> fail $ "partial date "++datestr++" found, but the current year is unknown"+                  _               -> dateparts+      maybedate = fromGregorianValid (read y) (read m) (read d)+  case maybedate of+    Nothing   -> fail $ "bad date: " ++ datestr+    Just date -> return date   <?> "full or partial date"  ledgerdatetime :: GenParser Char JournalContext LocalTime@@ -423,6 +411,7 @@   ls <- many1 $ try linebeginningwithspaces   let parses p = isRight . parseWithCtx ctx p       postinglines = filter (not . (ledgercommentline `parses`)) ls+      -- group any metadata lines with the posting line above       postinglinegroups :: [String] -> [String]       postinglinegroups [] = []       postinglinegroups (pline:ls) = (unlines $ pline:mdlines):postinglinegroups rest@@ -494,28 +483,28 @@   let applysign = if isJust sign then negate else id   sym <- commoditysymbol    sp <- many spacenonewline-  (q,p,comma) <- amountquantity+  (q,p,d,s,spos) <- number   pri <- priceamount-  let c = Commodity {symbol=sym,side=L,spaced=not $ null sp,comma=comma,precision=p}+  let c = Commodity {symbol=sym,side=L,spaced=not $ null sp,decimalpoint=d,precision=p,separator=s,separatorpositions=spos}   return $ applysign $ Mixed [Amount c q pri]   <?> "left-symbol amount"  rightsymbolamount :: GenParser Char JournalContext MixedAmount rightsymbolamount = do-  (q,p,comma) <- amountquantity+  (q,p,d,s,spos) <- number   sp <- many spacenonewline   sym <- commoditysymbol   pri <- priceamount-  let c = Commodity {symbol=sym,side=R,spaced=not $ null sp,comma=comma,precision=p}+  let c = Commodity {symbol=sym,side=R,spaced=not $ null sp,decimalpoint=d,precision=p,separator=s,separatorpositions=spos}   return $ Mixed [Amount c q pri]   <?> "right-symbol amount"  nosymbolamount :: GenParser Char JournalContext MixedAmount nosymbolamount = do-  (q,p,comma) <- amountquantity+  (q,p,d,s,spos) <- number   pri <- priceamount   defc <- getCommodity-  let c = fromMaybe Commodity{symbol="",side=L,spaced=False,comma=comma,precision=p} defc+  let c = fromMaybe Commodity{symbol="",side=L,spaced=False,decimalpoint=d,precision=p,separator=s,separatorpositions=spos} defc   return $ Mixed [Amount c q pri]   <?> "no-symbol amount" @@ -532,60 +521,138 @@ simplecommoditysymbol :: GenParser Char JournalContext String simplecommoditysymbol = many1 (noneOf nonsimplecommoditychars) -priceamount :: GenParser Char JournalContext (Maybe MixedAmount)+priceamount :: GenParser Char JournalContext (Maybe Price) priceamount =     try (do           many spacenonewline           char '@'-          many spacenonewline-          a <- someamount -- XXX could parse more prices ad infinitum, shouldn't-          return $ Just a-          ) <|> return Nothing+          try (do+                char '@'+                many spacenonewline+                a <- someamount -- XXX can parse more prices ad infinitum, shouldn't+                return $ Just $ TotalPrice a)+           <|> (do+            many spacenonewline+            a <- someamount -- XXX can parse more prices ad infinitum, shouldn't+            return $ Just $ UnitPrice a))+         <|> return Nothing  -- gawd.. trying to parse a ledger number without error: --- | Parse a ledger-style numeric quantity and also return the number of--- digits to the right of the decimal point and whether thousands are--- separated by comma.-amountquantity :: GenParser Char JournalContext (Double, Int, Bool)-amountquantity = do-  sign <- optionMaybe $ string "-"-  (intwithcommas,frac) <- numberparts-  let comma = ',' `elem` intwithcommas-  let precision = length frac-  -- read the actual value. We expect this read to never fail.-  let int = filter (/= ',') intwithcommas-  let int' = if null int then "0" else int-  let frac' = if null frac then "0" else frac-  let sign' = fromMaybe "" sign-  let quantity = read $ sign'++int'++"."++frac'-  return (quantity, precision, comma)-  <?> "commodity quantity"+type Quantity = Double --- | parse the two strings of digits before and after a possible decimal--- point.  The integer part may contain commas, or either part may be--- empty, or there may be no point.-numberparts :: GenParser Char JournalContext (String,String)-numberparts = numberpartsstartingwithdigit <|> numberpartsstartingwithpoint+-- -- | Parse a ledger-style numeric quantity and also return the number of+-- -- digits to the right of the decimal point and whether thousands are+-- -- separated by comma.+-- amountquantity :: GenParser Char JournalContext (Quantity, Int, Bool)+-- amountquantity = do+--   sign <- optionMaybe $ string "-"+--   (intwithcommas,frac) <- numberparts+--   let comma = ',' `elem` intwithcommas+--   let precision = length frac+--   -- read the actual value. We expect this read to never fail.+--   let int = filter (/= ',') intwithcommas+--   let int' = if null int then "0" else int+--   let frac' = if null frac then "0" else frac+--   let sign' = fromMaybe "" sign+--   let quantity = read $ sign'++int'++"."++frac'+--   return (quantity, precision, comma)+--   <?> "commodity quantity" -numberpartsstartingwithdigit :: GenParser Char JournalContext (String,String)-numberpartsstartingwithdigit = do-  let digitorcomma = digit <|> char ','-  first <- digit-  rest <- many digitorcomma-  frac <- try (do {char '.'; many digit}) <|> return ""-  return (first:rest,frac)-                     -numberpartsstartingwithpoint :: GenParser Char JournalContext (String,String)-numberpartsstartingwithpoint = do-  char '.'-  frac <- many1 digit-  return ("",frac)+-- -- | parse the two strings of digits before and after a possible decimal+-- -- point.  The integer part may contain commas, or either part may be+-- -- empty, or there may be no point.+-- numberparts :: GenParser Char JournalContext (String,String)+-- numberparts = numberpartsstartingwithdigit <|> numberpartsstartingwithpoint++-- numberpartsstartingwithdigit :: GenParser Char JournalContext (String,String)+-- numberpartsstartingwithdigit = do+--   let digitorcomma = digit <|> char ','+--   first <- digit+--   rest <- many digitorcomma+--   frac <- try (do {char '.'; many digit}) <|> return ""+--   return (first:rest,frac)                      +-- numberpartsstartingwithpoint :: GenParser Char JournalContext (String,String)+-- numberpartsstartingwithpoint = do+--   char '.'+--   frac <- many1 digit+--   return ("",frac) -tests_JournalReader = TestList [+-- | Parse a numeric quantity for its value and display attributes.  Some+-- international number formats (cf+-- http://en.wikipedia.org/wiki/Decimal_separator) are accepted: either+-- period or comma may be used for the decimal point, and the other of+-- these may be used for separating digit groups in the integer part (eg a+-- thousands separator).  This returns the numeric value, the precision+-- (number of digits to the right of the decimal point), the decimal point+-- and separator characters (defaulting to . and ,), and the positions of+-- separators (counting leftward from the decimal point, the last is+-- assumed to repeat).+number :: GenParser Char JournalContext (Quantity, Int, Char, Char, [Int])+number = do+  sign <- optionMaybe $ string "-"+  parts <- many1 $ choice' [many1 digit, many1 $ char ',', many1 $ char '.']+  let numeric = isNumber . headDef '_'+      (_, puncparts) = partition numeric parts+      (ok,decimalpoint',separator') =+          case puncparts of+            []     -> (True, Nothing, Nothing)  -- no punctuation chars+            [d:""] -> (True, Just d, Nothing)   -- just one punctuation char, assume it's a decimal point+            [_]    -> (False, Nothing, Nothing) -- adjacent punctuation chars, not ok+            _:_:_  -> let (s:ss, d) = (init puncparts, last puncparts) -- two or more punctuation chars+                     in if (any ((/=1).length) puncparts  -- adjacent punctuation chars, not ok+                            || any (s/=) ss                -- separator chars differ, not ok+                            || head parts == s)            -- number begins with a separator char, not ok+                         then (False, Nothing, Nothing)+                         else if s == d+                               then (True, Nothing, Just $ head s) -- just one kind of punctuation, assume separator chars+                               else (True, Just $ head d, Just $ head s) -- separators and a decimal point+  when (not ok) (fail $ "number seems ill-formed: "++concat parts)+  let (intparts',fracparts') = span ((/= decimalpoint') . Just . head) parts+      (intparts, fracpart) = (filter numeric intparts', filter numeric fracparts')+      separatorpositions = reverse $ map length $ drop 1 intparts+      int = concat $ "":intparts+      frac = concat $ "":fracpart+      precision = length frac+      int' = if null int then "0" else int+      frac' = if null frac then "0" else frac+      sign' = fromMaybe "" sign+      quantity = read $ sign'++int'++"."++frac' -- this read should never fail+      (decimalpoint, separator) = case (decimalpoint', separator') of (Just d,  Just s)   -> (d,s)+                                                                      (Just '.',Nothing)  -> ('.',',')+                                                                      (Just ',',Nothing)  -> (',','.')+                                                                      (Nothing, Just '.') -> (',','.')+                                                                      (Nothing, Just ',') -> ('.',',')+                                                                      _                   -> ('.',',')+  return (quantity,precision,decimalpoint,separator,separatorpositions)+  <?> "number" -   "ledgerTransaction" ~: do+tests_Hledger_Read_JournalReader = TestList [++    "number" ~: do+      let s `is` n = assertParseEqual (parseWithCtx nullctx number s) n+          assertFails = assertBool "" . isLeft . parseWithCtx nullctx number +      assertFails ""+      "0"          `is` (0, 0, '.', ',', [])+      "1"          `is` (1, 0, '.', ',', [])+      "1.1"        `is` (1.1, 1, '.', ',', [])+      "1,000.1"    `is` (1000.1, 1, '.', ',', [3])+      "1.00.000,1" `is` (100000.1, 1, ',', '.', [3,2])+      "1,000,000"  `is` (1000000, 0, '.', ',', [3,3])+      "1."         `is` (1,   0, '.', ',', [])+      "1,"         `is` (1,   0, ',', '.', [])+      ".1"         `is` (0.1, 1, '.', ',', [])+      ",1"         `is` (0.1, 1, ',', '.', [])+      assertFails "1,000.000,1"+      assertFails "1.000,000.1"+      assertFails "1,000.000.1"+      assertFails "1,,1"+      assertFails "1..1"+      assertFails ".1,"+      assertFails ",1."++   ,"ledgerTransaction" ~: do     assertParseEqual (parseWithCtx nullctx ledgerTransaction entry1_str) entry1     assertBool "ledgerTransaction should not parse just a date"                    $ isLeft $ parseWithCtx nullctx ledgerTransaction "2009/1/1\n"@@ -650,20 +717,36 @@          assertMixedAmountParse parseresult mixedamount =              (either (const "parse error") showMixedAmountDebug parseresult) ~?= (showMixedAmountDebug mixedamount)      assertMixedAmountParse (parseWithCtx nullctx someamount "1 @ $2")-                            (Mixed [Amount unknown 1 (Just $ Mixed [Amount dollar{precision=0} 2 Nothing])])+                            (Mixed [Amount unknown 1 (Just $ UnitPrice $ Mixed [Amount dollar{precision=0} 2 Nothing])])    ,"postingamount" ~: do     assertParseEqual (parseWithCtx nullctx postingamount " $47.18") (Mixed [dollars 47.18])     assertParseEqual (parseWithCtx nullctx postingamount " $1.")-                (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,comma=False,precision=0} 1 Nothing])+                (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} 1 Nothing])+  ,"postingamount with unit price" ~: do+    assertParseEqual+     (parseWithCtx nullctx postingamount " $10 @ €0.5")+     (Mixed [Amount{commodity=dollar{precision=0},+                    quantity=10,+                    price=(Just $ UnitPrice $ Mixed [Amount{commodity=euro{precision=1},+                                                            quantity=0.5,+                                                            price=Nothing}])}])+  ,"postingamount with total price" ~: do+    assertParseEqual+     (parseWithCtx nullctx postingamount " $10 @@ €5")+     (Mixed [Amount{commodity=dollar{precision=0},+                    quantity=10,+                    price=(Just $ TotalPrice $ Mixed [Amount{commodity=euro{precision=0},+                                                             quantity=5,+                                                             price=Nothing}])}])    ,"leftsymbolamount" ~: do     assertParseEqual (parseWithCtx nullctx leftsymbolamount "$1")-                     (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,comma=False,precision=0} 1 Nothing])+                     (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} 1 Nothing])     assertParseEqual (parseWithCtx nullctx leftsymbolamount "$-1")-                     (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,comma=False,precision=0} (-1) Nothing])+                     (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} (-1) Nothing])     assertParseEqual (parseWithCtx nullctx leftsymbolamount "-$1")-                     (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,comma=False,precision=0} (-1) Nothing])+                     (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} (-1) Nothing])   ] 
Hledger/Read/TimelogReader.hs view
@@ -43,8 +43,8 @@ -}  module Hledger.Read.TimelogReader (-       tests_TimelogReader,        reader,+       tests_Hledger_Read_TimelogReader ) where import Control.Monad.Error (ErrorT(..))@@ -96,6 +96,6 @@   comment <- optionMaybe (many1 spacenonewline >> liftM2 (++) getParentAccount restofline)   return $ TimeLogEntry (read [code]) datetime (maybe "" rstrip comment) -tests_TimelogReader = TestList [+tests_Hledger_Read_TimelogReader = TestList [  ] 
Hledger/Read/Utils.hs view
@@ -13,6 +13,7 @@  import Hledger.Data.Types (Journal, JournalContext(..), Commodity, JournalUpdate) import Hledger.Data.Utils+import Hledger.Data.Dates (getCurrentYear) import Hledger.Data.Journal (nullctx, nulljournal, journalFinalise)  @@ -25,7 +26,8 @@ parseJournalWith p f s = do   tc <- liftIO getClockTime   tl <- liftIO getCurrentLocalTime-  case runParser p nullctx f s of+  y <- liftIO getCurrentYear+  case runParser p nullctx{ctxYear=Just y} f s of     Right (updates,ctx) -> do                            j <- updates `ap` return nulljournal                            case journalFinalise tc tl f s ctx j of
hledger-lib.cabal view
@@ -1,10 +1,11 @@ name:           hledger-lib-version: 0.13+version: 0.14 category:       Finance-synopsis:       Core types and utilities for working with hledger (or c++ ledger) data.+synopsis:       Reusable types and utilities for the hledger accounting tool and financial apps in general. description:-                This is the reusable core of the hledger financial app; use it to-                build hledger and c++-ledger compatible finance tools.+                hledger is a haskell port and friendly fork of John Wiegley's ledger accounting tool.+                This package provides core data types, parsers and utilities used by the hledger tools.+                It also aims to be a useful library for building h/ledger-compatible tools or unrelated financial apps in haskell.  license:        GPL license-file:   LICENSE@@ -41,12 +42,14 @@                   Hledger.Data.TimeLog                   Hledger.Data.Types                   Hledger.Data.Utils+                  Hledger.Data.UTF8                   Hledger.Read                   Hledger.Read.Utils                   Hledger.Read.JournalReader                   Hledger.Read.TimelogReader   Build-Depends:                   base >= 3 && < 5+                 ,bytestring                  ,containers                  ,directory                  ,filepath