From 95d50b25c990e8c945ce2507b16ff3c8b039d286 Mon Sep 17 00:00:00 2001 From: Rutger Broekhoff Date: Mon, 25 Aug 2025 19:48:19 +0200 Subject: OCaml --- lib/dune | 5 + lib/iban.ml | 87 +++++++++++ lib/iban.mli | 8 + lib/ingcsv.ml | 487 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/ledger.ml | 110 +++++++++++++ 5 files changed, 697 insertions(+) create mode 100644 lib/dune create mode 100644 lib/iban.ml create mode 100644 lib/iban.mli create mode 100644 lib/ingcsv.ml create mode 100644 lib/ledger.ml (limited to 'lib') diff --git a/lib/dune b/lib/dune new file mode 100644 index 0000000..ff9a2ee --- /dev/null +++ b/lib/dune @@ -0,0 +1,5 @@ +(library + (name rdcapsis) + (preprocess + (pps ppx_jane)) + (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 @@ +open Core +open Option.Let_syntax + +type t = string + +(* Modulo-97 arithmetic. Prevents us from having to use Zarith here. *) +module M97 : sig + type t + + val of_int : int -> t + val lt : t -> t -> bool + val equal : t -> t -> bool + val ( * ) : t -> t -> t + val ( + ) : t -> t -> t + val ( ~$ ) : int -> t +end = struct + type t = int + + let of_int x = x % 97 + let equal = Int.( = ) + let lt = Int.( < ) + let ( * ) x y = x * y % 97 + let ( + ) x y = (x + y) % 97 + let ( ~$ ) = of_int +end + +let m97_of_alnum c = + let v = Char.to_int c in + if Char.is_digit c then Some (M97.of_int (v - Char.to_int '0')) + else if Char.is_alpha c then + if Char.is_lowercase c then Some (M97.of_int (v - Char.to_int 'a' + 10)) + else Some (M97.of_int (v - Char.to_int 'A' + 10)) + else None + +let m97_of_digit c = + match m97_of_alnum c with Some v when M97.(lt v ~$10) -> Some v | _ -> None + +let m97_of_alpha c = + match m97_of_alnum c with + | Some v when not M97.(lt v ~$10) -> Some v + | _ -> None + +let string_fold_option ~(init : 'a) ~(f : 'a -> char -> 'a option) s = + let rec go i (acc : 'a) : 'a option = + if i >= String.length s then Some acc + else Option.(f acc (String.unsafe_get s i) >>= go (i + 1)) + in + go 0 init + +let m97_of_iban s = + string_fold_option s ~init:`In_country1 ~f:(fun st c -> + match st with + | `In_country1 -> + let%map co1 = m97_of_alpha c in + `In_country2 co1 + | `In_country2 co1 -> + let%map co2 = m97_of_alpha c in + `In_check1 M97.((co1 * ~$100) + co2) + | `In_check1 co -> + let%map ch1 = m97_of_digit c in + `In_check2 (co, ch1) + | `In_check2 (co, ch1) -> + let%map ch2 = m97_of_digit c in + `In_bban M97.(co, (ch1 * ~$10) + ch2, ~$0) + | `In_bban (co, ch, bban) -> + let%map v = m97_of_alnum c in + let bban' = + M97.(if lt v ~$10 then (bban * ~$10) + v else (bban * ~$100) + v) + in + `In_bban (co, ch, bban')) + |> function + | Some (`In_bban (co, ch, bban)) -> + Some M97.((bban * ~$1000000) + (co * ~$100) + ch) + | _ -> None + +let check_iban s = + String.length s <= 34 && Option.exists (m97_of_iban s) ~f:M97.(equal ~$1) + +let make s : t option = if check_iban s then Some s else None +let to_string = Fn.id + +let of_string s = + match make s with + | Some iban -> iban + | None -> Printf.failwithf "Iban.of_string: %S" s () + +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 @@ +open Core + +type t + +val make : string -> t option + +include Stringable.S with type t := t +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 @@ +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_; + } 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 @@ +open Core + +type account_type = Asset | Equity | Liability | Expense | Income + +type tx_type = + | Interest_tx + | Online_banking_tx + | Recurrent_direct_tx + | Payment_terminal_tx + | Cash_payment_tx + | Atm_tx + | Auto_save_rounding_tx + | Batch_tx + | Direct_debit_tx + | Periodic_tx + +type iban_tag = Account_tag | Counterparty_iban_tag [@@deriving compare] + +type unit_tag = Filed_tag | GooglePay_tag | AutoRoundSavings_tag +[@@deriving compare] + +type string_tag = + | Desc_tag + | User_tag + | Counterparty_name_tag + | Reference_tag + | Mandate_id_tag + | Creditor_id_tag + | Other_party_tag + | Transaction_tag + | Terminal_tag + | Card_seq_no_tag + | Savings_account_tag +[@@deriving compare] + +module Label = struct + type 'a t = + | Iban_label : iban_tag -> Iban.t t + | String_label : string_tag -> string t + | Timestamp_label : Time_ns.t t + | Unit_label : unit_tag -> unit t + + let int_to_cmp x : ('a, 'a) Dmap.cmp = + if x < 0 then Lt else if x > 0 then Gt else Eq + + let compare (type a1 a2) (v1 : a1 t) (v2 : a2 t) : (a1, a2) Dmap.cmp = + match (v1, v2) with + | Iban_label t1, Iban_label t2 -> int_to_cmp @@ [%compare: iban_tag] t1 t2 + | String_label t1, String_label t2 -> + int_to_cmp @@ [%compare: string_tag] t1 t2 + | Timestamp_label, Timestamp_label -> Eq + | Unit_label t1, Unit_label t2 -> int_to_cmp @@ [%compare: unit_tag] t1 t2 + | Iban_label _, _ -> Lt + | String_label _, Iban_label _ -> Gt + | String_label _, _ -> Lt + | Timestamp_label, Unit_label _ -> Lt + | Timestamp_label, _ -> Gt + | Unit_label _, _ -> Gt +end + +module Labels = Dmap.Make (Label) + +module Money : sig + type t + + val equal : t -> t -> bool + val compare : t -> t -> int + val of_z : Z.t -> t + val to_z : t -> Z.t + val ( + ) : t -> t -> t + val ( - ) : t -> t -> t +end = struct + type t = Z.t + + let equal = Z.equal + let compare = Z.compare + let of_z = Fn.id + let to_z = Fn.id + let ( + ) x y = Z.(x + y) + let ( - ) x y = Z.(x - y) +end + +type scalar = Amount of Money.t | Rate of Z.t [@@deriving equal, compare] +type account_id = string list +type commodity_id = string (* TODO: consider making this UUID *) + +type account = { + id : account_id; + description : string list; + commodity_id : commodity_id; + balance : Money.t; +} + +type bal_assert = { account : account_id; amount : Money.t; labels : Labels.t } + +module Account_id_key = struct + type t = account_id + type comparator_witness +end + +type tx = { + cleared : Date.t option; + commodity_id : commodity_id; + debit : scalar Map.M(Account_id_key).t; + credit : scalar Map.M(Account_id_key).t; + labels : Labels.t; +} + +type item = Tx_item of tx | Bal_assert_item of bal_assert +type ledger = Ledger of item list -- cgit v1.2.3