From 95d50b25c990e8c945ce2507b16ff3c8b039d286 Mon Sep 17 00:00:00 2001 From: Rutger Broekhoff Date: Mon, 25 Aug 2025 19:48:19 +0200 Subject: OCaml --- lib/ingcsv.ml | 487 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 lib/ingcsv.ml (limited to 'lib/ingcsv.ml') 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 @@ +open Core +module Time_ns = Time_ns_unix + +module Debit_credit = struct + type t = Debit | Credit + + let of_string = function + | "Debit" -> Debit + | "Credit" -> Credit + | s -> Printf.failwithf "DebitCredit.of_string: %S" s () + + let to_string = function Debit -> "Debit" | Credit -> "Credit" +end + +module Cents = struct + type t = Z.t + + let of_string s = + (* TODO: consider being more bitchy here *) + String.lsplit2_exn s ~on:',' |> Tuple2.map ~f:Z.of_string + |> fun (high, low) -> Z.((high * ~$100) + low) +end + +module Transaction_type = struct + type t = + | Accept_giro (* AC (acceptgiro) *) + | Atm_withdrawal (* GM (geldautomaat, Giromaat) *) + | Batch_payment (* VZ (verzamelbetaling); 'Batch payment' *) + | Branch_posting (* FL (filiaalboeking) *) + | Deposit (* ST (storting) *) + | Direct_debit (* IC (incasso); 'SEPA direct debit' *) + | Ideal (* ID (iDEAL); 'iDEAL' *) + | Online_banking (* GT (internetbankieren, Girotel); 'Online Banking' *) + | Office_withdrawal (* PK (opname kantoor, postkantoor) *) + | Payment_terminal (* BA (betaalautomaat); 'Payment terminal' *) + | Periodic_transfer (* PO (periodieke overschrijving) *) + | Phone_banking (* GF (telefonisch bankieren, Girofoon) *) + | Transfer (* OV (overboeking); 'Transfer' *) + | Various (* DV (diversen) *) + [@@deriving equal, string] + + let of_code = function + | "AC" -> Accept_giro + | "GM" -> Atm_withdrawal + | "VZ" -> Batch_payment + | "FL" -> Branch_posting + | "ST" -> Deposit + | "IC" -> Direct_debit + | "ID" -> Ideal + | "GT" -> Online_banking + | "PK" -> Office_withdrawal + | "BA" -> Payment_terminal + | "PO" -> Periodic_transfer + | "GF" -> Phone_banking + | "OV" -> Transfer + | "DV" -> Various + | s -> Printf.failwithf "TransactionType.of_code: %S" s () + + let of_type = function + | "SEPA direct debit" -> Direct_debit + | "Batch payment" -> Batch_payment + | "Online Banking" -> Online_banking + | "Payment terminal" -> Payment_terminal + | "Transfer" -> Transfer + | "iDEAL" -> Ideal + | s -> Printf.failwithf "TransactionType.of_type: %S" s () +end + +module Primitive_tx = struct + type t = { + date : Date.t; + description : string; + account : Iban.t; + counterparty : Iban.t option; + type_ : Transaction_type.t; + debit_credit : Debit_credit.t; + amount : Cents.t; + notifications : string; + resulting_balance : Cents.t; + tag : string; + } + [@@deriving fields] + + let opt_field (f : string -> 'a) (v : string) : 'a option = + if String.is_empty (String.strip v) then None else Some (f v) + + let parse : t Delimited.Read.t = + let open Delimited.Read.Let_syntax in + let%map_open date = at_header "Date" ~f:Date.of_string + and description = at_header "Name / Description" ~f:Fn.id + and account = at_header "Account" ~f:Iban.of_string + and counterparty = at_header "Counterparty" ~f:(opt_field Iban.of_string) + and code = at_header "Code" ~f:Transaction_type.of_code + and debit_credit = at_header "Debit/credit" ~f:Debit_credit.of_string + and amount = at_header "Amount (EUR)" ~f:Cents.of_string + and type_ = at_header "Transaction type" ~f:Transaction_type.of_type + and notifications = at_header "Notifications" ~f:Fn.id + and resulting_balance = at_header "Resulting balance" ~f:Cents.of_string + and tag = at_header "Tag" ~f:Fn.id in + if not ([%equal: Transaction_type.t] code type_) then + Printf.failwithf + "Primitive_tx.parse: parsed transaction code (%S) and type (%S) do not \ + match" + (Transaction_type.to_string code) + (Transaction_type.to_string type_) + (); + { + date; + description; + account; + counterparty; + type_; + debit_credit; + amount; + notifications; + resulting_balance; + tag; + } +end + +type tx_base = { + date : Date.t; + account : Iban.t; + amount : Cents.t; + res_bal : Cents.t; + tag : string; +} + +type tx_specifics = + | Payment_terminal_payment of { + counterparty_name : string; + card_sequence_no : string; + timestamp : Time_ns.t; + transaction : string; + terminal : string; + google_pay : bool; + } + | Payment_terminal_cashback of { + counterparty_name : string; + card_sequence_no : string; + timestamp : Time_ns.t; + transaction : string; + terminal : string; + } + | Online_banking_credit of { + counterparty_name : string; + counterparty_iban : Iban.t; + description : string; + timestamp : Time_ns.t; + } + | Online_banking_debit of { + counterparty_name : string; + counterparty_iban : Iban.t; + description : string; + mtimestamp : Time_ns.t option; + } + | Recurrent_direct_debit of { + counterparty_name : string; + counterparty_iban : Iban.t; + description : string; + reference : string; + mandate_id : string; + creditor_id : string; + other_party : string option; + } + | Rounding_savings_deposit of { savings_account : string } + | Deposit of { + counterparty_name : string; + counterparty_iban : Iban.t; + description : string; + reference : string; + } + | Ideal_debit of { + counterparty_name : string; + counterparty_iban : Iban.t; + description : string; + timestamp : Time_ns.t; + reference : string; + } + | Batch_payment of { + counterparty_name : string; + counterparty_iban : Iban.t; + description : string; + reference : string; + } + +type tx = Tx of tx_base * tx_specifics + +let assert_value_date (ptx : Primitive_tx.t) s = + let val_date = Date_unix.parse s ~fmt:"%d/%m/%Y" in + if not Date.(val_date = ptx.date) then + failwith + "assert_value_date: expected transaction date and value date to be the \ + same" + +let[@warning "-8"] specifics_from_prim_exn (ams_tz : Time_ns.Zone.t) : + Primitive_tx.t -> tx_specifics = function + | { type_ = Payment_terminal; debit_credit = Debit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^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})$" + in + let [| + _; + card_seq_no; + timestamp_str; + transaction; + _; + gpay_term; + no_gpay_term; + val_date_str; + |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let timestamp = + Time_ns.parse timestamp_str ~allow_trailing_input:false + ~fmt:"%d/%m/%Y %H:%M" ~zone:ams_tz + in + Payment_terminal_payment + { + counterparty_name = ptx.description; + card_sequence_no = card_seq_no; + timestamp; + transaction; + terminal = + (if String.is_empty gpay_term then no_gpay_term else gpay_term); + google_pay = String.is_empty no_gpay_term; + } + | { type_ = Payment_terminal; debit_credit = Credit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^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})$" + in + let [| _; card_seq_no; timestamp_str; transaction; term; val_date_str |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let timestamp = + Time_ns.parse timestamp_str ~allow_trailing_input:false + ~fmt:"%d/%m/%Y %H:%M" ~zone:ams_tz + in + Payment_terminal_cashback + { + counterparty_name = ptx.description; + card_sequence_no = card_seq_no; + timestamp; + transaction; + terminal = term; + } + | { type_ = Online_banking; debit_credit = Credit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^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})$" + in + let [| _; name; desc; iban_str; timestamp_str; val_date_str |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let iban = Iban.of_string iban_str + and timestamp = + Time_ns.parse timestamp_str ~allow_trailing_input:false + ~fmt:"%d-%m-%Y %H:%M:%S" ~zone:ams_tz + in + if not String.(name = ptx.description) then + failwith + "specifics_from_prim (Online_banking/Credit): expected counterparty \ + name to match primitive description"; + if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then + failwith + "specifics_from_prim (Online_banking/Credit): expected IBAN to match \ + and primitive counterparty IBAN"; + Online_banking_credit + { + counterparty_name = name; + counterparty_iban = iban; + description = desc; + timestamp; + } + | { type_ = Online_banking; debit_credit = Debit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^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})$" + in + let [| _; name; desc; iban_str; _; timestamp_str; val_date_str |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let iban = Iban.of_string iban_str + and mtimestamp = + if String.is_empty timestamp_str then None + else + Some + (Time_ns.parse timestamp_str ~allow_trailing_input:false + ~fmt:"%d-%m-%Y %H:%M:%S" ~zone:ams_tz) + in + if not String.(name = ptx.description) then + failwith + "specifics_from_prim (Online_banking/Debit): expected counterparty \ + name to match primitive description"; + if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then + failwith + "specifics_from_prim (Online_banking/Debit): expected IBAN to match \ + and primitive counterparty IBAN"; + Online_banking_debit + { + counterparty_name = name; + counterparty_iban = iban; + description = desc; + mtimestamp; + } + | { type_ = Direct_debit; debit_credit = Debit; _ } as ptx + when String.is_suffix ptx.notifications + ~suffix:"Recurrent SEPA direct debit" -> + let regex = + Re.Pcre.regexp + "^Name: (.* ING Verzekeren) Description: (.*) IBAN: ([A-Z0-9]+) \ + Reference: (.*) Mandate ID: (.*) Creditor ID: (.*) Recurrent SEPA \ + direct debit$" + in + let [| _; name; desc; iban_str; ref_; mandate_id; creditor_id |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + let iban = Iban.of_string iban_str in + if not String.(name = ptx.description) then + failwith + "specifics_from_prim (Direct_debit/Debit): expected counterparty \ + name to match primitive description"; + if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then + failwith + "specifics_from_prim (Direct_debit/Debit): expected IBAN to match \ + and primitive counterparty IBAN"; + Recurrent_direct_debit + { + counterparty_name = name; + counterparty_iban = iban; + description = desc; + reference = ref_; + mandate_id; + creditor_id; + other_party = None; + } + | { type_ = Direct_debit; debit_credit = Debit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^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})$" + in + let [| + _; + name; + desc; + iban_str; + ref_; + mandate_id; + creditor_id; + _; + other_party; + val_date_str; + |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let iban = Iban.of_string iban_str in + if not String.(name = ptx.description) then + failwith + "specifics_from_prim (Direct_debit/Debit): expected counterparty \ + name to match primitive description"; + if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then + failwith + "specifics_from_prim (Direct_debit/Debit): expected IBAN to match \ + and primitive counterparty IBAN"; + Recurrent_direct_debit + { + counterparty_name = name; + counterparty_iban = iban; + description = desc; + reference = ref_; + mandate_id; + creditor_id; + other_party = + (if String.is_empty other_party then None else Some other_party); + } + | { type_ = Transfer; debit_credit = Credit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) \ + Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" + in + let [| _; name; desc; iban_str; ref_; val_date_str |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let iban = Iban.of_string iban_str in + if not String.(name = ptx.description) then + failwith + "specifics_from_prim (Transfer/Credit): expected counterparty name \ + to match primitive description"; + if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then + failwith + "specifics_from_prim (Direct_debit/Debit): expected IBAN to match \ + and primitive counterparty IBAN"; + Deposit + { + counterparty_name = name; + counterparty_iban = iban; + description = desc; + reference = ref_; + } + | { type_ = Transfer; debit_credit = Debit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^To Oranje spaarrekening ([A-Z0-9]+) Afronding Value date: \ + ([0-9]{2}/[0-9]{2}/[0-9]{4})$" + in + let [| _; savings_account; val_date_str |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + Rounding_savings_deposit { savings_account } + | { type_ = Ideal; debit_credit = Debit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^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})$" + in + let [| _; name; desc; iban_str; timestamp_str; ref_; val_date_str |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let timestamp = + Time_ns.parse timestamp_str ~allow_trailing_input:false + ~fmt:"%d-%m-%Y %H:%M" ~zone:ams_tz + in + let iban = Iban.of_string iban_str in + if not String.(name = ptx.description) then + failwith + "specifics_from_prim (Ideal/Debit): expected counterparty name to \ + match primitive description"; + if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then + failwith + "specifics_from_prim (Ideal/Debit): expected IBAN to match and \ + primitive counterparty IBAN"; + Ideal_debit + { + counterparty_name = name; + counterparty_iban = iban; + description = desc; + timestamp; + reference = ref_; + } + | { type_ = Batch_payment; debit_credit = Credit; _ } as ptx -> + let regex = + Re.Pcre.regexp + "^Name: (.*) Description: (.*) IBAN: ([A-Z0-9]+) Reference: (.*) \ + Value date: ([0-9]{2}/[0-9]{2}/[0-9]{4})$" + in + let [| _; name; desc; iban_str; ref_; val_date_str |] = + Re.Pcre.extract ~rex:regex ptx.notifications + in + assert_value_date ptx val_date_str; + let iban = Iban.of_string iban_str in + if not String.(name = ptx.description) then + failwith + "specifics_from_prim (Batch_payment/Credit): expected counterparty \ + name to match primitive description"; + if not (Option.equal Iban.equal (Some iban) ptx.counterparty) then + failwith + "specifics_from_prim (Batch_payment/Credit): expected IBAN to match \ + and primitive counterparty IBAN"; + Batch_payment + { + counterparty_name = name; + counterparty_iban = iban; + description = desc; + reference = ref_; + } -- cgit v1.2.3