-- | Brenner - Penny financial institution interfaces -- -- Brenner provides a uniform way to interact with downloaded data -- from financial Given a parser, Brenner will import the transactions -- and store them in a database. From there it is easy to merge the -- transactions (without duplicates) into a ledger file, and then to -- clear transactions from statements in an automated fashion. module Penny.Brenner ( FitAcct(..) , Config(..) , Su.S3(..) , L.Radix(..) , L.PeriodGrp(..) , L.CommaGrp(..) , Y.Translator(..) , L.Side(..) , L.SpaceBetween(..) , usePayeeOrDesc , brennerMain , ofxParser , ofxPrepassParser ) where import qualified Penny.Brenner.Types as Y import qualified Data.Text as X import qualified Data.Version as V import qualified Penny.Liberty as Ly import qualified Penny.Lincoln as L import qualified Penny.Lincoln.Builders as Bd import qualified Penny.Brenner.Clear as C import qualified Penny.Brenner.Database as D import qualified Penny.Brenner.Import as I import qualified Penny.Brenner.Info as Info import qualified Penny.Brenner.Merge as M import qualified Penny.Brenner.OFX as O import qualified Penny.Brenner.Print as P import qualified Data.Sums as Su import qualified System.Console.MultiArg as MA import System.Environment (getProgName) import qualified System.Exit as Exit -- | Brenner, with a pre-compiled configuration. brennerMain :: V.Version -- ^ Binary version -> Config -> IO () brennerMain v cf = do let cf' = convertConfig cf pn <- getProgName ei <- MA.modesIO (globalOpts v) (preProcessor cf') case ei of Left showHelp -> putStr (showHelp pn) >> Exit.exitSuccess Right g -> g type GetHelp = MA.ProgName -> String -- | Parses global options for a pre-compiled configuration. globalOpts :: V.Version -- ^ Binary version -> MA.Opts GetHelp Y.FitAcctName globalOpts v = MA.optsHelpVersion help (Ly.version v) [ MA.OptSpec ["fit-account"] "f" (MA.OneArg (return . Y.FitAcctName . X.pack)) ] -- | Pre-processes global options for a pre-compiled configuration. preProcessor :: Y.Config -> [Y.FitAcctName] -> Either String (Either (IO ()) [MA.Mode (MA.ProgName -> String) (IO ())]) preProcessor cf args = do mayFi <- case args of [] -> return $ Y.defaultFitAcct cf _ -> let pdct a = Y.fitAcctName a == s s = last args toFilter = case Y.defaultFitAcct cf of Nothing -> Y.moreFitAccts cf Just d -> d : Y.moreFitAccts cf in case filter pdct toFilter of [] -> Left $ "financial institution account " ++ (X.unpack . Y.unFitAcctName $ s) ++ " not configured." c:[] -> return $ Just c _ -> Left $ "more than one financial institution account " ++ "named " ++ (X.unpack . Y.unFitAcctName $ s) ++ " configured." return . Right $ allModes cf mayFi -- | Each mode takes a Maybe FitAcct. Even if every mode needs a -- FitAcct to function, they take a Maybe FitAcct because otherwise -- the user will not even get online help if a FitAcct is not -- supplied. Each mode must fail on its own if it actually needs a -- FitAcct. allModes :: Y.Config -> Maybe Y.FitAcct -> [MA.Mode (MA.ProgName -> String) (IO ())] allModes c a = [ C.mode a , I.mode a , Info.mode c , M.mode a , P.mode a , D.mode a ] -- | Help for a pre-compiled configuration. help :: String -- ^ Program name -> String help n = unlines ls where ls = [ "usage: " ++ n ++ " [global-options]" ++ " COMMAND [local-options]" ++ " ARGS..." , "" , "where COMMAND is one of:" , "import, merge, clear, database, print, info" , "" , "For help on an individual command and its" ++ " local options, use " , n ++ " COMMAND --help" , "" , "Global Options:" , "-f, --fit-account ACCOUNT" , " use the given financial institution account" , " (use the \"info\" command to see which are available)." , " If this option does not appear," , " the default account is used if there is one." ] -- | Information to configure a single financial institution account. data FitAcct = FitAcct { fitAcctName :: String -- ^ Name for this financial institution account, e.g. @House -- Checking@ or @Megabank@. , fitAcctDesc :: String -- ^ Additional information about this financial institution -- account. Here I put information on where to find the statments -- for download on the website. , dbLocation :: String -- ^ Path and filename to where the database is kept. You can use -- an absolute or relative path (if it is relative, it will be -- resolved relative to the current directory at runtime.) , pennyAcct :: String -- ^ The account that you use in your Penny file to hold -- transactions for this card. Separate each sub-account with -- colons (as you do in the Penny file.) , defaultAcct :: String -- ^ When new transactions are created, one of the postings will -- be in the amexAcct given above. The other posting will be in -- this account. , currency :: String -- ^ The commodity for the currency of your card (e.g. @$@). , qtySpec :: Su.S3 L.Radix L.PeriodGrp L.CommaGrp -- ^ How to group digits when printing the resulting ledger. -- -- Penny remembers the formatting of quantities entered in your -- ledger. However, quantities imported from your bank statement -- do not have formatting to remember, so you have to tell Penny -- how to format them. , translator :: Y.Translator -- ^ See the documentation under the 'Translator' type for -- details. , side :: L.Side -- ^ When creating new transactions, the commodity will be on this -- side , spaceBetween :: L.SpaceBetween -- ^ When creating new transactions, is there a space between the -- commodity and the quantity , parser :: ( Y.ParserDesc , Y.FitFileLocation -> IO (Either String [Y.Posting])) -- ^ Parses a file of transactions from the financial -- institution. The function must open the file and parse it. This -- is in the IO monad not only because the function must open the -- file itself, but also so the function can perform arbitrary IO -- (run pdftotext, maybe?) If there is failure, the function can -- return an Exceptional String, which is the error -- message. Alternatively the function can raise an exception in the -- IO monad (currently Brenner makes no attempt to catch these) so -- if any of the IO functions throw you can simply not handle the -- exceptions. -- -- The first element of the pair is a help string which should -- indicate how to download the data, as a helpful reminder. , toLincolnPayee :: Y.Desc -> Y.Payee -> L.Payee -- ^ Sometimes the financial institution provides Payee information, -- sometimes it does not. Sometimes the Desc might have additional -- information that you might want to remove. This function can be -- used to do that. The resulting Lincoln Payee is used for any -- transactions that are created by the merge command. The resulting -- payee is also used when comparing new financial institution -- postings to already existing ledger transactions in order to -- guess at which payee and accounts to create in the transactions -- created by the merge command. } convertFitAcct :: FitAcct -> Y.FitAcct convertFitAcct (FitAcct fn fd db ax df cy gs tl sd sb ps tlp) = Y.FitAcct { Y.fitAcctName = Y.FitAcctName . X.pack $ fn , Y.fitAcctDesc = Y.FitAcctDesc . X.pack $ fd , Y.dbLocation = Y.DbLocation . X.pack $ db , Y.pennyAcct = Y.PennyAcct . Bd.account . X.pack $ ax , Y.defaultAcct = Y.DefaultAcct . Bd.account . X.pack $ df , Y.currency = Y.Currency . L.Commodity . X.pack $ cy , Y.qtySpec = gs , Y.translator = tl , Y.side = sd , Y.spaceBetween = sb , Y.parser = ps , Y.toLincolnPayee = tlp } data Config = Config { defaultFitAcct :: Maybe FitAcct , moreFitAccts :: [FitAcct] } convertConfig :: Config -> Y.Config convertConfig (Config d m) = Y.Config { Y.defaultFitAcct = fmap convertFitAcct d , Y.moreFitAccts = map convertFitAcct m } -- | A simple function to use for 'toLincolnPayee'. Uses the financial -- institution payee if it is available; otherwise, uses the financial -- institution description. usePayeeOrDesc :: Y.Desc -> Y.Payee -> L.Payee usePayeeOrDesc (Y.Desc d) (Y.Payee p) = L.Payee $ if X.null p then d else p -- | Parser for OFX data. ofxParser :: (Y.ParserDesc, Y.ParserFn) ofxParser = O.parser -- | Parser for OFX data, with a prepass phase. Any incoming data is -- first filtered through the given function. This allows you to -- correct broken OFX statements. For example, Bank of America issues -- OFX files that do not properly escape ampersands. Using this -- function you can change every ampersand to something properly -- escaped (or just change it to the word \"and\".) ofxPrepassParser :: (String -> String) -> (Y.ParserDesc, Y.ParserFn) ofxPrepassParser = O.prepassParser