penny-lib-0.6.0.0: Penny/Brenner/Amex.hs
-- | Parses Amex credit card data. See the help text in the 'help'
-- function for more details. Also, the file format is documented in
-- the file @doc\/amex-file-format.org@.
module Penny.Brenner.Amex (parser) where
import Control.Applicative ((<$>), (<*>), (<$), (<*), (*>), pure,
(<|>), optional)
import qualified Data.Time as Time
import qualified Penny.Brenner.Types as Y
import Text.Parsec.Text (Parser)
import qualified Text.Parsec as P
import Text.Parsec (many1, char, many, satisfy)
import qualified Data.Text as X
import qualified Data.Text.IO as TIO
import qualified Control.Monad.Exception.Synchronous as Ex
parser :: (String, Y.FitFileLocation
-> IO (Ex.Exceptional String [Y.Posting]))
parser = (help, loadIncoming)
help :: String
help = unlines
[ "Parses American Express transaction data in CSV format."
, "Not tested with American Express deposit accounts."
, "To download, click the \"Download\" link which is visible"
, "above and to the right of the transaction list. Under"
, "\"Download Current View\" Select \"CSV\""
, "and be sure to click"
, "\"Include all additional Transaction Details.\""
]
-- | Loads incoming Amex transactions.
loadIncoming :: Y.FitFileLocation
-> IO (Ex.Exceptional String [Y.Posting])
loadIncoming (Y.FitFileLocation loc) = do
txt <- TIO.readFile loc
let parsed = P.parse (P.many posting <* P.eof) "" txt
err s = "could not parse incoming postings: " ++ show s
return (Ex.mapException err . Ex.fromEither $ parsed)
skipThrough :: Char -> Parser ()
skipThrough c = () <$ many (satisfy (/= c)) <* char c
readThrough :: Char -> Parser String
readThrough c = many (satisfy (/= c)) <* char c
date :: Parser Y.Date
date = p >>= failOnErr
where
p = (,,)
<$> fmap read (many1 P.digit)
<* char '/'
<*> fmap read (many1 P.digit)
<* char '/'
<*> fmap read (many1 P.digit)
<* skipThrough ','
failOnErr (m, d, y) = maybe (fail "could not parse date")
(return . Y.Date)
$ Time.fromGregorianValid y m d
incDecAmount :: Parser (Y.IncDec, Y.Amount)
incDecAmount = do
incDec <- (Y.Decrease <$ char '-') <|> pure Y.Increase
amtStr <- readThrough ','
case Y.mkAmount amtStr of
Nothing -> fail $ "could not parse amount: " ++ amtStr
Just a -> return (incDec, a)
doubleQuoted :: Parser String
doubleQuoted = char '"' *> readThrough '"'
desc :: Parser Y.Desc
desc = fmap (Y.Desc . X.pack) $ doubleQuoted <* char ','
payee :: Parser Y.Payee
payee = fmap (Y.Payee . X.pack) $ doubleQuoted <* char ','
amexId :: Parser Y.FitId
amexId = fmap (Y.FitId . X.pack)
$ char '"' *> char '\'' *> readThrough '\''
<* char '"' <* char ','
-- | Skips a field. Will skip a quoted field or,
-- alternatively, skip everything through to the next comma. Do not
-- use for the last field, as it looks for a trailing comma.
skipField :: Parser ()
skipField =
()
<$ skipper
<* char ','
where
skipper = (() <$ optional doubleQuoted)
<|> (() <$ many (satisfy (/= ',')))
-- | Parses last field (currently unknown). Parsers the EOL character.
skipLast :: Parser ()
skipLast = skipThrough '\n'
posting :: Parser Y.Posting
posting =
f
<$> date -- 1
<* skipField -- 2 Unknown
<*> desc -- 3 Description
<* skipField -- 4 Unknown
<* skipField -- 5 Unknown
<* skipField -- 6 Unknown
<* skipField -- 7 Unknown
<*> incDecAmount -- 8
<* skipField -- 9 Unknown
<* skipField -- 10 Category
<*> payee -- 11 D.B.A.
<* skipField -- 12 Address
<* skipField -- 13 Postcode
<*> amexId -- 14
<* skipField -- 15 Unknown
<* skipLast -- 16
where
f dt ds (t, a) p i = Y.Posting dt ds t a p i