diff options
author | Rutger Broekhoff | 2025-08-25 19:48:19 +0200 |
---|---|---|
committer | Rutger Broekhoff | 2025-08-25 19:48:19 +0200 |
commit | 95d50b25c990e8c945ce2507b16ff3c8b039d286 (patch) | |
tree | c1ff4c7f9601c6980eed1a7235ba336c5c6f6106 | |
parent | 29b26dcbc1404925bbf12cddd66f7fcd3c57cfe7 (diff) | |
download | rdcapsis-95d50b25c990e8c945ce2507b16ff3c8b039d286.tar.gz rdcapsis-95d50b25c990e8c945ce2507b16ff3c8b039d286.zip |
OCaml
-rw-r--r-- | .envrc | 1 | ||||
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | .ocamlformat | 0 | ||||
-rw-r--r-- | app/Data/Iban.hs | 48 | ||||
-rw-r--r-- | app/Data/Ledger.hs | 115 | ||||
-rw-r--r-- | app/Data/Ledger/AutoFile.hs | 1 | ||||
-rw-r--r-- | app/Data/Res.hs | 31 | ||||
-rw-r--r-- | app/Format.hs | 3 | ||||
-rw-r--r-- | app/Import/Ing/Convert.hs | 257 | ||||
-rw-r--r-- | app/Import/Ing/CurrentAccountCsv.hs | 407 | ||||
-rw-r--r-- | app/Import/Ing/SavingsAccountCsv.hs | 164 | ||||
-rw-r--r-- | app/Import/Ing/Shared.hs | 44 | ||||
-rw-r--r-- | app/Main.hs | 4 | ||||
-rw-r--r-- | bin/dune | 4 | ||||
-rw-r--r-- | bin/main.ml | 1 | ||||
-rw-r--r-- | dune-project | 26 | ||||
-rw-r--r-- | lib/dune | 5 | ||||
-rw-r--r-- | lib/iban.ml | 87 | ||||
-rw-r--r-- | lib/iban.mli | 8 | ||||
-rw-r--r-- | lib/ingcsv.ml | 487 | ||||
-rw-r--r-- | lib/ledger.ml | 110 | ||||
-rw-r--r-- | rdcapsis.cabal | 47 | ||||
-rw-r--r-- | rdcapsis.opam | 39 | ||||
-rw-r--r-- | test/dune | 2 | ||||
-rw-r--r-- | test/test_rdcapsis.ml | 0 |
25 files changed, 772 insertions, 1121 deletions
@@ -0,0 +1 @@ | |||
eval $(opam env) | |||
@@ -1,3 +1,5 @@ | |||
1 | .\#* | 1 | .\#* |
2 | \#*\# | 2 | \#*\# |
3 | *~ | 3 | *~ |
4 | _build/ | ||
5 | _opam/ | ||
diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.ocamlformat | |||
diff --git a/app/Data/Iban.hs b/app/Data/Iban.hs deleted file mode 100644 index d9566b9..0000000 --- a/app/Data/Iban.hs +++ /dev/null | |||
@@ -1,48 +0,0 @@ | |||
1 | module Data.Iban (Iban, mkIban, toText) where | ||
2 | |||
3 | import Control.Applicative ((<|>)) | ||
4 | import Data.Attoparsec.Text as AP | ||
5 | import Data.Char | ||
6 | ( digitToInt, | ||
7 | isAscii, | ||
8 | isDigit, | ||
9 | ord, | ||
10 | toUpper, | ||
11 | ) | ||
12 | import Data.Text qualified as T | ||
13 | |||
14 | newtype Iban = Iban T.Text deriving (Show, Eq) | ||
15 | |||
16 | mkIban :: T.Text -> Either String Iban | ||
17 | mkIban t = validateIban t >> return (Iban t) | ||
18 | |||
19 | validateIban :: T.Text -> Either String () | ||
20 | validateIban = AP.parseOnly $ do | ||
21 | countryCode <- AP.count 2 AP.letter | ||
22 | checkDigits <- AP.count 2 AP.digit | ||
23 | chars <- AP.many1 (AP.letter <|> AP.digit) | ||
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 (" ++ countryCode ++ checkDigits ++ chars ++ ")" | ||
30 | else fail "IBAN has more than 34 characters" | ||
31 | where | ||
32 | letterToInt c = ord (toUpper c) - ord 'A' + 10 | ||
33 | charsToInteger = | ||
34 | foldl' | ||
35 | ( \acc -> \case | ||
36 | d | ||
37 | | isDigit d -> acc * 10 + toInteger (digitToInt d) | ||
38 | | isAscii d -> acc * 100 + toInteger (letterToInt d) | ||
39 | | otherwise -> error "unreachable" | ||
40 | ) | ||
41 | 0 | ||
42 | ibanToInteger countryCode checkDigits chars = | ||
43 | charsToInteger chars * 1000000 + charsToInteger countryCode * 100 + charsToInteger checkDigits | ||
44 | valid countryCode checkDigits chars = | ||
45 | ibanToInteger countryCode checkDigits chars `mod` 97 == 1 | ||
46 | |||
47 | toText :: Iban -> T.Text | ||
48 | toText (Iban t) = t | ||
diff --git a/app/Data/Ledger.hs b/app/Data/Ledger.hs deleted file mode 100644 index 4aa5137..0000000 --- a/app/Data/Ledger.hs +++ /dev/null | |||
@@ -1,115 +0,0 @@ | |||
1 | {-# LANGUAGE TemplateHaskell #-} | ||
2 | {-# LANGUAGE TypeFamilies #-} | ||
3 | |||
4 | module Data.Ledger where | ||
5 | |||
6 | import Data.Constraint.Extras.TH (deriveArgDict) | ||
7 | import Data.Dependent.Map (DMap, fromList, singleton, union, unionWithKey) | ||
8 | import Data.Dependent.Sum ((==>)) | ||
9 | import Data.Functor.Identity | ||
10 | import Data.Functor.Identity (Identity (..)) | ||
11 | import Data.GADT.Compare.TH (deriveGCompare, deriveGEq) | ||
12 | import Data.GADT.Show.TH (deriveGShow) | ||
13 | import Data.Iban | ||
14 | import Data.Map qualified as M | ||
15 | import Data.Text qualified as T | ||
16 | import Data.Time.Calendar | ||
17 | import Data.Time.Clock | ||
18 | import Data.UUID | ||
19 | import GHC.Generics | ||
20 | |||
21 | data AccountType = Asset | Equity | Liability | Expense | Income | ||
22 | |||
23 | data TxAction = Inc | Dec | ||
24 | |||
25 | txaOpp :: TxAction -> TxAction | ||
26 | txaOpp Inc = Dec | ||
27 | txaOpp Dec = Inc | ||
28 | |||
29 | onDebit :: AccountType -> TxAction | ||
30 | onDebit Asset = Inc | ||
31 | onDebit Equity = Dec | ||
32 | onDebit Liability = Dec | ||
33 | onDebit Expense = Inc | ||
34 | onDebit Income = Dec | ||
35 | |||
36 | onCredit :: AccountType -> TxAction | ||
37 | onCredit = txaOpp . onDebit | ||
38 | |||
39 | data TxType | ||
40 | = InterestCtx | ||
41 | | OnlineBankingTx | ||
42 | | RecurrentDirectTx | ||
43 | | PaymentTerminalTx | ||
44 | | CashPaymentTx | ||
45 | | AtmTx | ||
46 | | AutoSaveRoundingTx | ||
47 | | BatchTx | ||
48 | | DirectDebitTx | ||
49 | | PeriodicTx | ||
50 | |||
51 | data IbanTag = AccountTag | CounterpartyIbanTag deriving (Eq, Enum, Ord, Show) | ||
52 | |||
53 | data UnitTag = FiledTag | GooglePayTag | AutoRoundSavingsTag deriving (Eq, Enum, Ord, Show) | ||
54 | |||
55 | data TextTag = DescTag | UserTag | CounterpartyNameTag | ReferenceTag | MandateIdTag | CreditorIdTag | OtherPartyTag | TransactionTag | TerminalTag | CardSeqNoTag | SavingsAccountTag deriving (Eq, Enum, Ord, Show) | ||
56 | |||
57 | data Label a where | ||
58 | TextLabel :: TextTag -> Label T.Text | ||
59 | IbanLabel :: IbanTag -> Label Iban | ||
60 | UnitLabel :: UnitTag -> Label () | ||
61 | TimestampLabel :: Label UTCTime | ||
62 | |||
63 | deriveGEq ''Label | ||
64 | deriveGCompare ''Label | ||
65 | deriveGShow ''Label | ||
66 | deriveArgDict ''Label | ||
67 | |||
68 | type Labels = DMap Label Identity | ||
69 | |||
70 | data Money = Money Integer deriving (Show) | ||
71 | |||
72 | infixl 6 +€, -€ | ||
73 | |||
74 | (+€) :: Money -> Money -> Money | ||
75 | Money x +€ Money y = Money (x + y) | ||
76 | |||
77 | (-€) :: Money -> Money -> Money | ||
78 | Money x -€ Money y = Money (x - y) | ||
79 | |||
80 | data Scalar = Amount Money | Rate Integer deriving (Show) | ||
81 | |||
82 | data AccountId = AccountId [T.Text] deriving (Show) | ||
83 | |||
84 | data CommodityId = CommodityId UUID deriving (Show) | ||
85 | |||
86 | data Account = Account | ||
87 | { id :: AccountId, | ||
88 | description :: [T.Text], | ||
89 | commodityId :: CommodityId, | ||
90 | balance :: Money | ||
91 | } | ||
92 | |||
93 | -- A balance assertion is only valid when all transactions before it have been | ||
94 | -- cleared and the balance of the account agrees with the amount in the | ||
95 | -- assertion. | ||
96 | data BalAssert = BalAssert | ||
97 | { account :: AccountId, | ||
98 | amount :: Money, | ||
99 | labels :: Labels | ||
100 | } | ||
101 | |||
102 | data Tx = Tx | ||
103 | { cleared :: Maybe Day, | ||
104 | commodityId :: CommodityId, -- the commodity w.r.t. which rates are calculated | ||
105 | debit :: M.Map AccountId Scalar, | ||
106 | credit :: M.Map AccountId Scalar, | ||
107 | labels :: Labels | ||
108 | } | ||
109 | deriving (Show, Generic) | ||
110 | |||
111 | -- data SeqTx = SeqTx [Integer] Tx | ||
112 | |||
113 | data Entry = TxEntry Tx | BalAssertEntry BalAssert | ||
114 | |||
115 | data Ledger = Ledger [Entry] | ||
diff --git a/app/Data/Ledger/AutoFile.hs b/app/Data/Ledger/AutoFile.hs deleted file mode 100644 index 15a1b16..0000000 --- a/app/Data/Ledger/AutoFile.hs +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | module Data.Ledger.AutoFile where | ||
diff --git a/app/Data/Res.hs b/app/Data/Res.hs deleted file mode 100644 index 3806e5a..0000000 --- a/app/Data/Res.hs +++ /dev/null | |||
@@ -1,31 +0,0 @@ | |||
1 | module Data.Res where | ||
2 | |||
3 | import Control.Applicative | ||
4 | import Data.String (IsString (fromString)) | ||
5 | |||
6 | data Res e r = Ok r | Err e | ||
7 | |||
8 | instance Functor (Res e) where | ||
9 | fmap f (Ok v) = Ok (f v) | ||
10 | fmap _ (Err e) = Err e | ||
11 | |||
12 | instance Applicative (Res e) where | ||
13 | pure = Ok | ||
14 | (Ok f) <*> (Ok v) = Ok (f v) | ||
15 | (Err e) <*> _ = Err e | ||
16 | _ <*> (Err e) = Err e | ||
17 | |||
18 | instance Monad (Res e) where | ||
19 | (Ok v) >>= f = f v | ||
20 | (Err e) >>= _ = Err e | ||
21 | |||
22 | instance (IsString e) => MonadFail (Res e) where | ||
23 | fail = Err . fromString | ||
24 | |||
25 | instance (IsString e) => Alternative (Res e) where | ||
26 | empty = fail "mzero" | ||
27 | m1@(Ok _) <|> _ = m1 | ||
28 | (Err _) <|> m2 = m2 | ||
29 | |||
30 | liftEither :: Either e r -> Res e r | ||
31 | liftEither = either Err Ok | ||
diff --git a/app/Format.hs b/app/Format.hs deleted file mode 100644 index a779d95..0000000 --- a/app/Format.hs +++ /dev/null | |||
@@ -1,3 +0,0 @@ | |||
1 | module Format where | ||
2 | |||
3 | import Text.Parsec | ||
diff --git a/app/Import/Ing/Convert.hs b/app/Import/Ing/Convert.hs deleted file mode 100644 index 5dcda0b..0000000 --- a/app/Import/Ing/Convert.hs +++ /dev/null | |||
@@ -1,257 +0,0 @@ | |||
1 | module Import.Ing.Convert where | ||
2 | |||
3 | import Control.Monad (when) | ||
4 | import Data.Decimal | ||
5 | import Data.Dependent.Map | ||
6 | import Data.Dependent.Sum ((==>)) | ||
7 | import Data.Functor.Identity | ||
8 | import Data.Iban qualified as Iban | ||
9 | import Data.Ledger as L | ||
10 | import Data.Map qualified as M | ||
11 | import Data.Res | ||
12 | import Data.Text qualified as T | ||
13 | import Import.Ing.CurrentAccountCsv as C | ||
14 | import Import.Ing.SavingsAccountCsv as S | ||
15 | |||
16 | virtCheckingAcc :: L.AccountId | ||
17 | virtCheckingAcc = AccountId ["Unfiled", "Checking"] | ||
18 | |||
19 | virtSavingsAcc :: L.AccountId | ||
20 | virtSavingsAcc = AccountId ["Unfiled", "Savings"] | ||
21 | |||
22 | virtCounterparty :: L.AccountId | ||
23 | virtCounterparty = AccountId ["Unfiled", "Counterparty"] | ||
24 | |||
25 | toCents :: Decimal -> Res String L.Money | ||
26 | toCents m | ||
27 | | f == 0 = | ||
28 | return (L.Money m') | ||
29 | | otherwise = | ||
30 | fail "Cannot convert to whole cents: amount of money is more specific" | ||
31 | where | ||
32 | (m', f) = properFraction (m * 100) | ||
33 | |||
34 | condUnitLabel :: UnitTag -> Bool -> L.Labels | ||
35 | condUnitLabel _ False = empty | ||
36 | condUnitLabel t True = singleton (UnitLabel t) (Identity ()) | ||
37 | |||
38 | lesFromCurrentAcc :: CommodityId -> C.Tx -> Res String [L.Entry] | ||
39 | lesFromCurrentAcc eucId tx@(C.Tx base _) = do | ||
40 | tx' <- txFromCurrentAcc eucId tx | ||
41 | ba <- baFromCurrentAccBase base | ||
42 | return [BalAssertEntry ba, TxEntry tx'] | ||
43 | |||
44 | baFromCurrentAccBase :: C.TxBase -> Res String L.BalAssert | ||
45 | baFromCurrentAccBase base = do | ||
46 | resBal <- toCents base.resBal | ||
47 | return $ | ||
48 | L.BalAssert | ||
49 | { account = virtCheckingAcc, | ||
50 | amount = resBal, | ||
51 | labels = | ||
52 | fromList [IbanLabel AccountTag ==> base.account] | ||
53 | } | ||
54 | |||
55 | baFromCurrentAcc :: C.Tx -> Res String L.BalAssert | ||
56 | baFromCurrentAcc (C.Tx base _) = baFromCurrentAccBase base | ||
57 | |||
58 | txFromCurrentAcc :: CommodityId -> C.Tx -> Res String L.Tx | ||
59 | txFromCurrentAcc eucId (C.Tx base spec) = do | ||
60 | when (base.amount < 0) $ | ||
61 | fail "Transaction amount may not be lower than zero" | ||
62 | amount <- L.Amount <$> toCents base.amount | ||
63 | case spec of | ||
64 | PaymentTerminalPayment | ||
65 | { counterpartyName, | ||
66 | cardSequenceNo, | ||
67 | timestamp, | ||
68 | transaction, | ||
69 | terminal, | ||
70 | googlePay | ||
71 | } -> | ||
72 | return $ | ||
73 | L.Tx | ||
74 | { cleared = Just base.date, | ||
75 | commodityId = eucId, | ||
76 | credit = M.singleton virtCheckingAcc amount, | ||
77 | debit = M.singleton virtCounterparty amount, | ||
78 | labels = | ||
79 | fromList | ||
80 | [ IbanLabel AccountTag ==> base.account, | ||
81 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
82 | TextLabel CardSeqNoTag ==> cardSequenceNo, | ||
83 | TextLabel TerminalTag ==> terminal, | ||
84 | TextLabel TransactionTag ==> transaction, | ||
85 | TimestampLabel ==> timestamp | ||
86 | ] | ||
87 | `union` condUnitLabel GooglePayTag googlePay | ||
88 | } | ||
89 | PaymentTerminalCashback | ||
90 | { counterpartyName, | ||
91 | cardSequenceNo, | ||
92 | timestamp, | ||
93 | transaction, | ||
94 | terminal | ||
95 | } -> | ||
96 | return $ | ||
97 | L.Tx | ||
98 | { cleared = Just base.date, | ||
99 | commodityId = eucId, | ||
100 | debit = M.singleton virtCheckingAcc amount, | ||
101 | credit = M.singleton virtCounterparty amount, | ||
102 | labels = | ||
103 | fromList | ||
104 | [ IbanLabel AccountTag ==> base.account, | ||
105 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
106 | TextLabel CardSeqNoTag ==> cardSequenceNo, | ||
107 | TextLabel TerminalTag ==> terminal, | ||
108 | TextLabel TransactionTag ==> transaction, | ||
109 | TimestampLabel ==> timestamp | ||
110 | ] | ||
111 | } | ||
112 | OnlineBankingCredit | ||
113 | { counterpartyName, | ||
114 | counterpartyIban, | ||
115 | description, | ||
116 | timestamp | ||
117 | } -> | ||
118 | return $ | ||
119 | L.Tx | ||
120 | { cleared = Just base.date, | ||
121 | commodityId = eucId, | ||
122 | debit = M.singleton virtCheckingAcc amount, | ||
123 | credit = M.singleton virtCounterparty amount, | ||
124 | labels = | ||
125 | fromList | ||
126 | [ IbanLabel AccountTag ==> base.account, | ||
127 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
128 | IbanLabel CounterpartyIbanTag ==> counterpartyIban, | ||
129 | TextLabel DescTag ==> description, | ||
130 | TimestampLabel ==> timestamp | ||
131 | ] | ||
132 | } | ||
133 | OnlineBankingDebit | ||
134 | { counterpartyName, | ||
135 | counterpartyIban, | ||
136 | description, | ||
137 | mtimestamp | ||
138 | } -> | ||
139 | return $ | ||
140 | L.Tx | ||
141 | { cleared = Just base.date, | ||
142 | commodityId = eucId, | ||
143 | debit = M.singleton virtCounterparty amount, | ||
144 | credit = M.singleton virtCheckingAcc amount, | ||
145 | labels = | ||
146 | fromList | ||
147 | [ IbanLabel AccountTag ==> base.account, | ||
148 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
149 | IbanLabel CounterpartyIbanTag ==> counterpartyIban, | ||
150 | TextLabel DescTag ==> description | ||
151 | ] | ||
152 | `union` (maybe empty (singleton TimestampLabel . Identity) mtimestamp) | ||
153 | } | ||
154 | RecurrentDirectDebit | ||
155 | { counterpartyName, | ||
156 | counterpartyIban, | ||
157 | description, | ||
158 | reference, | ||
159 | mandateId, | ||
160 | creditorId, | ||
161 | otherParty | ||
162 | } -> | ||
163 | return $ | ||
164 | L.Tx | ||
165 | { cleared = Just base.date, | ||
166 | commodityId = eucId, | ||
167 | credit = M.singleton virtCheckingAcc amount, | ||
168 | debit = M.singleton virtCounterparty amount, | ||
169 | labels = | ||
170 | fromList | ||
171 | [ IbanLabel AccountTag ==> base.account, | ||
172 | IbanLabel CounterpartyIbanTag ==> counterpartyIban, | ||
173 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
174 | TextLabel DescTag ==> description, | ||
175 | TextLabel ReferenceTag ==> reference, | ||
176 | TextLabel MandateIdTag ==> mandateId, | ||
177 | TextLabel CreditorIdTag ==> creditorId | ||
178 | ] | ||
179 | `union` (maybe empty (singleton (TextLabel OtherPartyTag) . Identity) otherParty) | ||
180 | } | ||
181 | RoundingSavingsDeposit | ||
182 | { savingsAccount | ||
183 | } -> | ||
184 | return $ | ||
185 | L.Tx | ||
186 | { cleared = Just base.date, | ||
187 | commodityId = eucId, | ||
188 | credit = M.singleton virtCheckingAcc amount, | ||
189 | debit = M.singleton virtSavingsAcc amount, | ||
190 | labels = | ||
191 | fromList | ||
192 | [ UnitLabel AutoRoundSavingsTag ==> (), | ||
193 | TextLabel SavingsAccountTag ==> savingsAccount | ||
194 | ] | ||
195 | } | ||
196 | DepositTransfer | ||
197 | { counterpartyName, | ||
198 | counterpartyIban, | ||
199 | description, | ||
200 | reference | ||
201 | } -> | ||
202 | return $ | ||
203 | L.Tx | ||
204 | { cleared = Just base.date, | ||
205 | commodityId = eucId, | ||
206 | debit = M.singleton virtCheckingAcc amount, | ||
207 | credit = M.singleton virtCounterparty amount, | ||
208 | labels = | ||
209 | fromList | ||
210 | [ IbanLabel CounterpartyIbanTag ==> counterpartyIban, | ||
211 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
212 | TextLabel DescTag ==> description, | ||
213 | TextLabel ReferenceTag ==> reference | ||
214 | ] | ||
215 | } | ||
216 | IdealDebit | ||
217 | { counterpartyName, | ||
218 | counterpartyIban, | ||
219 | description, | ||
220 | timestamp, | ||
221 | reference | ||
222 | } -> | ||
223 | return $ | ||
224 | L.Tx | ||
225 | { cleared = Just base.date, | ||
226 | commodityId = eucId, | ||
227 | debit = M.singleton virtCheckingAcc amount, | ||
228 | credit = M.singleton virtCounterparty amount, | ||
229 | labels = | ||
230 | fromList | ||
231 | [ IbanLabel CounterpartyIbanTag ==> counterpartyIban, | ||
232 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
233 | TextLabel DescTag ==> description, | ||
234 | TextLabel ReferenceTag ==> reference, | ||
235 | TimestampLabel ==> timestamp | ||
236 | ] | ||
237 | } | ||
238 | BatchPayment | ||
239 | { counterpartyName, | ||
240 | counterpartyIban, | ||
241 | description, | ||
242 | reference | ||
243 | } -> | ||
244 | return $ | ||
245 | L.Tx | ||
246 | { cleared = Just base.date, | ||
247 | commodityId = eucId, | ||
248 | debit = M.singleton virtCheckingAcc amount, | ||
249 | credit = M.singleton virtCounterparty amount, | ||
250 | labels = | ||
251 | fromList | ||
252 | [ IbanLabel CounterpartyIbanTag ==> counterpartyIban, | ||
253 | TextLabel CounterpartyNameTag ==> counterpartyName, | ||
254 | TextLabel DescTag ==> description, | ||
255 | TextLabel ReferenceTag ==> reference | ||
256 | ] | ||
257 | } | ||
diff --git a/app/Import/Ing/CurrentAccountCsv.hs b/app/Import/Ing/CurrentAccountCsv.hs deleted file mode 100644 index 21ca53d..0000000 --- a/app/Import/Ing/CurrentAccountCsv.hs +++ /dev/null | |||
@@ -1,407 +0,0 @@ | |||
1 | {-# LANGUAGE OverloadedLists #-} | ||
2 | {-# LANGUAGE OverloadedStrings #-} | ||
3 | |||
4 | module Import.Ing.CurrentAccountCsv where | ||
5 | |||
6 | import Control.Applicative ((<|>)) | ||
7 | import Control.Monad (when) | ||
8 | import Data.ByteString.Lazy qualified as BS | ||
9 | import Data.Csv ((.:)) | ||
10 | import Data.Csv qualified as C | ||
11 | import Data.Decimal (Decimal) | ||
12 | import Data.Generics.Product.Subtype (upcast) | ||
13 | import Data.Iban (Iban) | ||
14 | import Data.Res (Res (Err, Ok)) | ||
15 | import Data.Text qualified as T | ||
16 | import Data.Time.Calendar (Day) | ||
17 | import Data.Time.Clock (UTCTime) | ||
18 | import Data.Time.Zones (TZ, loadTZFromDB) | ||
19 | import Data.Vector qualified as V | ||
20 | import GHC.Generics | ||
21 | import Import.Ing.Shared | ||
22 | ( DebitCredit (Credit, Debit), | ||
23 | maybeCP, | ||
24 | parseDateM, | ||
25 | parseDecimalM, | ||
26 | parseIbanM, | ||
27 | parseTimestampM, | ||
28 | scsvOptions, | ||
29 | ) | ||
30 | import System.IO (Handle) | ||
31 | import Text.Regex.TDFA ((=~~)) | ||
32 | |||
33 | data Tx = Tx TxBase TxSpecifics deriving (Show) | ||
34 | |||
35 | data TxBase = TxBase | ||
36 | { date :: !Day, | ||
37 | account :: !Iban, | ||
38 | amount :: !Decimal, | ||
39 | resBal :: !Decimal, | ||
40 | tag :: !T.Text | ||
41 | } | ||
42 | deriving (Show, Generic) | ||
43 | |||
44 | data TxSpecifics | ||
45 | = PaymentTerminalPayment | ||
46 | { counterpartyName :: !T.Text, | ||
47 | cardSequenceNo :: !T.Text, | ||
48 | timestamp :: !UTCTime, | ||
49 | transaction :: !T.Text, | ||
50 | terminal :: !T.Text, | ||
51 | googlePay :: !Bool | ||
52 | } | ||
53 | | PaymentTerminalCashback | ||
54 | { counterpartyName :: !T.Text, | ||
55 | cardSequenceNo :: !T.Text, | ||
56 | timestamp :: !UTCTime, | ||
57 | transaction :: !T.Text, | ||
58 | terminal :: !T.Text | ||
59 | } | ||
60 | | OnlineBankingCredit | ||
61 | { counterpartyName :: !T.Text, | ||
62 | counterpartyIban :: !Iban, | ||
63 | description :: !T.Text, | ||
64 | timestamp :: !UTCTime | ||
65 | } | ||
66 | | OnlineBankingDebit | ||
67 | { counterpartyName :: !T.Text, | ||
68 | counterpartyIban :: !Iban, | ||
69 | description :: T.Text, | ||
70 | mtimestamp :: !(Maybe UTCTime) | ||
71 | } | ||
72 | | RecurrentDirectDebit | ||
73 | { counterpartyName :: !T.Text, | ||
74 | counterpartyIban :: !Iban, | ||
75 | description :: !T.Text, | ||
76 | reference :: !T.Text, | ||
77 | mandateId :: !T.Text, | ||
78 | creditorId :: !T.Text, | ||
79 | otherParty :: !(Maybe T.Text) | ||
80 | } | ||
81 | | RoundingSavingsDeposit | ||
82 | {savingsAccount :: !T.Text} | ||
83 | | DepositTransfer | ||
84 | { counterpartyName :: !T.Text, | ||
85 | counterpartyIban :: !Iban, | ||
86 | description :: !T.Text, | ||
87 | reference :: !T.Text | ||
88 | } | ||
89 | | IdealDebit | ||
90 | { counterpartyName :: !T.Text, | ||
91 | counterpartyIban :: !Iban, | ||
92 | description :: !T.Text, | ||
93 | timestamp :: !UTCTime, | ||
94 | reference :: !T.Text | ||
95 | } | ||
96 | | BatchPayment | ||
97 | { counterpartyName :: !T.Text, | ||
98 | counterpartyIban :: !Iban, | ||
99 | description :: !T.Text, | ||
100 | reference :: !T.Text | ||
101 | } | ||
102 | deriving (Show, Generic) | ||
103 | |||
104 | data TransactionType | ||
105 | = AcceptGiroType -- AC (acceptgiro) | ||
106 | | AtmWithdrawalType -- GM (geldautomaat, Giromaat) | ||
107 | | BatchPaymentType -- VZ (verzamelbetaling); 'Batch payment' | ||
108 | | BranchPostingType -- FL (filiaalboeking) | ||
109 | | DepositType -- ST (storting) | ||
110 | | DirectDebitType -- IC (incasso); 'SEPA direct debit' | ||
111 | | IdealType -- ID (iDEAL); 'iDEAL' | ||
112 | | OnlineBankingType -- GT (internetbankieren, Girotel); 'Online Banking' | ||
113 | | OfficeWithdrawalType -- PK (opname kantoor, postkantoor) | ||
114 | | PaymentTerminalType -- BA (betaalautomaat); 'Payment terminal' | ||
115 | | PeriodicTransferType -- PO (periodieke overschrijving) | ||
116 | | PhoneBankingType -- GF (telefonisch bankieren, Girofoon) | ||
117 | | TransferType -- OV (overboeking); 'Transfer' | ||
118 | | VariousType -- DV (diversen) | ||
119 | deriving (Eq, Show) | ||
120 | |||
121 | parseCode :: T.Text -> C.Parser TransactionType | ||
122 | parseCode "AC" = return AcceptGiroType | ||
123 | parseCode "GM" = return AtmWithdrawalType | ||
124 | parseCode "VZ" = return BatchPaymentType | ||
125 | parseCode "FL" = return BranchPostingType | ||
126 | parseCode "ST" = return DepositType | ||
127 | parseCode "IC" = return DirectDebitType | ||
128 | parseCode "ID" = return IdealType | ||
129 | parseCode "GT" = return OnlineBankingType | ||
130 | parseCode "PK" = return OfficeWithdrawalType | ||
131 | parseCode "BA" = return PaymentTerminalType | ||
132 | parseCode "PO" = return PeriodicTransferType | ||
133 | parseCode "GF" = return PhoneBankingType | ||
134 | parseCode "OV" = return TransferType | ||
135 | parseCode "DV" = return VariousType | ||
136 | parseCode t = fail $ "Unknown transaction code '" ++ T.unpack t ++ "'" | ||
137 | |||
138 | parseType :: T.Text -> C.Parser TransactionType | ||
139 | parseType "SEPA direct debit" = return DirectDebitType | ||
140 | parseType "Batch payment" = return BatchPaymentType | ||
141 | parseType "Online Banking" = return OnlineBankingType | ||
142 | parseType "Payment terminal" = return PaymentTerminalType | ||
143 | parseType "Transfer" = return TransferType | ||
144 | parseType "iDEAL" = return IdealType | ||
145 | parseType t = fail $ "Unknown transaction type '" ++ T.unpack t ++ "'" | ||
146 | |||
147 | data PrimTx = PrimTx | ||
148 | { date :: !Day, | ||
149 | description :: !T.Text, | ||
150 | account :: !Iban, | ||
151 | counterparty :: !(Maybe Iban), | ||
152 | transactionType :: !TransactionType, | ||
153 | debitCredit :: !DebitCredit, | ||
154 | amount :: !Decimal, | ||
155 | notifications :: !T.Text, | ||
156 | resBal :: !Decimal, | ||
157 | tag :: !T.Text | ||
158 | } | ||
159 | deriving (Show, Generic) | ||
160 | |||
161 | debitCreditCP :: T.Text -> C.Parser DebitCredit | ||
162 | debitCreditCP "Debit" = return Debit | ||
163 | debitCreditCP "Credit" = return Credit | ||
164 | debitCreditCP t = fail ("Unknown debit/credit value '" ++ T.unpack t ++ "'") | ||
165 | |||
166 | instance C.FromNamedRecord PrimTx where | ||
167 | parseNamedRecord m = do | ||
168 | code <- m .: "Code" >>= parseCode | ||
169 | txType <- m .: "Transaction type" >>= parseType | ||
170 | if code /= txType | ||
171 | then fail "Expected code and transaction type to agree" | ||
172 | else | ||
173 | PrimTx | ||
174 | <$> (m .: "Date" >>= parseDateM "%0Y%m%d") | ||
175 | <*> m .: "Name / Description" | ||
176 | <*> (m .: "Account" >>= parseIbanM) | ||
177 | <*> (m .: "Counterparty" >>= maybeCP parseIbanM) | ||
178 | <*> return txType | ||
179 | <*> (m .: "Debit/credit" >>= debitCreditCP) | ||
180 | <*> (m .: "Amount (EUR)" >>= parseDecimalM) | ||
181 | <*> m .: "Notifications" | ||
182 | <*> (m .: "Resulting balance" >>= parseDecimalM) | ||
183 | <*> m .: "Tag" | ||
184 | |||
185 | processPrimTx :: TZ -> PrimTx -> Res String Tx | ||
186 | processPrimTx amsTz ptx = Tx (txBaseFromPrim ptx) <$> specificsFromPrim amsTz ptx | ||
187 | |||
188 | parseValueDate :: T.Text -> Res String Day | ||
189 | parseValueDate = parseDateM "%d/%m/%Y" | ||
190 | |||
191 | assertValueDate :: Day -> T.Text -> Res String () | ||
192 | assertValueDate expected t = do | ||
193 | valDate <- parseDateM "%d/%m/%Y" t | ||
194 | when (valDate /= expected) $ | ||
195 | fail "Expected transaction date and value date to be the same" | ||
196 | |||
197 | assertValueDatePtx :: PrimTx -> T.Text -> Res String () | ||
198 | assertValueDatePtx PrimTx {date = expected} = assertValueDate expected | ||
199 | |||
200 | specificsFromPrim :: TZ -> PrimTx -> Res String TxSpecifics | ||
201 | specificsFromPrim amsTz ptx@PrimTx {transactionType = PaymentTerminalType, debitCredit = Debit} = do | ||
202 | 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 | ||
203 | (_, _, _, [cardSeqNo, timestampTxt, transaction, _, gpayTerm, noGpayTerm, valDateTxt]) <- | ||
204 | ptx.notifications =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
205 | assertValueDatePtx ptx valDateTxt | ||
206 | timestamp <- parseTimestampM "%d/%m/%Y %H:%M" amsTz timestampTxt | ||
207 | return $ | ||
208 | PaymentTerminalPayment | ||
209 | { counterpartyName = ptx.description, | ||
210 | cardSequenceNo = cardSeqNo, | ||
211 | timestamp = timestamp, | ||
212 | transaction = transaction, | ||
213 | terminal = if T.null gpayTerm then noGpayTerm else gpayTerm, | ||
214 | googlePay = T.null noGpayTerm | ||
215 | } | ||
216 | specificsFromPrim amsTz ptx@PrimTx {transactionType = PaymentTerminalType, debitCredit = Credit} = do | ||
217 | 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 | ||
218 | (_, _, _, [cardSeqNo, timestampTxt, transaction, term, valDateTxt]) <- | ||
219 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
220 | assertValueDatePtx ptx valDateTxt | ||
221 | timestamp <- parseTimestampM "%d/%m/%Y %H:%M" amsTz timestampTxt | ||
222 | return $ | ||
223 | PaymentTerminalCashback | ||
224 | { counterpartyName = ptx.description, | ||
225 | cardSequenceNo = cardSeqNo, | ||
226 | timestamp = timestamp, | ||
227 | transaction = transaction, | ||
228 | terminal = term | ||
229 | } | ||
230 | specificsFromPrim amsTz ptx@PrimTx {transactionType = OnlineBankingType, debitCredit = Credit} = do | ||
231 | 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 | ||
232 | (_, _, _, [name, desc, ibanTxt, timestampTxt, valDateTxt]) <- | ||
233 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
234 | assertValueDatePtx ptx valDateTxt | ||
235 | iban <- parseIbanM ibanTxt | ||
236 | timestamp <- parseTimestampM "%d-%m-%Y %H:%M:%S" amsTz timestampTxt | ||
237 | when (name /= ptx.description) $ | ||
238 | fail "Expected counterparty name for online banking credit to match primitive description" | ||
239 | when (Just iban /= ptx.counterparty) $ | ||
240 | fail "Expected IBAN for online banking credit to match and primitive counterparty IBAN" | ||
241 | return $ | ||
242 | OnlineBankingCredit | ||
243 | { counterpartyName = name, | ||
244 | counterpartyIban = iban, | ||
245 | description = desc, | ||
246 | timestamp = timestamp | ||
247 | } | ||
248 | specificsFromPrim amsTz ptx@PrimTx {transactionType = OnlineBankingType, debitCredit = Debit} = do | ||
249 | 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 | ||
250 | (_, _, _, [name, desc, ibanTxt, _, timestampTxt, valDateTxt]) <- | ||
251 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
252 | assertValueDatePtx ptx valDateTxt | ||
253 | iban <- parseIbanM ibanTxt | ||
254 | timestamp <- | ||
255 | if T.null timestampTxt | ||
256 | then pure Nothing | ||
257 | else Just <$> parseTimestampM "%d-%m-%Y %H:%M:%S" amsTz timestampTxt | ||
258 | when (name /= ptx.description) $ | ||
259 | fail "Expected counterparty name for online banking debit to match primitive description" | ||
260 | when (Just iban /= ptx.counterparty) $ | ||
261 | fail "Expected IBAN for online banking debit to match and primitive counterparty IBAN" | ||
262 | return $ | ||
263 | OnlineBankingDebit | ||
264 | { counterpartyIban = iban, | ||
265 | counterpartyName = name, | ||
266 | description = desc, | ||
267 | mtimestamp = timestamp | ||
268 | } | ||
269 | specificsFromPrim _ ptx@PrimTx {transactionType = DirectDebitType, debitCredit = Debit} = | ||
270 | normalRecurrentDirectDebit <|> ingInsurancePayment | ||
271 | where | ||
272 | normalRecurrentDirectDebit = do | ||
273 | 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 | ||
274 | (_, _, _, [name, desc, ibanTxt, ref, mandateId, creditorId, _, otherParty, valDateTxt]) <- | ||
275 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
276 | assertValueDatePtx ptx valDateTxt | ||
277 | iban <- parseIbanM ibanTxt | ||
278 | when (name /= ptx.description) $ | ||
279 | fail "Expected counterparty name for direct debit to match primitive description" | ||
280 | when (Just iban /= ptx.counterparty) $ | ||
281 | fail "Expected IBAN for direct debit to match and primitive counterparty IBAN" | ||
282 | return $ | ||
283 | RecurrentDirectDebit | ||
284 | { counterpartyName = name, | ||
285 | counterpartyIban = iban, | ||
286 | description = desc, | ||
287 | reference = ref, | ||
288 | mandateId = mandateId, | ||
289 | creditorId = creditorId, | ||
290 | otherParty = if T.null otherParty then Nothing else Just otherParty | ||
291 | } | ||
292 | ingInsurancePayment = do | ||
293 | let regex = "^Name: (.* ING Verzekeren) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) Mandate ID: (.*) Creditor ID: (.*) Recurrent SEPA direct debit$" :: String | ||
294 | (_, _, _, [name, desc, ibanTxt, ref, mandateId, creditorId]) <- | ||
295 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
296 | iban <- parseIbanM ibanTxt | ||
297 | when (name /= ptx.description) $ | ||
298 | fail "Expected counterparty name for direct debit to match primitive description" | ||
299 | when (Just iban /= ptx.counterparty) $ | ||
300 | fail "Expected IBAN for direct debit to match and primitive counterparty IBAN" | ||
301 | return $ | ||
302 | RecurrentDirectDebit | ||
303 | { counterpartyName = name, | ||
304 | counterpartyIban = iban, | ||
305 | description = desc, | ||
306 | reference = ref, | ||
307 | mandateId = mandateId, | ||
308 | creditorId = creditorId, | ||
309 | otherParty = Nothing | ||
310 | } | ||
311 | specificsFromPrim _ ptx@PrimTx {transactionType = TransferType, debitCredit = Credit} = do | ||
312 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
313 | (_, _, _, [name, desc, ibanTxt, ref, valDateTxt]) <- | ||
314 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
315 | assertValueDatePtx ptx valDateTxt | ||
316 | iban <- parseIbanM ibanTxt | ||
317 | when (name /= ptx.description) $ | ||
318 | fail "Expected counterparty name for deposit transfer to match primitive description" | ||
319 | when (Just iban /= ptx.counterparty) $ | ||
320 | fail "Expected IBAN for deposit transfer to match and primitive counterparty IBAN" | ||
321 | return $ | ||
322 | DepositTransfer | ||
323 | { counterpartyName = name, | ||
324 | counterpartyIban = iban, | ||
325 | description = desc, | ||
326 | reference = ref | ||
327 | } | ||
328 | specificsFromPrim _ ptx@PrimTx {transactionType = TransferType, debitCredit = Debit} = do | ||
329 | let regex = "^To Oranje spaarrekening ([A-Z0-9]+) Afronding Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
330 | (_, _, _, [savingsAccount, valDateTxt]) <- | ||
331 | ptx.notifications =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
332 | assertValueDatePtx ptx valDateTxt | ||
333 | return $ RoundingSavingsDeposit {savingsAccount = savingsAccount} | ||
334 | specificsFromPrim amsTz ptx@PrimTx {transactionType = IdealType, debitCredit = Debit} = do | ||
335 | 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 | ||
336 | (_, _, _, [name, desc, ibanTxt, timestampTxt, ref, valDateTxt]) <- | ||
337 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
338 | assertValueDatePtx ptx valDateTxt | ||
339 | timestamp <- parseTimestampM "%d-%m-%Y %H:%M" amsTz timestampTxt | ||
340 | iban <- parseIbanM ibanTxt | ||
341 | when (name /= ptx.description) $ | ||
342 | fail "Expected counterparty name for iDEAL payment to match primitive description" | ||
343 | when (Just iban /= ptx.counterparty) $ | ||
344 | fail "Expected IBAN for iDEAL payment to match and primitive counterparty IBAN" | ||
345 | return $ | ||
346 | IdealDebit | ||
347 | { counterpartyName = name, | ||
348 | counterpartyIban = iban, | ||
349 | description = desc, | ||
350 | timestamp = timestamp, | ||
351 | reference = ref | ||
352 | } | ||
353 | specificsFromPrim _ ptx@PrimTx {transactionType = BatchPaymentType, debitCredit = Credit} = do | ||
354 | let regex = "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" :: String | ||
355 | (_, _, _, [name, desc, ibanTxt, ref, valDateTxt]) <- | ||
356 | notifications ptx =~~ regex :: Res String (T.Text, T.Text, T.Text, [T.Text]) | ||
357 | assertValueDatePtx ptx valDateTxt | ||
358 | iban <- parseIbanM ibanTxt | ||
359 | when (name /= ptx.description) $ | ||
360 | fail "Expected counterparty name for batch payment to match primitive description" | ||
361 | when (Just iban /= ptx.counterparty) $ | ||
362 | fail "Expected IBAN for batch payment to match and primitive counterparty IBAN" | ||
363 | return $ | ||
364 | BatchPayment | ||
365 | { counterpartyName = name, | ||
366 | counterpartyIban = iban, | ||
367 | description = desc, | ||
368 | reference = ref | ||
369 | } | ||
370 | specificsFromPrim _ ptx = | ||
371 | fail $ | ||
372 | "Could not extract data from transaction (" | ||
373 | ++ show (transactionType ptx) | ||
374 | ++ " / " | ||
375 | ++ show (debitCredit ptx) | ||
376 | ++ ")" | ||
377 | |||
378 | txBaseFromPrim :: PrimTx -> TxBase | ||
379 | txBaseFromPrim = upcast | ||
380 | |||
381 | readFile :: Handle -> IO (V.Vector Tx) | ||
382 | readFile h = do | ||
383 | tz <- loadTZFromDB "Europe/Amsterdam" | ||
384 | contents <- BS.hGetContents h | ||
385 | primTxs <- case C.decodeByNameWith scsvOptions contents of | ||
386 | Left err -> fail err | ||
387 | Right | ||
388 | ( [ "Date", | ||
389 | "Name / Description", | ||
390 | "Account", | ||
391 | "Counterparty", | ||
392 | "Code", | ||
393 | "Debit/credit", | ||
394 | "Amount (EUR)", | ||
395 | "Transaction type", | ||
396 | "Notifications", | ||
397 | "Resulting balance", | ||
398 | "Tag" | ||
399 | ], | ||
400 | txs | ||
401 | ) -> | ||
402 | return txs | ||
403 | Right _ -> | ||
404 | fail "Headers do not match expected pattern" | ||
405 | case V.mapM (processPrimTx tz) primTxs of | ||
406 | Err err -> fail err | ||
407 | Ok txs -> return txs | ||
diff --git a/app/Import/Ing/SavingsAccountCsv.hs b/app/Import/Ing/SavingsAccountCsv.hs deleted file mode 100644 index 16b5f92..0000000 --- a/app/Import/Ing/SavingsAccountCsv.hs +++ /dev/null | |||
@@ -1,164 +0,0 @@ | |||
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 (maybeCP, parseDateM, parseDecimalM, parseIbanM, 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 <*> ptxAccountId <*> ptxAccountName <*> ptxAmount <*> 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 | instance C.FromNamedRecord PrimTx where | ||
125 | parseNamedRecord m = | ||
126 | PrimTx | ||
127 | <$> (m .: "Datum" >>= parseDateM "%Y-%m-%d") | ||
128 | <*> m .: "Omschrijving" | ||
129 | <*> m .: "Rekening" | ||
130 | <*> m .: "Rekening naam" | ||
131 | <*> (m .: "Tegenrekening" >>= maybeCP parseIbanM) | ||
132 | <*> (m .: "Af Bij" >>= debitCreditCP) | ||
133 | <*> (m .: "Bedrag" >>= parseDecimalM) | ||
134 | <*> m .: "Valuta" | ||
135 | <*> (m .: "Mutatiesoort" >>= mutationTypeCP) | ||
136 | <*> m .: "Mededelingen" | ||
137 | <*> (m .: "Saldo na mutatie" >>= parseDecimalM) | ||
138 | |||
139 | readFile :: Handle -> IO (V.Vector Tx) | ||
140 | readFile h = do | ||
141 | contents <- BS.hGetContents h | ||
142 | primTxs <- case C.decodeByNameWith scsvOptions contents of | ||
143 | Left err -> fail err | ||
144 | Right | ||
145 | ( [ "Datum", | ||
146 | "Omschrijving", | ||
147 | "Rekening", | ||
148 | "Rekening naam", | ||
149 | "Tegenrekening", | ||
150 | "Af Bij", | ||
151 | "Bedrag", | ||
152 | "Valuta", | ||
153 | "Mutatiesoort", | ||
154 | "Mededelingen", | ||
155 | "Saldo na mutatie" | ||
156 | ], | ||
157 | txs | ||
158 | ) -> | ||
159 | return txs | ||
160 | Right _ -> | ||
161 | fail "Headers do not match expected pattern" | ||
162 | case V.mapM processPrimTx primTxs of | ||
163 | Left err -> fail err | ||
164 | Right txs -> return txs | ||
diff --git a/app/Import/Ing/Shared.hs b/app/Import/Ing/Shared.hs deleted file mode 100644 index b5d1703..0000000 --- a/app/Import/Ing/Shared.hs +++ /dev/null | |||
@@ -1,44 +0,0 @@ | |||
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 | scsvOptions :: C.DecodeOptions | ||
17 | scsvOptions = C.defaultDecodeOptions {C.decDelimiter = fromIntegral (ord ';')} | ||
18 | |||
19 | maybeCP :: (T.Text -> C.Parser a) -> T.Text -> C.Parser (Maybe a) | ||
20 | maybeCP p t = if T.null t then return Nothing else Just <$> p t | ||
21 | |||
22 | parseDecimalM :: (MonadFail m) => T.Text -> m Decimal | ||
23 | parseDecimalM = | ||
24 | either fail return | ||
25 | . AP.parseOnly | ||
26 | ( do | ||
27 | decPart <- AP.decimal | ||
28 | _ <- AP.char ',' | ||
29 | f1 <- AP.digit | ||
30 | f2 <- AP.digit | ||
31 | AP.endOfInput | ||
32 | let fracPart = fromIntegral $ digitToInt f1 * 10 + digitToInt f2 | ||
33 | return $ normalizeDecimal (Decimal 2 (decPart * 100 + fracPart)) | ||
34 | ) | ||
35 | |||
36 | parseIbanM :: (MonadFail m) => T.Text -> m Iban | ||
37 | parseIbanM = either fail return . mkIban | ||
38 | |||
39 | parseDateM :: (MonadFail m) => String -> T.Text -> m Day | ||
40 | parseDateM fmt = parseTimeM False defaultTimeLocale fmt . T.unpack | ||
41 | |||
42 | parseTimestampM :: (MonadFail m) => String -> TZ -> T.Text -> m UTCTime | ||
43 | parseTimestampM fmt amsTz t = do | ||
44 | localTimeToUTCTZ amsTz <$> parseTimeM False defaultTimeLocale fmt (T.unpack t) | ||
diff --git a/app/Main.hs b/app/Main.hs deleted file mode 100644 index 82505bf..0000000 --- a/app/Main.hs +++ /dev/null | |||
@@ -1,4 +0,0 @@ | |||
1 | module Main where | ||
2 | |||
3 | main :: IO () | ||
4 | main = putStrLn "Hello!" | ||
diff --git a/bin/dune b/bin/dune new file mode 100644 index 0000000..b82e38f --- /dev/null +++ b/bin/dune | |||
@@ -0,0 +1,4 @@ | |||
1 | (executable | ||
2 | (public_name rdcapsis) | ||
3 | (name main) | ||
4 | (libraries rdcapsis)) | ||
diff --git a/bin/main.ml b/bin/main.ml new file mode 100644 index 0000000..7bf6048 --- /dev/null +++ b/bin/main.ml | |||
@@ -0,0 +1 @@ | |||
let () = print_endline "Hello, World!" | |||
diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..00fe620 --- /dev/null +++ b/dune-project | |||
@@ -0,0 +1,26 @@ | |||
1 | (lang dune 3.17) | ||
2 | |||
3 | (name rdcapsis) | ||
4 | |||
5 | (generate_opam_files true) | ||
6 | |||
7 | (source | ||
8 | (github username/reponame)) | ||
9 | |||
10 | (authors "Author Name <[email protected]>") | ||
11 | |||
12 | (maintainers "Maintainer Name <[email protected]>") | ||
13 | |||
14 | (license LICENSE) | ||
15 | |||
16 | (documentation https://url/to/documentation) | ||
17 | |||
18 | (package | ||
19 | (name rdcapsis) | ||
20 | (synopsis "A short synopsis") | ||
21 | (description "A longer description") | ||
22 | (depends ocaml zarith core dmap delimited_parsing re (utop :dev) (merlin :dev) (ocamlformat :dev)) | ||
23 | (tags | ||
24 | ("add topics" "to describe" your project))) | ||
25 | |||
26 | ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html | ||
diff --git a/lib/dune b/lib/dune new file mode 100644 index 0000000..ff9a2ee --- /dev/null +++ b/lib/dune | |||
@@ -0,0 +1,5 @@ | |||
1 | (library | ||
2 | (name rdcapsis) | ||
3 | (preprocess | ||
4 | (pps ppx_jane)) | ||
5 | (libraries core zarith dmap delimited_parsing re core_unix.date_unix)) | ||
diff --git a/lib/iban.ml b/lib/iban.ml new file mode 100644 index 0000000..6e47e9d --- /dev/null +++ b/lib/iban.ml | |||
@@ -0,0 +1,87 @@ | |||
1 | open Core | ||
2 | open Option.Let_syntax | ||
3 | |||
4 | type t = string | ||
5 | |||
6 | (* Modulo-97 arithmetic. Prevents us from having to use Zarith here. *) | ||
7 | module M97 : sig | ||
8 | type t | ||
9 | |||
10 | val of_int : int -> t | ||
11 | val lt : t -> t -> bool | ||
12 | val equal : t -> t -> bool | ||
13 | val ( * ) : t -> t -> t | ||
14 | val ( + ) : t -> t -> t | ||
15 | val ( ~$ ) : int -> t | ||
16 | end = struct | ||
17 | type t = int | ||
18 | |||
19 | let of_int x = x % 97 | ||
20 | let equal = Int.( = ) | ||
21 | let lt = Int.( < ) | ||
22 | let ( * ) x y = x * y % 97 | ||
23 | let ( + ) x y = (x + y) % 97 | ||
24 | let ( ~$ ) = of_int | ||
25 | end | ||
26 | |||
27 | let m97_of_alnum c = | ||
28 | let v = Char.to_int c in | ||
29 | if Char.is_digit c then Some (M97.of_int (v - Char.to_int '0')) | ||
30 | else if Char.is_alpha c then | ||
31 | if Char.is_lowercase c then Some (M97.of_int (v - Char.to_int 'a' + 10)) | ||
32 | else Some (M97.of_int (v - Char.to_int 'A' + 10)) | ||
33 | else None | ||
34 | |||
35 | let m97_of_digit c = | ||
36 | match m97_of_alnum c with Some v when M97.(lt v ~$10) -> Some v | _ -> None | ||
37 | |||
38 | let m97_of_alpha c = | ||
39 | match m97_of_alnum c with | ||
40 | | Some v when not M97.(lt v ~$10) -> Some v | ||
41 | | _ -> None | ||
42 | |||
43 | let string_fold_option ~(init : 'a) ~(f : 'a -> char -> 'a option) s = | ||
44 | let rec go i (acc : 'a) : 'a option = | ||
45 | if i >= String.length s then Some acc | ||
46 | else Option.(f acc (String.unsafe_get s i) >>= go (i + 1)) | ||
47 | in | ||
48 | go 0 init | ||
49 | |||
50 | let m97_of_iban s = | ||
51 | string_fold_option s ~init:`In_country1 ~f:(fun st c -> | ||
52 | match st with | ||
53 | | `In_country1 -> | ||
54 | let%map co1 = m97_of_alpha c in | ||
55 | `In_country2 co1 | ||
56 | | `In_country2 co1 -> | ||
57 | let%map co2 = m97_of_alpha c in | ||
58 | `In_check1 M97.((co1 * ~$100) + co2) | ||
59 | | `In_check1 co -> | ||
60 | let%map ch1 = m97_of_digit c in | ||
61 | `In_check2 (co, ch1) | ||
62 | | `In_check2 (co, ch1) -> | ||
63 | let%map ch2 = m97_of_digit c in | ||
64 | `In_bban M97.(co, (ch1 * ~$10) + ch2, ~$0) | ||
65 | | `In_bban (co, ch, bban) -> | ||
66 | let%map v = m97_of_alnum c in | ||
67 | let bban' = | ||
68 | M97.(if lt v ~$10 then (bban * ~$10) + v else (bban * ~$100) + v) | ||
69 | in | ||
70 | `In_bban (co, ch, bban')) | ||
71 | |> function | ||
72 | | Some (`In_bban (co, ch, bban)) -> | ||
73 | Some M97.((bban * ~$1000000) + (co * ~$100) + ch) | ||
74 | | _ -> None | ||
75 | |||
76 | let check_iban s = | ||
77 | String.length s <= 34 && Option.exists (m97_of_iban s) ~f:M97.(equal ~$1) | ||
78 | |||
79 | let make s : t option = if check_iban s then Some s else None | ||
80 | let to_string = Fn.id | ||
81 | |||
82 | let of_string s = | ||
83 | match make s with | ||
84 | | Some iban -> iban | ||
85 | | None -> Printf.failwithf "Iban.of_string: %S" s () | ||
86 | |||
87 | let equal = String.equal | ||
diff --git a/lib/iban.mli b/lib/iban.mli new file mode 100644 index 0000000..944928c --- /dev/null +++ b/lib/iban.mli | |||
@@ -0,0 +1,8 @@ | |||
1 | open Core | ||
2 | |||
3 | type t | ||
4 | |||
5 | val make : string -> t option | ||
6 | |||
7 | include Stringable.S with type t := t | ||
8 | include Equal.S with type t := t | ||
diff --git a/lib/ingcsv.ml b/lib/ingcsv.ml new file mode 100644 index 0000000..a8eba51 --- /dev/null +++ b/lib/ingcsv.ml | |||
@@ -0,0 +1,487 @@ | |||
1 | open Core | ||
2 | module Time_ns = Time_ns_unix | ||
3 | |||
4 | module Debit_credit = struct | ||
5 | type t = Debit | Credit | ||
6 | |||
7 | let of_string = function | ||
8 | | "Debit" -> Debit | ||
9 | | "Credit" -> Credit | ||
10 | | s -> Printf.failwithf "DebitCredit.of_string: %S" s () | ||
11 | |||
12 | let to_string = function Debit -> "Debit" | Credit -> "Credit" | ||
13 | end | ||
14 | |||
15 | module Cents = struct | ||
16 | type t = Z.t | ||
17 | |||
18 | let of_string s = | ||
19 | (* TODO: consider being more bitchy here *) | ||
20 | String.lsplit2_exn s ~on:',' |> Tuple2.map ~f:Z.of_string | ||
21 | |> fun (high, low) -> Z.((high * ~$100) + low) | ||
22 | end | ||
23 | |||
24 | module Transaction_type = struct | ||
25 | type t = | ||
26 | | Accept_giro (* AC (acceptgiro) *) | ||
27 | | Atm_withdrawal (* GM (geldautomaat, Giromaat) *) | ||
28 | | Batch_payment (* VZ (verzamelbetaling); 'Batch payment' *) | ||
29 | | Branch_posting (* FL (filiaalboeking) *) | ||
30 | | Deposit (* ST (storting) *) | ||
31 | | Direct_debit (* IC (incasso); 'SEPA direct debit' *) | ||
32 | | Ideal (* ID (iDEAL); 'iDEAL' *) | ||
33 | | Online_banking (* GT (internetbankieren, Girotel); 'Online Banking' *) | ||
34 | | Office_withdrawal (* PK (opname kantoor, postkantoor) *) | ||
35 | | Payment_terminal (* BA (betaalautomaat); 'Payment terminal' *) | ||
36 | | Periodic_transfer (* PO (periodieke overschrijving) *) | ||
37 | | Phone_banking (* GF (telefonisch bankieren, Girofoon) *) | ||
38 | | Transfer (* OV (overboeking); 'Transfer' *) | ||
39 | | Various (* DV (diversen) *) | ||
40 | [@@deriving equal, string] | ||
41 | |||
42 | let of_code = function | ||
43 | | "AC" -> Accept_giro | ||
44 | | "GM" -> Atm_withdrawal | ||
45 | | "VZ" -> Batch_payment | ||
46 | | "FL" -> Branch_posting | ||
47 | | "ST" -> Deposit | ||
48 | | "IC" -> Direct_debit | ||
49 | | "ID" -> Ideal | ||
50 | | "GT" -> Online_banking | ||
51 | | "PK" -> Office_withdrawal | ||
52 | | "BA" -> Payment_terminal | ||
53 | | "PO" -> Periodic_transfer | ||
54 | | "GF" -> Phone_banking | ||
55 | | "OV" -> Transfer | ||
56 | | "DV" -> Various | ||
57 | | s -> Printf.failwithf "TransactionType.of_code: %S" s () | ||
58 | |||
59 | let of_type = function | ||
60 | | "SEPA direct debit" -> Direct_debit | ||
61 | | "Batch payment" -> Batch_payment | ||
62 | | "Online Banking" -> Online_banking | ||
63 | | "Payment terminal" -> Payment_terminal | ||
64 | | "Transfer" -> Transfer | ||
65 | | "iDEAL" -> Ideal | ||
66 | | s -> Printf.failwithf "TransactionType.of_type: %S" s () | ||
67 | end | ||
68 | |||
69 | module Primitive_tx = struct | ||
70 | type t = { | ||
71 | date : Date.t; | ||
72 | description : string; | ||
73 | account : Iban.t; | ||
74 | counterparty : Iban.t option; | ||
75 | type_ : Transaction_type.t; | ||
76 | debit_credit : Debit_credit.t; | ||
77 | amount : Cents.t; | ||
78 | notifications : string; | ||
79 | resulting_balance : Cents.t; | ||
80 | tag : string; | ||
81 | } | ||
82 | [@@deriving fields] | ||
83 | |||
84 | let opt_field (f : string -> 'a) (v : string) : 'a option = | ||
85 | if String.is_empty (String.strip v) then None else Some (f v) | ||
86 | |||
87 | let parse : t Delimited.Read.t = | ||
88 | let open Delimited.Read.Let_syntax in | ||
89 | let%map_open date = at_header "Date" ~f:Date.of_string | ||
90 | and description = at_header "Name / Description" ~f:Fn.id | ||
91 | and account = at_header "Account" ~f:Iban.of_string | ||
92 | and counterparty = at_header "Counterparty" ~f:(opt_field Iban.of_string) | ||
93 | and code = at_header "Code" ~f:Transaction_type.of_code | ||
94 | and debit_credit = at_header "Debit/credit" ~f:Debit_credit.of_string | ||
95 | and amount = at_header "Amount (EUR)" ~f:Cents.of_string | ||
96 | and type_ = at_header "Transaction type" ~f:Transaction_type.of_type | ||
97 | and notifications = at_header "Notifications" ~f:Fn.id | ||
98 | and resulting_balance = at_header "Resulting balance" ~f:Cents.of_string | ||
99 | and tag = at_header "Tag" ~f:Fn.id in | ||
100 | if not ([%equal: Transaction_type.t] code type_) then | ||
101 | Printf.failwithf | ||
102 | "Primitive_tx.parse: parsed transaction code (%S) and type (%S) do not \ | ||
103 | match" | ||
104 | (Transaction_type.to_string code) | ||
105 | (Transaction_type.to_string type_) | ||
106 | (); | ||
107 | { | ||
108 | date; | ||
109 | description; | ||
110 | account; | ||
111 | counterparty; | ||
112 | type_; | ||
113 | debit_credit; | ||
114 | amount; | ||
115 | notifications; | ||
116 | resulting_balance; | ||
117 | tag; | ||
118 | } | ||
119 | end | ||
120 | |||
121 | type tx_base = { | ||
122 | date : Date.t; | ||
123 | account : Iban.t; | ||
124 | amount : Cents.t; | ||
125 | res_bal : Cents.t; | ||
126 | tag : string; | ||
127 | } | ||
128 | |||
129 | type tx_specifics = | ||
130 | | Payment_terminal_payment of { | ||
131 | counterparty_name : string; | ||
132 | card_sequence_no : string; | ||
133 | timestamp : Time_ns.t; | ||
134 | transaction : string; | ||
135 | terminal : string; | ||
136 | google_pay : bool; | ||
137 | } | ||
138 | | Payment_terminal_cashback of { | ||
139 | counterparty_name : string; | ||
140 | card_sequence_no : string; | ||
141 | timestamp : Time_ns.t; | ||
142 | transaction : string; | ||
143 | terminal : string; | ||
144 | } | ||
145 | | Online_banking_credit of { | ||
146 | counterparty_name : string; | ||
147 | counterparty_iban : Iban.t; | ||
148 | description : string; | ||
149 | timestamp : Time_ns.t; | ||
150 | } | ||
151 | | Online_banking_debit of { | ||
152 | counterparty_name : string; | ||
153 | counterparty_iban : Iban.t; | ||
154 | description : string; | ||
155 | mtimestamp : Time_ns.t option; | ||
156 | } | ||
157 | | Recurrent_direct_debit of { | ||
158 | counterparty_name : string; | ||
159 | counterparty_iban : Iban.t; | ||
160 | description : string; | ||
161 | reference : string; | ||
162 | mandate_id : string; | ||
163 | creditor_id : string; | ||
164 | other_party : string option; | ||
165 | } | ||
166 | | Rounding_savings_deposit of { savings_account : string } | ||
167 | | Deposit of { | ||
168 | counterparty_name : string; | ||
169 | counterparty_iban : Iban.t; | ||
170 | description : string; | ||
171 | reference : string; | ||
172 | } | ||
173 | | Ideal_debit of { | ||
174 | counterparty_name : string; | ||
175 | counterparty_iban : Iban.t; | ||
176 | description : string; | ||
177 | timestamp : Time_ns.t; | ||
178 | reference : string; | ||
179 | } | ||
180 | | Batch_payment of { | ||
181 | counterparty_name : string; | ||
182 | counterparty_iban : Iban.t; | ||
183 | description : string; | ||
184 | reference : string; | ||
185 | } | ||
186 | |||
187 | type tx = Tx of tx_base * tx_specifics | ||
188 | |||
189 | let assert_value_date (ptx : Primitive_tx.t) s = | ||
190 | let val_date = Date_unix.parse s ~fmt:"%d/%m/%Y" in | ||
191 | if not Date.(val_date = ptx.date) then | ||
192 | failwith | ||
193 | "assert_value_date: expected transaction date and value date to be the \ | ||
194 | same" | ||
195 | |||
196 | let[@warning "-8"] specifics_from_prim_exn (ams_tz : Time_ns.Zone.t) : | ||
197 | Primitive_tx.t -> tx_specifics = function | ||
198 | | { type_ = Payment_terminal; debit_credit = Debit; _ } as ptx -> | ||
199 | let regex = | ||
200 | Re.Pcre.regexp | ||
201 | "^Card sequence no.: ([0-9]+) ? ([0-9]{2}/[0-9]{2}/[0-9]{4} \ | ||
202 | [0-9]{2}:[0-9]{2}) Transaction: (.*) Term: ((.+) Google Pay|(.+)) \ | ||
203 | Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
204 | in | ||
205 | let [| | ||
206 | _; | ||
207 | card_seq_no; | ||
208 | timestamp_str; | ||
209 | transaction; | ||
210 | _; | ||
211 | gpay_term; | ||
212 | no_gpay_term; | ||
213 | val_date_str; | ||
214 | |] = | ||
215 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
216 | in | ||
217 | assert_value_date ptx val_date_str; | ||
218 | let timestamp = | ||
219 | Time_ns.parse timestamp_str ~allow_trailing_input:false | ||
220 | ~fmt:"%d/%m/%Y %H:%M" ~zone:ams_tz | ||
221 | in | ||
222 | Payment_terminal_payment | ||
223 | { | ||
224 | counterparty_name = ptx.description; | ||
225 | card_sequence_no = card_seq_no; | ||
226 | timestamp; | ||
227 | transaction; | ||
228 | terminal = | ||
229 | (if String.is_empty gpay_term then no_gpay_term else gpay_term); | ||
230 | google_pay = String.is_empty no_gpay_term; | ||
231 | } | ||
232 | | { type_ = Payment_terminal; debit_credit = Credit; _ } as ptx -> | ||
233 | let regex = | ||
234 | Re.Pcre.regexp | ||
235 | "^Card sequence no.: ([0-9]+) ? ([0-9]{2}/[0-9]{2}/[0-9]{4} \ | ||
236 | [0-9]{2}:[0-9]{2}) Transaction: (.*) Term: (.*) Cashback \ | ||
237 | transaction Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
238 | in | ||
239 | let [| _; card_seq_no; timestamp_str; transaction; term; val_date_str |] = | ||
240 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
241 | in | ||
242 | assert_value_date ptx val_date_str; | ||
243 | let timestamp = | ||
244 | Time_ns.parse timestamp_str ~allow_trailing_input:false | ||
245 | ~fmt:"%d/%m/%Y %H:%M" ~zone:ams_tz | ||
246 | in | ||
247 | Payment_terminal_cashback | ||
248 | { | ||
249 | counterparty_name = ptx.description; | ||
250 | card_sequence_no = card_seq_no; | ||
251 | timestamp; | ||
252 | transaction; | ||
253 | terminal = term; | ||
254 | } | ||
255 | | { type_ = Online_banking; debit_credit = Credit; _ } as ptx -> | ||
256 | let regex = | ||
257 | Re.Pcre.regexp | ||
258 | "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Date/time: \ | ||
259 | ([0-9]{2}-[0-9]{2}-[0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2}) Value date: \ | ||
260 | ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
261 | in | ||
262 | let [| _; name; desc; iban_str; timestamp_str; val_date_str |] = | ||
263 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
264 | in | ||
265 | assert_value_date ptx val_date_str; | ||
266 | let iban = Iban.of_string iban_str | ||
267 | and timestamp = | ||
268 | Time_ns.parse timestamp_str ~allow_trailing_input:false | ||
269 | ~fmt:"%d-%m-%Y %H:%M:%S" ~zone:ams_tz | ||
270 | in | ||
271 | if not String.(name = ptx.description) then | ||
272 | failwith | ||
273 | "specifics_from_prim (Online_banking/Credit): expected counterparty \ | ||
274 | name to match primitive description"; | ||
275 | if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then | ||
276 | failwith | ||
277 | "specifics_from_prim (Online_banking/Credit): expected IBAN to match \ | ||
278 | and primitive counterparty IBAN"; | ||
279 | Online_banking_credit | ||
280 | { | ||
281 | counterparty_name = name; | ||
282 | counterparty_iban = iban; | ||
283 | description = desc; | ||
284 | timestamp; | ||
285 | } | ||
286 | | { type_ = Online_banking; debit_credit = Debit; _ } as ptx -> | ||
287 | let regex = | ||
288 | Re.Pcre.regexp | ||
289 | "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) (Date/time: \ | ||
290 | ([0-9]{2}-[0-9]{2}-[0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2}) )?Value \ | ||
291 | date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
292 | in | ||
293 | let [| _; name; desc; iban_str; _; timestamp_str; val_date_str |] = | ||
294 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
295 | in | ||
296 | assert_value_date ptx val_date_str; | ||
297 | let iban = Iban.of_string iban_str | ||
298 | and mtimestamp = | ||
299 | if String.is_empty timestamp_str then None | ||
300 | else | ||
301 | Some | ||
302 | (Time_ns.parse timestamp_str ~allow_trailing_input:false | ||
303 | ~fmt:"%d-%m-%Y %H:%M:%S" ~zone:ams_tz) | ||
304 | in | ||
305 | if not String.(name = ptx.description) then | ||
306 | failwith | ||
307 | "specifics_from_prim (Online_banking/Debit): expected counterparty \ | ||
308 | name to match primitive description"; | ||
309 | if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then | ||
310 | failwith | ||
311 | "specifics_from_prim (Online_banking/Debit): expected IBAN to match \ | ||
312 | and primitive counterparty IBAN"; | ||
313 | Online_banking_debit | ||
314 | { | ||
315 | counterparty_name = name; | ||
316 | counterparty_iban = iban; | ||
317 | description = desc; | ||
318 | mtimestamp; | ||
319 | } | ||
320 | | { type_ = Direct_debit; debit_credit = Debit; _ } as ptx | ||
321 | when String.is_suffix ptx.notifications | ||
322 | ~suffix:"Recurrent SEPA direct debit" -> | ||
323 | let regex = | ||
324 | Re.Pcre.regexp | ||
325 | "^Name: (.* ING Verzekeren) Description: (.*) IBAN: ([A-Z0-9]+) \ | ||
326 | Reference: (.*) Mandate ID: (.*) Creditor ID: (.*) Recurrent SEPA \ | ||
327 | direct debit$" | ||
328 | in | ||
329 | let [| _; name; desc; iban_str; ref_; mandate_id; creditor_id |] = | ||
330 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
331 | in | ||
332 | let iban = Iban.of_string iban_str in | ||
333 | if not String.(name = ptx.description) then | ||
334 | failwith | ||
335 | "specifics_from_prim (Direct_debit/Debit): expected counterparty \ | ||
336 | name to match primitive description"; | ||
337 | if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then | ||
338 | failwith | ||
339 | "specifics_from_prim (Direct_debit/Debit): expected IBAN to match \ | ||
340 | and primitive counterparty IBAN"; | ||
341 | Recurrent_direct_debit | ||
342 | { | ||
343 | counterparty_name = name; | ||
344 | counterparty_iban = iban; | ||
345 | description = desc; | ||
346 | reference = ref_; | ||
347 | mandate_id; | ||
348 | creditor_id; | ||
349 | other_party = None; | ||
350 | } | ||
351 | | { type_ = Direct_debit; debit_credit = Debit; _ } as ptx -> | ||
352 | let regex = | ||
353 | Re.Pcre.regexp | ||
354 | "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) \ | ||
355 | Mandate ID: (.*) Creditor ID: (.*) Recurrent SEPA direct debit \ | ||
356 | (Other party: (.*) )?Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
357 | in | ||
358 | let [| | ||
359 | _; | ||
360 | name; | ||
361 | desc; | ||
362 | iban_str; | ||
363 | ref_; | ||
364 | mandate_id; | ||
365 | creditor_id; | ||
366 | _; | ||
367 | other_party; | ||
368 | val_date_str; | ||
369 | |] = | ||
370 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
371 | in | ||
372 | assert_value_date ptx val_date_str; | ||
373 | let iban = Iban.of_string iban_str in | ||
374 | if not String.(name = ptx.description) then | ||
375 | failwith | ||
376 | "specifics_from_prim (Direct_debit/Debit): expected counterparty \ | ||
377 | name to match primitive description"; | ||
378 | if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then | ||
379 | failwith | ||
380 | "specifics_from_prim (Direct_debit/Debit): expected IBAN to match \ | ||
381 | and primitive counterparty IBAN"; | ||
382 | Recurrent_direct_debit | ||
383 | { | ||
384 | counterparty_name = name; | ||
385 | counterparty_iban = iban; | ||
386 | description = desc; | ||
387 | reference = ref_; | ||
388 | mandate_id; | ||
389 | creditor_id; | ||
390 | other_party = | ||
391 | (if String.is_empty other_party then None else Some other_party); | ||
392 | } | ||
393 | | { type_ = Transfer; debit_credit = Credit; _ } as ptx -> | ||
394 | let regex = | ||
395 | Re.Pcre.regexp | ||
396 | "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) \ | ||
397 | Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
398 | in | ||
399 | let [| _; name; desc; iban_str; ref_; val_date_str |] = | ||
400 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
401 | in | ||
402 | assert_value_date ptx val_date_str; | ||
403 | let iban = Iban.of_string iban_str in | ||
404 | if not String.(name = ptx.description) then | ||
405 | failwith | ||
406 | "specifics_from_prim (Transfer/Credit): expected counterparty name \ | ||
407 | to match primitive description"; | ||
408 | if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then | ||
409 | failwith | ||
410 | "specifics_from_prim (Direct_debit/Debit): expected IBAN to match \ | ||
411 | and primitive counterparty IBAN"; | ||
412 | Deposit | ||
413 | { | ||
414 | counterparty_name = name; | ||
415 | counterparty_iban = iban; | ||
416 | description = desc; | ||
417 | reference = ref_; | ||
418 | } | ||
419 | | { type_ = Transfer; debit_credit = Debit; _ } as ptx -> | ||
420 | let regex = | ||
421 | Re.Pcre.regexp | ||
422 | "^To Oranje spaarrekening ([A-Z0-9]+) Afronding Value date: \ | ||
423 | ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
424 | in | ||
425 | let [| _; savings_account; val_date_str |] = | ||
426 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
427 | in | ||
428 | assert_value_date ptx val_date_str; | ||
429 | Rounding_savings_deposit { savings_account } | ||
430 | | { type_ = Ideal; debit_credit = Debit; _ } as ptx -> | ||
431 | let regex = | ||
432 | Re.Pcre.regexp | ||
433 | "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: \ | ||
434 | ([0-9]{2}-[0-9]{2}-[0-9]{4} [0-9]{2}:[0-9]{2}) ([0-9]+) Value date: \ | ||
435 | ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
436 | in | ||
437 | let [| _; name; desc; iban_str; timestamp_str; ref_; val_date_str |] = | ||
438 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
439 | in | ||
440 | assert_value_date ptx val_date_str; | ||
441 | let timestamp = | ||
442 | Time_ns.parse timestamp_str ~allow_trailing_input:false | ||
443 | ~fmt:"%d-%m-%Y %H:%M" ~zone:ams_tz | ||
444 | in | ||
445 | let iban = Iban.of_string iban_str in | ||
446 | if not String.(name = ptx.description) then | ||
447 | failwith | ||
448 | "specifics_from_prim (Ideal/Debit): expected counterparty name to \ | ||
449 | match primitive description"; | ||
450 | if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then | ||
451 | failwith | ||
452 | "specifics_from_prim (Ideal/Debit): expected IBAN to match and \ | ||
453 | primitive counterparty IBAN"; | ||
454 | Ideal_debit | ||
455 | { | ||
456 | counterparty_name = name; | ||
457 | counterparty_iban = iban; | ||
458 | description = desc; | ||
459 | timestamp; | ||
460 | reference = ref_; | ||
461 | } | ||
462 | | { type_ = Batch_payment; debit_credit = Credit; _ } as ptx -> | ||
463 | let regex = | ||
464 | Re.Pcre.regexp | ||
465 | "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) \ | ||
466 | Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" | ||
467 | in | ||
468 | let [| _; name; desc; iban_str; ref_; val_date_str |] = | ||
469 | Re.Pcre.extract ~rex:regex ptx.notifications | ||
470 | in | ||
471 | assert_value_date ptx val_date_str; | ||
472 | let iban = Iban.of_string iban_str in | ||
473 | if not String.(name = ptx.description) then | ||
474 | failwith | ||
475 | "specifics_from_prim (Batch_payment/Credit): expected counterparty \ | ||
476 | name to match primitive description"; | ||
477 | if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then | ||
478 | failwith | ||
479 | "specifics_from_prim (Batch_payment/Credit): expected IBAN to match \ | ||
480 | and primitive counterparty IBAN"; | ||
481 | Batch_payment | ||
482 | { | ||
483 | counterparty_name = name; | ||
484 | counterparty_iban = iban; | ||
485 | description = desc; | ||
486 | reference = ref_; | ||
487 | } | ||
diff --git a/lib/ledger.ml b/lib/ledger.ml new file mode 100644 index 0000000..fd1b2a9 --- /dev/null +++ b/lib/ledger.ml | |||
@@ -0,0 +1,110 @@ | |||
1 | open Core | ||
2 | |||
3 | type account_type = Asset | Equity | Liability | Expense | Income | ||
4 | |||
5 | type tx_type = | ||
6 | | Interest_tx | ||
7 | | Online_banking_tx | ||
8 | | Recurrent_direct_tx | ||
9 | | Payment_terminal_tx | ||
10 | | Cash_payment_tx | ||
11 | | Atm_tx | ||
12 | | Auto_save_rounding_tx | ||
13 | | Batch_tx | ||
14 | | Direct_debit_tx | ||
15 | | Periodic_tx | ||
16 | |||
17 | type iban_tag = Account_tag | Counterparty_iban_tag [@@deriving compare] | ||
18 | |||
19 | type unit_tag = Filed_tag | GooglePay_tag | AutoRoundSavings_tag | ||
20 | [@@deriving compare] | ||
21 | |||
22 | type string_tag = | ||
23 | | Desc_tag | ||
24 | | User_tag | ||
25 | | Counterparty_name_tag | ||
26 | | Reference_tag | ||
27 | | Mandate_id_tag | ||
28 | | Creditor_id_tag | ||
29 | | Other_party_tag | ||
30 | | Transaction_tag | ||
31 | | Terminal_tag | ||
32 | | Card_seq_no_tag | ||
33 | | Savings_account_tag | ||
34 | [@@deriving compare] | ||
35 | |||
36 | module Label = struct | ||
37 | type 'a t = | ||
38 | | Iban_label : iban_tag -> Iban.t t | ||
39 | | String_label : string_tag -> string t | ||
40 | | Timestamp_label : Time_ns.t t | ||
41 | | Unit_label : unit_tag -> unit t | ||
42 | |||
43 | let int_to_cmp x : ('a, 'a) Dmap.cmp = | ||
44 | if x < 0 then Lt else if x > 0 then Gt else Eq | ||
45 | |||
46 | let compare (type a1 a2) (v1 : a1 t) (v2 : a2 t) : (a1, a2) Dmap.cmp = | ||
47 | match (v1, v2) with | ||
48 | | Iban_label t1, Iban_label t2 -> int_to_cmp @@ [%compare: iban_tag] t1 t2 | ||
49 | | String_label t1, String_label t2 -> | ||
50 | int_to_cmp @@ [%compare: string_tag] t1 t2 | ||
51 | | Timestamp_label, Timestamp_label -> Eq | ||
52 | | Unit_label t1, Unit_label t2 -> int_to_cmp @@ [%compare: unit_tag] t1 t2 | ||
53 | | Iban_label _, _ -> Lt | ||
54 | | String_label _, Iban_label _ -> Gt | ||
55 | | String_label _, _ -> Lt | ||
56 | | Timestamp_label, Unit_label _ -> Lt | ||
57 | | Timestamp_label, _ -> Gt | ||
58 | | Unit_label _, _ -> Gt | ||
59 | end | ||
60 | |||
61 | module Labels = Dmap.Make (Label) | ||
62 | |||
63 | module Money : sig | ||
64 | type t | ||
65 | |||
66 | val equal : t -> t -> bool | ||
67 | val compare : t -> t -> int | ||
68 | val of_z : Z.t -> t | ||
69 | val to_z : t -> Z.t | ||
70 | val ( + ) : t -> t -> t | ||
71 | val ( - ) : t -> t -> t | ||
72 | end = struct | ||
73 | type t = Z.t | ||
74 | |||
75 | let equal = Z.equal | ||
76 | let compare = Z.compare | ||
77 | let of_z = Fn.id | ||
78 | let to_z = Fn.id | ||
79 | let ( + ) x y = Z.(x + y) | ||
80 | let ( - ) x y = Z.(x - y) | ||
81 | end | ||
82 | |||
83 | type scalar = Amount of Money.t | Rate of Z.t [@@deriving equal, compare] | ||
84 | type account_id = string list | ||
85 | type commodity_id = string (* TODO: consider making this UUID *) | ||
86 | |||
87 | type account = { | ||
88 | id : account_id; | ||
89 | description : string list; | ||
90 | commodity_id : commodity_id; | ||
91 | balance : Money.t; | ||
92 | } | ||
93 | |||
94 | type bal_assert = { account : account_id; amount : Money.t; labels : Labels.t } | ||
95 | |||
96 | module Account_id_key = struct | ||
97 | type t = account_id | ||
98 | type comparator_witness | ||
99 | end | ||
100 | |||
101 | type tx = { | ||
102 | cleared : Date.t option; | ||
103 | commodity_id : commodity_id; | ||
104 | debit : scalar Map.M(Account_id_key).t; | ||
105 | credit : scalar Map.M(Account_id_key).t; | ||
106 | labels : Labels.t; | ||
107 | } | ||
108 | |||
109 | type item = Tx_item of tx | Bal_assert_item of bal_assert | ||
110 | type ledger = Ledger of item list | ||
diff --git a/rdcapsis.cabal b/rdcapsis.cabal deleted file mode 100644 index 3b4bd16..0000000 --- a/rdcapsis.cabal +++ /dev/null | |||
@@ -1,47 +0,0 @@ | |||
1 | cabal-version: 3.0 | ||
2 | name: rdcapsis | ||
3 | version: 0.1.0.0 | ||
4 | maintainer: [email protected] | ||
5 | author: Rutger Broekhoff | ||
6 | build-type: Simple | ||
7 | |||
8 | executable rdcapsis | ||
9 | main-is: Main.hs | ||
10 | hs-source-dirs: app | ||
11 | other-modules: | ||
12 | Data.Iban | ||
13 | Data.Ledger | ||
14 | Data.Ledger.AutoFile | ||
15 | Data.Res | ||
16 | Import.Ing.Convert | ||
17 | Import.Ing.CurrentAccountCsv | ||
18 | Import.Ing.SavingsAccountCsv | ||
19 | Import.Ing.Shared | ||
20 | |||
21 | default-language: GHC2024 | ||
22 | default-extensions: | ||
23 | AllowAmbiguousTypes DuplicateRecordFields NoMonomorphismRestriction | ||
24 | OverloadedRecordDot OverloadedLabels OverloadedStrings | ||
25 | |||
26 | ghc-options: -Wall -threaded | ||
27 | build-depends: | ||
28 | Decimal, | ||
29 | attoparsec, | ||
30 | base ^>=4.21.0.0, | ||
31 | bytestring, | ||
32 | cassava, | ||
33 | containers, | ||
34 | constraints-extras, | ||
35 | dependent-map, | ||
36 | dependent-sum, | ||
37 | dependent-sum-template, | ||
38 | generic-lens, | ||
39 | lens, | ||
40 | parsec, | ||
41 | pretty-simple, | ||
42 | regex-tdfa, | ||
43 | text, | ||
44 | time, | ||
45 | tz, | ||
46 | uuid, | ||
47 | vector | ||
diff --git a/rdcapsis.opam b/rdcapsis.opam new file mode 100644 index 0000000..876bf93 --- /dev/null +++ b/rdcapsis.opam | |||
@@ -0,0 +1,39 @@ | |||
1 | # This file is generated by dune, edit dune-project instead | ||
2 | opam-version: "2.0" | ||
3 | synopsis: "A short synopsis" | ||
4 | description: "A longer description" | ||
5 | maintainer: ["Maintainer Name <[email protected]>"] | ||
6 | authors: ["Author Name <[email protected]>"] | ||
7 | license: "LICENSE" | ||
8 | tags: ["add topics" "to describe" "your" "project"] | ||
9 | homepage: "https://github.com/username/reponame" | ||
10 | doc: "https://url/to/documentation" | ||
11 | bug-reports: "https://github.com/username/reponame/issues" | ||
12 | depends: [ | ||
13 | "dune" {>= "3.17"} | ||
14 | "ocaml" | ||
15 | "zarith" | ||
16 | "core" | ||
17 | "dmap" | ||
18 | "delimited_parsing" | ||
19 | "re" | ||
20 | "utop" {dev} | ||
21 | "merlin" {dev} | ||
22 | "ocamlformat" {dev} | ||
23 | "odoc" {with-doc} | ||
24 | ] | ||
25 | build: [ | ||
26 | ["dune" "subst"] {dev} | ||
27 | [ | ||
28 | "dune" | ||
29 | "build" | ||
30 | "-p" | ||
31 | name | ||
32 | "-j" | ||
33 | jobs | ||
34 | "@install" | ||
35 | "@runtest" {with-test} | ||
36 | "@doc" {with-doc} | ||
37 | ] | ||
38 | ] | ||
39 | dev-repo: "git+https://github.com/username/reponame.git" | ||
diff --git a/test/dune b/test/dune new file mode 100644 index 0000000..d48b587 --- /dev/null +++ b/test/dune | |||
@@ -0,0 +1,2 @@ | |||
1 | (test | ||
2 | (name test_rdcapsis)) | ||
diff --git a/test/test_rdcapsis.ml b/test/test_rdcapsis.ml new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/test/test_rdcapsis.ml | |||