datasynth_group/aggregate/translation/classify.rs
1//! IAS 21 monetary / non-monetary classifier — Task 6.1.
2//!
3//! Maps a GL account code to the rate-basis category it must be
4//! translated under per IAS 21 (and ASC 830 — both frameworks classify
5//! the items below identically for translation purposes).
6//!
7//! # Classification rules
8//!
9//! The classifier tries **specific account codes first** (every named
10//! constant from [`datasynth_core::accounts`]) and then falls back to
11//! **leading-digit ranges** for codes the orchestrator emits beyond the
12//! canonical set:
13//!
14//! | Range | Type | Rate basis |
15//! |-----------|-----------------------------------|----------------------|
16//! | 1000–1199 | Cash, AR, IC AR, securities | `BsMonetary` |
17//! | 1200–1299 | Inventory | `BsNonMonetary` |
18//! | 1300–1399 | Prepaid expenses | `BsNonMonetary` |
19//! | 1400–1499 | Other current assets (mixed) | `BsMonetary` * |
20//! | 1500–1899 | Fixed assets, accumulated deprec. | `BsNonMonetary` |
21//! | 1900–1999 | Intangibles, goodwill | `BsNonMonetary` |
22//! | 2xxx | All liabilities | `BsMonetary` |
23//! | 3xxx | Equity | `Equity` |
24//! | 4xxx | Revenue | `PlRevenue` |
25//! | 5xxx | COGS | `PlExpense` |
26//! | 6xxx | Operating expenses | `PlExpense` |
27//! | 7xxx | Interest, other expense | `PlExpense` |
28//! | 8xxx | OCI | `PlOci` |
29//! | 9xxx | Memo / suspense | `BsMonetary` (default)|
30//!
31//! \* The 1400–1499 block is mostly monetary in DataSynth's chart
32//! (tax receivable 1460, derivative asset 1450), with a small tail of
33//! non-monetary items (`WIP 1420`, `FINISHED_GOODS 1410`). The named
34//! constants for the latter override the range default.
35//!
36//! # Framework parameter
37//!
38//! `framework` is currently **informational** — IFRS (IAS 21), US GAAP
39//! (ASC 830), French GAAP, and German GAAP all classify the canonical
40//! GL accounts in the same buckets for translation purposes. The
41//! parameter is reserved for future framework-specific edge cases (for
42//! example, certain HGB-specific equity reserves or PCG long-term
43//! provisions that may need bespoke historical-rate handling).
44//!
45//! # Empty / malformed codes
46//!
47//! An empty or non-numeric account code falls through to
48//! [`TranslationAccountType::BsMonetary`] — the safest default for an
49//! unrecognised code (closing rate is the IAS 21 fall-back when no
50//! historical-cost basis is documented).
51
52use serde::{Deserialize, Serialize};
53
54use datasynth_standards::framework::AccountingFramework;
55
56use datasynth_core::accounts::{
57 cash_accounts, control_accounts, equity_accounts, expense_accounts, intangible_accounts,
58 liability_accounts, manufacturing_accounts, provision_accounts, revenue_accounts, tax_accounts,
59 treasury_accounts,
60};
61
62/// IAS 21 / ASC 830 rate-basis category for a GL account.
63///
64/// Drives the rate selected when translating each TB line from
65/// functional to presentation currency.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum TranslationAccountType {
69 /// Balance-sheet monetary item — cash, AR, AP, loans, bonds.
70 /// Translated at the **closing rate** (IAS 21.23(a)).
71 BsMonetary,
72 /// Balance-sheet non-monetary item — inventory, fixed assets,
73 /// intangibles, goodwill, prepaid. Translated at the
74 /// **historical rate** (IAS 21.23(b)).
75 BsNonMonetary,
76 /// P&L revenue. Translated at the **average rate** (IAS 21.40).
77 PlRevenue,
78 /// P&L expense including COGS. Translated at the **average rate**.
79 PlExpense,
80 /// Equity — common stock, retained earnings, reserves. Translated
81 /// at the **historical rate** at the date of initial recognition.
82 Equity,
83 /// OCI — translated at the average rate (or the rate at the date
84 /// of the underlying gain / loss for some items).
85 PlOci,
86}
87
88/// Classify a GL account for IAS 21 / ASC 830 translation purposes.
89///
90/// Tries specific named constants first (so that the canonical codes
91/// from [`datasynth_core::accounts`] always classify exactly), then
92/// falls back to leading-digit ranges. Empty or malformed codes return
93/// [`TranslationAccountType::BsMonetary`] defensively.
94///
95/// `framework` is reserved for future framework-specific edge cases —
96/// see the module rustdoc.
97pub fn classify_account(
98 account_code: &str,
99 framework: AccountingFramework,
100) -> TranslationAccountType {
101 // `framework` is currently a placeholder for future edge cases;
102 // discard explicitly so future readers see the intent.
103 let _ = framework;
104
105 // ── 1. Specific named constants ──────────────────────────────────
106 //
107 // These take priority over range-based logic so that, e.g.,
108 // `MANUFACTURING_ACCRUAL (2150)` is still classified as
109 // `BsMonetary` via the 2xxx rule (it's a payable), but
110 // `WIP (1420)` and `FINISHED_GOODS (1410)` override the otherwise
111 // ambiguous 1400-range default.
112 if let Some(ty) = classify_named(account_code) {
113 return ty;
114 }
115
116 // ── 2. Range-based fall-back ─────────────────────────────────────
117 classify_by_range(account_code)
118}
119
120/// Match the canonical named constants from
121/// [`datasynth_core::accounts`]. Returns `None` if the code isn't one
122/// of the named codes, allowing the caller to fall back to range logic.
123fn classify_named(code: &str) -> Option<TranslationAccountType> {
124 use TranslationAccountType::*;
125
126 // ── Cash + monetary current assets (BsMonetary) ──────────────────
127 if matches!(
128 code,
129 cash_accounts::OPERATING_CASH
130 | cash_accounts::BANK_ACCOUNT
131 | cash_accounts::PETTY_CASH
132 | cash_accounts::WIRE_CLEARING
133 | control_accounts::AR_CONTROL
134 | control_accounts::IC_AR_CLEARING
135 | control_accounts::GR_IR_CLEARING
136 | tax_accounts::INPUT_VAT
137 | tax_accounts::TAX_RECEIVABLE
138 | tax_accounts::DEFERRED_TAX_ASSET
139 | treasury_accounts::DERIVATIVE_ASSET
140 | treasury_accounts::CASH_POOL_IC_RECEIVABLE
141 ) {
142 return Some(BsMonetary);
143 }
144
145 // ── Inventory + manufacturing (BsNonMonetary) ────────────────────
146 if matches!(
147 code,
148 control_accounts::INVENTORY
149 | manufacturing_accounts::WIP
150 | manufacturing_accounts::FINISHED_GOODS
151 ) {
152 return Some(BsNonMonetary);
153 }
154
155 // ── Fixed assets, accumulated depreciation (BsNonMonetary) ───────
156 if matches!(
157 code,
158 control_accounts::FIXED_ASSETS | control_accounts::ACCUMULATED_DEPRECIATION
159 ) {
160 return Some(BsNonMonetary);
161 }
162
163 // ── Intangibles + goodwill (BsNonMonetary) ───────────────────────
164 if matches!(
165 code,
166 intangible_accounts::GOODWILL
167 | intangible_accounts::CUSTOMER_RELATIONSHIPS
168 | intangible_accounts::TRADE_NAME
169 | intangible_accounts::TECHNOLOGY
170 | intangible_accounts::ACCUMULATED_AMORTIZATION
171 ) {
172 return Some(BsNonMonetary);
173 }
174
175 // ── Liabilities (BsMonetary) ─────────────────────────────────────
176 if matches!(
177 code,
178 control_accounts::AP_CONTROL
179 | control_accounts::IC_AP_CLEARING
180 | liability_accounts::ACCRUED_EXPENSES
181 | liability_accounts::ACCRUED_SALARIES
182 | liability_accounts::ACCRUED_BENEFITS
183 | liability_accounts::UNEARNED_REVENUE
184 | liability_accounts::SHORT_TERM_DEBT
185 | liability_accounts::LONG_TERM_DEBT
186 | liability_accounts::IC_PAYABLE
187 | tax_accounts::SALES_TAX_PAYABLE
188 | tax_accounts::VAT_PAYABLE
189 | tax_accounts::WITHHOLDING_TAX_PAYABLE
190 | tax_accounts::INCOME_TAX_PAYABLE
191 | tax_accounts::DEFERRED_TAX_LIABILITY
192 | manufacturing_accounts::LABOR_ACCRUAL
193 | manufacturing_accounts::WARRANTY_PROVISION
194 | provision_accounts::PROVISION_LIABILITY
195 | treasury_accounts::INTEREST_PAYABLE
196 | treasury_accounts::DEBT_PREMIUM
197 | treasury_accounts::DEBT_DISCOUNT
198 | treasury_accounts::DERIVATIVE_LIABILITY
199 | treasury_accounts::CASH_POOL_IC_PAYABLE
200 ) {
201 return Some(BsMonetary);
202 }
203
204 // ── Equity ──────────────────────────────────────────────────────
205 if matches!(
206 code,
207 equity_accounts::COMMON_STOCK
208 | equity_accounts::APIC
209 | equity_accounts::RETAINED_EARNINGS
210 | equity_accounts::CURRENT_YEAR_EARNINGS
211 | equity_accounts::TREASURY_STOCK
212 | equity_accounts::CTA
213 | equity_accounts::INCOME_SUMMARY
214 | equity_accounts::DIVIDENDS_PAID
215 | treasury_accounts::OCI_CASH_FLOW_HEDGE
216 ) {
217 return Some(Equity);
218 }
219
220 // ── Revenue (PlRevenue) ─────────────────────────────────────────
221 if matches!(
222 code,
223 revenue_accounts::PRODUCT_REVENUE
224 | revenue_accounts::SERVICE_REVENUE
225 | revenue_accounts::IC_REVENUE
226 | revenue_accounts::PURCHASE_DISCOUNT_INCOME
227 | revenue_accounts::OTHER_REVENUE
228 | revenue_accounts::SALES_DISCOUNTS
229 | revenue_accounts::SALES_RETURNS
230 | intangible_accounts::BARGAIN_PURCHASE_GAIN
231 ) {
232 return Some(PlRevenue);
233 }
234
235 // ── Expenses incl. COGS (PlExpense) ──────────────────────────────
236 if matches!(
237 code,
238 expense_accounts::COGS
239 | expense_accounts::RAW_MATERIALS
240 | expense_accounts::DIRECT_LABOR
241 | expense_accounts::MANUFACTURING_OVERHEAD
242 | expense_accounts::DEPRECIATION
243 | expense_accounts::SALARIES_WAGES
244 | expense_accounts::BENEFITS
245 | expense_accounts::RENT
246 | expense_accounts::UTILITIES
247 | expense_accounts::OFFICE_SUPPLIES
248 | expense_accounts::TRAVEL_ENTERTAINMENT
249 | expense_accounts::PROFESSIONAL_FEES
250 | expense_accounts::INSURANCE
251 | expense_accounts::BAD_DEBT
252 | expense_accounts::INTEREST_EXPENSE
253 | expense_accounts::PURCHASE_DISCOUNTS
254 | expense_accounts::FX_GAIN_LOSS
255 | intangible_accounts::AMORTIZATION_EXPENSE
256 | provision_accounts::PROVISION_EXPENSE
257 | treasury_accounts::HEDGE_INEFFECTIVENESS
258 ) {
259 return Some(PlExpense);
260 }
261
262 None
263}
264
265/// Range-based classification — called when the account isn't one of
266/// the named constants.
267fn classify_by_range(code: &str) -> TranslationAccountType {
268 use TranslationAccountType::*;
269
270 // Defensive default for empty / non-numeric codes.
271 let leading = match code.chars().next() {
272 Some(c) if c.is_ascii_digit() => c,
273 _ => return BsMonetary,
274 };
275
276 match leading {
277 '1' => classify_asset_range(code),
278 '2' => BsMonetary,
279 '3' => Equity,
280 '4' => PlRevenue,
281 '5' | '6' | '7' => PlExpense,
282 '8' => PlOci,
283 '9' => BsMonetary,
284 _ => BsMonetary,
285 }
286}
287
288/// Fine-grained classification within the 1xxx asset range.
289fn classify_asset_range(code: &str) -> TranslationAccountType {
290 use TranslationAccountType::*;
291
292 // Parse the first 4 digits if available; otherwise fall through to
293 // `BsMonetary` as the safe default.
294 let prefix4: String = code.chars().take(4).collect();
295 let n: u32 = match prefix4.parse() {
296 Ok(n) => n,
297 Err(_) => return BsMonetary,
298 };
299
300 match n {
301 1000..=1199 => BsMonetary, // cash, AR, securities, IC AR, input VAT
302 1200..=1299 => BsNonMonetary, // inventory
303 1300..=1399 => BsNonMonetary, // prepaid expenses
304 1400..=1499 => BsMonetary, // mixed; named constants override (WIP, FG, derivative asset)
305 1500..=1899 => BsNonMonetary, // fixed assets, accumulated depreciation
306 1900..=1999 => BsNonMonetary, // intangibles, goodwill
307 _ => BsMonetary,
308 }
309}