Skip to main content

datasynth_generators/treasury/
treasury_accounting.rs

1//! Treasury accounting journal entry pipeline.
2//!
3//! Generates journal entries from treasury instruments:
4//! - Debt interest accruals (quarterly approximation)
5//! - Hedge fair-value / cash-flow accounting with ineffectiveness
6//! - Cash pool sweep intercompany entries
7
8use chrono::NaiveDate;
9use datasynth_core::accounts::{expense_accounts, treasury_accounts};
10use datasynth_core::models::{
11    BusinessProcess, CashPoolSweep, DebtInstrument, HedgeRelationship, HedgeType,
12    HedgingInstrument, JournalEntry, JournalEntryLine, TransactionSource,
13};
14use rust_decimal::Decimal;
15
16/// Generates journal entries for treasury instruments.
17///
18/// Follows the same static-method pattern as `ManufacturingCostAccounting`:
19/// a zero-sized struct with associated functions that accept domain objects
20/// and return balanced `Vec<JournalEntry>`.
21pub struct TreasuryAccounting;
22
23impl TreasuryAccounting {
24    // ------------------------------------------------------------------
25    // 1. Debt interest accruals
26    // ------------------------------------------------------------------
27
28    /// Generate interest accrual JEs for active debt instruments.
29    ///
30    /// For each instrument where `period_end <= maturity_date`, posts:
31    ///   DR Interest Expense ("7100")
32    ///   CR Interest Payable ("2160")
33    ///
34    /// Amount = principal * (annual_interest_rate / 4) — quarterly
35    /// approximation.
36    pub fn generate_debt_jes(
37        instruments: &[DebtInstrument],
38        period_end: NaiveDate,
39    ) -> Vec<JournalEntry> {
40        let mut jes = Vec::new();
41
42        for debt in instruments {
43            // Skip matured debt — no further interest accrues.
44            if period_end > debt.maturity_date {
45                continue;
46            }
47
48            let quarterly_interest =
49                (debt.principal * debt.interest_rate / Decimal::from(4)).round_dp(2);
50
51            if quarterly_interest == Decimal::ZERO {
52                continue;
53            }
54
55            let mut je = JournalEntry::new_simple(
56                format!("JE-TREAS-INT-{}", debt.id),
57                debt.entity_id.clone(),
58                period_end,
59                format!("Interest accrual on {} from {}", debt.id, debt.lender),
60            );
61            je.header.currency = debt.currency.clone();
62            je.header.business_process = Some(BusinessProcess::Treasury);
63            je.header.source = TransactionSource::Automated;
64            let doc_id = je.header.document_id;
65
66            je.add_line(JournalEntryLine {
67                document_id: doc_id,
68                line_number: 1,
69                gl_account: expense_accounts::INTEREST_EXPENSE.to_string(),
70                account_code: expense_accounts::INTEREST_EXPENSE.to_string(),
71                debit_amount: quarterly_interest,
72                local_amount: quarterly_interest,
73                reference: Some(debt.id.clone()),
74                text: Some(format!("Interest expense — {}", debt.lender)),
75                ..Default::default()
76            });
77            je.add_line(JournalEntryLine {
78                document_id: doc_id,
79                line_number: 2,
80                gl_account: treasury_accounts::INTEREST_PAYABLE.to_string(),
81                account_code: treasury_accounts::INTEREST_PAYABLE.to_string(),
82                credit_amount: quarterly_interest,
83                local_amount: -quarterly_interest,
84                reference: Some(debt.id.clone()),
85                text: Some(format!("Interest payable — {}", debt.lender)),
86                ..Default::default()
87            });
88
89            jes.push(je);
90        }
91
92        jes
93    }
94
95    // ------------------------------------------------------------------
96    // 2. Hedge accounting JEs
97    // ------------------------------------------------------------------
98
99    /// Generate hedge accounting JEs for hedging instruments.
100    ///
101    /// For each active instrument with non-zero fair value:
102    /// - Cash flow hedge: DR/CR Derivative Asset/Liability vs OCI ("3510")
103    /// - Fair value hedge: DR/CR Derivative Asset/Liability vs FX Gain/Loss ("7500")
104    ///
105    /// If the hedge relationship is ineffective *and* `ineffectiveness_amount > 0`,
106    /// an additional JE recognises ineffectiveness in P&L.
107    pub fn generate_hedge_jes(
108        instruments: &[HedgingInstrument],
109        relationships: &[HedgeRelationship],
110        period_end: NaiveDate,
111        entity_id: &str,
112    ) -> Vec<JournalEntry> {
113        let mut jes = Vec::new();
114
115        for instrument in instruments {
116            if !instrument.is_active() {
117                continue;
118            }
119            if instrument.fair_value == Decimal::ZERO {
120                continue;
121            }
122
123            // Find matching hedge relationship.
124            let relationship = relationships
125                .iter()
126                .find(|r| r.hedging_instrument_id == instrument.id);
127
128            let hedge_type = relationship
129                .map(|r| r.hedge_type)
130                .unwrap_or(HedgeType::FairValueHedge);
131
132            let abs_fv = instrument.fair_value.abs();
133            let is_asset = instrument.fair_value > Decimal::ZERO;
134
135            // Determine the P&L / OCI account based on hedge type.
136            let pnl_oci_account = match hedge_type {
137                HedgeType::CashFlowHedge | HedgeType::NetInvestmentHedge => {
138                    treasury_accounts::OCI_CASH_FLOW_HEDGE
139                }
140                HedgeType::FairValueHedge => expense_accounts::FX_GAIN_LOSS,
141            };
142
143            let mut je = JournalEntry::new_simple(
144                format!("JE-TREAS-HEDGE-{}", instrument.id),
145                entity_id.to_string(),
146                period_end,
147                format!(
148                    "Hedge accounting — {} ({})",
149                    instrument.id,
150                    if is_asset { "asset" } else { "liability" }
151                ),
152            );
153            je.header.currency = instrument.currency.clone();
154            je.header.business_process = Some(BusinessProcess::Treasury);
155            je.header.source = TransactionSource::Automated;
156            let doc_id = je.header.document_id;
157
158            if is_asset {
159                // DR Derivative Asset, CR OCI/FX Gain-Loss
160                je.add_line(JournalEntryLine {
161                    document_id: doc_id,
162                    line_number: 1,
163                    gl_account: treasury_accounts::DERIVATIVE_ASSET.to_string(),
164                    account_code: treasury_accounts::DERIVATIVE_ASSET.to_string(),
165                    debit_amount: abs_fv,
166                    local_amount: abs_fv,
167                    reference: Some(instrument.id.clone()),
168                    text: Some("Derivative asset — positive MTM".to_string()),
169                    ..Default::default()
170                });
171                je.add_line(JournalEntryLine {
172                    document_id: doc_id,
173                    line_number: 2,
174                    gl_account: pnl_oci_account.to_string(),
175                    account_code: pnl_oci_account.to_string(),
176                    credit_amount: abs_fv,
177                    local_amount: -abs_fv,
178                    reference: Some(instrument.id.clone()),
179                    text: Some(format!(
180                        "{} — hedge gain",
181                        if hedge_type == HedgeType::CashFlowHedge
182                            || hedge_type == HedgeType::NetInvestmentHedge
183                        {
184                            "OCI"
185                        } else {
186                            "FX Gain/Loss"
187                        }
188                    )),
189                    ..Default::default()
190                });
191            } else {
192                // DR OCI/FX Gain-Loss, CR Derivative Liability
193                je.add_line(JournalEntryLine {
194                    document_id: doc_id,
195                    line_number: 1,
196                    gl_account: pnl_oci_account.to_string(),
197                    account_code: pnl_oci_account.to_string(),
198                    debit_amount: abs_fv,
199                    local_amount: abs_fv,
200                    reference: Some(instrument.id.clone()),
201                    text: Some(format!(
202                        "{} — hedge loss",
203                        if hedge_type == HedgeType::CashFlowHedge
204                            || hedge_type == HedgeType::NetInvestmentHedge
205                        {
206                            "OCI"
207                        } else {
208                            "FX Gain/Loss"
209                        }
210                    )),
211                    ..Default::default()
212                });
213                je.add_line(JournalEntryLine {
214                    document_id: doc_id,
215                    line_number: 2,
216                    gl_account: treasury_accounts::DERIVATIVE_LIABILITY.to_string(),
217                    account_code: treasury_accounts::DERIVATIVE_LIABILITY.to_string(),
218                    credit_amount: abs_fv,
219                    local_amount: -abs_fv,
220                    reference: Some(instrument.id.clone()),
221                    text: Some("Derivative liability — negative MTM".to_string()),
222                    ..Default::default()
223                });
224            }
225
226            jes.push(je);
227
228            // Ineffectiveness JE (if applicable).
229            if let Some(rel) = relationship {
230                if !rel.is_effective && rel.ineffectiveness_amount > Decimal::ZERO {
231                    let ineff = rel.ineffectiveness_amount.round_dp(2);
232
233                    let mut je_ineff = JournalEntry::new_simple(
234                        format!("JE-TREAS-INEFF-{}", instrument.id),
235                        entity_id.to_string(),
236                        period_end,
237                        format!("Hedge ineffectiveness — {}", instrument.id),
238                    );
239                    je_ineff.header.currency = instrument.currency.clone();
240                    je_ineff.header.business_process = Some(BusinessProcess::Treasury);
241                    je_ineff.header.source = TransactionSource::Automated;
242                    let doc_id_ineff = je_ineff.header.document_id;
243
244                    je_ineff.add_line(JournalEntryLine {
245                        document_id: doc_id_ineff,
246                        line_number: 1,
247                        gl_account: treasury_accounts::HEDGE_INEFFECTIVENESS.to_string(),
248                        account_code: treasury_accounts::HEDGE_INEFFECTIVENESS.to_string(),
249                        debit_amount: ineff,
250                        local_amount: ineff,
251                        reference: Some(instrument.id.clone()),
252                        text: Some("Hedge ineffectiveness expense".to_string()),
253                        ..Default::default()
254                    });
255                    je_ineff.add_line(JournalEntryLine {
256                        document_id: doc_id_ineff,
257                        line_number: 2,
258                        gl_account: treasury_accounts::OCI_CASH_FLOW_HEDGE.to_string(),
259                        account_code: treasury_accounts::OCI_CASH_FLOW_HEDGE.to_string(),
260                        credit_amount: ineff,
261                        local_amount: -ineff,
262                        reference: Some(instrument.id.clone()),
263                        text: Some("OCI reclassification — ineffectiveness".to_string()),
264                        ..Default::default()
265                    });
266
267                    jes.push(je_ineff);
268                }
269            }
270        }
271
272        jes
273    }
274
275    // ------------------------------------------------------------------
276    // 3. Cash pool sweep JEs
277    // ------------------------------------------------------------------
278
279    /// Generate intercompany JEs for cash pool sweeps.
280    ///
281    /// For each sweep with a non-zero amount:
282    ///   DR Cash Pool IC Receivable ("1155")
283    ///   CR Cash Pool IC Payable ("2055")
284    pub fn generate_cash_pool_sweep_jes(
285        sweeps: &[CashPoolSweep],
286        header_entity: &str,
287    ) -> Vec<JournalEntry> {
288        let mut jes = Vec::new();
289
290        for sweep in sweeps {
291            if sweep.amount == Decimal::ZERO {
292                continue;
293            }
294
295            let abs_amount = sweep.amount.abs();
296
297            let mut je = JournalEntry::new_simple(
298                format!("JE-TREAS-SWEEP-{}", sweep.id),
299                header_entity.to_string(),
300                sweep.date,
301                format!(
302                    "Cash pool sweep {} → {} (pool {})",
303                    sweep.from_account_id, sweep.to_account_id, sweep.pool_id
304                ),
305            );
306            je.header.currency = sweep.currency.clone();
307            je.header.business_process = Some(BusinessProcess::Treasury);
308            je.header.source = TransactionSource::Automated;
309            let doc_id = je.header.document_id;
310
311            je.add_line(JournalEntryLine {
312                document_id: doc_id,
313                line_number: 1,
314                gl_account: treasury_accounts::CASH_POOL_IC_RECEIVABLE.to_string(),
315                account_code: treasury_accounts::CASH_POOL_IC_RECEIVABLE.to_string(),
316                debit_amount: abs_amount,
317                local_amount: abs_amount,
318                reference: Some(sweep.id.clone()),
319                text: Some(format!(
320                    "IC receivable — sweep from {}",
321                    sweep.from_account_id
322                )),
323                ..Default::default()
324            });
325            je.add_line(JournalEntryLine {
326                document_id: doc_id,
327                line_number: 2,
328                gl_account: treasury_accounts::CASH_POOL_IC_PAYABLE.to_string(),
329                account_code: treasury_accounts::CASH_POOL_IC_PAYABLE.to_string(),
330                credit_amount: abs_amount,
331                local_amount: -abs_amount,
332                reference: Some(sweep.id.clone()),
333                text: Some(format!("IC payable — sweep to {}", sweep.to_account_id)),
334                ..Default::default()
335            });
336
337            jes.push(je);
338        }
339
340        jes
341    }
342}