-- | 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