use chrono::NaiveDate;
use datasynth_config::schema::EclConfig;
use datasynth_core::accounts::{control_accounts::AR_CONTROL, expense_accounts::BAD_DEBT};
use datasynth_core::models::expected_credit_loss::{
EclApproach, EclModel, EclPortfolioSegment, EclProvisionMovement, EclStage, EclStageAllocation,
ProvisionMatrix, ProvisionMatrixRow, ScenarioWeights,
};
use datasynth_core::models::journal_entry::{
JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
};
use datasynth_core::models::subledger::ar::AgingBucket;
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
const ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS: &str = "1105";
const RATE_CURRENT: Decimal = dec!(0.005);
const RATE_1_30: Decimal = dec!(0.02);
const RATE_31_60: Decimal = dec!(0.05);
const RATE_61_90: Decimal = dec!(0.10);
const RATE_OVER_90: Decimal = dec!(0.25);
#[derive(Debug, Default)]
pub struct EclSnapshot {
pub ecl_models: Vec<EclModel>,
pub provision_movements: Vec<EclProvisionMovement>,
pub journal_entries: Vec<JournalEntry>,
}
pub struct EclGenerator {
uuid_factory: DeterministicUuidFactory,
}
impl EclGenerator {
pub fn new(seed: u64) -> Self {
Self {
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ExpectedCreditLoss),
}
}
pub fn generate(
&mut self,
entity_code: &str,
measurement_date: NaiveDate,
bucket_exposures: &[(AgingBucket, Decimal)],
config: &EclConfig,
period_label: &str,
framework: &str,
) -> EclSnapshot {
self.generate_with_prior(
entity_code,
measurement_date,
bucket_exposures,
config,
period_label,
framework,
None,
)
}
pub fn generate_with_prior(
&mut self,
entity_code: &str,
measurement_date: NaiveDate,
bucket_exposures: &[(AgingBucket, Decimal)],
config: &EclConfig,
period_label: &str,
framework: &str,
prior_closing: Option<Decimal>,
) -> EclSnapshot {
let base_w = Decimal::try_from(config.base_scenario_weight).unwrap_or(dec!(0.50));
let base_m = Decimal::try_from(config.base_scenario_multiplier).unwrap_or(dec!(1.0));
let opt_w = Decimal::try_from(config.optimistic_scenario_weight).unwrap_or(dec!(0.30));
let opt_m = Decimal::try_from(config.optimistic_scenario_multiplier).unwrap_or(dec!(0.8));
let pes_w = Decimal::try_from(config.pessimistic_scenario_weight).unwrap_or(dec!(0.20));
let pes_m = Decimal::try_from(config.pessimistic_scenario_multiplier).unwrap_or(dec!(1.4));
let blended_multiplier = (base_w * base_m + opt_w * opt_m + pes_w * pes_m).round_dp(6);
let scenario_weights = ScenarioWeights {
base: base_w,
base_multiplier: base_m,
optimistic: opt_w,
optimistic_multiplier: opt_m,
pessimistic: pes_w,
pessimistic_multiplier: pes_m,
blended_multiplier,
};
let mut matrix_rows: Vec<ProvisionMatrixRow> = Vec::with_capacity(5);
let mut total_provision = Decimal::ZERO;
let mut total_exposure = Decimal::ZERO;
for bucket in AgingBucket::all() {
let exposure = bucket_exposures
.iter()
.find(|(b, _)| *b == bucket)
.map(|(_, e)| *e)
.unwrap_or(Decimal::ZERO);
let historical_rate = historical_rate_for_bucket(bucket);
let applied_rate = (historical_rate * blended_multiplier).round_dp(6);
let provision = (exposure * applied_rate).round_dp(2);
total_exposure += exposure;
total_provision += provision;
matrix_rows.push(ProvisionMatrixRow {
bucket,
historical_loss_rate: historical_rate,
forward_looking_adjustment: blended_multiplier,
applied_loss_rate: applied_rate,
exposure,
provision,
});
}
let blended_loss_rate = if total_exposure.is_zero() {
Decimal::ZERO
} else {
(total_provision / total_exposure).round_dp(6)
};
let provision_matrix = ProvisionMatrix {
entity_code: entity_code.to_string(),
measurement_date,
scenario_weights,
aging_buckets: matrix_rows,
total_provision,
total_exposure,
blended_loss_rate,
};
let stage_allocations =
build_stage_allocations(&provision_matrix.aging_buckets, blended_multiplier);
let segment = EclPortfolioSegment {
segment_name: "Trade Receivables".to_string(),
exposure_at_default: total_exposure,
total_ecl: total_provision,
staging: stage_allocations,
};
let model_id = self.uuid_factory.next().to_string();
let ecl_model = EclModel {
id: model_id,
entity_code: entity_code.to_string(),
approach: EclApproach::Simplified,
measurement_date,
framework: framework.to_string(),
portfolio_segments: vec![segment],
provision_matrix: Some(provision_matrix),
total_ecl: total_provision,
total_exposure,
};
let over90_provision = ecl_model
.provision_matrix
.as_ref()
.and_then(|m| {
m.aging_buckets
.iter()
.find(|r| r.bucket == AgingBucket::Over90Days)
.map(|r| r.provision)
})
.unwrap_or(Decimal::ZERO);
let estimated_write_offs = (over90_provision * dec!(0.20)).round_dp(2);
let recoveries = Decimal::ZERO;
let opening = prior_closing.unwrap_or(Decimal::ZERO);
let new_originations = total_provision;
let stage_transfers = Decimal::ZERO;
let closing = (opening + new_originations + stage_transfers - estimated_write_offs
+ recoveries)
.round_dp(2);
let pl_charge =
(new_originations + stage_transfers + recoveries - estimated_write_offs).round_dp(2);
let movement_id = self.uuid_factory.next().to_string();
let movement = EclProvisionMovement {
id: movement_id,
entity_code: entity_code.to_string(),
period: period_label.to_string(),
opening,
new_originations,
stage_transfers,
write_offs: estimated_write_offs,
recoveries,
closing,
pl_charge,
};
let je = build_ecl_journal_entry(
&mut self.uuid_factory,
entity_code,
measurement_date,
pl_charge,
);
EclSnapshot {
ecl_models: vec![ecl_model],
provision_movements: vec![movement],
journal_entries: vec![je],
}
}
}
fn historical_rate_for_bucket(bucket: AgingBucket) -> Decimal {
match bucket {
AgingBucket::Current => RATE_CURRENT,
AgingBucket::Days1To30 => RATE_1_30,
AgingBucket::Days31To60 => RATE_31_60,
AgingBucket::Days61To90 => RATE_61_90,
AgingBucket::Over90Days => RATE_OVER_90,
}
}
fn build_stage_allocations(
rows: &[ProvisionMatrixRow],
forward_looking_adjustment: Decimal,
) -> Vec<EclStageAllocation> {
let mut stage1_exposure = Decimal::ZERO;
let mut stage1_ecl = Decimal::ZERO;
let mut stage1_hist_rate = Decimal::ZERO;
let mut stage2_exposure = Decimal::ZERO;
let mut stage2_ecl = Decimal::ZERO;
let mut stage2_hist_rate = Decimal::ZERO;
let mut stage3_exposure = Decimal::ZERO;
let mut stage3_ecl = Decimal::ZERO;
let mut stage3_hist_rate = Decimal::ZERO;
for row in rows {
match row.bucket {
AgingBucket::Current => {
stage1_exposure += row.exposure;
stage1_ecl += row.provision;
stage1_hist_rate = row.historical_loss_rate;
}
AgingBucket::Days1To30 | AgingBucket::Days31To60 | AgingBucket::Days61To90 => {
stage2_exposure += row.exposure;
stage2_ecl += row.provision;
if row.historical_loss_rate > stage2_hist_rate {
stage2_hist_rate = row.historical_loss_rate;
}
}
AgingBucket::Over90Days => {
stage3_exposure += row.exposure;
stage3_ecl += row.provision;
stage3_hist_rate = row.historical_loss_rate;
}
}
}
let lgd_stage1 = dec!(1.0);
let lgd_stage2 = dec!(1.0);
let lgd_stage3 = dec!(0.60);
let pd_stage1 = (stage1_hist_rate * forward_looking_adjustment).round_dp(6);
let pd_stage2 = (stage2_hist_rate * forward_looking_adjustment).round_dp(6);
let pd_stage3 = if lgd_stage3.is_zero() {
Decimal::ZERO
} else {
(stage3_hist_rate * forward_looking_adjustment / lgd_stage3).round_dp(6)
};
vec![
EclStageAllocation {
stage: EclStage::Stage1Month12,
exposure: stage1_exposure,
probability_of_default: pd_stage1,
loss_given_default: lgd_stage1,
ecl_amount: stage1_ecl,
forward_looking_adjustment,
},
EclStageAllocation {
stage: EclStage::Stage2Lifetime,
exposure: stage2_exposure,
probability_of_default: pd_stage2,
loss_given_default: lgd_stage2,
ecl_amount: stage2_ecl,
forward_looking_adjustment,
},
EclStageAllocation {
stage: EclStage::Stage3CreditImpaired,
exposure: stage3_exposure,
probability_of_default: pd_stage3,
loss_given_default: lgd_stage3,
ecl_amount: stage3_ecl,
forward_looking_adjustment,
},
]
}
fn build_ecl_journal_entry(
_uuid_factory: &mut DeterministicUuidFactory,
entity_code: &str,
posting_date: NaiveDate,
pl_charge: Decimal,
) -> JournalEntry {
let amount = pl_charge.max(Decimal::ZERO);
let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
header.header_text = Some(format!(
"ECL provision — Bad Debt Expense / Allowance for Doubtful Accounts ({posting_date})"
));
header.source = TransactionSource::Adjustment;
header.reference = Some("IFRS9/ASC326-ECL".to_string());
let _ = AR_CONTROL;
let doc_id = header.document_id;
let mut je = JournalEntry::new(header);
if amount > Decimal::ZERO {
je.add_line(JournalEntryLine::debit(
doc_id,
1,
BAD_DEBT.to_string(),
amount,
));
je.add_line(JournalEntryLine::credit(
doc_id,
2,
ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS.to_string(),
amount,
));
}
je
}