diff options
| author | Rutger Broekhoff | 2025-03-18 15:29:27 +0100 |
|---|---|---|
| committer | Rutger Broekhoff | 2025-03-18 15:31:11 +0100 |
| commit | 86c8896ee69b068368b4ef9a4c3923285907c328 (patch) | |
| tree | dc6e4f58a511c58e2910e9f7ea900165da7d47c6 | |
| download | rdcapsis-86c8896ee69b068368b4ef9a4c3923285907c328.tar.gz rdcapsis-86c8896ee69b068368b4ef9a4c3923285907c328.zip | |
Parsing ING statements (POC)
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | app/Data/Iban.hs | 40 | ||||
| -rw-r--r-- | app/Format.hs | 3 | ||||
| -rw-r--r-- | app/Import/Ing/CurrentAccountCsv.hs | 356 | ||||
| -rw-r--r-- | app/Import/Ing/SavingsAccountCsv.hs | 162 | ||||
| -rw-r--r-- | app/Import/Ing/Shared.hs | 47 | ||||
| -rw-r--r-- | app/Main.hs | 100 | ||||
| -rw-r--r-- | wayligmative.cabal | 34 |
8 files changed, 745 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bfa210 --- /dev/null +++ b/.gitignore | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | .\#* | ||
| 2 | \#*\# | ||
| 3 | *~ | ||
diff --git a/app/Data/Iban.hs b/app/Data/Iban.hs new file mode 100644 index 0000000..a42e192 --- /dev/null +++ b/app/Data/Iban.hs | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | module Data.Iban (Iban, mkIban) where | ||
| 2 | |||
| 3 | import Control.Applicative ((<|>)) | ||
| 4 | import Data.Attoparsec.Text as AP | ||
| 5 | import Data.Char | ||
| 6 | ( digitToInt, | ||
| 7 | ord, | ||
| 8 | toUpper, | ||
| 9 | ) | ||
| 10 | import Data.Text qualified as T | ||
| 11 | |||
| 12 | newtype Iban = Iban T.Text deriving (Show, Eq) | ||
| 13 | |||
| 14 | mkIban :: T.Text -> Either String Iban | ||
| 15 | mkIban t = validateIban t >> return (Iban t) | ||
| 16 | |||
| 17 | validateIban :: T.Text -> Either String () | ||
| 18 | validateIban t = AP.parseOnly ibanP t | ||
| 19 | where | ||
| 20 | ibanP = do | ||
| 21 | countryCode <- AP.count 2 ibanLetter | ||
| 22 | checkDigits <- AP.count 2 ibanDigit | ||
| 23 | chars <- AP.many1 ibanChar | ||
| 24 | endOfInput | ||
| 25 | if length chars < 30 | ||
| 26 | then | ||
| 27 | if valid countryCode checkDigits chars | ||
| 28 | then return () | ||
| 29 | else fail $ "IBAN checksum does not match (" ++ T.unpack t ++ ")" | ||
| 30 | else fail "IBAN has more than 34 characters" | ||
| 31 | where | ||
| 32 | ibanChar = ibanDigit <|> ibanLetter | ||
| 33 | ibanDigit = toInteger . digitToInt <$> AP.digit | ||
| 34 | ibanLetter = letterToInt <$> AP.letter | ||
| 35 | letterToInt c = toInteger (ord (toUpper c) - ord 'A' + 10) | ||
| 36 | charsToInteger = foldl' (\acc d -> if d >= 10 then acc * 100 + d else acc * 10 + d) 0 | ||
| 37 | ibanToInteger countryCode checkDigits chars = | ||
| 38 | charsToInteger chars * 1000000 + charsToInteger countryCode * 100 + charsToInteger checkDigits | ||
| 39 | valid countryCode checkDigits chars = | ||
| 40 | ibanToInteger countryCode checkDigits chars `mod` 97 == 1 | ||
diff --git a/app/Format.hs b/app/Format.hs new file mode 100644 index 0000000..a779d95 --- /dev/null +++ b/app/Format.hs | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | module Format where | ||
| 2 | |||
| 3 | import Text.Parsec | ||
diff --git a/app/Import/Ing/CurrentAccountCsv.hs b/app/Import/Ing/CurrentAccountCsv.hs new file mode 100644 index 0000000..bf28730 --- /dev/null +++ b/app/Import/Ing/CurrentAccountCsv.hs | |||
| @@ -0,0 +1,356 @@ | |||
| 1 | {-# LANGUAGE OverloadedLists #-} | ||
| 2 | {-# LANGUAGE OverloadedStrings #-} | ||
| 3 | |||
| 4 | module Import.Ing.CurrentAccountCsv where | ||
| 5 | |||
| 6 | import Control.Applicative ((<|>)) | ||
| 7 | import Data.ByteString.Lazy qualified as BS | ||
| 8 | import Data.Csv ((.:)) | ||
| 9 | import Data.Csv qualified as C | ||
| 10 | import Data.Decimal (Decimal) | ||
| 11 | import Data.Functor ((<&>)) | ||
| 12 | import Data.Iban (Iban) | ||
| 13 | import Data.Text qualified as T | ||
| 14 | import Data.Time.Calendar (Day) | ||
| 15 | import Data.Time.Clock (UTCTime) | ||
| 16 | import Data.Time.Zones (TZ, loadTZFromDB) | ||
| 17 | import Data.Vector qualified as V | ||
| 18 | import Import.Ing.Shared | ||
| 19 | ( DebitCredit (Credit, Debit), | ||
| 20 | dateCP, | ||
| 21 | decimalCP, | ||
| 22 | ibanCP, | ||
| 23 | maybeCP, | ||
| 24 | scsvOptions, | ||
| 25 | timestampCP, | ||
| 26 | ) | ||
| 27 | import System.IO (Handle) | ||
| 28 | import Text.Regex.TDFA ((=~~)) | ||
| 29 | |||
| 30 | data TransactionType | ||
| 31 | = AcceptGiro -- AC (acceptgiro) | ||
| 32 | | AtmWithdrawal -- GM (geldautomaat, Giromaat) | ||
| 33 | | BatchPayment -- VZ (verzamelbetaling); 'Batch payment' | ||
| 34 | | BranchPosting -- FL (filiaalboeking) | ||
| 35 | | Deposit -- ST (storting) | ||
| 36 | | DirectDebit -- IC (incasso); 'SEPA direct debit' | ||
| 37 | | Ideal -- ID (iDEAL); 'iDEAL' | ||
| 38 | | OnlineBanking -- GT (internetbankieren, Girotel); 'Online Banking' | ||
| 39 | | OfficeWithdrawal -- PK (opname kantoor, postkantoor) | ||
| 40 | | PaymentTerminal -- BA (betaalautomaat); 'Payment terminal' | ||
| 41 | | PeriodicTransfer -- PO (periodieke overschrijving) | ||
| 42 | | PhoneBanking -- GF (telefonisch bankieren, Girofoon) | ||
| 43 | | Transfer -- OV (overboeking); 'Transfer' | ||
| 44 | | Various -- DV (diversen) | ||
| 45 | deriving (Eq, Show) | ||
| 46 | |||
| 47 | parseCode :: T.Text -> C.Parser TransactionType | ||
| 48 | parseCode "AC" = return AcceptGiro | ||
| 49 | parseCode "GM" = return AtmWithdrawal | ||
| 50 | parseCode "VZ" = return BatchPayment | ||
| 51 | parseCode "FL" = return BranchPosting | ||
| 52 | parseCode "ST" = return Deposit | ||
| 53 | parseCode "IC" = return DirectDebit | ||
| 54 | parseCode "ID" = return Ideal | ||
| 55 | parseCode "GT" = return OnlineBanking | ||
| 56 | parseCode "PK" = return OfficeWithdrawal | ||
| 57 | parseCode "BA" = return PaymentTerminal | ||
| 58 | parseCode "PO" = return PeriodicTransfer | ||
| 59 | parseCode "GF" = return PhoneBanking | ||
| 60 | parseCode "OV" = return Transfer | ||
| 61 | parseCode "DV" = return Various | ||
| 62 | parseCode t = fail $ "Unknown transaction code '" ++ T.unpack t ++ "'" | ||
| 63 | |||
| 64 | parseType :: T.Text -> C.Parser TransactionType | ||
| 65 | parseType "SEPA direct debit" = return DirectDebit | ||
| 66 | parseType "Batch payment" = return BatchPayment | ||
| 67 | parseType "Online Banking" = return OnlineBanking | ||
| 68 | parseType "Payment terminal" = return PaymentTerminal | ||
| 69 | parseType "Transfer" = return Transfer | ||
| 70 | parseType "iDEAL" = return Ideal | ||
| 71 | parseType t = fail $ "Unknown transaction type '" ++ T.unpack t ++ "'" | ||
| 72 | |||
| 73 | data PrimTx = PrimTx | ||
| 74 | { ptDate :: !Day, | ||
| 75 | ptDesc :: !(Maybe T.Text), | ||
| 76 | ptAccount :: !Iban, | ||
| 77 | ptCounterparty :: !(Maybe Iban), | ||
| 78 | ptDebitCredit :: !DebitCredit, | ||
| 79 | ptAmount :: !Decimal, | ||
| 80 | ptResBal :: !Decimal, | ||
| 81 | ptTag :: !T.Text, | ||
| 82 | ptMoreData :: !MoreData | ||
| 83 | } | ||
| 84 | deriving (Show) | ||
| 85 | |||
| 86 | data MoreData | ||
| 87 | = PaymentTerminalData | ||
| 88 | { ptCardSequenceNo :: !T.Text, | ||
| 89 | ptTimestamp :: !UTCTime, | ||
| 90 | ptTransaction :: !T.Text, | ||
| 91 | ptTerminal :: !T.Text, | ||
| 92 | ptValueDate :: !Day, | ||
| 93 | ptGooglePay :: !Bool | ||
| 94 | } | ||
| 95 | | DepositTransferData | ||
| 96 | { dtName :: !T.Text, | ||
| 97 | dtDescription :: !T.Text, | ||
| 98 | dtIban :: !Iban, | ||
| 99 | dtReference :: !T.Text, | ||
| 100 | dtValueDate :: !Day | ||
| 101 | } | ||
| 102 | | RoundingSavingsDeposit | ||
| 103 | { rsdSavingsAccount :: !T.Text, | ||
| 104 | rsdValueDate :: !Day | ||
| 105 | } | ||
| 106 | | OnlineBankingCredit | ||
| 107 | { obcName :: !T.Text, | ||
| 108 | obcDescription :: !T.Text, | ||
| 109 | obcIban :: !Iban, | ||
| 110 | obcTimestamp :: !UTCTime, | ||
| 111 | obcValueDate :: !Day | ||
| 112 | } | ||
| 113 | | OnlineBankingDebit | ||
| 114 | { obdName :: !T.Text, | ||
| 115 | obdDescription :: !T.Text, | ||
| 116 | obdIban :: !Iban, | ||
| 117 | obdTimestamp :: !(Maybe UTCTime), | ||
| 118 | obdValueDate :: !Day | ||
| 119 | } | ||
| 120 | | RecurrentDirectDebitData | ||
| 121 | { rddName :: !T.Text, | ||
| 122 | rddDescription :: !T.Text, | ||
| 123 | rddIban :: !Iban, | ||
| 124 | rddReference :: !T.Text, | ||
| 125 | rddMandateId :: !T.Text, | ||
| 126 | rddCreditorId :: !T.Text, | ||
| 127 | rddOtherParty :: !(Maybe T.Text), | ||
| 128 | rddValueDate :: !Day | ||
| 129 | } | ||
| 130 | | IdealDebitData | ||
| 131 | { idName :: !T.Text, | ||
| 132 | idDescription :: !T.Text, | ||
| 133 | idIban :: !Iban, | ||
| 134 | idTimestamp :: !UTCTime, | ||
| 135 | idReference :: !T.Text, | ||
| 136 | idValueDate :: !Day | ||
| 137 | } | ||
| 138 | | PaymentTerminalCashbackData | ||
| 139 | { ptcCardSequenceNo :: !T.Text, | ||
| 140 | ptcTimestamp :: !UTCTime, | ||
| 141 | ptcTransaction :: !T.Text, | ||
| 142 | ptcTerminal :: !T.Text, | ||
| 143 | ptcValueDate :: !Day | ||
| 144 | } | ||
| 145 | | BatchPaymentData | ||
| 146 | { bpName :: !T.Text, | ||
| 147 | bpDescription :: !T.Text, | ||
| 148 | bpIban :: !Iban, | ||
| 149 | bpReference :: !T.Text, | ||
| 150 | bpValueDate :: !Day | ||
| 151 | } | ||
| 152 | deriving (Show) | ||
| 153 | |||
| 154 | maybeNotProvided :: T.Text -> Maybe T.Text | ||
| 155 | maybeNotProvided t = if t == "NOTPROVIDED" then Nothing else Just t | ||
| 156 | |||
| 157 | valueDateCP :: T.Text -> C.Parser Day | ||
| 158 | valueDateCP = dateCP "%d/%m/%Y" | ||
| 159 | |||
| 160 | data PartTx = PartTx !Day !TransactionType !DebitCredit | ||
| 161 | |||
| 162 | notificationsCP :: TZ -> PartTx -> T.Text -> C.Parser MoreData | ||
| 163 | notificationsCP _ (PartTx _ Transfer Credit) t = do | ||
| 164 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 165 | (_, _, _, [name, desc, ibanTxt, ref, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 166 | iban <- ibanCP ibanTxt | ||
| 167 | valDate <- valueDateCP valDateTxt | ||
| 168 | return $ | ||
| 169 | DepositTransferData | ||
| 170 | { dtName = name, | ||
| 171 | dtDescription = desc, | ||
| 172 | dtIban = iban, | ||
| 173 | dtReference = ref, | ||
| 174 | dtValueDate = valDate | ||
| 175 | } | ||
| 176 | notificationsCP _ (PartTx _ Transfer Debit) t = do | ||
| 177 | let regex = "^To Oranje spaarrekening ([A-Z0-9]+) Afronding Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 178 | (_, _, _, [savingsAccount, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 179 | valDate <- valueDateCP valDateTxt | ||
| 180 | return $ | ||
| 181 | RoundingSavingsDeposit | ||
| 182 | { rsdSavingsAccount = savingsAccount, | ||
| 183 | rsdValueDate = valDate | ||
| 184 | } | ||
| 185 | notificationsCP amsTz (PartTx _ PaymentTerminal Debit) t = do | ||
| 186 | let regex = "^Card sequence no.: ([0-9]+) ? ([0-9]{2}/[0-9]{2}/[0-9]{4} [0-9]{2}:[0-9]{2}) Transaction: (.*) Term: ((.+) Google Pay|(.+)) Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 187 | (_, _, _, [cardSeqNo, timestampTxt, transaction, _, gpayTerm, noGpayTerm, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 188 | timestamp <- timestampCP "%d/%m/%Y %H:%M" amsTz timestampTxt | ||
| 189 | valDate <- valueDateCP valDateTxt | ||
| 190 | return $ | ||
| 191 | PaymentTerminalData | ||
| 192 | { ptCardSequenceNo = cardSeqNo, | ||
| 193 | ptTimestamp = timestamp, | ||
| 194 | ptTransaction = transaction, | ||
| 195 | ptTerminal = if T.null gpayTerm then noGpayTerm else gpayTerm, | ||
| 196 | ptValueDate = valDate, | ||
| 197 | ptGooglePay = T.null noGpayTerm | ||
| 198 | } | ||
| 199 | notificationsCP amsTz (PartTx _ PaymentTerminal Credit) t = do | ||
| 200 | let regex = "^Card sequence no.: ([0-9]+) ? ([0-9]{2}/[0-9]{2}/[0-9]{4} [0-9]{2}:[0-9]{2}) Transaction: (.*) Term: (.*) Cashback transaction Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 201 | (_, _, _, [cardSeqNo, timestampTxt, transaction, term, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 202 | timestamp <- timestampCP "%d/%m/%Y %H:%M" amsTz timestampTxt | ||
| 203 | valDate <- valueDateCP valDateTxt | ||
| 204 | return $ | ||
| 205 | PaymentTerminalCashbackData | ||
| 206 | { ptcCardSequenceNo = cardSeqNo, | ||
| 207 | ptcTimestamp = timestamp, | ||
| 208 | ptcTransaction = transaction, | ||
| 209 | ptcTerminal = term, | ||
| 210 | ptcValueDate = valDate | ||
| 211 | } | ||
| 212 | notificationsCP amsTz (PartTx _ OnlineBanking Credit) t = do | ||
| 213 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Date/time: ([0-9]{2}-[0-9]{2}-[0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2}) Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 214 | (_, _, _, [name, desc, ibanTxt, timestampTxt, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 215 | iban <- ibanCP ibanTxt | ||
| 216 | timestamp <- timestampCP "%d-%m-%Y %H:%M:%S" amsTz timestampTxt | ||
| 217 | valDate <- valueDateCP valDateTxt | ||
| 218 | return $ | ||
| 219 | OnlineBankingCredit | ||
| 220 | { obcName = name, | ||
| 221 | obcDescription = desc, | ||
| 222 | obcIban = iban, | ||
| 223 | obcTimestamp = timestamp, | ||
| 224 | obcValueDate = valDate | ||
| 225 | } | ||
| 226 | notificationsCP amsTz (PartTx _ OnlineBanking Debit) t = do | ||
| 227 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) (Date/time: ([0-9]{2}-[0-9]{2}-[0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2}) )?Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 228 | (_, _, _, [name, desc, ibanTxt, _, timestampTxt, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 229 | iban <- ibanCP ibanTxt | ||
| 230 | timestamp <- | ||
| 231 | if T.null timestampTxt | ||
| 232 | then pure Nothing | ||
| 233 | else Just <$> timestampCP "%d-%m-%Y %H:%M:%S" amsTz timestampTxt | ||
| 234 | valDate <- valueDateCP valDateTxt | ||
| 235 | return $ | ||
| 236 | OnlineBankingDebit | ||
| 237 | { obdName = name, | ||
| 238 | obdDescription = desc, | ||
| 239 | obdIban = iban, | ||
| 240 | obdTimestamp = timestamp, | ||
| 241 | obdValueDate = valDate | ||
| 242 | } | ||
| 243 | notificationsCP _ (PartTx date DirectDebit Debit) t = normalRecurrentDirectDebit <|> ingInsurancePayment | ||
| 244 | where | ||
| 245 | normalRecurrentDirectDebit = do | ||
| 246 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) Mandate ID: (.*) Creditor ID: (.*) Recurrent SEPA direct debit (Other party: (.*) )?Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 247 | (_, _, _, [name, desc, ibanTxt, ref, mandateId, creditorId, _, otherParty, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 248 | iban <- ibanCP ibanTxt | ||
| 249 | valDate <- valueDateCP valDateTxt | ||
| 250 | return $ | ||
| 251 | RecurrentDirectDebitData | ||
| 252 | { rddName = name, | ||
| 253 | rddDescription = desc, | ||
| 254 | rddIban = iban, | ||
| 255 | rddReference = ref, | ||
| 256 | rddMandateId = mandateId, | ||
| 257 | rddCreditorId = creditorId, | ||
| 258 | rddOtherParty = if T.null otherParty then Nothing else Just otherParty, | ||
| 259 | rddValueDate = valDate | ||
| 260 | } | ||
| 261 | ingInsurancePayment = do | ||
| 262 | let regex = "^Name: (.* ING Verzekeren) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) Mandate ID: (.*) Creditor ID: (.*) Recurrent SEPA direct debit$" :: String | ||
| 263 | (_, _, _, [name, desc, ibanTxt, ref, mandateId, creditorId]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 264 | iban <- ibanCP ibanTxt | ||
| 265 | return $ | ||
| 266 | RecurrentDirectDebitData | ||
| 267 | { rddName = name, | ||
| 268 | rddDescription = desc, | ||
| 269 | rddIban = iban, | ||
| 270 | rddReference = ref, | ||
| 271 | rddMandateId = mandateId, | ||
| 272 | rddCreditorId = creditorId, | ||
| 273 | rddOtherParty = Nothing, | ||
| 274 | rddValueDate = date | ||
| 275 | } | ||
| 276 | notificationsCP amsTz (PartTx _ Ideal Debit) t = do | ||
| 277 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: ([0-9]{2}-[0-9]{2}-[0-9]{4} [0-9]{2}:[0-9]{2}) ([0-9]+) Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 278 | (_, _, _, [name, desc, ibanTxt, timestampTxt, ref, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 279 | iban <- ibanCP ibanTxt | ||
| 280 | timestamp <- timestampCP "%d-%m-%Y %H:%M" amsTz timestampTxt | ||
| 281 | valDate <- valueDateCP valDateTxt | ||
| 282 | return $ | ||
| 283 | IdealDebitData | ||
| 284 | { idName = name, | ||
| 285 | idDescription = desc, | ||
| 286 | idIban = iban, | ||
| 287 | idTimestamp = timestamp, | ||
| 288 | idReference = ref, | ||
| 289 | idValueDate = valDate | ||
| 290 | } | ||
| 291 | notificationsCP _ (PartTx _ BatchPayment Credit) t = do | ||
| 292 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
| 293 | (_, _, _, [name, desc, ibanTxt, ref, valDateTxt]) <- t =~~ regex :: C.Parser (T.Text, T.Text, T.Text, [T.Text]) | ||
| 294 | iban <- ibanCP ibanTxt | ||
| 295 | valDate <- valueDateCP valDateTxt | ||
| 296 | return $ | ||
| 297 | BatchPaymentData | ||
| 298 | { bpName = name, | ||
| 299 | bpDescription = desc, | ||
| 300 | bpIban = iban, | ||
| 301 | bpReference = ref, | ||
| 302 | bpValueDate = valDate | ||
| 303 | } | ||
| 304 | notificationsCP _ (PartTx _ ty cd) _ = fail $ "Unmatched type and debit/credit combination (" ++ show ty ++ ", " ++ show cd ++ ")" | ||
| 305 | |||
| 306 | debitCreditCP :: T.Text -> C.Parser DebitCredit | ||
| 307 | debitCreditCP "Debit" = return Debit | ||
| 308 | debitCreditCP "Credit" = return Credit | ||
| 309 | debitCreditCP t = fail ("Unknown debit/credit value '" ++ T.unpack t ++ "'") | ||
| 310 | |||
| 311 | parseNamedRecord :: TZ -> C.NamedRecord -> C.Parser PrimTx | ||
| 312 | parseNamedRecord amsTz m = do | ||
| 313 | date <- m .: "Date" >>= dateCP "%0Y%m%d" | ||
| 314 | debitCredit <- m .: "Debit/credit" >>= debitCreditCP | ||
| 315 | codeText <- m .: "Code" | ||
| 316 | tyText <- m .: "Transaction type" | ||
| 317 | tyFromCode <- parseCode codeText | ||
| 318 | ty <- parseType tyText | ||
| 319 | if ty /= tyFromCode | ||
| 320 | then | ||
| 321 | fail $ "Code '" ++ T.unpack codeText ++ "' and transaction type '" ++ T.unpack tyText ++ "' do not agree" | ||
| 322 | else | ||
| 323 | PrimTx date | ||
| 324 | <$> (m .: "Name / Description" <&> maybeNotProvided) | ||
| 325 | <*> (m .: "Account" >>= ibanCP) | ||
| 326 | <*> (m .: "Counterparty" >>= maybeCP ibanCP) | ||
| 327 | <*> pure debitCredit | ||
| 328 | <*> (m .: "Amount (EUR)" >>= decimalCP) | ||
| 329 | <*> (m .: "Resulting balance" >>= decimalCP) | ||
| 330 | <*> m .: "Tag" | ||
| 331 | <*> (m .: "Notifications" >>= notificationsCP amsTz (PartTx date ty debitCredit)) | ||
| 332 | |||
| 333 | readFile :: Handle -> IO (V.Vector PrimTx) | ||
| 334 | readFile h = do | ||
| 335 | tz <- loadTZFromDB "Europe/Amsterdam" | ||
| 336 | contents <- BS.hGetContents h | ||
| 337 | case C.decodeByNameWithP (parseNamedRecord tz) scsvOptions contents of | ||
| 338 | Left err -> fail err | ||
| 339 | Right | ||
| 340 | ( [ "Date", | ||
| 341 | "Name / Description", | ||
| 342 | "Account", | ||
| 343 | "Counterparty", | ||
| 344 | "Code", | ||
| 345 | "Debit/credit", | ||
| 346 | "Amount (EUR)", | ||
| 347 | "Transaction type", | ||
| 348 | "Notifications", | ||
| 349 | "Resulting balance", | ||
| 350 | "Tag" | ||
| 351 | ], | ||
| 352 | txs | ||
| 353 | ) -> | ||
| 354 | return txs | ||
| 355 | Right _ -> | ||
| 356 | fail "Headers do not match expected pattern" | ||
diff --git a/app/Import/Ing/SavingsAccountCsv.hs b/app/Import/Ing/SavingsAccountCsv.hs new file mode 100644 index 0000000..f6632fc --- /dev/null +++ b/app/Import/Ing/SavingsAccountCsv.hs | |||
| @@ -0,0 +1,162 @@ | |||
| 1 | {-# LANGUAGE OverloadedLists #-} | ||
| 2 | {-# LANGUAGE OverloadedStrings #-} | ||
| 3 | |||
| 4 | module Import.Ing.SavingsAccountCsv where | ||
| 5 | |||
| 6 | import Data.ByteString.Lazy qualified as BS | ||
| 7 | import Data.Csv ((.:)) | ||
| 8 | import Data.Csv qualified as C | ||
| 9 | import Data.Decimal (Decimal) | ||
| 10 | import Data.Iban (Iban, mkIban) | ||
| 11 | import Data.Maybe (isJust) | ||
| 12 | import Data.Text qualified as T | ||
| 13 | import Data.Time.Calendar (Day) | ||
| 14 | import Data.Vector qualified as V | ||
| 15 | import Import.Ing.Shared (dateCP, decimalCP, eitherToCP, ibanCP, maybeCP, scsvOptions) | ||
| 16 | import System.IO (Handle) | ||
| 17 | import Text.Regex.TDFA ((=~~)) | ||
| 18 | |||
| 19 | data DebitCredit = Debit | Credit deriving (Show, Eq) | ||
| 20 | |||
| 21 | data MutationType = DepositMutation | WithdrawalMutation | InterestMutation deriving (Show) | ||
| 22 | |||
| 23 | data TxBase = TxBase | ||
| 24 | { txbDate :: !Day, | ||
| 25 | txbAccountId :: !T.Text, | ||
| 26 | txbAccountName :: !T.Text, | ||
| 27 | txbAmount :: !Decimal, | ||
| 28 | txbResBal :: !Decimal | ||
| 29 | } | ||
| 30 | deriving (Show) | ||
| 31 | |||
| 32 | data TxSpecifics | ||
| 33 | = Interest | ||
| 34 | | Withdrawal | ||
| 35 | { wToCurrentAccountIban :: !Iban, | ||
| 36 | wDescription :: !T.Text | ||
| 37 | } | ||
| 38 | | Deposit | ||
| 39 | { dFromCurrentAccountIban :: !Iban, | ||
| 40 | dDescription :: !T.Text | ||
| 41 | } | ||
| 42 | | CurrentAccountAutoSaveRounding {caasFromCurrentAccountIban :: !Iban} | ||
| 43 | deriving (Show) | ||
| 44 | |||
| 45 | data Tx = Tx TxBase TxSpecifics deriving (Show) | ||
| 46 | |||
| 47 | instance MonadFail (Either String) where | ||
| 48 | fail = Left | ||
| 49 | |||
| 50 | txBaseFromPrim :: PrimTx -> Either String TxBase | ||
| 51 | txBaseFromPrim ptx@PrimTx {ptxCommodity = "EUR"} = | ||
| 52 | return $ TxBase (ptxDate ptx) (ptxAccountId ptx) (ptxAccountName ptx) (ptxAmount ptx) (ptxResBal ptx) | ||
| 53 | txBaseFromPrim ptx = | ||
| 54 | Left $ "Unexpected commodity '" ++ T.unpack (ptxCommodity ptx) ++ "' (expected EUR)" | ||
| 55 | |||
| 56 | specificsFromPrim :: PrimTx -> Either String TxSpecifics | ||
| 57 | specificsFromPrim ptx@PrimTx {ptxMutationType = InterestMutation} | ||
| 58 | | isJust (ptxCounterparty ptx) = Left "Expected no counterparty for interest transaction" | ||
| 59 | | ptxDebitCredit ptx /= Credit = | ||
| 60 | Left "Expected interest transaction to be of credit ('Bij') type, got debit ('Af')" | ||
| 61 | | not (T.null (ptxNotifications ptx)) = | ||
| 62 | Left "Expected no notifications for interest transaction" | ||
| 63 | | ptxDescription ptx /= "Rente" = | ||
| 64 | Left $ "Expected interest transaction to have description 'Rente', got '" ++ T.unpack (ptxDescription ptx) ++ "'" | ||
| 65 | | otherwise = return Interest | ||
| 66 | specificsFromPrim ptx@PrimTx {ptxMutationType = WithdrawalMutation} = do | ||
| 67 | let regex = "Overboeking naar betaalrekening (.*)" :: String | ||
| 68 | (_, _, _, [ibanTxt]) <- ptxDescription ptx =~~ regex :: Either String (T.Text, T.Text, T.Text, [T.Text]) | ||
| 69 | iban <- mkIban ibanTxt | ||
| 70 | case ptxCounterparty ptx of | ||
| 71 | Nothing -> Left "Expected counterparty for withdrawal transaction" | ||
| 72 | Just cpIban -> | ||
| 73 | if cpIban /= iban | ||
| 74 | then Left "Expected counterparty and IBAN in description to be equal" | ||
| 75 | else return $ Withdrawal {wToCurrentAccountIban = iban, wDescription = ptxNotifications ptx} | ||
| 76 | specificsFromPrim ptx@PrimTx {ptxMutationType = DepositMutation} = do | ||
| 77 | let regex = "(Afronding|Overboeking) van betaalrekening (.*)" :: String | ||
| 78 | (_, _, _, [ty, ibanTxt]) <- ptxDescription ptx =~~ regex :: Either String (T.Text, T.Text, T.Text, [T.Text]) | ||
| 79 | iban <- mkIban ibanTxt | ||
| 80 | case ptxCounterparty ptx of | ||
| 81 | Nothing -> Left "Expected counterparty for deposit transaction" | ||
| 82 | Just cpIban -> | ||
| 83 | if cpIban /= iban | ||
| 84 | then Left "Expected counterparty and IBAN in description to be equal" | ||
| 85 | else case ty of | ||
| 86 | "Afronding" -> | ||
| 87 | if not (T.null (ptxNotifications ptx)) | ||
| 88 | then | ||
| 89 | Left "Expected no notifications for auto-save rounding transaction" | ||
| 90 | else return $ CurrentAccountAutoSaveRounding {caasFromCurrentAccountIban = iban} | ||
| 91 | "Overboeking" -> | ||
| 92 | return $ Deposit {dFromCurrentAccountIban = iban, dDescription = ptxNotifications ptx} | ||
| 93 | _ -> error "unreachable" | ||
| 94 | |||
| 95 | processPrimTx :: PrimTx -> Either String Tx | ||
| 96 | processPrimTx ptx = Tx <$> txBaseFromPrim ptx <*> specificsFromPrim ptx | ||
| 97 | |||
| 98 | data PrimTx = PrimTx | ||
| 99 | { ptxDate :: !Day, | ||
| 100 | ptxDescription :: !T.Text, | ||
| 101 | ptxAccountId :: !T.Text, | ||
| 102 | ptxAccountName :: !T.Text, | ||
| 103 | ptxCounterparty :: !(Maybe Iban), | ||
| 104 | ptxDebitCredit :: !DebitCredit, | ||
| 105 | ptxAmount :: !Decimal, | ||
| 106 | ptxCommodity :: !T.Text, | ||
| 107 | ptxMutationType :: !MutationType, | ||
| 108 | ptxNotifications :: !T.Text, | ||
| 109 | ptxResBal :: !Decimal | ||
| 110 | } | ||
| 111 | deriving (Show) | ||
| 112 | |||
| 113 | debitCreditCP :: T.Text -> C.Parser DebitCredit | ||
| 114 | debitCreditCP "Af" = return Debit | ||
| 115 | debitCreditCP "Bij" = return Credit | ||
| 116 | debitCreditCP t = fail ("Unknown debit/credit value '" ++ T.unpack t ++ "'") | ||
| 117 | |||
| 118 | mutationTypeCP :: T.Text -> C.Parser MutationType | ||
| 119 | mutationTypeCP "Inleg" = return DepositMutation | ||
| 120 | mutationTypeCP "Opname" = return WithdrawalMutation | ||
| 121 | mutationTypeCP "Rente" = return InterestMutation | ||
| 122 | mutationTypeCP t = fail ("Unknown mutation type '" ++ T.unpack t ++ "'") | ||
| 123 | |||
| 124 | parseNamedRecord :: C.NamedRecord -> C.Parser Tx | ||
| 125 | parseNamedRecord m = | ||
| 126 | eitherToCP . processPrimTx | ||
| 127 | =<< PrimTx | ||
| 128 | <$> (m .: "Datum" >>= dateCP "%Y-%m-%d") | ||
| 129 | <*> m .: "Omschrijving" | ||
| 130 | <*> m .: "Rekening" | ||
| 131 | <*> m .: "Rekening naam" | ||
| 132 | <*> (m .: "Tegenrekening" >>= maybeCP ibanCP) | ||
| 133 | <*> (m .: "Af Bij" >>= debitCreditCP) | ||
| 134 | <*> (m .: "Bedrag" >>= decimalCP) | ||
| 135 | <*> m .: "Valuta" | ||
| 136 | <*> (m .: "Mutatiesoort" >>= mutationTypeCP) | ||
| 137 | <*> m .: "Mededelingen" | ||
| 138 | <*> (m .: "Saldo na mutatie" >>= decimalCP) | ||
| 139 | |||
| 140 | readFile :: Handle -> IO (V.Vector Tx) | ||
| 141 | readFile h = do | ||
| 142 | contents <- BS.hGetContents h | ||
| 143 | case C.decodeByNameWithP parseNamedRecord scsvOptions contents of | ||
| 144 | Left err -> fail err | ||
| 145 | Right | ||
| 146 | ( [ "Datum", | ||
| 147 | "Omschrijving", | ||
| 148 | "Rekening", | ||
| 149 | "Rekening naam", | ||
| 150 | "Tegenrekening", | ||
| 151 | "Af Bij", | ||
| 152 | "Bedrag", | ||
| 153 | "Valuta", | ||
| 154 | "Mutatiesoort", | ||
| 155 | "Mededelingen", | ||
| 156 | "Saldo na mutatie" | ||
| 157 | ], | ||
| 158 | txs | ||
| 159 | ) -> | ||
| 160 | return txs | ||
| 161 | Right _ -> | ||
| 162 | fail "Headers do not match expected pattern" | ||
diff --git a/app/Import/Ing/Shared.hs b/app/Import/Ing/Shared.hs new file mode 100644 index 0000000..c70f225 --- /dev/null +++ b/app/Import/Ing/Shared.hs | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | module Import.Ing.Shared where | ||
| 2 | |||
| 3 | import Data.Attoparsec.Text qualified as AP | ||
| 4 | import Data.Char (digitToInt, ord) | ||
| 5 | import Data.Csv qualified as C | ||
| 6 | import Data.Decimal (Decimal, DecimalRaw (Decimal), normalizeDecimal) | ||
| 7 | import Data.Iban (Iban, mkIban) | ||
| 8 | import Data.Text qualified as T | ||
| 9 | import Data.Time.Calendar (Day) | ||
| 10 | import Data.Time.Clock (UTCTime) | ||
| 11 | import Data.Time.Format (defaultTimeLocale, parseTimeM) | ||
| 12 | import Data.Time.Zones (TZ, localTimeToUTCTZ) | ||
| 13 | |||
| 14 | data DebitCredit = Debit | Credit deriving (Show) | ||
| 15 | |||
| 16 | readDecimal :: T.Text -> Either String Decimal | ||
| 17 | readDecimal = AP.parseOnly $ do | ||
| 18 | decPart <- AP.decimal | ||
| 19 | _ <- AP.char ',' | ||
| 20 | f1 <- AP.digit | ||
| 21 | f2 <- AP.digit | ||
| 22 | AP.endOfInput | ||
| 23 | let fracPart = fromIntegral $ digitToInt f1 * 10 + digitToInt f2 | ||
| 24 | return $ normalizeDecimal (Decimal 2 (decPart * 100 + fracPart)) | ||
| 25 | |||
| 26 | scsvOptions :: C.DecodeOptions | ||
| 27 | scsvOptions = C.defaultDecodeOptions {C.decDelimiter = fromIntegral (ord ';')} | ||
| 28 | |||
| 29 | eitherToCP :: Either String a -> C.Parser a | ||
| 30 | eitherToCP = either fail return | ||
| 31 | |||
| 32 | decimalCP :: T.Text -> C.Parser Decimal | ||
| 33 | decimalCP = eitherToCP . readDecimal | ||
| 34 | |||
| 35 | dateCP :: String -> T.Text -> C.Parser Day | ||
| 36 | dateCP fmt = parseTimeM False defaultTimeLocale fmt . T.unpack | ||
| 37 | |||
| 38 | maybeCP :: (T.Text -> C.Parser a) -> T.Text -> C.Parser (Maybe a) | ||
| 39 | maybeCP p t = if T.null t then return Nothing else Just <$> p t | ||
| 40 | |||
| 41 | ibanCP :: T.Text -> C.Parser Iban | ||
| 42 | ibanCP = eitherToCP . mkIban | ||
| 43 | |||
| 44 | timestampCP :: String -> TZ -> T.Text -> C.Parser UTCTime | ||
| 45 | timestampCP fmt amsTz t = do | ||
| 46 | localTime <- parseTimeM False defaultTimeLocale fmt (T.unpack t) | ||
| 47 | return $ localTimeToUTCTZ amsTz localTime | ||
diff --git a/app/Main.hs b/app/Main.hs new file mode 100644 index 0000000..2438203 --- /dev/null +++ b/app/Main.hs | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | module Main where | ||
| 2 | |||
| 3 | import Brick.AttrMap qualified as A | ||
| 4 | import Brick.Main qualified as M | ||
| 5 | import Brick.Types | ||
| 6 | ( BrickEvent (..), | ||
| 7 | Widget, | ||
| 8 | ) | ||
| 9 | import Brick.Types qualified as T | ||
| 10 | import Brick.Util (bg, on) | ||
| 11 | import Brick.Widgets.Center qualified as C | ||
| 12 | import Brick.Widgets.Core | ||
| 13 | ( padAll, | ||
| 14 | str, | ||
| 15 | ) | ||
| 16 | import Brick.Widgets.Dialog qualified as D | ||
| 17 | import Graphics.Vty qualified as V | ||
| 18 | import Import.Ing.SavingsAccountCsv qualified | ||
| 19 | import System.IO (IOMode (ReadMode), withFile) | ||
| 20 | import Text.Pretty.Simple (pPrint) | ||
| 21 | |||
| 22 | -- data AccountType = Asset | Equity | Liability | Expense | Income | ||
| 23 | -- | ||
| 24 | -- data TxAction = Inc | Dec | ||
| 25 | -- | ||
| 26 | -- txAopp :: TxAction -> TxAction | ||
| 27 | -- txaOpp Inc = Dec | ||
| 28 | -- txaOpp Dec = Inc | ||
| 29 | -- | ||
| 30 | -- onDebit :: AccountType -> TxAction | ||
| 31 | -- onDebit Asset = Inc | ||
| 32 | -- onDebit Equity = Dec | ||
| 33 | -- onDebit Liability = Dec | ||
| 34 | -- onDebit Expense = Inc | ||
| 35 | -- onDebit Income = Dec | ||
| 36 | -- | ||
| 37 | -- onCredit :: AccountType -> TxAction | ||
| 38 | -- onCredit = txaOpp . onDebit | ||
| 39 | -- | ||
| 40 | -- data Tx = Tx { txDebit :: [(Account, Decimal)], txCredit :: [(Account, Decimal)] } deriving Show | ||
| 41 | data Choice = Red | Blue | Green | ||
| 42 | deriving (Show) | ||
| 43 | |||
| 44 | data Name | ||
| 45 | = RedButton | ||
| 46 | | BlueButton | ||
| 47 | | GreenButton | ||
| 48 | deriving (Show, Eq, Ord) | ||
| 49 | |||
| 50 | drawUI :: D.Dialog Choice Name -> [Widget Name] | ||
| 51 | drawUI d = [ui] | ||
| 52 | where | ||
| 53 | ui = D.renderDialog d $ C.hCenter $ padAll 1 $ str "This is the dialog body." | ||
| 54 | |||
| 55 | appEvent :: BrickEvent Name e -> T.EventM Name (D.Dialog Choice Name) () | ||
| 56 | appEvent (VtyEvent ev) = | ||
| 57 | case ev of | ||
| 58 | V.EvKey V.KEsc [] -> M.halt | ||
| 59 | V.EvKey V.KEnter [] -> M.halt | ||
| 60 | _ -> D.handleDialogEvent ev | ||
| 61 | appEvent _ = return () | ||
| 62 | |||
| 63 | initialState :: D.Dialog Choice Name | ||
| 64 | initialState = D.dialog (Just $ str "Title") (Just (RedButton, choices)) 50 | ||
| 65 | where | ||
| 66 | choices = | ||
| 67 | [ ("Red", RedButton, Red), | ||
| 68 | ("Blue", BlueButton, Blue), | ||
| 69 | ("Green", GreenButton, Green) | ||
| 70 | ] | ||
| 71 | |||
| 72 | theMap :: A.AttrMap | ||
| 73 | theMap = | ||
| 74 | A.attrMap | ||
| 75 | V.defAttr | ||
| 76 | [ (D.dialogAttr, V.white `on` V.blue), | ||
| 77 | (D.buttonAttr, V.black `on` V.white), | ||
| 78 | (D.buttonSelectedAttr, bg V.yellow) | ||
| 79 | ] | ||
| 80 | |||
| 81 | theApp :: M.App (D.Dialog Choice Name) e Name | ||
| 82 | theApp = | ||
| 83 | M.App | ||
| 84 | { M.appDraw = drawUI, | ||
| 85 | M.appChooseCursor = M.showFirstCursor, | ||
| 86 | M.appHandleEvent = appEvent, | ||
| 87 | M.appStartEvent = return (), | ||
| 88 | M.appAttrMap = const theMap | ||
| 89 | } | ||
| 90 | |||
| 91 | main :: IO () | ||
| 92 | main = do | ||
| 93 | let filename = "/home/rutgerbrf/Code/P/wayligmative/test.csv" | ||
| 94 | putStrLn $ "Reading " ++ filename | ||
| 95 | withFile filename ReadMode $ \h -> do | ||
| 96 | entries <- Import.Ing.SavingsAccountCsv.readFile h | ||
| 97 | pPrint entries | ||
| 98 | |||
| 99 | -- d <- M.defaultMain theApp initialState | ||
| 100 | -- putStrLn $ "You chose: " <> show (D.dialogSelection d) | ||
diff --git a/wayligmative.cabal b/wayligmative.cabal new file mode 100644 index 0000000..b604e20 --- /dev/null +++ b/wayligmative.cabal | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | cabal-version: 3.0 | ||
| 2 | name: wayligmative | ||
| 3 | version: 0.1.0.0 | ||
| 4 | maintainer: [email protected] | ||
| 5 | author: Rutger Broekhoff | ||
| 6 | build-type: Simple | ||
| 7 | |||
| 8 | executable wayligmative | ||
| 9 | main-is: Main.hs | ||
| 10 | hs-source-dirs: app | ||
| 11 | other-modules: | ||
| 12 | Data.Iban | ||
| 13 | Import.Ing.CurrentAccountCsv | ||
| 14 | Import.Ing.Shared | ||
| 15 | Import.Ing.SavingsAccountCsv | ||
| 16 | |||
| 17 | default-language: GHC2024 | ||
| 18 | ghc-options: -Wall -threaded | ||
| 19 | build-depends: | ||
| 20 | base ^>=4.20.0.0, | ||
| 21 | parsec, | ||
| 22 | brick, | ||
| 23 | vty, | ||
| 24 | cassava, | ||
| 25 | Decimal, | ||
| 26 | text, | ||
| 27 | time, | ||
| 28 | attoparsec, | ||
| 29 | containers, | ||
| 30 | vector, | ||
| 31 | bytestring, | ||
| 32 | regex-tdfa, | ||
| 33 | tz, | ||
| 34 | pretty-simple | ||