Skip to main content

datasynth_generators/standards/
ecl_generator.rs

1//! Expected Credit Loss generator — IFRS 9 / ASC 326.
2//!
3//! Implements the **simplified approach** for trade receivables using a
4//! provision matrix based on AR aging data.
5//!
6//! # Loss rates by aging bucket (historical baseline)
7//!
8//! | Bucket      | Rate  |
9//! |-------------|-------|
10//! | Current     | 0.5%  |
11//! | 1–30 days   | 2.0%  |
12//! | 31–60 days  | 5.0%  |
13//! | 61–90 days  | 10.0% |
14//! | Over 90 days| 25.0% |
15//!
16//! Forward-looking adjustments are applied via scenario-weighted multipliers
17//! (base / optimistic / pessimistic) configured in [`EclConfig`].
18//!
19//! # Outputs
20//! - [`EclModel`] — complete ECL model with provision matrix
21//! - [`EclProvisionMovement`] — provision roll-forward for the period
22//! - [`JournalEntry`] — Bad Debt Expense DR / Allowance for Doubtful Accounts CR
23
24use chrono::NaiveDate;
25use datasynth_config::schema::EclConfig;
26use datasynth_core::accounts::{control_accounts::AR_CONTROL, expense_accounts::BAD_DEBT};
27use datasynth_core::models::expected_credit_loss::{
28    EclApproach, EclModel, EclPortfolioSegment, EclProvisionMovement, EclStage, EclStageAllocation,
29    ProvisionMatrix, ProvisionMatrixRow, ScenarioWeights,
30};
31use datasynth_core::models::journal_entry::{
32    JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
33};
34use datasynth_core::models::subledger::ar::AgingBucket;
35use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
36use rust_decimal::Decimal;
37use rust_decimal_macros::dec;
38
39// ============================================================================
40// GL account for "Allowance for Doubtful Accounts" (contra-asset)
41// We use a sub-account of AR control: 1105
42// ============================================================================
43const ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS: &str = "1105";
44
45// ============================================================================
46// Historical loss rates (as decimal fractions, NOT percentages)
47// ============================================================================
48
49/// Historical loss rate for the Current bucket (0.5%).
50const RATE_CURRENT: Decimal = dec!(0.005);
51/// Historical loss rate for the 1-30 days bucket (2.0%).
52const RATE_1_30: Decimal = dec!(0.02);
53/// Historical loss rate for the 31-60 days bucket (5.0%).
54const RATE_31_60: Decimal = dec!(0.05);
55/// Historical loss rate for the 61-90 days bucket (10.0%).
56const RATE_61_90: Decimal = dec!(0.10);
57/// Historical loss rate for the Over 90 days bucket (25.0%).
58const RATE_OVER_90: Decimal = dec!(0.25);
59
60// ============================================================================
61// Snapshot
62// ============================================================================
63
64/// All outputs from one ECL generation run.
65#[derive(Debug, Default)]
66pub struct EclSnapshot {
67    /// ECL models (one per company processed).
68    pub ecl_models: Vec<EclModel>,
69    /// Provision movement roll-forwards.
70    pub provision_movements: Vec<EclProvisionMovement>,
71    /// Journal entries (Bad Debt Expense / Allowance).
72    pub journal_entries: Vec<JournalEntry>,
73}
74
75// ============================================================================
76// Generator
77// ============================================================================
78
79/// Generates ECL models using the simplified approach for trade receivables.
80pub struct EclGenerator {
81    uuid_factory: DeterministicUuidFactory,
82}
83
84impl EclGenerator {
85    /// Create a new ECL generator with a deterministic seed.
86    pub fn new(seed: u64) -> Self {
87        Self {
88            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ExpectedCreditLoss),
89        }
90    }
91
92    /// Generate ECL model from AR aging bucket totals.
93    ///
94    /// # Parameters
95    /// - `entity_code`: company / entity identifier
96    /// - `measurement_date`: balance-sheet date
97    /// - `bucket_exposures`: gross AR balance per aging bucket (ordered: Current, 1-30, 31-60, 61-90, Over90)
98    /// - `config`: ECL configuration (scenario weights / multipliers)
99    /// - `period_label`: label for the provision movement (e.g. "2024-Q4")
100    /// - `framework`: "IFRS_9" or "ASC_326"
101    pub fn generate(
102        &mut self,
103        entity_code: &str,
104        measurement_date: NaiveDate,
105        bucket_exposures: &[(AgingBucket, Decimal)],
106        config: &EclConfig,
107        period_label: &str,
108        framework: &str,
109    ) -> EclSnapshot {
110        self.generate_with_prior(
111            entity_code,
112            measurement_date,
113            bucket_exposures,
114            config,
115            period_label,
116            framework,
117            None,
118        )
119    }
120
121    /// Generate an ECL snapshot, optionally accepting the prior period's closing provision
122    /// balance to use as the opening balance for the rollforward.
123    ///
124    /// Pass `prior_closing = Some(amount)` when generating a subsequent period in a
125    /// multi-period series.  For the first period, use `None` (equivalent to zero opening).
126    ///
127    /// Note: full multi-period continuity also requires carrying forward `EclModel` state
128    /// (stage transfers, write-off history) which is a larger architectural change.
129    pub fn generate_with_prior(
130        &mut self,
131        entity_code: &str,
132        measurement_date: NaiveDate,
133        bucket_exposures: &[(AgingBucket, Decimal)],
134        config: &EclConfig,
135        period_label: &str,
136        framework: &str,
137        prior_closing: Option<Decimal>,
138    ) -> EclSnapshot {
139        // ---- Step 1: compute scenario-weighted forward-looking multiplier ------
140        let base_w = Decimal::try_from(config.base_scenario_weight).unwrap_or(dec!(0.50));
141        let base_m = Decimal::try_from(config.base_scenario_multiplier).unwrap_or(dec!(1.0));
142        let opt_w = Decimal::try_from(config.optimistic_scenario_weight).unwrap_or(dec!(0.30));
143        let opt_m = Decimal::try_from(config.optimistic_scenario_multiplier).unwrap_or(dec!(0.8));
144        let pes_w = Decimal::try_from(config.pessimistic_scenario_weight).unwrap_or(dec!(0.20));
145        let pes_m = Decimal::try_from(config.pessimistic_scenario_multiplier).unwrap_or(dec!(1.4));
146
147        let blended_multiplier = (base_w * base_m + opt_w * opt_m + pes_w * pes_m).round_dp(6);
148
149        let scenario_weights = ScenarioWeights {
150            base: base_w,
151            base_multiplier: base_m,
152            optimistic: opt_w,
153            optimistic_multiplier: opt_m,
154            pessimistic: pes_w,
155            pessimistic_multiplier: pes_m,
156            blended_multiplier,
157        };
158
159        // ---- Step 2: build provision matrix rows --------------------------------
160        let mut matrix_rows: Vec<ProvisionMatrixRow> = Vec::with_capacity(5);
161        let mut total_provision = Decimal::ZERO;
162        let mut total_exposure = Decimal::ZERO;
163
164        for bucket in AgingBucket::all() {
165            let exposure = bucket_exposures
166                .iter()
167                .find(|(b, _)| *b == bucket)
168                .map(|(_, e)| *e)
169                .unwrap_or(Decimal::ZERO);
170
171            let historical_rate = historical_rate_for_bucket(bucket);
172            let applied_rate = (historical_rate * blended_multiplier).round_dp(6);
173            let provision = (exposure * applied_rate).round_dp(2);
174
175            total_exposure += exposure;
176            total_provision += provision;
177
178            matrix_rows.push(ProvisionMatrixRow {
179                bucket,
180                historical_loss_rate: historical_rate,
181                forward_looking_adjustment: blended_multiplier,
182                applied_loss_rate: applied_rate,
183                exposure,
184                provision,
185            });
186        }
187
188        let blended_loss_rate = if total_exposure.is_zero() {
189            Decimal::ZERO
190        } else {
191            (total_provision / total_exposure).round_dp(6)
192        };
193
194        let provision_matrix = ProvisionMatrix {
195            entity_code: entity_code.to_string(),
196            measurement_date,
197            scenario_weights,
198            aging_buckets: matrix_rows,
199            total_provision,
200            total_exposure,
201            blended_loss_rate,
202        };
203
204        // ---- Step 3: build portfolio segment (simplified: single segment) ------
205        // Under simplified approach we map the entire AR portfolio to Stage 2
206        // (lifetime ECL is always recognised, no stage 1/12-month ECL).
207        // We distribute the provision matrix total into stage allocations for
208        // reporting completeness (Stage 1 = Current bucket, Stage 2 = 1-90 days,
209        // Stage 3 = Over 90 days).
210        let stage_allocations =
211            build_stage_allocations(&provision_matrix.aging_buckets, blended_multiplier);
212
213        let segment = EclPortfolioSegment {
214            segment_name: "Trade Receivables".to_string(),
215            exposure_at_default: total_exposure,
216            total_ecl: total_provision,
217            staging: stage_allocations,
218        };
219
220        // ---- Step 4: top-level ECL model ----------------------------------------
221        let model_id = self.uuid_factory.next().to_string();
222        let ecl_model = EclModel {
223            id: model_id,
224            entity_code: entity_code.to_string(),
225            approach: EclApproach::Simplified,
226            measurement_date,
227            framework: framework.to_string(),
228            portfolio_segments: vec![segment],
229            provision_matrix: Some(provision_matrix),
230            total_ecl: total_provision,
231            total_exposure,
232        };
233
234        // ---- Step 5: provision movement ------------------------------------------
235        // For a first-period run opening = 0; closing = total provision.
236        // Write-offs are estimated at 20% of the Over 90 bucket provision.
237        let over90_provision = ecl_model
238            .provision_matrix
239            .as_ref()
240            .and_then(|m| {
241                m.aging_buckets
242                    .iter()
243                    .find(|r| r.bucket == AgingBucket::Over90Days)
244                    .map(|r| r.provision)
245            })
246            .unwrap_or(Decimal::ZERO);
247
248        let estimated_write_offs = (over90_provision * dec!(0.20)).round_dp(2);
249        let recoveries = Decimal::ZERO;
250        // Opening balance: use the prior period's closing provision when provided,
251        // otherwise default to zero (first period).  Full multi-period continuity
252        // (stage transfers, write-off history) requires broader architectural changes.
253        let opening = prior_closing.unwrap_or(Decimal::ZERO);
254        let new_originations = total_provision;
255        let stage_transfers = Decimal::ZERO;
256        let closing = (opening + new_originations + stage_transfers - estimated_write_offs
257            + recoveries)
258            .round_dp(2);
259        let pl_charge =
260            (new_originations + stage_transfers + recoveries - estimated_write_offs).round_dp(2);
261
262        let movement_id = self.uuid_factory.next().to_string();
263        let movement = EclProvisionMovement {
264            id: movement_id,
265            entity_code: entity_code.to_string(),
266            period: period_label.to_string(),
267            opening,
268            new_originations,
269            stage_transfers,
270            write_offs: estimated_write_offs,
271            recoveries,
272            closing,
273            pl_charge,
274        };
275
276        // ---- Step 6: journal entry ----------------------------------------------
277        let je = build_ecl_journal_entry(
278            &mut self.uuid_factory,
279            entity_code,
280            measurement_date,
281            pl_charge,
282        );
283
284        EclSnapshot {
285            ecl_models: vec![ecl_model],
286            provision_movements: vec![movement],
287            journal_entries: vec![je],
288        }
289    }
290}
291
292// ============================================================================
293// Helpers
294// ============================================================================
295
296/// Returns the historical loss rate for each aging bucket.
297fn historical_rate_for_bucket(bucket: AgingBucket) -> Decimal {
298    match bucket {
299        AgingBucket::Current => RATE_CURRENT,
300        AgingBucket::Days1To30 => RATE_1_30,
301        AgingBucket::Days31To60 => RATE_31_60,
302        AgingBucket::Days61To90 => RATE_61_90,
303        AgingBucket::Over90Days => RATE_OVER_90,
304    }
305}
306
307/// Build stage allocations from provision matrix rows.
308///
309/// Mapping:
310/// - Stage 1 (12-month ECL): Current bucket
311/// - Stage 2 (Lifetime): 1-30, 31-60, 61-90 days
312/// - Stage 3 (Credit-impaired): Over 90 days
313fn build_stage_allocations(
314    rows: &[ProvisionMatrixRow],
315    forward_looking_adjustment: Decimal,
316) -> Vec<EclStageAllocation> {
317    let mut stage1_exposure = Decimal::ZERO;
318    let mut stage1_ecl = Decimal::ZERO;
319    let mut stage1_hist_rate = Decimal::ZERO;
320
321    let mut stage2_exposure = Decimal::ZERO;
322    let mut stage2_ecl = Decimal::ZERO;
323    let mut stage2_hist_rate = Decimal::ZERO;
324
325    let mut stage3_exposure = Decimal::ZERO;
326    let mut stage3_ecl = Decimal::ZERO;
327    let mut stage3_hist_rate = Decimal::ZERO;
328
329    for row in rows {
330        match row.bucket {
331            AgingBucket::Current => {
332                stage1_exposure += row.exposure;
333                stage1_ecl += row.provision;
334                stage1_hist_rate = row.historical_loss_rate;
335            }
336            AgingBucket::Days1To30 | AgingBucket::Days31To60 | AgingBucket::Days61To90 => {
337                stage2_exposure += row.exposure;
338                stage2_ecl += row.provision;
339                // Use the highest historical rate in the group for the summary
340                if row.historical_loss_rate > stage2_hist_rate {
341                    stage2_hist_rate = row.historical_loss_rate;
342                }
343            }
344            AgingBucket::Over90Days => {
345                stage3_exposure += row.exposure;
346                stage3_ecl += row.provision;
347                stage3_hist_rate = row.historical_loss_rate;
348            }
349        }
350    }
351
352    // PD / LGD: simplified approach doesn't separate PD and LGD, but we
353    // model as PD × LGD = applied_loss_rate. For Stage 1/2 assume LGD = 1.0;
354    // for Stage 3 assume LGD = 0.60 (40% recovery).
355    let lgd_stage1 = dec!(1.0);
356    let lgd_stage2 = dec!(1.0);
357    let lgd_stage3 = dec!(0.60);
358
359    let pd_stage1 = (stage1_hist_rate * forward_looking_adjustment).round_dp(6);
360    let pd_stage2 = (stage2_hist_rate * forward_looking_adjustment).round_dp(6);
361    let pd_stage3 = if lgd_stage3.is_zero() {
362        Decimal::ZERO
363    } else {
364        (stage3_hist_rate * forward_looking_adjustment / lgd_stage3).round_dp(6)
365    };
366
367    vec![
368        EclStageAllocation {
369            stage: EclStage::Stage1Month12,
370            exposure: stage1_exposure,
371            probability_of_default: pd_stage1,
372            loss_given_default: lgd_stage1,
373            ecl_amount: stage1_ecl,
374            forward_looking_adjustment,
375        },
376        EclStageAllocation {
377            stage: EclStage::Stage2Lifetime,
378            exposure: stage2_exposure,
379            probability_of_default: pd_stage2,
380            loss_given_default: lgd_stage2,
381            ecl_amount: stage2_ecl,
382            forward_looking_adjustment,
383        },
384        EclStageAllocation {
385            stage: EclStage::Stage3CreditImpaired,
386            exposure: stage3_exposure,
387            probability_of_default: pd_stage3,
388            loss_given_default: lgd_stage3,
389            ecl_amount: stage3_ecl,
390            forward_looking_adjustment,
391        },
392    ]
393}
394
395/// Build the ECL journal entry:
396///
397/// ```text
398/// DR  Bad Debt Expense (6900)                    pl_charge
399///   CR  Allowance for Doubtful Accounts (1105)   pl_charge
400/// ```
401fn build_ecl_journal_entry(
402    _uuid_factory: &mut DeterministicUuidFactory,
403    entity_code: &str,
404    posting_date: NaiveDate,
405    pl_charge: Decimal,
406) -> JournalEntry {
407    // Ignore zero or negative charges (no JE needed)
408    let amount = pl_charge.max(Decimal::ZERO);
409
410    let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
411    header.header_text = Some(format!(
412        "ECL provision — Bad Debt Expense / Allowance for Doubtful Accounts ({posting_date})"
413    ));
414    header.source = TransactionSource::Adjustment;
415    header.reference = Some("IFRS9/ASC326-ECL".to_string());
416    // Suppress unused-import warning: AR_CONTROL is documented but not in
417    // this JE because the allowance is a contra-asset sub-account.
418    let _ = AR_CONTROL;
419
420    let doc_id = header.document_id;
421    let mut je = JournalEntry::new(header);
422
423    if amount > Decimal::ZERO {
424        // DR Bad Debt Expense
425        je.add_line(JournalEntryLine::debit(
426            doc_id,
427            1,
428            BAD_DEBT.to_string(),
429            amount,
430        ));
431
432        // CR Allowance for Doubtful Accounts
433        je.add_line(JournalEntryLine::credit(
434            doc_id,
435            2,
436            ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS.to_string(),
437            amount,
438        ));
439    }
440
441    je
442}