diff options
| author | Rutger Broekhoff | 2025-08-25 23:39:51 +0200 |
|---|---|---|
| committer | Rutger Broekhoff | 2025-08-25 23:39:51 +0200 |
| commit | b8fbaa53b912347b3b50cac3e913a142db460b0a (patch) | |
| tree | 4563ebc8b04a4ad841ad2103d8ddc0698e844a45 | |
| parent | 3f5221c2da2a19cf5de05284821e9b854d31b7fb (diff) | |
| download | rdcapsis-b8fbaa53b912347b3b50cac3e913a142db460b0a.tar.gz rdcapsis-b8fbaa53b912347b3b50cac3e913a142db460b0a.zip | |
Conversion
| -rw-r--r-- | lib/convert.ml | 164 | ||||
| -rw-r--r-- | lib/ingcsv.ml | 71 | ||||
| -rw-r--r-- | lib/ledger.ml | 32 |
3 files changed, 245 insertions, 22 deletions
diff --git a/lib/convert.ml b/lib/convert.ml new file mode 100644 index 0000000..cbdb90f --- /dev/null +++ b/lib/convert.ml | |||
| @@ -0,0 +1,164 @@ | |||
| 1 | open Core | ||
| 2 | open Ledger | ||
| 3 | |||
| 4 | let virt_checking_acc = [ "Unfiled"; "Checking" ] | ||
| 5 | let virt_savings_acc = [ "Unfiled"; "Savings" ] | ||
| 6 | let virt_counterparty = [ "Unfiled"; "Counterparty" ] | ||
| 7 | |||
| 8 | (* TODO: clean up *) | ||
| 9 | type convert_err = Nonpositive_amount | Other of Tx.error | ||
| 10 | |||
| 11 | let cents n = Amount (Money.of_z n) | ||
| 12 | |||
| 13 | let tx_from_current_acc euc_id (Ingcsv.Tx (base, spec)) = | ||
| 14 | if Z.(lt base.amount ~$0) then Error Nonpositive_amount | ||
| 15 | else | ||
| 16 | Result.map_error ~f:(fun e -> Other e) | ||
| 17 | @@ | ||
| 18 | match spec with | ||
| 19 | | Payment_terminal_payment details -> | ||
| 20 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 21 | ~credit: | ||
| 22 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 23 | ~debit: | ||
| 24 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 25 | ~labels: | ||
| 26 | Labels.( | ||
| 27 | empty | ||
| 28 | |> add (Iban_label Account_tag) base.account | ||
| 29 | |> add (String_label Counterparty_name_tag) | ||
| 30 | details.counterparty_name | ||
| 31 | |> add (String_label Card_seq_no_tag) details.card_sequence_no | ||
| 32 | |> add (String_label Terminal_tag) details.terminal | ||
| 33 | |> add (String_label Transaction_tag) details.transaction | ||
| 34 | |> add Timestamp_label details.timestamp | ||
| 35 | |> | ||
| 36 | if details.google_pay then add (Unit_label Google_pay_tag) () | ||
| 37 | else Fn.id) | ||
| 38 | | Payment_terminal_cashback details -> | ||
| 39 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 40 | ~debit: | ||
| 41 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 42 | ~credit: | ||
| 43 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 44 | ~labels: | ||
| 45 | Labels.( | ||
| 46 | empty | ||
| 47 | |> add (Iban_label Account_tag) base.account | ||
| 48 | |> add (String_label Counterparty_name_tag) | ||
| 49 | details.counterparty_name | ||
| 50 | |> add (String_label Card_seq_no_tag) details.card_sequence_no | ||
| 51 | |> add (String_label Terminal_tag) details.terminal | ||
| 52 | |> add (String_label Transaction_tag) details.transaction | ||
| 53 | |> add Timestamp_label details.timestamp) | ||
| 54 | | Online_banking_credit details -> | ||
| 55 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 56 | ~debit: | ||
| 57 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 58 | ~credit: | ||
| 59 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 60 | ~labels: | ||
| 61 | Labels.( | ||
| 62 | empty | ||
| 63 | |> add (Iban_label Account_tag) base.account | ||
| 64 | |> add (String_label Counterparty_name_tag) | ||
| 65 | details.counterparty_name | ||
| 66 | |> add (Iban_label Counterparty_iban_tag) | ||
| 67 | details.counterparty_iban | ||
| 68 | |> add (String_label Desc_tag) details.description | ||
| 69 | |> add Timestamp_label details.timestamp) | ||
| 70 | | Online_banking_debit details -> | ||
| 71 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 72 | ~debit: | ||
| 73 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 74 | ~credit: | ||
| 75 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 76 | ~labels: | ||
| 77 | Labels.( | ||
| 78 | empty | ||
| 79 | |> add (Iban_label Account_tag) base.account | ||
| 80 | |> add (String_label Counterparty_name_tag) | ||
| 81 | details.counterparty_name | ||
| 82 | |> add (Iban_label Counterparty_iban_tag) | ||
| 83 | details.counterparty_iban | ||
| 84 | |> add (String_label Desc_tag) details.description) | ||
| 85 | | Recurrent_direct_debit details -> | ||
| 86 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 87 | ~debit: | ||
| 88 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 89 | ~credit: | ||
| 90 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 91 | ~labels: | ||
| 92 | Labels.( | ||
| 93 | empty | ||
| 94 | |> add (Iban_label Account_tag) base.account | ||
| 95 | |> add (Iban_label Counterparty_iban_tag) | ||
| 96 | details.counterparty_iban | ||
| 97 | |> add (String_label Counterparty_name_tag) | ||
| 98 | details.counterparty_name | ||
| 99 | |> add (String_label Desc_tag) details.description | ||
| 100 | |> add (String_label Reference_tag) details.reference | ||
| 101 | |> add (String_label Mandate_id_tag) details.mandate_id | ||
| 102 | |> add (String_label Creditor_id_tag) details.creditor_id | ||
| 103 | |> | ||
| 104 | match details.other_party with | ||
| 105 | | None -> Fn.id | ||
| 106 | | Some other_party -> | ||
| 107 | add (String_label Other_party_tag) other_party) | ||
| 108 | | Rounding_savings_deposit details -> | ||
| 109 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 110 | ~debit: | ||
| 111 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 112 | ~credit: | ||
| 113 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 114 | ~labels: | ||
| 115 | Labels.( | ||
| 116 | empty | ||
| 117 | |> add (Unit_label Auto_round_savings_tag) () | ||
| 118 | |> add (String_label Savings_account_tag) details.savings_account) | ||
| 119 | | Deposit details -> | ||
| 120 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 121 | ~debit: | ||
| 122 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 123 | ~credit: | ||
| 124 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 125 | ~labels: | ||
| 126 | Labels.( | ||
| 127 | empty | ||
| 128 | |> add (Iban_label Counterparty_iban_tag) | ||
| 129 | details.counterparty_iban | ||
| 130 | |> add (String_label Counterparty_name_tag) | ||
| 131 | details.counterparty_name | ||
| 132 | |> add (String_label Desc_tag) details.description | ||
| 133 | |> add (String_label Reference_tag) details.reference) | ||
| 134 | | Ideal_debit details -> | ||
| 135 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 136 | ~debit: | ||
| 137 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 138 | ~credit: | ||
| 139 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 140 | ~labels: | ||
| 141 | Labels.( | ||
| 142 | empty | ||
| 143 | |> add (Iban_label Counterparty_iban_tag) | ||
| 144 | details.counterparty_iban | ||
| 145 | |> add (String_label Counterparty_name_tag) | ||
| 146 | details.counterparty_name | ||
| 147 | |> add (String_label Desc_tag) details.description | ||
| 148 | |> add (String_label Reference_tag) details.reference | ||
| 149 | |> add Timestamp_label details.timestamp) | ||
| 150 | | Batch_payment details -> | ||
| 151 | Tx.make ~cleared:(Some base.date) ~commodity_id:euc_id | ||
| 152 | ~debit: | ||
| 153 | (Account_id_map.singleton virt_counterparty @@ cents base.amount) | ||
| 154 | ~credit: | ||
| 155 | (Account_id_map.singleton virt_checking_acc @@ cents base.amount) | ||
| 156 | ~labels: | ||
| 157 | Labels.( | ||
| 158 | empty | ||
| 159 | |> add (Iban_label Counterparty_iban_tag) | ||
| 160 | details.counterparty_iban | ||
| 161 | |> add (String_label Counterparty_name_tag) | ||
| 162 | details.counterparty_name | ||
| 163 | |> add (String_label Desc_tag) details.description | ||
| 164 | |> add (String_label Reference_tag) details.reference) | ||
diff --git a/lib/ingcsv.ml b/lib/ingcsv.ml index 203a353..f3536bf 100644 --- a/lib/ingcsv.ml +++ b/lib/ingcsv.ml | |||
| @@ -68,6 +68,8 @@ module Transaction_type = struct | |||
| 68 | end | 68 | end |
| 69 | 69 | ||
| 70 | module Primitive_tx = struct | 70 | module Primitive_tx = struct |
| 71 | exception Inconsistent_transaction_code | ||
| 72 | |||
| 71 | type t = { | 73 | type t = { |
| 72 | date : Date.t; | 74 | date : Date.t; |
| 73 | description : string; | 75 | description : string; |
| @@ -85,6 +87,21 @@ module Primitive_tx = struct | |||
| 85 | let opt_field (f : string -> 'a) (v : string) : 'a option = | 87 | let opt_field (f : string -> 'a) (v : string) : 'a option = |
| 86 | if String.is_empty (String.strip v) then None else Some (f v) | 88 | if String.is_empty (String.strip v) then None else Some (f v) |
| 87 | 89 | ||
| 90 | let headers = | ||
| 91 | [ | ||
| 92 | "Date"; | ||
| 93 | "Name / Description"; | ||
| 94 | "Account"; | ||
| 95 | "Counterparty"; | ||
| 96 | "Code"; | ||
| 97 | "Debit/credit"; | ||
| 98 | "Amount (EUR)"; | ||
| 99 | "Transaction type"; | ||
| 100 | "Notifications"; | ||
| 101 | "Resulting balance"; | ||
| 102 | "Tag"; | ||
| 103 | ] | ||
| 104 | |||
| 88 | let parse : t Delimited.Read.t = | 105 | let parse : t Delimited.Read.t = |
| 89 | let open Delimited.Read.Let_syntax in | 106 | let open Delimited.Read.Let_syntax in |
| 90 | let%map_open date = at_header "Date" ~f:Date.of_string | 107 | let%map_open date = at_header "Date" ~f:Date.of_string |
| @@ -99,12 +116,7 @@ module Primitive_tx = struct | |||
| 99 | and resulting_balance = at_header "Resulting balance" ~f:Cents.of_string | 116 | and resulting_balance = at_header "Resulting balance" ~f:Cents.of_string |
| 100 | and tag = at_header "Tag" ~f:Fn.id in | 117 | and tag = at_header "Tag" ~f:Fn.id in |
| 101 | if not ([%equal: Transaction_type.t] code type_) then | 118 | if not ([%equal: Transaction_type.t] code type_) then |
| 102 | Printf.failwithf | 119 | raise Inconsistent_transaction_code; |
| 103 | "Primitive_tx.parse: parsed transaction code (%S) and type (%S) do not \ | ||
| 104 | match" | ||
| 105 | (Transaction_type.to_string code) | ||
| 106 | (Transaction_type.to_string type_) | ||
| 107 | (); | ||
| 108 | { | 120 | { |
| 109 | date; | 121 | date; |
| 110 | description; | 122 | description; |
| @@ -123,7 +135,7 @@ type tx_base = { | |||
| 123 | date : Date.t; | 135 | date : Date.t; |
| 124 | account : Iban.t; | 136 | account : Iban.t; |
| 125 | amount : Cents.t; | 137 | amount : Cents.t; |
| 126 | res_bal : Cents.t; | 138 | resulting_balance : Cents.t; |
| 127 | tag : string; | 139 | tag : string; |
| 128 | } | 140 | } |
| 129 | 141 | ||
| @@ -196,6 +208,7 @@ type parse_err = | |||
| 196 | | Inconsistent_value_date | 208 | | Inconsistent_value_date |
| 197 | | Inconsistent_counterparty_name | 209 | | Inconsistent_counterparty_name |
| 198 | | Inconsistent_counterparty_iban | 210 | | Inconsistent_counterparty_iban |
| 211 | | Inconsistent_transaction_code | ||
| 199 | 212 | ||
| 200 | let assert_value_date (ptx : Primitive_tx.t) d = | 213 | let assert_value_date (ptx : Primitive_tx.t) d = |
| 201 | if Date.(d = ptx.date) then Ok () else Error Inconsistent_value_date | 214 | if Date.(d = ptx.date) then Ok () else Error Inconsistent_value_date |
| @@ -383,7 +396,7 @@ let parse_batch_payment_credit_notifs notifs = | |||
| 383 | (name, desc, iban, ref_, val_date) | 396 | (name, desc, iban, ref_, val_date) |
| 384 | | _ | (exception _) -> Error No_notifs_match | 397 | | _ | (exception _) -> Error No_notifs_match |
| 385 | 398 | ||
| 386 | let specifics_from_prim_exn (ams_tz : Time_ns.Zone.t) : | 399 | let tx_specifics_from_prim (ams_tz : Time_ns.Zone.t) : |
| 387 | Primitive_tx.t -> (tx_specifics, parse_err) result = function | 400 | Primitive_tx.t -> (tx_specifics, parse_err) result = function |
| 388 | | { type_ = Payment_terminal; debit_credit = Debit; _ } as ptx -> | 401 | | { type_ = Payment_terminal; debit_credit = Debit; _ } as ptx -> |
| 389 | let%bind | 402 | let%bind |
| @@ -531,3 +544,45 @@ let specifics_from_prim_exn (ams_tz : Time_ns.Zone.t) : | |||
| 531 | reference = ref_; | 544 | reference = ref_; |
| 532 | } | 545 | } |
| 533 | | _ -> Error Unknown_type_combination | 546 | | _ -> Error Unknown_type_combination |
| 547 | |||
| 548 | let tx_base_from_prim (ptx : Primitive_tx.t) : tx_base = | ||
| 549 | { | ||
| 550 | date = ptx.date; | ||
| 551 | account = ptx.account; | ||
| 552 | amount = ptx.amount; | ||
| 553 | resulting_balance = ptx.resulting_balance; | ||
| 554 | tag = ptx.tag; | ||
| 555 | } | ||
| 556 | |||
| 557 | let tx_from_prim ptx ~ams_tz : (tx, parse_err) result = | ||
| 558 | let base = tx_base_from_prim ptx in | ||
| 559 | let%map specifics = tx_specifics_from_prim ams_tz ptx in | ||
| 560 | Tx (base, specifics) | ||
| 561 | |||
| 562 | type csv_err = Parse_err of parse_err | Exn of exn | ||
| 563 | |||
| 564 | module List = struct | ||
| 565 | include List | ||
| 566 | |||
| 567 | let map_result ~(f : 'a -> ('b, 'c) result) : 'a list -> ('b list, 'c) result | ||
| 568 | = | ||
| 569 | let open Result.Let_syntax in | ||
| 570 | let rec go = function | ||
| 571 | | [] -> return [] | ||
| 572 | | x :: xs -> | ||
| 573 | let%map x' = f x and xs' = go xs in | ||
| 574 | x' :: xs' | ||
| 575 | in | ||
| 576 | go | ||
| 577 | end | ||
| 578 | |||
| 579 | let read_channel (c : In_channel.t) ~ams_tz : (tx list, csv_err) result = | ||
| 580 | let%bind ptxs = | ||
| 581 | try | ||
| 582 | Ok | ||
| 583 | (Delimited.Read.read_lines ~header:(`Require Primitive_tx.headers) | ||
| 584 | Primitive_tx.parse c) | ||
| 585 | with e -> Error (Exn e) | ||
| 586 | in | ||
| 587 | List.map_result ptxs ~f:(tx_from_prim ~ams_tz) | ||
| 588 | |> Result.map_error ~f:(fun e -> Parse_err e) | ||
diff --git a/lib/ledger.ml b/lib/ledger.ml index 1d9a63c..3b52bcc 100644 --- a/lib/ledger.ml +++ b/lib/ledger.ml | |||
| @@ -16,7 +16,7 @@ type tx_type = | |||
| 16 | 16 | ||
| 17 | type iban_tag = Account_tag | Counterparty_iban_tag [@@deriving compare] | 17 | type iban_tag = Account_tag | Counterparty_iban_tag [@@deriving compare] |
| 18 | 18 | ||
| 19 | type unit_tag = Filed_tag | GooglePay_tag | AutoRoundSavings_tag | 19 | type unit_tag = Filed_tag | Google_pay_tag | Auto_round_savings_tag |
| 20 | [@@deriving compare] | 20 | [@@deriving compare] |
| 21 | 21 | ||
| 22 | type string_tag = | 22 | type string_tag = |
| @@ -81,22 +81,26 @@ end = struct | |||
| 81 | end | 81 | end |
| 82 | 82 | ||
| 83 | type scalar = Amount of Money.t | Rate of Z.t [@@deriving equal, compare] | 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 *) | 84 | type commodity_id = string (* TODO: consider making this UUID *) |
| 86 | 85 | ||
| 86 | module Account_id = struct | ||
| 87 | type t = string list [@@deriving sexp, compare] | ||
| 88 | end | ||
| 89 | |||
| 87 | type account = { | 90 | type account = { |
| 88 | id : account_id; | 91 | id : Account_id.t; |
| 89 | description : string list; | 92 | description : string list; |
| 90 | commodity_id : commodity_id; | 93 | commodity_id : commodity_id; |
| 91 | balance : Money.t; | 94 | balance : Money.t; |
| 92 | } | 95 | } |
| 93 | 96 | ||
| 94 | type bal_assert = { account : account_id; amount : Money.t; labels : Labels.t } | 97 | type bal_assert = { |
| 98 | account : Account_id.t; | ||
| 99 | amount : Money.t; | ||
| 100 | labels : Labels.t; | ||
| 101 | } | ||
| 95 | 102 | ||
| 96 | module Account_id_key = struct | 103 | module Account_id_map = Map.Make (Account_id) |
| 97 | type t = account_id | ||
| 98 | type comparator_witness | ||
| 99 | end | ||
| 100 | 104 | ||
| 101 | module Tx : sig | 105 | module Tx : sig |
| 102 | type t | 106 | type t |
| @@ -105,23 +109,23 @@ module Tx : sig | |||
| 105 | val make : | 109 | val make : |
| 106 | cleared:Date.t option -> | 110 | cleared:Date.t option -> |
| 107 | commodity_id:commodity_id -> | 111 | commodity_id:commodity_id -> |
| 108 | debit:scalar Map.M(Account_id_key).t -> | 112 | debit:scalar Account_id_map.t -> |
| 109 | credit:scalar Map.M(Account_id_key).t -> | 113 | credit:scalar Account_id_map.t -> |
| 110 | labels:Labels.t -> | 114 | labels:Labels.t -> |
| 111 | (t, error) result | 115 | (t, error) result |
| 112 | 116 | ||
| 113 | val cleared : t -> Date.t option | 117 | val cleared : t -> Date.t option |
| 114 | val commodity_id : t -> commodity_id | 118 | val commodity_id : t -> commodity_id |
| 115 | val debit : t -> scalar Map.M(Account_id_key).t | 119 | val debit : t -> scalar Account_id_map.t |
| 116 | val credit : t -> scalar Map.M(Account_id_key).t | 120 | val credit : t -> scalar Account_id_map.t |
| 117 | val labels : t -> Labels.t | 121 | val labels : t -> Labels.t |
| 118 | end = struct | 122 | end = struct |
| 119 | (* We hide this because we only want to allow constructing balanced transactions *) | 123 | (* We hide this because we only want to allow constructing balanced transactions *) |
| 120 | type t = { | 124 | type t = { |
| 121 | cleared : Date.t option; | 125 | cleared : Date.t option; |
| 122 | commodity_id : commodity_id; | 126 | commodity_id : commodity_id; |
| 123 | debit : scalar Map.M(Account_id_key).t; | 127 | debit : scalar Account_id_map.t; |
| 124 | credit : scalar Map.M(Account_id_key).t; | 128 | credit : scalar Account_id_map.t; |
| 125 | labels : Labels.t; | 129 | labels : Labels.t; |
| 126 | } | 130 | } |
| 127 | [@@deriving fields] | 131 | [@@deriving fields] |