1use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7
8use crate::{AccountId, Object};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum AccountKind {
14 Asset,
16 Liability,
18 Equity,
20 Income,
22 Expense,
24 Memo,
26}
27
28impl AccountKind {
29 pub fn debit_normal(self) -> bool {
32 matches!(self, AccountKind::Asset | AccountKind::Expense)
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
40pub struct Account {
41 pub id: AccountId,
43 pub kind: AccountKind,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub parent: Option<AccountId>,
48 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
51 pub attributes: BTreeMap<String, String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Chart {
57 pub name: String,
59 pub version: String,
61 pub accounts: Vec<Account>,
63}
64
65impl Chart {
66 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 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95pub enum ChartError {
96 #[error("account {0} is defined twice")]
98 Duplicate(AccountId),
99 #[error("account {0} refines {1}, which is not in the chart")]
101 UnknownParent(AccountId, AccountId),
102 #[error("account {0} is of a different kind than its parent")]
104 KindMismatch(AccountId),
105 #[error("account {0} is part of a cycle")]
107 Cycle(AccountId),
108}
109
110impl Chart {
111 pub fn get(&self, id: &AccountId) -> Option<&Account> {
113 self.accounts.iter().find(|a| &a.id == id)
114 }
115
116 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 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; }
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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ChartTranslation {
162 pub chart: String,
164 pub version: String,
166 pub into: String,
168 pub accounts: BTreeMap<String, AccountId>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
174pub enum TranslationError {
175 #[error("the translation is into {0}, not into this chart")]
177 WrongChart(String),
178 #[error("national account {0} maps onto {1}, which is not in the chart")]
180 UnknownAccount(String, AccountId),
181}
182
183impl ChartTranslation {
184 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 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 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 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}