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_; }