penny-lib-0.8.0.0: Penny/Brenner.hs
-- | 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(..)
, R.GroupSpecs(..)
, R.GroupSpec(..)
, Y.Translator(..)
, L.Side(..)
, L.SpaceBetween(..)
, brennerMain
) where
import qualified Penny.Brenner.Types as Y
import Data.Maybe (mapMaybe)
import qualified Data.Text as X
import qualified Penny.Lincoln as L
import qualified Penny.Lincoln.Builders as Bd
import qualified Penny.Copper.Render as R
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.Merge as M
import qualified Penny.Brenner.Print as P
import Control.Applicative ((<*>))
import qualified System.Console.MultiArg as MA
import qualified Control.Monad.Exception.Synchronous as Ex
brennerMain :: Config -> IO ()
brennerMain cf = do
let cf' = convertConfig cf
ioAction <- MA.modesWithHelp (help cf') globalOpts
(preProcessor cf')
ioAction
data Arg = AFitAcct String
deriving (Eq, Show)
toFitAcctOpt :: Arg -> Maybe String
toFitAcctOpt a = case a of { AFitAcct s -> Just s }
globalOpts :: [MA.OptSpec Arg]
globalOpts =
[ MA.OptSpec ["fit-account"] "f" (MA.OneArg AFitAcct) ]
preProcessor
:: Y.Config -> [Arg] -> Either (a -> IO ()) [MA.Mode (IO ())]
preProcessor cf as = Ex.toEither . Ex.mapException (const . fail) $ do
let mayFiStr = case mapMaybe toFitAcctOpt as of
[] -> Nothing
xs -> Just . last $ xs
fi <- case mayFiStr of
Nothing -> return $ Y.defaultFitAcct cf
Just s ->
let pdct (Y.Name n, _) = n == X.pack s
in case filter pdct (Y.moreFitAccts cf) of
[] -> Ex.throw $
"financial institution account "
++ s ++ " not configured."
(_, c):[] -> return $ Just c
_ -> Ex.throw $
"more than one financial institution account "
++ "named " ++ s ++ " configured."
return $ [C.mode, I.mode, M.mode, P.mode, D.mode] <*> [fi]
help
:: Y.Config
-> String
-- ^ Program name
-> String
help c n = unlines ls ++ cs
where
ls = [ "usage: " ++ n ++ " [global-options]"
++ " COMMAND [local-options]"
++ " ARGS..."
, ""
, "where COMMAND is one of:"
, "import, merge, clear, database, print"
, ""
, "For help on an individual command and its"
++ " local options, use "
, n ++ " COMMAND --help"
, ""
, "Global Options:"
, "-f, --fit-account ACCOUNT"
, " Use one of the Additional Financial Institution"
, " Accounts shown below. If this option does not appear,"
, " the default account is used if there is one."
, "-h, --help"
, " Show help and exit"
, ""
]
showPair (Y.Name a, cd) = "Additional financial institution "
++ "account: " ++ X.unpack a ++ "\n" ++ showFitAcct cd
cs = showDefaultFitAcct (Y.defaultFitAcct c)
++ more
more = if null (Y.moreFitAccts c)
then "No additional financial institution accounts\n"
else concatMap showPair . Y.moreFitAccts $ c
showDefaultFitAcct :: Maybe Y.FitAcct -> String
showDefaultFitAcct mc = case mc of
Nothing -> "No default financial institution account\n"
Just c -> "Default financial institution account:\n" ++ showFitAcct c
label :: String -> String -> String
label l o = " " ++ l ++ ": " ++ o ++ "\n"
showAccount :: L.Account -> String
showAccount =
X.unpack
. X.intercalate (X.singleton ':')
. map L.unSubAccount
. L.unAccount
showFitAcct :: Y.FitAcct -> String
showFitAcct c =
label "Database location"
(X.unpack . Y.unDbLocation . Y.dbLocation $ c)
++ label "Penny account"
(showAccount . Y.unPennyAcct . Y.pennyAcct $ c)
++ label "Account for new offsetting postings"
(showAccount . Y.unDefaultAcct . Y.defaultAcct $ c)
++ label "Currency"
(X.unpack . L.unCommodity . Y.unCurrency . Y.currency $ c)
++ "\n"
++ "More information about the parser:\n"
++ (fst . Y.parser $ c)
++ "\n\n"
-- | Information to configure a single financial institution account.
data FitAcct = FitAcct
{ 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. @$@).
, groupSpecs :: R.GroupSpecs
-- ^ How to group digits when printing the resulting ledger. All
-- quantities (not just those affected by this program) will be
-- formatted using this specification.
, 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 :: ( String
, Y.FitFileLocation -> IO (Ex.Exceptional 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.
} deriving Show
convertFitAcct :: FitAcct -> Y.FitAcct
convertFitAcct (FitAcct db ax df cy gs tl sd sb ps) = Y.FitAcct
{ 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.groupSpecs = gs
, Y.translator = tl
, Y.side = sd
, Y.spaceBetween = sb
, Y.parser = ps
}
data Config = Config
{ defaultFitAcct :: Maybe FitAcct
, moreFitAccts :: [(String, FitAcct)]
} deriving Show
convertConfig :: Config -> Y.Config
convertConfig (Config d m) = Y.Config
{ Y.defaultFitAcct = fmap convertFitAcct d
, Y.moreFitAccts =
let f (n, c) = (Y.Name (X.pack n), convertFitAcct c)
in map f m
}