Skip to main content

librotenado/
chart.rs

1// SPDX-License-Identifier: MIT
2//! The harmonised chart of accounts (spec part 0004) and translations of national charts into it.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7
8use crate::{AccountId, Object};
9
10/// What an account is, in the sense of double-entry bookkeeping.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum AccountKind {
14    /// What the organisation owns or is owed.
15    Asset,
16    /// What it owes.
17    Liability,
18    /// What belongs to its owners or members.
19    Equity,
20    /// What increases the result.
21    Income,
22    /// What decreases the result.
23    Expense,
24    /// Outside the balance sheet and the result: commitments, opening and closing balance sheet accounts.
25    Memo,
26}
27
28impl AccountKind {
29    /// Whether the account normally has a debit balance (assets and expenses) – postings there are positive. Memo
30    /// accounts have no normal side.
31    pub fn debit_normal(self) -> bool {
32        matches!(self, AccountKind::Asset | AccountKind::Expense)
33    }
34}
35
36/// An account: of the harmonised chart, or opened in a set of books to refine one (spec parts 0002, 0004).
37///
38/// Names are not part of an account, as they differ by language: translation data names it by its identity.
39#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
40pub struct Account {
41    /// Its identity, stable across versions of the chart.
42    pub id: AccountId,
43    /// What it is.
44    pub kind: AccountKind,
45    /// The account it refines; none for a top-level account.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub parent: Option<AccountId>,
48    /// Further distinctions the account carries (for example a tax treatment or a non-profit sphere), as
49    /// attribute → value. Attributes are inherited by refining accounts unless they set their own value.
50    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
51    pub attributes: BTreeMap<String, String>,
52}
53
54/// A chart of accounts: a hierarchy of accounts with attributes, under a name and version.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Chart {
57    /// The chart's name, for example `librotenado`.
58    pub name: String,
59    /// Its version.
60    pub version: String,
61    /// The accounts.
62    pub accounts: Vec<Account>,
63}
64
65impl Chart {
66    /// The harmonised chart of version 0.1 (`spec/data/chart.json`).
67    pub fn v0_1() -> Self {
68        crate::from_root(include_bytes!("../../../spec/data/chart.json")).expect("the published chart is valid")
69    }
70}
71
72impl ChartTranslation {
73    /// The translation of the French Plan comptable général, version of 1 January 2026, into the harmonised chart
74    /// 0.1 (`spec/data/translations/fr-pcg.json`).
75    pub fn fr_pcg() -> Self {
76        crate::from_root(include_bytes!("../../../spec/data/translations/fr-pcg.json"))
77            .expect("the published translation is valid")
78    }
79}
80
81impl Object for Account {
82    const TYPE: &'static str = "account";
83}
84
85impl Object for Chart {
86    const TYPE: &'static str = "chart";
87}
88
89impl Object for ChartTranslation {
90    const TYPE: &'static str = "chart_translation";
91}
92
93/// Why a chart is not valid.
94#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95pub enum ChartError {
96    /// Two accounts with the same identity.
97    #[error("account {0} is defined twice")]
98    Duplicate(AccountId),
99    /// A parent that is not in the chart.
100    #[error("account {0} refines {1}, which is not in the chart")]
101    UnknownParent(AccountId, AccountId),
102    /// An account that refines one of a different kind.
103    #[error("account {0} is of a different kind than its parent")]
104    KindMismatch(AccountId),
105    /// Accounts that refine each other in a circle.
106    #[error("account {0} is part of a cycle")]
107    Cycle(AccountId),
108}
109
110impl Chart {
111    /// The account with this identity.
112    pub fn get(&self, id: &AccountId) -> Option<&Account> {
113        self.accounts.iter().find(|a| &a.id == id)
114    }
115
116    /// Checks that identities are unique, parents exist and are of the same kind, and there are no cycles.
117    pub fn validate(&self) -> Result<(), ChartError> {
118        let mut by_id = BTreeMap::new();
119        for account in &self.accounts {
120            if by_id.insert(&account.id, account).is_some() {
121                return Err(ChartError::Duplicate(account.id.clone()));
122            }
123        }
124        for account in &self.accounts {
125            let Some(parent_id) = &account.parent else { continue };
126            let parent =
127                by_id.get(parent_id).ok_or_else(|| ChartError::UnknownParent(account.id.clone(), parent_id.clone()))?;
128            if parent.kind != account.kind {
129                return Err(ChartError::KindMismatch(account.id.clone()));
130            }
131            let mut seen = BTreeSet::from([&account.id]);
132            let mut next = account.parent.as_ref();
133            while let Some(id) = next {
134                if !seen.insert(id) {
135                    return Err(ChartError::Cycle(account.id.clone()));
136                }
137                next = by_id.get(id).and_then(|a| a.parent.as_ref());
138            }
139        }
140        Ok(())
141    }
142
143    /// The account's attributes including those inherited from its parents (its own values win).
144    pub fn attributes(&self, id: &AccountId) -> BTreeMap<String, String> {
145        let mut chain = Vec::new();
146        let mut next = self.get(id);
147        while let Some(account) = next {
148            if chain.len() > self.accounts.len() {
149                break; // a cycle; validate() reports it
150            }
151            chain.push(account);
152            next = account.parent.as_ref().and_then(|p| self.get(p));
153        }
154        chain.iter().rev().flat_map(|a| a.attributes.clone()).collect()
155    }
156}
157
158/// How a national chart of accounts translates into the harmonised one: each national account onto exactly one
159/// harmonised account.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ChartTranslation {
162    /// The national chart, for example `de-skr04`.
163    pub chart: String,
164    /// Its version.
165    pub version: String,
166    /// The harmonised chart and version it translates into, as `name/version`.
167    pub into: String,
168    /// National account number → harmonised account.
169    pub accounts: BTreeMap<String, AccountId>,
170}
171
172/// Why a translation does not fit its chart.
173#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
174pub enum TranslationError {
175    /// The translation targets a different chart or version.
176    #[error("the translation is into {0}, not into this chart")]
177    WrongChart(String),
178    /// A national account maps onto an account the chart does not have.
179    #[error("national account {0} maps onto {1}, which is not in the chart")]
180    UnknownAccount(String, AccountId),
181}
182
183impl ChartTranslation {
184    /// Checks the translation against the harmonised chart. Every national account maps onto exactly one account
185    /// by construction (unambiguous); this checks that those accounts exist.
186    pub fn validate(&self, chart: &Chart) -> Result<(), TranslationError> {
187        let target = format!("{}/{}", chart.name, chart.version);
188        if self.into != target {
189            return Err(TranslationError::WrongChart(self.into.clone()));
190        }
191        match self.accounts.iter().find(|(_, id)| chart.get(id).is_none()) {
192            Some((code, id)) => Err(TranslationError::UnknownAccount(code.clone(), id.clone())),
193            None => Ok(()),
194        }
195    }
196
197    /// Whether the translation can be reversed: no two national accounts map onto the same harmonised account.
198    /// Where this fails, the harmonised chart is coarser than the national one and has to grow (the model grows,
199    /// translations do not bend).
200    pub fn coarser_accounts(&self) -> Vec<AccountId> {
201        let mut seen = BTreeSet::new();
202        let mut shared = BTreeSet::new();
203        for id in self.accounts.values() {
204            if !seen.insert(id) {
205                shared.insert(id.clone());
206            }
207        }
208        shared.into_iter().collect()
209    }
210
211    /// The national account for a harmonised one, if the translation is reversible there.
212    pub fn national(&self, id: &AccountId) -> Option<&str> {
213        let mut matches = self.accounts.iter().filter(|(_, target)| *target == id);
214        match (matches.next(), matches.next()) {
215            (Some((code, _)), None) => Some(code),
216            _ => None,
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    use crate::booking::tests::account as id;
226
227    // Test accounts by number: 1 bank, 2 office expenses, 3 office software; others as the tests need.
228    const BANK: u8 = 1;
229    const OFFICE: u8 = 2;
230    const SOFTWARE: u8 = 3;
231
232    fn account(n: u8, kind: AccountKind, parent: Option<u8>, attrs: &[(&str, &str)]) -> Account {
233        Account {
234            id: id(n),
235            kind,
236            parent: parent.map(id),
237            attributes: attrs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
238        }
239    }
240
241    fn chart() -> Chart {
242        Chart {
243            name: "librotenado".into(),
244            version: "0.1".into(),
245            accounts: vec![
246                account(BANK, AccountKind::Asset, None, &[]),
247                account(OFFICE, AccountKind::Expense, None, &[("tax", "input-vat-deductible")]),
248                account(SOFTWARE, AccountKind::Expense, Some(OFFICE), &[("useful-life", "3y")]),
249            ],
250        }
251    }
252
253    #[test]
254    fn a_valid_chart_inherits_attributes() {
255        let c = chart();
256        assert_eq!(c.validate(), Ok(()));
257        let attrs = c.attributes(&id(SOFTWARE));
258        assert_eq!(attrs.get("tax").map(String::as_str), Some("input-vat-deductible"));
259        assert_eq!(attrs.get("useful-life").map(String::as_str), Some("3y"));
260        assert!(AccountKind::Expense.debit_normal() && !AccountKind::Income.debit_normal());
261    }
262
263    #[test]
264    fn the_chart_of_version_0_1_with_the_pcg() {
265        let chart = Chart::v0_1();
266        assert_eq!(chart.validate(), Ok(()));
267        let roots: Vec<AccountKind> = chart.accounts.iter().filter(|a| a.parent.is_none()).map(|a| a.kind).collect();
268        assert_eq!(roots.len(), 6, "one root per kind");
269
270        let pcg = ChartTranslation::fr_pcg();
271        assert_eq!(pcg.validate(&chart), Ok(()));
272        assert!(pcg.coarser_accounts().is_empty(), "every PCG account has an account of its own");
273        let kind = |number: &str| chart.get(&pcg.accounts[number]).map(|a| a.kind);
274        assert_eq!(kind("512"), Some(AccountKind::Asset), "banks");
275        assert_eq!(kind("519"), Some(AccountKind::Liability), "bank overdrafts, under banks in the PCG");
276        assert_eq!(kind("401"), Some(AccountKind::Liability));
277        assert_eq!(kind("409"), Some(AccountKind::Asset), "suppliers with debit balances");
278        assert_eq!(kind("44566"), Some(AccountKind::Asset), "deductible VAT");
279        assert_eq!(kind("44571"), Some(AccountKind::Liability), "VAT collected");
280        assert_eq!(kind("101"), Some(AccountKind::Equity));
281        assert_eq!(kind("6064"), Some(AccountKind::Expense));
282        assert_eq!(kind("706"), Some(AccountKind::Income));
283        assert_eq!(kind("890"), Some(AccountKind::Memo));
284        assert_eq!(pcg.national(&pcg.accounts["6064"]), Some("6064"), "reversible");
285    }
286
287    #[test]
288    fn finds_broken_hierarchies() {
289        let mut c = chart();
290        c.accounts.push(account(BANK, AccountKind::Asset, None, &[]));
291        assert_eq!(c.validate(), Err(ChartError::Duplicate(id(BANK))));
292
293        let mut c = chart();
294        c.accounts.push(account(10, AccountKind::Asset, Some(11), &[]));
295        assert_eq!(c.validate(), Err(ChartError::UnknownParent(id(10), id(11))));
296
297        let mut c = chart();
298        c.accounts.push(account(12, AccountKind::Expense, Some(BANK), &[]));
299        assert_eq!(c.validate(), Err(ChartError::KindMismatch(id(12))));
300
301        let mut c = chart();
302        c.accounts.push(account(13, AccountKind::Asset, Some(14), &[]));
303        c.accounts.push(account(14, AccountKind::Asset, Some(13), &[]));
304        assert_eq!(c.validate(), Err(ChartError::Cycle(id(13))));
305    }
306
307    #[test]
308    fn translations_must_hit_the_chart_and_show_where_the_model_must_grow() {
309        let c = chart();
310        let mut t = ChartTranslation {
311            chart: "example-national".into(),
312            version: "2026".into(),
313            into: "librotenado/0.1".into(),
314            accounts: BTreeMap::from([("1800".into(), id(BANK)), ("6815".into(), id(OFFICE))]),
315        };
316        assert_eq!(t.validate(&c), Ok(()));
317        assert!(t.coarser_accounts().is_empty());
318        assert_eq!(t.national(&id(BANK)), Some("1800"));
319
320        t.accounts.insert("6816".into(), id(OFFICE));
321        assert_eq!(t.coarser_accounts(), [id(OFFICE)]);
322        assert_eq!(t.national(&id(OFFICE)), None);
323
324        t.accounts.insert("9999".into(), id(99));
325        assert_eq!(t.validate(&c), Err(TranslationError::UnknownAccount("9999".into(), id(99))));
326    }
327}