Skip to main content

datasynth_core/
pcg_loader.rs

1//! Loader for the comprehensive Plan Comptable Général (PCG) 2024 structure.
2//!
3//! Uses the official PCG tree from [arrhes/PCG](https://github.com/arrhes/PCG) to build
4//! a Chart of Accounts with correct French account numbers and labels.
5
6use serde::Deserialize;
7
8use crate::models::{
9    AccountSubType, AccountType, ChartOfAccounts, CoAComplexity, GLAccount, IndustrySector,
10};
11
12/// Root of the PCG JSON: array of top-level classes (1–8).
13pub type PcgRoot = Vec<PcgNode>;
14
15/// One node in the PCG tree (class, subclass, or account).
16#[derive(Debug, Clone, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub struct PcgNode {
19    pub number: u32,
20    pub label: String,
21    #[serde(default)]
22    pub system: String,
23    #[serde(default)]
24    pub accounts: Vec<PcgNode>,
25}
26
27/// Embedded PCG 2024 JSON (from https://github.com/arrhes/PCG).
28const PCG_2024_JSON: &str = include_str!("../resources/pcg_2024.json");
29
30/// Load the PCG 2024 tree from the embedded JSON.
31pub fn load_pcg_2024() -> Result<PcgRoot, serde_json::Error> {
32    serde_json::from_str(PCG_2024_JSON)
33}
34
35/// Flatten the PCG tree into (account_code, label, class) for postable accounts.
36/// Includes nodes that are "base" or "developed", or leaves (empty accounts).
37/// Stops when we have at least `max_accounts` (for complexity capping).
38fn flatten_pcg(
39    nodes: &[PcgNode],
40    class_from_prefix: u8,
41    out: &mut Vec<(u32, String, u8)>,
42    max_accounts: usize,
43) {
44    if out.len() >= max_accounts {
45        return;
46    }
47    for node in nodes {
48        let class = if node.number < 10 {
49            node.number as u8
50        } else {
51            class_from_prefix
52        };
53        let is_leaf = node.accounts.is_empty();
54        let is_postable = is_leaf
55            || node.system == "base"
56            || node.system == "developed"
57            || (node.system == "condensed" && node.accounts.is_empty());
58        if is_postable {
59            out.push((node.number, node.label.clone(), class));
60        }
61        if !node.accounts.is_empty() && out.len() < max_accounts {
62            flatten_pcg(&node.accounts, class, out, max_accounts);
63        }
64    }
65}
66
67/// Normalize a PCG account number into our 6-digit GL format.
68///
69/// The PCG tree includes intermediate nodes like `41` (tiers) or `411` (clients).
70/// For our generators and exports we use a 6-digit "base" account format; we
71/// therefore right-pad shorter prefixes with zeros:
72/// - 41 → 410000
73/// - 411 → 411000
74/// - 6011 → 601100
75fn normalize_pcg_account_number(number: u32) -> String {
76    let s = number.to_string();
77    if s.len() >= 6 {
78        return s;
79    }
80    let pow = (6 - s.len()) as u32;
81    let factor = 10u32.pow(pow);
82    format!("{:06}", number * factor)
83}
84
85/// Extract 2-digit PCG subclass: 1011→10, 164→16, 4111→41
86fn pcg_subclass(number: u32) -> u32 {
87    let mut n = number;
88    while n >= 100 {
89        n /= 10;
90    }
91    n
92}
93
94/// Extract 3-digit PCG account group: 1011→101, 164→164, 4111→411
95fn pcg_account_group(number: u32) -> u32 {
96    let mut n = number;
97    while n >= 1000 {
98        n /= 10;
99    }
100    n
101}
102
103/// Map PCG class and account number to our AccountType and AccountSubType.
104fn pcg_to_account_type(class: u8, number: u32) -> (AccountType, AccountSubType) {
105    use AccountSubType::{
106        AccountsPayable, AccountsReceivable, AccruedLiabilities, AccumulatedDepreciation, Cash,
107        CommonStock, FixedAssets, Inventory, LongTermDebt, OperatingExpenses, OtherAssets,
108        OtherLiabilities, ProductRevenue, RetainedEarnings, SuspenseClearing,
109    };
110    use AccountType::{Asset, Equity, Expense, Liability, Revenue};
111    let sub = pcg_subclass(number);
112    match class {
113        1 => {
114            if (10..=14).contains(&sub) {
115                let group = pcg_account_group(number);
116                if (101..=109).contains(&group) {
117                    (Equity, CommonStock)
118                } else {
119                    (Equity, RetainedEarnings)
120                }
121            } else if sub == 15 {
122                (Liability, AccruedLiabilities)
123            } else if (16..=17).contains(&sub) {
124                (Liability, LongTermDebt)
125            } else {
126                (Liability, OtherLiabilities)
127            }
128        }
129        2 => {
130            if (28..=29).contains(&sub) {
131                (Asset, AccumulatedDepreciation)
132            } else {
133                (Asset, FixedAssets)
134            }
135        }
136        3 => (Asset, Inventory),
137        4 => {
138            if sub == 40 {
139                (Liability, AccountsPayable)
140            } else if sub == 41 {
141                (Asset, AccountsReceivable)
142            } else if sub == 42 {
143                (Liability, AccruedLiabilities)
144            } else {
145                (Liability, OtherLiabilities)
146            }
147        }
148        5 => (Asset, Cash),
149        6 => (Expense, OperatingExpenses),
150        7 => (Revenue, ProductRevenue),
151        8 => (Asset, SuspenseClearing),
152        _ => (Asset, OtherAssets),
153    }
154}
155
156/// Build a Chart of Accounts from the comprehensive PCG 2024 structure.
157/// Respects `complexity` by limiting the number of accounts (Small ~100, Medium ~400, Large up to full tree).
158pub fn build_chart_of_accounts_from_pcg_2024(
159    complexity: CoAComplexity,
160    industry: IndustrySector,
161) -> Result<ChartOfAccounts, serde_json::Error> {
162    let root = load_pcg_2024()?;
163    let max_accounts = complexity.target_count();
164    let mut flat = Vec::with_capacity(max_accounts.min(5000));
165    for class_node in &root {
166        let class = class_node.number as u8;
167        flatten_pcg(&class_node.accounts, class, &mut flat, max_accounts);
168    }
169
170    let coa_id = format!("COA_PCG_2024_{:?}_{}", industry, max_accounts);
171    let name = format!("Plan Comptable Général 2024 – {:?}", industry);
172    let mut coa = ChartOfAccounts::new(coa_id, name, "FR".to_string(), industry, complexity);
173    coa.account_format = "######".to_string();
174
175    for (number, label, class) in flat {
176        let code = normalize_pcg_account_number(number);
177        let (acc_type, sub_type) = pcg_to_account_type(class, number);
178        let mut account = GLAccount::new(code, label, acc_type, sub_type);
179        account.requires_cost_center = acc_type == AccountType::Expense;
180        if class == 8 {
181            account.is_suspense_account = true;
182        }
183        coa.add_account(account);
184    }
185
186    Ok(coa)
187}
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_load_pcg_2024() {
196        let root = load_pcg_2024().unwrap();
197        assert_eq!(root.len(), 8); // Classes 1-8
198        assert_eq!(root[0].number, 1);
199        assert_eq!(root[0].label, "Comptes de capitaux");
200    }
201
202    #[test]
203    fn test_pcg_subclass() {
204        assert_eq!(super::pcg_subclass(10), 10);
205        assert_eq!(super::pcg_subclass(101), 10);
206        assert_eq!(super::pcg_subclass(1011), 10);
207        assert_eq!(super::pcg_subclass(164), 16);
208        assert_eq!(super::pcg_subclass(4111), 41);
209        assert_eq!(super::pcg_subclass(28), 28);
210        assert_eq!(super::pcg_subclass(281), 28);
211    }
212
213    #[test]
214    fn test_pcg_account_group() {
215        assert_eq!(super::pcg_account_group(101), 101);
216        assert_eq!(super::pcg_account_group(1011), 101);
217        assert_eq!(super::pcg_account_group(10131), 101);
218        assert_eq!(super::pcg_account_group(164), 164);
219        assert_eq!(super::pcg_account_group(4111), 411);
220    }
221
222    #[test]
223    fn test_pcg_to_account_type_multidigit() {
224        use crate::models::{AccountSubType, AccountType};
225        // Class 1: 1011 (Capital souscrit) should be Equity/CommonStock, not AccruedLiabilities
226        let (ty, sub) = super::pcg_to_account_type(1, 1011);
227        assert_eq!(ty, AccountType::Equity);
228        assert_eq!(sub, AccountSubType::CommonStock);
229
230        // 129 (Résultat) should be Equity/RetainedEarnings
231        let (ty, sub) = super::pcg_to_account_type(1, 129);
232        assert_eq!(ty, AccountType::Equity);
233        assert_eq!(sub, AccountSubType::RetainedEarnings);
234
235        // 1641 (Emprunts) should be Liability/LongTermDebt
236        let (ty, sub) = super::pcg_to_account_type(1, 1641);
237        assert_eq!(ty, AccountType::Liability);
238        assert_eq!(sub, AccountSubType::LongTermDebt);
239
240        // 151 (Provisions) should be Liability/AccruedLiabilities
241        let (ty, sub) = super::pcg_to_account_type(1, 151);
242        assert_eq!(ty, AccountType::Liability);
243        assert_eq!(sub, AccountSubType::AccruedLiabilities);
244
245        // Class 2: 2815 (Amort. immob.) should be AccumulatedDepreciation
246        let (ty, sub) = super::pcg_to_account_type(2, 2815);
247        assert_eq!(ty, AccountType::Asset);
248        assert_eq!(sub, AccountSubType::AccumulatedDepreciation);
249
250        // Class 4: 4111 (Clients) should be AccountsReceivable
251        let (ty, sub) = super::pcg_to_account_type(4, 4111);
252        assert_eq!(ty, AccountType::Asset);
253        assert_eq!(sub, AccountSubType::AccountsReceivable);
254
255        // 4011 (Fournisseurs) should be AccountsPayable
256        let (ty, sub) = super::pcg_to_account_type(4, 4011);
257        assert_eq!(ty, AccountType::Liability);
258        assert_eq!(sub, AccountSubType::AccountsPayable);
259
260        // 421 (Personnel) should be AccruedLiabilities
261        let (ty, sub) = super::pcg_to_account_type(4, 421);
262        assert_eq!(ty, AccountType::Liability);
263        assert_eq!(sub, AccountSubType::AccruedLiabilities);
264    }
265
266    #[test]
267    fn test_build_coa_from_pcg() {
268        let coa = build_chart_of_accounts_from_pcg_2024(
269            CoAComplexity::Small,
270            IndustrySector::Manufacturing,
271        )
272        .unwrap();
273        assert_eq!(coa.country, "FR");
274        assert!(coa.account_count() >= 50);
275        assert!(coa.account_count() <= 150);
276    }
277}