summaryrefslogtreecommitdiffstats
path: root/lib/account
diff options
context:
space:
mode:
authorRutger Broekhoff2026-03-09 22:29:18 +0100
committerRutger Broekhoff2026-03-09 22:29:18 +0100
commitc90ff5253efd858a2bf0c20eaa2ee9763a402783 (patch)
treed1c5ab837ece7034d882368f1beeeb56b934ac4d /lib/account
parent2f94997e2befc70ada84bd04a56831efe2747220 (diff)
downloadrdcapsis-c90ff5253efd858a2bf0c20eaa2ee9763a402783.tar.gz
rdcapsis-c90ff5253efd858a2bf0c20eaa2ee9763a402783.zip
oha!
Diffstat (limited to 'lib/account')
-rw-r--r--lib/account/account.ml121
-rw-r--r--lib/account/type.ml15
-rw-r--r--lib/account/type_hierarchy.ml83
-rw-r--r--lib/account/type_hierarchy.mli15
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 @@
1open 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
7module Path = struct
8 type t = string list [@@deriving compare, sexp]
9end
10
11(** Ensures that only accounts with valid type hierarchies can be constructed.
12*)
13module 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
24end = 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
42end
43
44type t = Kernel.t
45
46type 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. *)
57let 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 :) *)
93type root = t String.Map.t
94
95let 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
114module Balanced_batch_acc_paths = Balanced_batch.Make (Path)
115
116let 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 @@
1type t = Type_hierarchy.path [@@deriving equal]
2
3let 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] *)
9let 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
15let 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 @@
1open Prelude
2
3type tree = { car : tree String.Map.t }
4type trunk = (Money.polarity * tree) String.Map.t
5type path = Base of string | Sub of string * path
6
7let 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
12let 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). *)
37let 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 *)
48let 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
52let 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
56let super : path -> path option = function
57 | Base _ -> None
58 | Sub (_, super) -> Some super
59
60let 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
66let 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
70let rec polarity = function
71 | Base x ->
72 let pol, _ = Map.find_exn canonical x in
73 pol
74 | Sub (_, p') -> polarity p'
75
76let assert_valid acc =
77 match get_node acc with None -> failwith "invalid base account" | _ -> acc
78
79let asset = Base "Asset" |> assert_valid
80let equity = Base "Equity" |> assert_valid
81let expense = Base "Expense" |> assert_valid
82let income = Base "Income" |> assert_valid
83let 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 @@
1type 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
5val children : path -> path list
6val sub : path -> string -> path option
7val super : path -> path option
8val equal_path : path -> path -> bool
9val is_prefix : path -> prefix:path -> bool
10val polarity : path -> Money.polarity
11val asset : path
12val equity : path
13val expense : path
14val income : path
15val liability : path