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] |