1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978
|
{-# LANGUAGE OverloadedStrings #-}
-- | Parser for downloaded OFX files.
--
-- This parser was written based on the OFX version 1.03
-- specification, which is available at
--
-- <http://www.ofx.net>
--
-- It will probably work on earlier versions of OFX without
-- incident. However, it may or may not not work on newer versions of
-- OFX, which are XML based (this version of OFX is SGML based.)
--
-- It will also parse QFX files, which are OFX files with minor
-- proprietary additions by Intuit, the maker of Quicken.
--
-- An OFX file consists of three parts: the HTTP headers (which this
-- parser does NOT handle because typically they will not show up in
-- files downloaded to disk), the OFX headers, and the OFX data. This
-- parser handles the OFX headers and the OFX data.
--
-- The parser in this module simply parses the tags and data into a
-- tree, which you can manipulate with other functions. Some functions
-- are provided to find the transactions in the tree and place them
-- into a 'Transaction' type, which is the data you are most likely
-- interested in. If you are interested in other data you can query
-- the 'Tag' tree for what you need.
--
-- For example, to read in the filename given on the command line and
-- parse it and print it nicely:
--
-- > import System.Environment
-- > import Text.Parsec
-- > import Text.PrettyPrint
-- > import Data.OFX
-- > import System.IO
-- > import System.Exit
-- >
-- > main :: IO ()
-- > main = do
-- > filename:[] <- getArgs
-- > contents <- readFile filename
-- > ofx <- case parse ofxFile filename contents of
-- > Left e -> do
-- > hPutStrLn stderr . show $ e
-- > exitFailure
-- > Right g -> return g
-- > putStrLn . render . pFile $ ofx
-- > putStrLn
-- > . render
-- > . pExceptional text (pList . map pTransaction)
-- > . transactions
-- > $ ofx
-- > putStrLn . render . pMaybe text . fiName $ ofx
-- > putStrLn . render . pMaybe text . accountNumber $ ofx
--
module Data.OFX
( -- * Error handling
Err
-- * The OFX data tree
, HeaderTag
, HeaderValue
, OFXHeader(..)
, TagName
, TagData
, Tag(..)
, OFXFile(..)
-- * Manipulating the OFX tag tree
, find
, findPath
, tagData
, pathData
, findData
-- * Extracting specific data
, fiName
, creditCardNumber
, bankAccountNumber
, accountNumber
-- * Types to represent specific OFX data
, Transaction(..)
, transaction
, transactions
, TrnType(..)
, trnType
, Payee(..)
, payee
, CorrectAction(..)
, BankAcctTo(..)
, bankAcctTo
, CCAcctTo(..)
, ccAcctTo
, AcctType(..)
, acctType
, CurrencyData(..)
, currencyData
, Currency(..)
, currency
, OrigCurrency(..)
, origCurrency
-- * Parsec parsers
, ofxFile
, newline
, escChar
, header
, openingTag
, closingTag
, tag
, date
, time
, tzOffset
-- * Pretty printers
, pPayee
, pTransaction
, pTag
, pHeader
, pFile
, pEither
, pMaybe
, pList
, label
, pExceptional
) where
--
-- # Imports
--
import Control.Applicative
( (<$), (<|>), (*>), many, optional,
(<$>), (<*), (<*>), pure )
import Control.Monad (replicateM)
import qualified Data.Time as T
import Text.Parsec.String (Parser)
import Text.Parsec
( lookAhead, char, manyTill, anyChar, (<?>), eof,
try, digit, many1, spaces, string, choice )
import qualified Text.Parsec as P
import Data.Maybe (listToMaybe)
import qualified Data.Monoid as M
import Data.Monoid ((<>), mempty)
import Text.PrettyPrint
( Doc, hang, text, sep, vcat, nest, (<+>), ($$),
parens, brackets )
--
-- # Error handling
--
-- | Error handling. Errors are indicated with a Left String;
-- successes with a Right.
type Err = Either String
--
-- # Data types
--
-- | Headers consists of simple @tag:value@ pairs; this represents the
-- tag.
type HeaderTag = String
-- | The value in an OFX header.
type HeaderValue = String
-- | An OFX file starts with a number of headers, which take the form
-- @tag:value@ followed by a newline. These are followed by a blank
-- line.
data OFXHeader = OFXHeader HeaderTag HeaderValue
deriving (Eq, Show)
-- | The name of an OFX tag
type TagName = String
-- | The data accompanying an OFX tag.
type TagData = String
-- | The main OFX data consists of a series of tags. OFX 1.03 is SGML,
-- not XML. This means that opening tags need not have closing
-- tags. In OFX, a tag either has data and no child elements, or it
-- has no data and it has child elements.
data Tag = Tag TagName (Either TagData [Tag])
deriving (Eq, Show)
-- | All the data from an OFX file.
data OFXFile = OFXFile
{ fHeader :: [OFXHeader]
, fTag :: Tag
-- ^ All the data will be contained in a root tag with the TagName
-- @OFX@.
} deriving (Eq, Show)
--
-- # Parsers
--
-- | Parses either a UNIX or an MS-DOS newline. According to 1.2.2,
-- OFX does not contain any white space between tags. However, since I
-- have seen OFX files that do have whitespace between tags, the
-- parser makes allowance for this.
newline :: Parser ()
newline = () <$ char '\n' <|> () <$ (char '\r' *> char '\n')
<?> "newline"
-- | Parses a character, possibly with an escape sequence. The
-- greater-than sign, less-than sign, and ampersand must be entered
-- with escape sequences.
--
-- According to OFX spec section 2.3.2.1, ampersands, less-than signs,
-- and greater-than signs must appear as entities. However some banks
-- deliver broken OFX files that do not use entities for ampersands
-- (and possibly for less-than or greater-than signs too, although I
-- have not yet observed such behavior.) There is now an error message
-- that reflects this problem. Client code can filter the OFX data
-- for known offenders before passing it to this library.
escChar :: Parser Char
escChar =
do
c <- anyChar
case c of
'&' -> do
let mkParser (ch, str) = try (ch <$ string str)
ent <- choice (map mkParser
[('<', "lt"), ('>', "gt"), ('&', "amp")])
<?> ( "entity (\"lt\", \"gt\", or \"amp\")\n"
++ "some banks create broken OFX files. Try\n"
++ "removing the ampersands from the file and\n"
++ "trying again.")
_ <- char ';'
return ent
_ -> return c
<?> "character"
header :: Parser OFXHeader
header
= OFXHeader
<$> manyTill anyChar (char ':')
<* optional (many (char ' '))
<*> manyTill anyChar newline
<?> "OFX header"
-- | Parses any opening tag. Returns the name of the tag.
openingTag :: Parser TagName
openingTag =
do
_ <- char '<'
cs <- manyTill escChar (char '>')
case cs of
[] -> fail "opening tag with empty name"
x:_ ->
if x == '/'
then fail "this is a closing tag"
else return cs
<?> "opening tag"
-- | Parses a closing tag with the given name.
closingTag :: TagName -> Parser ()
closingTag n =
do
_ <- char '<'
_ <- char '/'
cs <- manyTill escChar (char '>')
if cs == n
then return ()
else fail $ "expecting closing tag named " ++ n
++ "; saw closing tag named " ++ cs
<?> "closing tag named " ++ n
-- | Parses any tag. The tag itself must be followed by at least one
-- character: either the next tag if this is an aggregate tag, or the
-- data if this is a data tag. OFX does not allow empty tags.
--
-- The OFX spec seems to say that OFX files do not include trailing
-- newlines after tags or data, but I have seen these newlines in QFX
-- files, so this parses optional trailing newlines and spaces.
tag :: Parser Tag
tag =
do
-- try is needed because openingTag will overlap with closingTag
n <- try (openingTag <* spaces)
children <- many tag
if null children
then Tag n . Left
<$> manyTill escChar
(eof <|> lookAhead (() <$ char '<') <|> newline)
<* spaces
<* optional (try (closingTag n))
<* spaces
else Tag n (Right children) <$ spaces <* closingTag n
<* spaces
<?> "OFX tag"
-- | Parses an entire OFX file, including headers.
ofxFile :: Parser OFXFile
ofxFile
= OFXFile
<$> manyTill header newline
<*> tag
<* spaces
<* eof
<?> "OFX file"
-- | Parses an OFX date; provides an error message if the parse fails.
parseDate :: String -> Err T.ZonedTime
parseDate s = case P.parse date "" s of
Left e -> Left $ "could not parse date: " ++ s ++ ": "
++ show e
Right g -> return g
-- | Parses an OFX date. Fails if the date is not valid or if there is
-- no date to be parsed.
date :: Parser T.ZonedTime
date =
do
ys <- fmap read $ replicateM 4 digit
ms <- fmap read $ replicateM 2 digit
ds <- fmap read $ replicateM 2 digit
day <- case T.fromGregorianValid ys ms ds of
Nothing -> fail $ "invalid date: " ++ show ys
++ "-" ++ show ms ++ "-" ++ show ds
Just d -> return d
mayTime <- optional time
case mayTime of
Nothing ->
let localTime = T.LocalTime day T.midnight
in return $ T.ZonedTime localTime T.utc
Just (t, z) -> return $ T.ZonedTime (T.LocalTime day t) z
<?> "date"
-- | Parses an OFX time. Fails if the time is not valid or if there is
-- no time to parse. Fails if there is no time to parse; however, if
-- there is a time but no zone, returns the time and UTC for the zone.
time :: Parser (T.TimeOfDay, T.TimeZone)
time =
do
h <- fmap read $ replicateM 2 digit
m <- fmap read $ replicateM 2 digit
s <- fmap read $ replicateM 2 digit
(milli, tz) <- do
mayDot <- optional (char '.')
case mayDot of
Nothing -> return (0, T.utc)
Just _ -> do
mil <- fmap ((/ 1000) . read) $ replicateM 3 digit
mayTz <- optional tzOffset
case mayTz of
Nothing -> return (mil, T.utc)
Just t -> return (mil, t)
let sec = s + milli
return (T.TimeOfDay h m sec, tz)
<?> "time"
-- | Parses a time zone offset. Fails if there is no time zone offset
-- to parse.
tzOffset :: Parser T.TimeZone
tzOffset =
do
_ <- char '['
sn <- parseSign
whole <- many1 digit
mayDot <- optional (char '.')
frac <- case mayDot of
Nothing -> return "0"
Just _ -> many1 digit
mayColon <- optional (char ':')
name <- case mayColon of
Nothing -> return ""
Just _ -> many1 P.letter
_ <- char ']'
let off = sn $ round ((read (whole ++ "." ++ frac))
* (60 :: Double))
return $ T.TimeZone off False name
<?> "time zone offset"
where
parseSign = do
mayMinus <- optional (char '-')
case mayMinus of
Nothing -> do
mayPlus <- optional (char '+')
return $ case mayPlus of
Nothing -> id
Just _ -> negate
Just _ -> return negate
--
-- # Manipulating trees of tags
--
-- | Finds child tags with the given name. When a tag is found, that
-- tag is not searched for further children with the same name.
find :: TagName -> Tag -> [Tag]
find n t@(Tag x p)
| n == x = [t]
| otherwise = case p of
Left _ -> []
Right ts -> concatMap (find n) ts
-- | Descends through a tree of tags to find a tag at a specific
-- location in the tree. Fails if any part of the search fails. For
-- example, to find the financial institution ORG tag, where @t@ is
-- the root @OFX@ tag:
--
-- > findPath ["SIGNONMSGSRSV1", "SONRS", "FI", "ORG"] t
findPath :: [TagName] -> Tag -> Maybe Tag
findPath [] t = Just t
findPath (n:ns) t = case listToMaybe (find n t) of
Nothing -> Nothing
Just r -> findPath ns r
-- | Gets the data from a tag, if it is a tag with data.
tagData :: Tag -> Maybe TagData
tagData (Tag _ ei) = either return (const Nothing) ei
-- | Goes to a certain path in the tag hierarchy and pulls the
-- requested data, if the tag is present and it is a data tag. For
-- example, to get the name of the financial institution:
--
-- > pathData ["SIGNONMSGSRSV1", "SONRS", "FI", "ORG"] f
pathData :: [TagName] -> OFXFile -> Maybe TagData
pathData p (OFXFile _ t) = findPath p t >>= tagData
-- | Gets the name of the financial institution from the FI tag, if
-- available. The OFX spec does not require this tag to be present.
fiName :: OFXFile -> Maybe TagData
fiName = pathData ["SIGNONMSGSRSV1", "SONRS", "FI", "ORG"]
-- | Gets the credit card number, if available. The OFX spec does not
-- require this tag to be present.
creditCardNumber :: OFXFile -> Maybe TagData
creditCardNumber =
pathData [ "CREDITCARDMSGSRSV1", "CCSTMTTRNRS", "CCSTMTRS",
"CCACCTFROM", "ACCTID" ]
-- | Gets the bank account number, if available. The OFX spec does not
-- require this tag to be present.
bankAccountNumber :: OFXFile -> Maybe TagData
bankAccountNumber =
pathData [ "BANKMSGSRSV1", "STMTTRNRS", "STMTRS",
"BANKACCTFROM", "ACCTID" ]
-- | Gets either the credit card or bank account number, if available.
accountNumber :: OFXFile -> Maybe TagData
accountNumber f = creditCardNumber f <|> bankAccountNumber f
-- | Finds the first tag (either this tag or any children) that has
-- the given name and that is a data tag (not an aggregate tag.) If no
-- data tag with the given name is found, returns Nothing.
findData :: TagName -> Tag -> Maybe TagData
findData n (Tag tn e) = case e of
Left d -> if tn == n then Just d else Nothing
Right ts -> M.getFirst . M.mconcat . map M.First
. map (findData n) $ ts
-- | Finds the first tag (either this tag or any children) that has
-- the given name and that is a data tag. Gives an error message if
-- the tag is not found.
required :: TagName -> Tag -> Err TagData
required n t = case findData n t of
Nothing -> Left $ "required tag missing: " ++ n
Just r -> return r
--
-- # OFX data
--
-- | OFX transaction types. These are used in STMTTRN aggregates, see
-- OFX spec section 11.4.2.3.1.1.
data TrnType
= TCREDIT
| TDEBIT
| TINT
-- ^ Interest earned or paid (which it is depends on sign of amount)
| TDIV
-- ^ Dividend
| TFEE
| TSRVCHG
| TDEP
-- ^ Deposit
| TATM
-- ^ ATM debit or credit (which it is depends on sign of amount)
| TPOS
-- ^ Point of sale debit or credit (which it is depends on sign of
-- amount)
| TXFER
-- ^ Transfer
| TCHECK
| TPAYMENT
-- ^ Electronic payment
| TCASH
-- ^ Cash withdrawal
| TDIRECTDEP
-- ^ Direct deposit
| TDIRECTDEBIT
-- ^ Merchant initiated debit
| TREPEATPMT
-- ^ Repeating payment / standing order
| TOTHER
deriving (Eq, Ord, Show)
-- | A single STMTTRN, see OFX spec section 11.4.2.3.1. This is most
-- likely what you are interested in after downloading a statement
-- from a bank.
data Transaction = Transaction
{ txTRNTYPE :: TrnType
-- ^ Transaction type
, txDTPOSTED :: T.ZonedTime
-- ^ Date transaction was posted to account
, txDTUSER :: Maybe T.ZonedTime
-- ^ Date user initiated transaction, if known
, txDTAVAIL :: Maybe T.ZonedTime
-- ^ Date funds are available
, txTRNAMT :: String
-- ^ Amount of transaction. This is left as the string that was
-- originally in the download. That means the transaction may
-- include a plus or minus sign (no sign is the same as a plus
-- sign.) According to section 3.2.9.2, amounts are always signed
-- from the perspective of the customer.
--
-- Typically negative amounts:
--
-- * Investment buy amount, investment sell quantity
--
-- * Bank statement debit amounts, checks, fees
--
-- * Credit card purchases
--
-- * Margin balance (unless the institution owes the client money)
--
-- Typically positive amounts:
--
-- * Investment sell amount, investment buy quantity
--
-- * Bank statement credits
--
-- * Credit card payments
--
-- * Ledger balance (unless the account is overdrawn)
--
-- Formats for amounts are described in 3.2.9.1. If there is no
-- decimal point, there is an implied decimal point at the end of
-- the value. Trailing and leading spaces \"should\" be
-- stripped. Positive or minus is indicated with a leading sign; a
-- plus sign is assumed if there is no sign.
--
-- An amount has a maximum of 32 alphanumeric characters,
-- including digits and punctuation.
--
-- The radix point is indicated with either a period or a
-- comma. Amounts \"should\" not include any digit grouping
-- characters.
, txFITID :: String
-- ^ Transaction ID issued by financial institution. Used to
-- detect duplicate downloads.
, txCORRECTFITID :: Maybe String
-- ^ If present, this indicates the FITID of a previously sent
-- transaction that is corrected by this record. This transaction
-- replaces or deletes the transaction that it corrects, based on
-- the value of CORRECTACTION below.
, txCORRECTACTION :: Maybe CorrectAction
-- ^ See 'CorrectAction' and 'txCORRECTFITID'
, txSRVRTID :: Maybe String
-- ^ Server assigned transaction ID; used for transactions
-- initiated by client, such as payment or funds transfer
, txCHECKNUM :: Maybe String
-- ^ Check or other reference number
, txREFNUM :: Maybe String
-- ^ Reference number that uniquely identifies the
-- transaction. Can be used in addition to or instead of a
-- CHECKNUM.
, txSIC :: Maybe String
-- ^ Standard Industrial Code
, txPAYEEID :: Maybe String
-- ^ Payee identifier if available
, txPayeeInfo :: Maybe (Either String Payee)
-- ^ Information on the payee. The OFX spec seems to be saying
-- that every transaction must have either NAME, wich is \"name of
-- payee or description of transaction\", or the Payee
-- aggregate. The former is indicated with a Left, the latter with
-- a Right.
, txAccountTo :: Maybe (Either BankAcctTo CCAcctTo)
-- ^ Information on a transfer. If this transaction wa sa
-- transfer, this may contain information about the account being
-- transferred to.
, txMEMO :: Maybe String
-- ^ Extra information not in NAME
, txCurrency :: Maybe (Either Currency OrigCurrency)
-- ^ Currency option. OFX spec says to choose either CURRENCY or
-- ORIGCURRENCY.
} deriving (Show)
data Payee = Payee
{ peNAME :: String
, peADDR1 :: String
, peADDR2 :: Maybe String
, peADDR3 :: Maybe String
, peCITY :: String
, peSTATE :: String
, pePOSTALCODE :: String
, peCOUNTRY :: Maybe String
, pePHONE :: String
} deriving (Eq, Show)
-- | Can be either REPLACE or DELETE.
data CorrectAction
= REPLACE
-- ^ Replaces the transaction referenced by the CORRECTFITID
| DELETE
-- ^ Deletes the transaction referenced by the CORRECTFITID
deriving (Eq, Show, Read)
data BankAcctTo = BankAcctTo
{ btBANKID :: String
-- ^ Routing and transit number
, btBRANCHID :: Maybe String
-- ^ Bank identifier for international banks
, btACCTID :: String
-- ^ Account number
, btACCTTYPE :: AcctType
-- ^ Type of account
, btACCTKEY :: Maybe String
-- ^ Checksum for international banks
} deriving Show
data CCAcctTo = CCAcctTo
{ ctACCTID :: String
-- ^ Account number
, ctACCTKEY :: Maybe String
-- ^ Checksum for international banks
} deriving (Eq, Show)
data AcctType
= ACHECKING
| ASAVINGS
| AMONEYMRKT
| ACREDITLINE
deriving (Eq, Show, Ord)
acctType :: String -> Err AcctType
acctType s
| s == "CHECKING" = return ACHECKING
| s == "SAVINGS" = return ASAVINGS
| s == "MONEYMRKT" = return AMONEYMRKT
| s == "CREDITLINE" = return ACREDITLINE
| otherwise = Left $ "unrecognized account type: " ++ s
-- | Holds all data both for CURRENCY and for ORIGCURRENCY.
data CurrencyData = CurrencyData
{ cdCURRATE :: String
-- ^ Ratio of CURDEF currency to CURSYM currency, in decimal form
, cdCURSYM :: String
-- ^ ISO-4217 3-letter currency identifier
} deriving (Eq, Show)
data Currency = Currency CurrencyData
deriving (Eq, Show)
data OrigCurrency = OrigCurrency CurrencyData
deriving (Eq, Show)
--
-- # Helpers to build aggregates
--
trnType :: TagData -> Maybe TrnType
trnType d = case d of
"CREDIT" -> Just TCREDIT
"DEBIT" -> Just TDEBIT
"INT" -> Just TINT
"DIV" -> Just TDIV
"FEE" -> Just TFEE
"SRVCHG" -> Just TSRVCHG
"DEP" -> Just TDEP
"ATM" -> Just TATM
"POS" -> Just TPOS
"XFER" -> Just TXFER
"CHECK" -> Just TCHECK
"PAYMENT" -> Just TPAYMENT
"CASH" -> Just TCASH
"DIRECTDEP" -> Just TDIRECTDEP
"DIRECTDEBIT" -> Just TDIRECTDEBIT
"REPEATPMT" -> Just TREPEATPMT
"OTHER" -> Just TOTHER
_ -> Nothing
-- | Gets a single Transaction from a tag. The tag should be the one
-- named STMTTRN. Fails with an error message if any required field
-- was not present.
transaction :: Tag -> Err Transaction
transaction t = do
let fromMaybe e = maybe (Left e) Right
trntyStr <- required "TRNTYPE" t
trnTy <- fromMaybe ("could not parse transaction type: " ++ trntyStr)
$ trnType trntyStr
dtpStr <- required "DTPOSTED" t
dtp <- parseDate dtpStr
let mayDtuStr = findData "DTUSER" t
dtu <- maybe (return Nothing) (fmap Just . parseDate) mayDtuStr
let mayDtAvail = findData "DTAVAIL" t
dta <- maybe (return Nothing) (fmap Just . parseDate) mayDtAvail
amt <- required "TRNAMT" t
fitid <- required "FITID" t
let correctFitId = findData "CORRECTFITID" t
correctAct <-
case findData "CORRECTACTION" t of
Nothing -> return Nothing
Just d ->
maybe (return Nothing)
(fromMaybe ("could not parse correct action: " ++ d))
. safeRead
$ d
let srvrtid = findData "SRVRTID" t
checknum = findData "CHECKNUM" t
refnum = findData "REFNUM" t
sic = findData "SIC" t
payeeId = findData "PAYEEID" t
let mayPyeInfo = fmap (return . Left) (findData "NAME" t)
<|> fmap (fmap Right) (payee t)
pyeInfo <- maybe (return Nothing) (fmap Just) mayPyeInfo
let mayAcctTo = (fmap (fmap Left) $ bankAcctTo t)
<|> (fmap (fmap Right) $ ccAcctTo t)
mayCcy = (fmap (fmap Left) $ currency t)
<|> (fmap (fmap Right) $ origCurrency t)
acctTo <- maybe (return Nothing) (fmap Just) mayAcctTo
ccy <- maybe (return Nothing) (fmap Just) mayCcy
let memo = findData "MEMO" t
return Transaction
{ txTRNTYPE = trnTy
, txDTPOSTED = dtp
, txDTUSER = dtu
, txDTAVAIL = dta
, txTRNAMT = amt
, txFITID = fitid
, txCORRECTFITID = correctFitId
, txCORRECTACTION = correctAct
, txSRVRTID = srvrtid
, txCHECKNUM = checknum
, txREFNUM = refnum
, txSIC = sic
, txPAYEEID = payeeId
, txPayeeInfo = pyeInfo
, txAccountTo = acctTo
, txMEMO = memo
, txCurrency = ccy
}
-- | Parses a Payee record from its parent tag.
payee
:: Tag
-- ^ The tag which contains the PAYEE tag, if there is one. This
-- would typically be a STMTTRN tag.
-> Maybe (Err Payee)
-- ^ Nothing if there is no PAYEE tag. Just if a PAYEE tag is found,
-- with a Left if the tag is lacking a required element, or a
-- Right if the tag is successfully parsed.
--
-- If there is more than one PAYEE tag, only the first one is
-- considered.
payee = fmap getPayee . listToMaybe . find "PAYEE"
where
getPayee t = Payee
<$> required "NAME" t
<*> required "ADDR1" t
<*> pure (findData "ADDR2" t)
<*> pure (findData "ADDR3" t)
<*> required "CITY" t
<*> required "STATE" t
<*> required "POSTALCODE" t
<*> pure (findData "COUNTRY" t)
<*> required "PHONE" t
currency :: Tag -> Maybe (Err Currency)
currency
= fmap (fmap Currency)
. fmap currencyData
. listToMaybe
. find "CURRENCY"
origCurrency :: Tag -> Maybe (Err OrigCurrency)
origCurrency
= fmap (fmap OrigCurrency)
. fmap currencyData
. listToMaybe
. find "ORIGCURRENCY"
-- | Parses currency data.
currencyData
:: Tag
-- ^ The tag that contains the data, e.g. CURRENCY or ORIGCURRENCY.
-> Err CurrencyData
currencyData t = CurrencyData
<$> required "CURRATE" t
<*> required "CURSYM" t
bankAcctTo :: Tag -> Maybe (Err BankAcctTo)
bankAcctTo = fmap getTo . listToMaybe . find "BANKACCTTO"
where
getTo t = BankAcctTo
<$> required "BANKID" t
<*> pure (findData "BRANCHID" t)
<*> required "ACCTID" t
<*> (required "ACCTTYPE" t >>= acctType)
<*> pure (findData "ACCTKEY" t)
ccAcctTo :: Tag -> Maybe (Err CCAcctTo)
ccAcctTo = fmap getTo . listToMaybe . find "CCACCTTO"
where
getTo t = CCAcctTo
<$> required "ACCTID" t
<*> pure (findData "ACCTKEY" t)
safeRead :: Read a => String -> Maybe a
safeRead s = case reads s of
(x, ""):[] -> Just x
_ -> Nothing
-- | Pulls all Transactions from a file. Might fail if the OFX file
-- does not conform to the specification (or if there are bugs in this
-- library.) In case of the former, you can manually parse the
-- transaction information yourself using functions like
-- 'pathData'. In case of the latter, please send bugreports :-)
transactions :: OFXFile -> Err [Transaction]
transactions = mapM transaction . find "STMTTRN" . fTag
--
-- # Pretty printers
--
pPayee :: Payee -> Doc
pPayee p = hang "Payee:" 2 ls
where
ls = sep [ label "Name" (text . peNAME $ p)
, label "Addr1" (text . peADDR1 $ p)
, label "Addr2" (pMaybe text . peADDR2 $ p)
, label "Addr3" (pMaybe text . peADDR3 $ p)
, label "City" (text . peCITY $ p)
, label "State" (text . peSTATE $ p)
, label "Postal" (text . pePOSTALCODE $ p)
, label "Country" (pMaybe text . peCOUNTRY $ p)
, label "Phone" (text . pePHONE $ p)
]
pTransaction :: Transaction -> Doc
pTransaction a = hang "Transaction:" 2 ls
where
ls = sep [ label "TRNTYPE" (text . show . txTRNTYPE $ a)
, label "DTPOSTED" (text . show . txDTPOSTED $ a)
, label "DTUSER" (text . show . txDTUSER $ a)
, label "DTAVAIL" (text . show . txDTAVAIL $ a)
, label "TRNAMT" (text . txTRNAMT $ a)
, label "FITID" (text . txFITID $ a)
, label "CORRECTFITID"
(pMaybe text . txCORRECTFITID $ a)
, label "CORRECTACTION"
(text . show . txCORRECTACTION $ a)
, label "SRVRTID" (pMaybe text . txSRVRTID $ a)
, label "CHECKNUM" (pMaybe text . txCHECKNUM $ a)
, label "REFNUM" (pMaybe text . txREFNUM $ a)
, label "SIC" (pMaybe text . txSIC $ a)
, label "PAYEEID" (pMaybe text . txPAYEEID $ a)
, label "PAYEEINFO"
(pMaybe (pEither text (text . show)) . txPayeeInfo $ a)
, label "ACCOUNTTO"
(pMaybe id . fmap (text . show)
. txAccountTo $ a)
, label "MEMO" (pMaybe text . txMEMO $ a)
, label "CURRENCY"
(pMaybe (text . show) . txCurrency $ a)
]
pTag :: Tag -> Doc
pTag (Tag n ei) = case ei of
Left d -> "<" <> text n <> ">" <> text d
Right ts -> vcat $ "<" <> text n <> ">"
: map (nest 2 . pTag) ts
++ ["</" <> text n <> ">"]
pHeader :: OFXHeader -> Doc
pHeader (OFXHeader t v) = text t <> ": " <> text v
pFile :: OFXFile -> Doc
pFile (OFXFile hs t)
= "OFX file:"
$$ nest 2 (vcat [ pList . map pHeader $ hs
, mempty
, pTag t ])
pEither :: (a -> Doc) -> (b -> Doc) -> Either a b -> Doc
pEither fa fb = either (\l -> "Left" <+> parens (fa l))
(\r -> "Right" <+> parens (fb r))
pMaybe :: (a -> Doc) -> Maybe a -> Doc
pMaybe f = maybe "Nothing" (\x -> "Just" <+> parens (f x))
pList :: [Doc] -> Doc
pList ds = case ds of
[] -> "[]"
x:[] -> brackets x
x:xs -> sep $ hang "[" 2 x
: map (\d -> hang "," 2 d) xs
++ [ "]" ]
label :: String -> Doc -> Doc
label s = hang (text (s ++ ":")) (length s + 2)
pExceptional
:: (e -> Doc)
-> (a -> Doc)
-> Either e a
-> Doc
pExceptional fe fa =
either (\e -> hang "Exception:" 2 $ parens (fe e))
(\g -> hang "Success:" 2 $ parens (fa g))
|