Skip to main content

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}