diff options
| author | Rutger Broekhoff | 2026-03-09 22:29:18 +0100 |
|---|---|---|
| committer | Rutger Broekhoff | 2026-03-09 22:29:18 +0100 |
| commit | c90ff5253efd858a2bf0c20eaa2ee9763a402783 (patch) | |
| tree | d1c5ab837ece7034d882368f1beeeb56b934ac4d /lib/account | |
| parent | 2f94997e2befc70ada84bd04a56831efe2747220 (diff) | |
| download | rdcapsis-c90ff5253efd858a2bf0c20eaa2ee9763a402783.tar.gz rdcapsis-c90ff5253efd858a2bf0c20eaa2ee9763a402783.zip | |
oha!
Diffstat (limited to 'lib/account')
| -rw-r--r-- | lib/account/account.ml | 121 | ||||
| -rw-r--r-- | lib/account/type.ml | 15 | ||||
| -rw-r--r-- | lib/account/type_hierarchy.ml | 83 | ||||
| -rw-r--r-- | lib/account/type_hierarchy.mli | 15 |
4 files changed, 234 insertions, 0 deletions
diff --git a/lib/account/account.ml b/lib/account/account.ml new file mode 100644 index 0000000..0ef3d28 --- /dev/null +++ b/lib/account/account.ml | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | open Prelude | ||
| 2 | |||
| 3 | (* TODO: Decide on public interface. Probably should not include | ||
| 4 | functions such as [unsafe_update_bal], but having [Balanced_batch] | ||
| 5 | under [Account] also feels a bit awkward. *) | ||
| 6 | |||
| 7 | module Path = struct | ||
| 8 | type t = string list [@@deriving compare, sexp] | ||
| 9 | end | ||
| 10 | |||
| 11 | (** Ensures that only accounts with valid type hierarchies can be constructed. | ||
| 12 | *) | ||
| 13 | module Kernel : sig | ||
| 14 | type extra = { description : string } | ||
| 15 | |||
| 16 | type t = private { type_ : Type.t; extra : extra; core : core } | ||
| 17 | |||
| 18 | and core = | ||
| 19 | (* Balance in some commodity *) | ||
| 20 | | Leaf of Money.Commodity_id.t * Money.Diff.t | ||
| 21 | | Subtree of t String.Map.t | ||
| 22 | |||
| 23 | val make : Type.t -> extra -> core -> t option | ||
| 24 | end = struct | ||
| 25 | type extra = { description : string } | ||
| 26 | |||
| 27 | type t = { type_ : Type.t; extra : extra; core : core } | ||
| 28 | |||
| 29 | and core = | ||
| 30 | (* Balance in some commodity *) | ||
| 31 | | Leaf of Money.Commodity_id.t * Money.Diff.t | ||
| 32 | | Subtree of t String.Map.t | ||
| 33 | |||
| 34 | let make type_ extra : core -> t option = function | ||
| 35 | | Leaf (comm, bal) -> Some { type_; extra; core = Leaf (comm, bal) } | ||
| 36 | | Subtree children -> | ||
| 37 | if | ||
| 38 | Map.for_all children ~f:(fun subacc -> | ||
| 39 | Type.is_super subacc.type_ ~super:type_ ~strict:false) | ||
| 40 | then Some { type_; extra; core = Subtree children } | ||
| 41 | else None | ||
| 42 | end | ||
| 43 | |||
| 44 | type t = Kernel.t | ||
| 45 | |||
| 46 | type update_bal_error = | ||
| 47 | | Empty_path | ||
| 48 | | Unmatching_commodity_id of { in_account : Money.Commodity_id.t } | ||
| 49 | | Not_a_leaf_account | ||
| 50 | | Not_a_subtree_account | ||
| 51 | | Not_found (* TODO: report at which level *) | ||
| 52 | |||
| 53 | (* We do not necessarily expect [aid] to be a valid path, as we | ||
| 54 | always do for paths in the type hierarchy. The difference is that | ||
| 55 | the type hierarchy is fixed, while the account hierarchy can | ||
| 56 | change over the course of a year. *) | ||
| 57 | let rec unsafe_update_bal_aux (aid : Path.t) (dc : Money.Debit_credit.t) | ||
| 58 | (by_amount : Money.Amount.t) (in_comm : Money.Commodity_id.t) (w : t) : | ||
| 59 | (t, update_bal_error) result = | ||
| 60 | match (aid, w.core) with | ||
| 61 | | [], Leaf (comm, bal) -> | ||
| 62 | if [%equal: Money.Commodity_id.t] in_comm comm then (* slay! *) | ||
| 63 | let core' = | ||
| 64 | Kernel.Leaf | ||
| 65 | ( comm, | ||
| 66 | Money.Diff.(bal + of_amount by_amount dc (Type.polarity w.type_)) | ||
| 67 | ) | ||
| 68 | in | ||
| 69 | Ok (Option.value_exn (Kernel.make w.type_ w.extra core')) | ||
| 70 | else (* bruh *) | ||
| 71 | Error (Unmatching_commodity_id { in_account = comm }) | ||
| 72 | | [], Subtree _ -> Error Not_a_leaf_account | ||
| 73 | | _ :: _, Leaf _ -> Error Not_a_subtree_account | ||
| 74 | | aid0 :: aid', Subtree subaccs -> ( | ||
| 75 | match Map.find subaccs aid0 with | ||
| 76 | | None -> Error Not_found | ||
| 77 | | Some subacc -> | ||
| 78 | let open Result.Let_syntax in | ||
| 79 | let%bind subacc' = | ||
| 80 | (* TODO: when reporting at which level Not_found fails, | ||
| 81 | we want to make sure that we extend the information | ||
| 82 | in the error with the current aid0 (so we recover a | ||
| 83 | full path to where the account is missing *) | ||
| 84 | unsafe_update_bal_aux aid' dc by_amount in_comm subacc | ||
| 85 | in | ||
| 86 | let core' = | ||
| 87 | Kernel.Subtree (Map.set subaccs ~key:aid0 ~data:subacc') | ||
| 88 | in | ||
| 89 | Ok (Option.value_exn (Kernel.make w.type_ w.extra core'))) | ||
| 90 | |||
| 91 | (* Unfortunate but true, there has to be some kind of a root account | ||
| 92 | with no type :) *) | ||
| 93 | type root = t String.Map.t | ||
| 94 | |||
| 95 | let unsafe_update_bal (aid : Path.t) (dc : Money.Debit_credit.t) | ||
| 96 | (by_amount : Money.Amount.t) (in_comm : Money.Commodity_id.t) (w : root) : | ||
| 97 | (root, update_bal_error) result = | ||
| 98 | match aid with | ||
| 99 | | [] -> Error Empty_path | ||
| 100 | | aid0 :: aid' -> ( | ||
| 101 | match Map.find w aid0 with | ||
| 102 | | None -> Error Not_found | ||
| 103 | | Some subacc -> | ||
| 104 | let open Result.Let_syntax in | ||
| 105 | (* TODO: when reporting at which level Not_found fails, | ||
| 106 | we want to make sure that we extend the information | ||
| 107 | in the error with the current aid0 (so we recover a | ||
| 108 | full path to where the account is missing *) | ||
| 109 | let%bind subacc' = | ||
| 110 | unsafe_update_bal_aux aid' dc by_amount in_comm subacc | ||
| 111 | in | ||
| 112 | Ok (Map.set w ~key:aid0 ~data:subacc')) | ||
| 113 | |||
| 114 | module Balanced_batch_acc_paths = Balanced_batch.Make (Path) | ||
| 115 | |||
| 116 | let apply_balanced_batch (b : Balanced_batch_acc_paths.t) (w : root) = | ||
| 117 | Map.fold_result (Balanced_batch_acc_paths.entries b) ~init:w | ||
| 118 | ~f:(fun ~key:aid ~(data : Balanced_batch_acc_paths.entry) w -> | ||
| 119 | let open Result.Let_syntax in | ||
| 120 | let%bind w = unsafe_update_bal aid data.dc data.amount data.commodity w in | ||
| 121 | Ok w) | ||
diff --git a/lib/account/type.ml b/lib/account/type.ml new file mode 100644 index 0000000..ad7a46e --- /dev/null +++ b/lib/account/type.ml | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | type t = Type_hierarchy.path [@@deriving equal] | ||
| 2 | |||
| 3 | let rec base (t : t) : t = | ||
| 4 | match Type_hierarchy.super t with | ||
| 5 | | None -> (* [t] is a base type *) t | ||
| 6 | | Some t' -> base t' | ||
| 7 | |||
| 8 | (** [a] is a (strict) supertype of [b] *) | ||
| 9 | let is_super t ~super ~strict = | ||
| 10 | Type_hierarchy.is_prefix super ~prefix:t | ||
| 11 | && | ||
| 12 | (* strict → t ≠ super *) | ||
| 13 | ((not strict) || not ([%equal: t] t super)) | ||
| 14 | |||
| 15 | let polarity = Type_hierarchy.polarity | ||
diff --git a/lib/account/type_hierarchy.ml b/lib/account/type_hierarchy.ml new file mode 100644 index 0000000..7f27830 --- /dev/null +++ b/lib/account/type_hierarchy.ml | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | open Prelude | ||
| 2 | |||
| 3 | type tree = { car : tree String.Map.t } | ||
| 4 | type trunk = (Money.polarity * tree) String.Map.t | ||
| 5 | type path = Base of string | Sub of string * path | ||
| 6 | |||
| 7 | let rec path_to_list ?(suffix = []) p = | ||
| 8 | match p with | ||
| 9 | | Base x -> x :: suffix | ||
| 10 | | Sub (x, p') -> path_to_list ~suffix:(x :: suffix) p' | ||
| 11 | |||
| 12 | let canonical : trunk = | ||
| 13 | let make alist : tree = { car = String.Map.of_alist_exn alist } in | ||
| 14 | String.Map.of_alist_exn | ||
| 15 | [ | ||
| 16 | ( "Asset", | ||
| 17 | ( Money.Increase_on_debit, | ||
| 18 | make | ||
| 19 | [ | ||
| 20 | ("Accounts_receivable", make []); | ||
| 21 | ("Bank", make [ ("Savings", make []); ("Checking", make []) ]); | ||
| 22 | ("Cash", make []); | ||
| 23 | ("Mutual_fund", make []); | ||
| 24 | ("Stock", make []); | ||
| 25 | ] ) ); | ||
| 26 | ("Equity", (Money.Increase_on_credit, make [])); | ||
| 27 | ("Expense", (Money.Increase_on_debit, make [])); | ||
| 28 | ("Income", (Money.Increase_on_credit, make [])); | ||
| 29 | ( "Liability", | ||
| 30 | ( Money.Increase_on_credit, | ||
| 31 | make [ ("Accounts_payable", make []); ("Credit", make []) ] ) ); | ||
| 32 | ] | ||
| 33 | |||
| 34 | (* In this module, only the following two function entertains the | ||
| 35 | option that the given path may not be valid (i.e., it does not | ||
| 36 | throw an exception for invalid paths). *) | ||
| 37 | let rec get_node : path -> tree option = | ||
| 38 | let open Option.Let_syntax in | ||
| 39 | function | ||
| 40 | | Base x -> | ||
| 41 | let%map _, t = Map.find canonical x in | ||
| 42 | t | ||
| 43 | | Sub (t, p) -> | ||
| 44 | let%bind super = get_node p in | ||
| 45 | Map.find super.car t | ||
| 46 | |||
| 47 | (** Always gives [Some] under valid paths, giving a list of valid paths *) | ||
| 48 | let children (p : path) : path list = | ||
| 49 | let node = Option.value_exn (get_node p) in | ||
| 50 | List.map (Map.keys node.car) ~f:(fun sub -> Sub (sub, p)) | ||
| 51 | |||
| 52 | let sub (p : path) (name : string) : path option = | ||
| 53 | let node = Option.value_exn (get_node p) in | ||
| 54 | if Map.mem node.car name then Some (Sub (name, p)) else None | ||
| 55 | |||
| 56 | let super : path -> path option = function | ||
| 57 | | Base _ -> None | ||
| 58 | | Sub (_, super) -> Some super | ||
| 59 | |||
| 60 | let rec equal_path p1 p2 = | ||
| 61 | match (p1, p2) with | ||
| 62 | | Base x1, Base x2 -> String.(x1 = x2) | ||
| 63 | | Sub (x1, p1'), Sub (x2, p2') -> String.(x1 = x2) && equal_path p1' p2' | ||
| 64 | | _, _ -> false | ||
| 65 | |||
| 66 | let is_prefix (p : path) ~(prefix : path) : bool = | ||
| 67 | List.is_prefix (path_to_list p) ~prefix:(path_to_list prefix) | ||
| 68 | ~equal:String.equal | ||
| 69 | |||
| 70 | let rec polarity = function | ||
| 71 | | Base x -> | ||
| 72 | let pol, _ = Map.find_exn canonical x in | ||
| 73 | pol | ||
| 74 | | Sub (_, p') -> polarity p' | ||
| 75 | |||
| 76 | let assert_valid acc = | ||
| 77 | match get_node acc with None -> failwith "invalid base account" | _ -> acc | ||
| 78 | |||
| 79 | let asset = Base "Asset" |> assert_valid | ||
| 80 | let equity = Base "Equity" |> assert_valid | ||
| 81 | let expense = Base "Expense" |> assert_valid | ||
| 82 | let income = Base "Income" |> assert_valid | ||
| 83 | let liability = Base "Liability" |> assert_valid | ||
diff --git a/lib/account/type_hierarchy.mli b/lib/account/type_hierarchy.mli new file mode 100644 index 0000000..c346628 --- /dev/null +++ b/lib/account/type_hierarchy.mli | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | type path | ||
| 2 | (** The 'kernel' of account types: a hierarchy of valid types. A valid type is a | ||
| 3 | path that leads to a node in the hierarchy. *) | ||
| 4 | |||
| 5 | val children : path -> path list | ||
| 6 | val sub : path -> string -> path option | ||
| 7 | val super : path -> path option | ||
| 8 | val equal_path : path -> path -> bool | ||
| 9 | val is_prefix : path -> prefix:path -> bool | ||
| 10 | val polarity : path -> Money.polarity | ||
| 11 | val asset : path | ||
| 12 | val equity : path | ||
| 13 | val expense : path | ||
| 14 | val income : path | ||
| 15 | val liability : path | ||