summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRutger Broekhoff2025-03-18 15:29:27 +0100
committerRutger Broekhoff2025-03-18 15:31:11 +0100
commit86c8896ee69b068368b4ef9a4c3923285907c328 (patch)
treedc6e4f58a511c58e2910e9f7ea900165da7d47c6
downloadrdcapsis-86c8896ee69b068368b4ef9a4c3923285907c328.tar.gz
rdcapsis-86c8896ee69b068368b4ef9a4c3923285907c328.zip
Parsing ING statements (POC)
-rw-r--r--.gitignore3
-rw-r--r--app/Data/Iban.hs40
-rw-r--r--app/Format.hs3
-rw-r--r--app/Import/Ing/CurrentAccountCsv.hs356
-rw-r--r--app/Import/Ing/SavingsAccountCsv.hs162
-rw-r--r--app/Import/Ing/Shared.hs47
-rw-r--r--app/Main.hs100
-rw-r--r--wayligmative.cabal34
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 @@
1module Data.Iban (Iban, mkIban) where
2
3import Control.Applicative ((<|>))
4import Data.Attoparsec.Text as AP
5import Data.Char
6 ( digitToInt,
7 ord,
8 toUpper,
9 )
10import Data.Text qualified as T
11
12newtype Iban = Iban T.Text deriving (Show, Eq)
13
14mkIban :: T.Text -> Either String Iban
15mkIban t = validateIban t >> return (Iban t)
16
17validateIban :: T.Text -> Either String ()
18validateIban 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 @@
1module Format where
2
3import 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
4module Import.Ing.CurrentAccountCsv where
5
6import Control.Applicative ((<|>))
7import Data.ByteString.Lazy qualified as BS
8import Data.Csv ((.:))
9import Data.Csv qualified as C
10import Data.Decimal (Decimal)
11import Data.Functor ((<&>))
12import Data.Iban (Iban)
13import Data.Text qualified as T
14import Data.Time.Calendar (Day)
15import Data.Time.Clock (UTCTime)
16import Data.Time.Zones (TZ, loadTZFromDB)
17import Data.Vector qualified as V
18import Import.Ing.Shared
19 ( DebitCredit (Credit, Debit),
20 dateCP,
21 decimalCP,
22 ibanCP,
23 maybeCP,
24 scsvOptions,
25 timestampCP,
26 )
27import System.IO (Handle)
28import Text.Regex.TDFA ((=~~))
29
30data 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
47parseCode :: T.Text -> C.Parser TransactionType
48parseCode "AC" = return AcceptGiro
49parseCode "GM" = return AtmWithdrawal
50parseCode "VZ" = return BatchPayment
51parseCode "FL" = return BranchPosting
52parseCode "ST" = return Deposit
53parseCode "IC" = return DirectDebit
54parseCode "ID" = return Ideal
55parseCode "GT" = return OnlineBanking
56parseCode "PK" = return OfficeWithdrawal
57parseCode "BA" = return PaymentTerminal
58parseCode "PO" = return PeriodicTransfer
59parseCode "GF" = return PhoneBanking
60parseCode "OV" = return Transfer
61parseCode "DV" = return Various
62parseCode t = fail $ "Unknown transaction code '" ++ T.unpack t ++ "'"
63
64parseType :: T.Text -> C.Parser TransactionType
65parseType "SEPA direct debit" = return DirectDebit
66parseType "Batch payment" = return BatchPayment
67parseType "Online Banking" = return OnlineBanking
68parseType "Payment terminal" = return PaymentTerminal
69parseType "Transfer" = return Transfer
70parseType "iDEAL" = return Ideal
71parseType t = fail $ "Unknown transaction type '" ++ T.unpack t ++ "'"
72
73data 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
86data 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
154maybeNotProvided :: T.Text -> Maybe T.Text
155maybeNotProvided t = if t == "NOTPROVIDED" then Nothing else Just t
156
157valueDateCP :: T.Text -> C.Parser Day
158valueDateCP = dateCP "%d/%m/%Y"
159
160data PartTx = PartTx !Day !TransactionType !DebitCredit
161
162notificationsCP :: TZ -> PartTx -> T.Text -> C.Parser MoreData
163notificationsCP _ (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 }
176notificationsCP _ (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 }
185notificationsCP 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 }
199notificationsCP 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 }
212notificationsCP 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 }
226notificationsCP 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 }
243notificationsCP _ (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 }
276notificationsCP 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 }
291notificationsCP _ (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 }
304notificationsCP _ (PartTx _ ty cd) _ = fail $ "Unmatched type and debit/credit combination (" ++ show ty ++ ", " ++ show cd ++ ")"
305
306debitCreditCP :: T.Text -> C.Parser DebitCredit
307debitCreditCP "Debit" = return Debit
308debitCreditCP "Credit" = return Credit
309debitCreditCP t = fail ("Unknown debit/credit value '" ++ T.unpack t ++ "'")
310
311parseNamedRecord :: TZ -> C.NamedRecord -> C.Parser PrimTx
312parseNamedRecord 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
333readFile :: Handle -> IO (V.Vector PrimTx)
334readFile 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
4module Import.Ing.SavingsAccountCsv where
5
6import Data.ByteString.Lazy qualified as BS
7import Data.Csv ((.:))
8import Data.Csv qualified as C
9import Data.Decimal (Decimal)
10import Data.Iban (Iban, mkIban)
11import Data.Maybe (isJust)
12import Data.Text qualified as T
13import Data.Time.Calendar (Day)
14import Data.Vector qualified as V
15import Import.Ing.Shared (dateCP, decimalCP, eitherToCP, ibanCP, maybeCP, scsvOptions)
16import System.IO (Handle)
17import Text.Regex.TDFA ((=~~))
18
19data DebitCredit = Debit | Credit deriving (Show, Eq)
20
21data MutationType = DepositMutation | WithdrawalMutation | InterestMutation deriving (Show)
22
23data TxBase = TxBase
24 { txbDate :: !Day,
25 txbAccountId :: !T.Text,
26 txbAccountName :: !T.Text,
27 txbAmount :: !Decimal,
28 txbResBal :: !Decimal
29 }
30 deriving (Show)
31
32data 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
45data Tx = Tx TxBase TxSpecifics deriving (Show)
46
47instance MonadFail (Either String) where
48 fail = Left
49
50txBaseFromPrim :: PrimTx -> Either String TxBase
51txBaseFromPrim ptx@PrimTx {ptxCommodity = "EUR"} =
52 return $ TxBase (ptxDate ptx) (ptxAccountId ptx) (ptxAccountName ptx) (ptxAmount ptx) (ptxResBal ptx)
53txBaseFromPrim ptx =
54 Left $ "Unexpected commodity '" ++ T.unpack (ptxCommodity ptx) ++ "' (expected EUR)"
55
56specificsFromPrim :: PrimTx -> Either String TxSpecifics
57specificsFromPrim 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
66specificsFromPrim 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}
76specificsFromPrim 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
95processPrimTx :: PrimTx -> Either String Tx
96processPrimTx ptx = Tx <$> txBaseFromPrim ptx <*> specificsFromPrim ptx
97
98data 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
113debitCreditCP :: T.Text -> C.Parser DebitCredit
114debitCreditCP "Af" = return Debit
115debitCreditCP "Bij" = return Credit
116debitCreditCP t = fail ("Unknown debit/credit value '" ++ T.unpack t ++ "'")
117
118mutationTypeCP :: T.Text -> C.Parser MutationType
119mutationTypeCP "Inleg" = return DepositMutation
120mutationTypeCP "Opname" = return WithdrawalMutation
121mutationTypeCP "Rente" = return InterestMutation
122mutationTypeCP t = fail ("Unknown mutation type '" ++ T.unpack t ++ "'")
123
124parseNamedRecord :: C.NamedRecord -> C.Parser Tx
125parseNamedRecord 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
140readFile :: Handle -> IO (V.Vector Tx)
141readFile 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 @@
1module Import.Ing.Shared where
2
3import Data.Attoparsec.Text qualified as AP
4import Data.Char (digitToInt, ord)
5import Data.Csv qualified as C
6import Data.Decimal (Decimal, DecimalRaw (Decimal), normalizeDecimal)
7import Data.Iban (Iban, mkIban)
8import Data.Text qualified as T
9import Data.Time.Calendar (Day)
10import Data.Time.Clock (UTCTime)
11import Data.Time.Format (defaultTimeLocale, parseTimeM)
12import Data.Time.Zones (TZ, localTimeToUTCTZ)
13
14data DebitCredit = Debit | Credit deriving (Show)
15
16readDecimal :: T.Text -> Either String Decimal
17readDecimal = 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
26scsvOptions :: C.DecodeOptions
27scsvOptions = C.defaultDecodeOptions {C.decDelimiter = fromIntegral (ord ';')}
28
29eitherToCP :: Either String a -> C.Parser a
30eitherToCP = either fail return
31
32decimalCP :: T.Text -> C.Parser Decimal
33decimalCP = eitherToCP . readDecimal
34
35dateCP :: String -> T.Text -> C.Parser Day
36dateCP fmt = parseTimeM False defaultTimeLocale fmt . T.unpack
37
38maybeCP :: (T.Text -> C.Parser a) -> T.Text -> C.Parser (Maybe a)
39maybeCP p t = if T.null t then return Nothing else Just <$> p t
40
41ibanCP :: T.Text -> C.Parser Iban
42ibanCP = eitherToCP . mkIban
43
44timestampCP :: String -> TZ -> T.Text -> C.Parser UTCTime
45timestampCP 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 @@
1module Main where
2
3import Brick.AttrMap qualified as A
4import Brick.Main qualified as M
5import Brick.Types
6 ( BrickEvent (..),
7 Widget,
8 )
9import Brick.Types qualified as T
10import Brick.Util (bg, on)
11import Brick.Widgets.Center qualified as C
12import Brick.Widgets.Core
13 ( padAll,
14 str,
15 )
16import Brick.Widgets.Dialog qualified as D
17import Graphics.Vty qualified as V
18import Import.Ing.SavingsAccountCsv qualified
19import System.IO (IOMode (ReadMode), withFile)
20import 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
41data Choice = Red | Blue | Green
42 deriving (Show)
43
44data Name
45 = RedButton
46 | BlueButton
47 | GreenButton
48 deriving (Show, Eq, Ord)
49
50drawUI :: D.Dialog Choice Name -> [Widget Name]
51drawUI d = [ui]
52 where
53 ui = D.renderDialog d $ C.hCenter $ padAll 1 $ str "This is the dialog body."
54
55appEvent :: BrickEvent Name e -> T.EventM Name (D.Dialog Choice Name) ()
56appEvent (VtyEvent ev) =
57 case ev of
58 V.EvKey V.KEsc [] -> M.halt
59 V.EvKey V.KEnter [] -> M.halt
60 _ -> D.handleDialogEvent ev
61appEvent _ = return ()
62
63initialState :: D.Dialog Choice Name
64initialState = 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
72theMap :: A.AttrMap
73theMap =
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
81theApp :: M.App (D.Dialog Choice Name) e Name
82theApp =
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
91main :: IO ()
92main = 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 @@
1cabal-version: 3.0
2name: wayligmative
3version: 0.1.0.0
4maintainer: [email protected]
5author: Rutger Broekhoff
6build-type: Simple
7
8executable 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