Skip to main content

datasynth_core/models/
treasury.rs

1//! Treasury and cash management data models.
2//!
3//! This module provides comprehensive treasury models including:
4//! - Daily cash positions per entity/account/currency
5//! - Forward-looking cash forecasts with probability-weighted items
6//! - Cash pooling structures (physical, notional, zero-balancing)
7//! - Hedging instruments (FX forwards, IR swaps) under ASC 815 / IFRS 9
8//! - Hedge relationship designations with effectiveness testing
9//! - Debt instruments with amortization schedules and covenant monitoring
10//! - Bank guarantees and letters of credit
11//! - Intercompany netting runs with multilateral settlement
12
13use chrono::{NaiveDate, NaiveTime};
14use rust_decimal::Decimal;
15use serde::{Deserialize, Serialize};
16
17// ---------------------------------------------------------------------------
18// Enums
19// ---------------------------------------------------------------------------
20
21/// Category of a cash flow item in a treasury forecast.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum TreasuryCashFlowCategory {
25    /// Accounts receivable collection
26    #[default]
27    ArCollection,
28    /// Accounts payable payment
29    ApPayment,
30    /// Payroll disbursement
31    PayrollDisbursement,
32    /// Tax payment to authority
33    TaxPayment,
34    /// Debt principal and interest service
35    DebtService,
36    /// Capital expenditure
37    CapitalExpenditure,
38    /// Intercompany settlement
39    IntercompanySettlement,
40    /// Project milestone payment
41    ProjectMilestone,
42    /// Other / unclassified cash flow
43    Other,
44}
45
46/// Type of cash pool structure.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum PoolType {
50    /// Physical sweeping of balances to a header account
51    #[default]
52    PhysicalPooling,
53    /// Balances remain in sub-accounts; interest calculated on notional aggregate
54    NotionalPooling,
55    /// Sub-accounts are swept to zero daily
56    ZeroBalancing,
57}
58
59/// Type of hedging instrument.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
61#[serde(rename_all = "snake_case")]
62pub enum HedgeInstrumentType {
63    /// Foreign exchange forward contract
64    #[default]
65    FxForward,
66    /// Foreign exchange option
67    FxOption,
68    /// Interest rate swap
69    InterestRateSwap,
70    /// Commodity forward contract
71    CommodityForward,
72    /// Cross-currency interest rate swap
73    CrossCurrencySwap,
74}
75
76/// Lifecycle status of a hedging instrument.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
78#[serde(rename_all = "snake_case")]
79pub enum InstrumentStatus {
80    /// Instrument is live and outstanding
81    #[default]
82    Active,
83    /// Instrument has reached maturity date
84    Matured,
85    /// Instrument was terminated early
86    Terminated,
87    /// Instrument was novated to a new counterparty
88    Novated,
89}
90
91/// Type of hedged item under ASC 815 / IFRS 9.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
93#[serde(rename_all = "snake_case")]
94pub enum HedgedItemType {
95    /// Highly probable future transaction
96    #[default]
97    ForecastedTransaction,
98    /// Binding contractual commitment
99    FirmCommitment,
100    /// Asset or liability already on balance sheet
101    RecognizedAsset,
102    /// Net investment in a foreign operation
103    NetInvestment,
104}
105
106/// Hedge accounting classification under ASC 815 / IFRS 9.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum HedgeType {
110    /// Fair value hedge — hedges the fair value of an asset/liability
111    #[default]
112    FairValueHedge,
113    /// Cash flow hedge — hedges variability of future cash flows
114    CashFlowHedge,
115    /// Net investment hedge — hedges FX risk in foreign subsidiaries
116    NetInvestmentHedge,
117}
118
119/// Method used to test hedge effectiveness.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
121#[serde(rename_all = "snake_case")]
122pub enum EffectivenessMethod {
123    /// Dollar-offset method (ratio of cumulative changes)
124    #[default]
125    DollarOffset,
126    /// Statistical regression analysis
127    Regression,
128    /// Critical terms match (qualitative)
129    CriticalTerms,
130}
131
132/// Type of debt instrument.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
134#[serde(rename_all = "snake_case")]
135pub enum DebtType {
136    /// Amortizing term loan
137    #[default]
138    TermLoan,
139    /// Revolving credit facility
140    RevolvingCredit,
141    /// Bond issuance
142    Bond,
143    /// Commercial paper (short-term)
144    CommercialPaper,
145    /// Bridge loan (interim financing)
146    BridgeLoan,
147}
148
149/// Interest rate type on a debt instrument.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
151#[serde(rename_all = "snake_case")]
152pub enum InterestRateType {
153    /// Fixed interest rate for the life of the instrument
154    #[default]
155    Fixed,
156    /// Floating rate (index + spread)
157    Variable,
158}
159
160/// Type of financial covenant on a debt instrument.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
162#[serde(rename_all = "snake_case")]
163pub enum CovenantType {
164    /// Total debt / total equity
165    #[default]
166    DebtToEquity,
167    /// EBIT / interest expense
168    InterestCoverage,
169    /// Current assets / current liabilities
170    CurrentRatio,
171    /// Minimum net worth requirement
172    NetWorth,
173    /// Total debt / EBITDA
174    DebtToEbitda,
175    /// (EBITDA - CapEx) / fixed charges
176    FixedChargeCoverage,
177}
178
179/// Measurement frequency for covenant testing.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
181#[serde(rename_all = "snake_case")]
182pub enum Frequency {
183    /// Monthly measurement
184    Monthly,
185    /// Quarterly measurement
186    #[default]
187    Quarterly,
188    /// Annual measurement
189    Annual,
190}
191
192/// Type of bank guarantee or letter of credit.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195pub enum GuaranteeType {
196    /// Commercial letter of credit (trade finance)
197    #[default]
198    CommercialLc,
199    /// Standby letter of credit (financial guarantee)
200    StandbyLc,
201    /// Bank guarantee
202    BankGuarantee,
203    /// Performance bond
204    PerformanceBond,
205}
206
207/// Lifecycle status of a bank guarantee.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
209#[serde(rename_all = "snake_case")]
210pub enum GuaranteeStatus {
211    /// Guarantee is active
212    #[default]
213    Active,
214    /// Guarantee has been drawn upon
215    Drawn,
216    /// Guarantee has expired
217    Expired,
218    /// Guarantee was cancelled
219    Cancelled,
220}
221
222/// Netting cycle frequency.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
224#[serde(rename_all = "snake_case")]
225pub enum NettingCycle {
226    /// Daily netting
227    Daily,
228    /// Weekly netting
229    Weekly,
230    /// Monthly netting
231    #[default]
232    Monthly,
233}
234
235/// Settlement direction for a netting position.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
237#[serde(rename_all = "snake_case")]
238pub enum PayOrReceive {
239    /// Entity must pay the net amount
240    #[default]
241    Pay,
242    /// Entity will receive the net amount
243    Receive,
244    /// Entity's position is zero
245    Flat,
246}
247
248// ---------------------------------------------------------------------------
249// Structs
250// ---------------------------------------------------------------------------
251
252/// Daily cash position per entity / bank account / currency.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CashPosition {
255    /// Unique position identifier
256    pub id: String,
257    /// Legal entity
258    pub entity_id: String,
259    /// Bank account holding the cash
260    pub bank_account_id: String,
261    /// Position currency
262    pub currency: String,
263    /// Position date
264    pub date: NaiveDate,
265    /// Balance at start of day
266    #[serde(with = "rust_decimal::serde::str")]
267    pub opening_balance: Decimal,
268    /// Total inflows during the day
269    #[serde(with = "rust_decimal::serde::str")]
270    pub inflows: Decimal,
271    /// Total outflows during the day
272    #[serde(with = "rust_decimal::serde::str")]
273    pub outflows: Decimal,
274    /// Balance at end of day (opening + inflows - outflows)
275    #[serde(with = "rust_decimal::serde::str")]
276    pub closing_balance: Decimal,
277    /// Available balance (after holds, pending transactions)
278    #[serde(with = "rust_decimal::serde::str")]
279    pub available_balance: Decimal,
280    /// Value-date balance (settlement-adjusted)
281    #[serde(with = "rust_decimal::serde::str")]
282    pub value_date_balance: Decimal,
283}
284
285impl CashPosition {
286    /// Creates a new cash position.
287    #[allow(clippy::too_many_arguments)]
288    pub fn new(
289        id: impl Into<String>,
290        entity_id: impl Into<String>,
291        bank_account_id: impl Into<String>,
292        currency: impl Into<String>,
293        date: NaiveDate,
294        opening_balance: Decimal,
295        inflows: Decimal,
296        outflows: Decimal,
297    ) -> Self {
298        let closing = (opening_balance + inflows - outflows).round_dp(2);
299        Self {
300            id: id.into(),
301            entity_id: entity_id.into(),
302            bank_account_id: bank_account_id.into(),
303            currency: currency.into(),
304            date,
305            opening_balance,
306            inflows,
307            outflows,
308            closing_balance: closing,
309            available_balance: closing,
310            value_date_balance: closing,
311        }
312    }
313
314    /// Overrides the available balance.
315    pub fn with_available_balance(mut self, balance: Decimal) -> Self {
316        self.available_balance = balance;
317        self
318    }
319
320    /// Overrides the value-date balance.
321    pub fn with_value_date_balance(mut self, balance: Decimal) -> Self {
322        self.value_date_balance = balance;
323        self
324    }
325
326    /// Computes closing balance from opening + inflows - outflows.
327    pub fn computed_closing_balance(&self) -> Decimal {
328        (self.opening_balance + self.inflows - self.outflows).round_dp(2)
329    }
330}
331
332/// A single item in a cash forecast.
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct CashForecastItem {
335    /// Unique item identifier
336    pub id: String,
337    /// Expected date of the cash flow
338    pub date: NaiveDate,
339    /// Category of the forecast item
340    pub category: TreasuryCashFlowCategory,
341    /// Expected amount (positive = inflow, negative = outflow)
342    #[serde(with = "rust_decimal::serde::str")]
343    pub amount: Decimal,
344    /// Probability of occurrence (0.0 to 1.0)
345    #[serde(with = "rust_decimal::serde::str")]
346    pub probability: Decimal,
347    /// Source document type (e.g., "SalesOrder", "PurchaseOrder")
348    pub source_document_type: Option<String>,
349    /// Source document identifier
350    pub source_document_id: Option<String>,
351}
352
353/// Forward-looking cash forecast for an entity.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct CashForecast {
356    /// Unique forecast identifier
357    pub id: String,
358    /// Legal entity
359    pub entity_id: String,
360    /// Forecast currency
361    pub currency: String,
362    /// Date the forecast was prepared
363    pub forecast_date: NaiveDate,
364    /// Number of days the forecast covers
365    pub horizon_days: u32,
366    /// Individual forecast line items
367    pub items: Vec<CashForecastItem>,
368    /// Net position (sum of probability-weighted amounts)
369    #[serde(with = "rust_decimal::serde::str")]
370    pub net_position: Decimal,
371    /// Confidence level for the forecast (0.0 to 1.0)
372    #[serde(with = "rust_decimal::serde::str")]
373    pub confidence_level: Decimal,
374}
375
376impl CashForecast {
377    /// Creates a new cash forecast.
378    pub fn new(
379        id: impl Into<String>,
380        entity_id: impl Into<String>,
381        currency: impl Into<String>,
382        forecast_date: NaiveDate,
383        horizon_days: u32,
384        items: Vec<CashForecastItem>,
385        confidence_level: Decimal,
386    ) -> Self {
387        let net_position = items
388            .iter()
389            .map(|item| (item.amount * item.probability).round_dp(2))
390            .sum::<Decimal>()
391            .round_dp(2);
392        Self {
393            id: id.into(),
394            entity_id: entity_id.into(),
395            currency: currency.into(),
396            forecast_date,
397            horizon_days,
398            items,
399            net_position,
400            confidence_level,
401        }
402    }
403
404    /// Recomputes the net position from the probability-weighted items.
405    pub fn computed_net_position(&self) -> Decimal {
406        self.items
407            .iter()
408            .map(|item| (item.amount * item.probability).round_dp(2))
409            .sum::<Decimal>()
410            .round_dp(2)
411    }
412}
413
414/// Cash pool grouping entity bank accounts.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct CashPool {
417    /// Unique pool identifier
418    pub id: String,
419    /// Descriptive name
420    pub name: String,
421    /// Type of pooling structure
422    pub pool_type: PoolType,
423    /// Master / header account receiving sweeps
424    pub header_account_id: String,
425    /// Participant sub-account identifiers
426    pub participant_accounts: Vec<String>,
427    /// Time of day when sweeps occur
428    pub sweep_time: NaiveTime,
429    /// Interest rate benefit from pooling (bps or decimal fraction)
430    #[serde(with = "rust_decimal::serde::str")]
431    pub interest_rate_benefit: Decimal,
432}
433
434impl CashPool {
435    /// Creates a new cash pool.
436    pub fn new(
437        id: impl Into<String>,
438        name: impl Into<String>,
439        pool_type: PoolType,
440        header_account_id: impl Into<String>,
441        sweep_time: NaiveTime,
442    ) -> Self {
443        Self {
444            id: id.into(),
445            name: name.into(),
446            pool_type,
447            header_account_id: header_account_id.into(),
448            participant_accounts: Vec::new(),
449            sweep_time,
450            interest_rate_benefit: Decimal::ZERO,
451        }
452    }
453
454    /// Adds a participant account.
455    pub fn with_participant(mut self, account_id: impl Into<String>) -> Self {
456        self.participant_accounts.push(account_id.into());
457        self
458    }
459
460    /// Sets the interest rate benefit.
461    pub fn with_interest_rate_benefit(mut self, benefit: Decimal) -> Self {
462        self.interest_rate_benefit = benefit;
463        self
464    }
465
466    /// Returns the total number of accounts in the pool (header + participants).
467    pub fn total_accounts(&self) -> usize {
468        1 + self.participant_accounts.len()
469    }
470}
471
472/// A single sweep transaction within a cash pool.
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct CashPoolSweep {
475    /// Unique sweep identifier
476    pub id: String,
477    /// Pool this sweep belongs to
478    pub pool_id: String,
479    /// Date of the sweep
480    pub date: NaiveDate,
481    /// Source account (balance swept from)
482    pub from_account_id: String,
483    /// Destination account (balance swept to)
484    pub to_account_id: String,
485    /// Amount swept
486    #[serde(with = "rust_decimal::serde::str")]
487    pub amount: Decimal,
488    /// Currency of the sweep
489    pub currency: String,
490}
491
492/// A hedging instrument (derivative contract).
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct HedgingInstrument {
495    /// Unique instrument identifier
496    pub id: String,
497    /// Type of derivative
498    pub instrument_type: HedgeInstrumentType,
499    /// Notional / face amount
500    #[serde(with = "rust_decimal::serde::str")]
501    pub notional_amount: Decimal,
502    /// Primary currency
503    pub currency: String,
504    /// Currency pair for FX instruments (e.g., "EUR/USD")
505    pub currency_pair: Option<String>,
506    /// Fixed rate (for swaps, forwards)
507    #[serde(default, with = "rust_decimal::serde::str_option")]
508    pub fixed_rate: Option<Decimal>,
509    /// Floating rate index name (e.g., "SOFR", "EURIBOR")
510    pub floating_index: Option<String>,
511    /// Strike rate for options
512    #[serde(default, with = "rust_decimal::serde::str_option")]
513    pub strike_rate: Option<Decimal>,
514    /// Trade date
515    pub trade_date: NaiveDate,
516    /// Maturity / expiry date
517    pub maturity_date: NaiveDate,
518    /// Counterparty name
519    pub counterparty: String,
520    /// Current fair value (mark-to-market)
521    #[serde(with = "rust_decimal::serde::str")]
522    pub fair_value: Decimal,
523    /// Current lifecycle status
524    pub status: InstrumentStatus,
525}
526
527impl HedgingInstrument {
528    /// Creates a new hedging instrument.
529    #[allow(clippy::too_many_arguments)]
530    pub fn new(
531        id: impl Into<String>,
532        instrument_type: HedgeInstrumentType,
533        notional_amount: Decimal,
534        currency: impl Into<String>,
535        trade_date: NaiveDate,
536        maturity_date: NaiveDate,
537        counterparty: impl Into<String>,
538    ) -> Self {
539        Self {
540            id: id.into(),
541            instrument_type,
542            notional_amount,
543            currency: currency.into(),
544            currency_pair: None,
545            fixed_rate: None,
546            floating_index: None,
547            strike_rate: None,
548            trade_date,
549            maturity_date,
550            counterparty: counterparty.into(),
551            fair_value: Decimal::ZERO,
552            status: InstrumentStatus::Active,
553        }
554    }
555
556    /// Sets the currency pair.
557    pub fn with_currency_pair(mut self, pair: impl Into<String>) -> Self {
558        self.currency_pair = Some(pair.into());
559        self
560    }
561
562    /// Sets the fixed rate.
563    pub fn with_fixed_rate(mut self, rate: Decimal) -> Self {
564        self.fixed_rate = Some(rate);
565        self
566    }
567
568    /// Sets the floating rate index.
569    pub fn with_floating_index(mut self, index: impl Into<String>) -> Self {
570        self.floating_index = Some(index.into());
571        self
572    }
573
574    /// Sets the strike rate.
575    pub fn with_strike_rate(mut self, rate: Decimal) -> Self {
576        self.strike_rate = Some(rate);
577        self
578    }
579
580    /// Sets the fair value.
581    pub fn with_fair_value(mut self, value: Decimal) -> Self {
582        self.fair_value = value;
583        self
584    }
585
586    /// Sets the status.
587    pub fn with_status(mut self, status: InstrumentStatus) -> Self {
588        self.status = status;
589        self
590    }
591
592    /// Returns `true` if the instrument is still outstanding.
593    pub fn is_active(&self) -> bool {
594        self.status == InstrumentStatus::Active
595    }
596
597    /// Returns the remaining tenor in days from the given date.
598    /// Returns 0 if the instrument has already matured.
599    pub fn remaining_tenor_days(&self, as_of: NaiveDate) -> i64 {
600        (self.maturity_date - as_of).num_days().max(0)
601    }
602}
603
604/// ASC 815 / IFRS 9 hedge relationship designation.
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct HedgeRelationship {
607    /// Unique relationship identifier
608    pub id: String,
609    /// Type of hedged item
610    pub hedged_item_type: HedgedItemType,
611    /// Description of what is being hedged
612    pub hedged_item_description: String,
613    /// Hedging instrument linked to this relationship
614    pub hedging_instrument_id: String,
615    /// Hedge accounting classification
616    pub hedge_type: HedgeType,
617    /// Date the hedge was designated
618    pub designation_date: NaiveDate,
619    /// Method used for effectiveness testing
620    pub effectiveness_test_method: EffectivenessMethod,
621    /// Effectiveness ratio (hedging instrument change / hedged item change)
622    #[serde(with = "rust_decimal::serde::str")]
623    pub effectiveness_ratio: Decimal,
624    /// Whether the hedge qualifies as effective (ratio within 80-125%)
625    pub is_effective: bool,
626    /// Ineffectiveness amount recognized in P&L
627    #[serde(with = "rust_decimal::serde::str")]
628    pub ineffectiveness_amount: Decimal,
629}
630
631impl HedgeRelationship {
632    /// Creates a new hedge relationship.
633    #[allow(clippy::too_many_arguments)]
634    pub fn new(
635        id: impl Into<String>,
636        hedged_item_type: HedgedItemType,
637        hedged_item_description: impl Into<String>,
638        hedging_instrument_id: impl Into<String>,
639        hedge_type: HedgeType,
640        designation_date: NaiveDate,
641        effectiveness_test_method: EffectivenessMethod,
642        effectiveness_ratio: Decimal,
643    ) -> Self {
644        let is_effective = Self::check_effectiveness(effectiveness_ratio);
645        Self {
646            id: id.into(),
647            hedged_item_type,
648            hedged_item_description: hedged_item_description.into(),
649            hedging_instrument_id: hedging_instrument_id.into(),
650            hedge_type,
651            designation_date,
652            effectiveness_test_method,
653            effectiveness_ratio,
654            is_effective,
655            ineffectiveness_amount: Decimal::ZERO,
656        }
657    }
658
659    /// Sets the ineffectiveness amount.
660    pub fn with_ineffectiveness_amount(mut self, amount: Decimal) -> Self {
661        self.ineffectiveness_amount = amount;
662        self
663    }
664
665    /// Checks whether the effectiveness ratio is within the 80-125% corridor.
666    ///
667    /// Under ASC 815 / IAS 39, a hedge is considered highly effective if the
668    /// ratio of changes in the hedging instrument to changes in the hedged item
669    /// falls within 0.80 to 1.25.
670    pub fn check_effectiveness(ratio: Decimal) -> bool {
671        let lower = Decimal::new(80, 2); // 0.80
672        let upper = Decimal::new(125, 2); // 1.25
673        ratio >= lower && ratio <= upper
674    }
675
676    /// Recomputes the `is_effective` flag from the current ratio.
677    pub fn update_effectiveness(&mut self) {
678        self.is_effective = Self::check_effectiveness(self.effectiveness_ratio);
679    }
680}
681
682/// A single payment in a debt amortization schedule.
683#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct AmortizationPayment {
685    /// Payment date
686    pub date: NaiveDate,
687    /// Principal portion of the payment
688    #[serde(with = "rust_decimal::serde::str")]
689    pub principal_payment: Decimal,
690    /// Interest portion of the payment
691    #[serde(with = "rust_decimal::serde::str")]
692    pub interest_payment: Decimal,
693    /// Outstanding balance after this payment
694    #[serde(with = "rust_decimal::serde::str")]
695    pub balance_after: Decimal,
696}
697
698impl AmortizationPayment {
699    /// Total payment (principal + interest).
700    pub fn total_payment(&self) -> Decimal {
701        (self.principal_payment + self.interest_payment).round_dp(2)
702    }
703}
704
705/// A financial covenant attached to a debt instrument.
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct DebtCovenant {
708    /// Unique covenant identifier
709    pub id: String,
710    /// Type of financial ratio being tested
711    pub covenant_type: CovenantType,
712    /// Covenant threshold value
713    #[serde(with = "rust_decimal::serde::str")]
714    pub threshold: Decimal,
715    /// How often the covenant is tested
716    pub measurement_frequency: Frequency,
717    /// Most recent actual measured value
718    #[serde(with = "rust_decimal::serde::str")]
719    pub actual_value: Decimal,
720    /// Date the measurement was taken
721    pub measurement_date: NaiveDate,
722    /// Whether the entity is in compliance
723    pub is_compliant: bool,
724    /// Distance from the covenant threshold (positive = headroom, negative = breach)
725    #[serde(with = "rust_decimal::serde::str")]
726    pub headroom: Decimal,
727    /// Whether a waiver was obtained for a breach
728    pub waiver_obtained: bool,
729}
730
731impl DebtCovenant {
732    /// Creates a new debt covenant.
733    #[allow(clippy::too_many_arguments)]
734    pub fn new(
735        id: impl Into<String>,
736        covenant_type: CovenantType,
737        threshold: Decimal,
738        measurement_frequency: Frequency,
739        actual_value: Decimal,
740        measurement_date: NaiveDate,
741    ) -> Self {
742        let (is_compliant, headroom) =
743            Self::evaluate_compliance(covenant_type, threshold, actual_value);
744        Self {
745            id: id.into(),
746            covenant_type,
747            threshold,
748            measurement_frequency,
749            actual_value,
750            measurement_date,
751            is_compliant,
752            headroom,
753            waiver_obtained: false,
754        }
755    }
756
757    /// Sets the waiver flag.
758    pub fn with_waiver(mut self, waiver: bool) -> Self {
759        self.waiver_obtained = waiver;
760        self
761    }
762
763    /// Evaluates compliance and computes headroom.
764    ///
765    /// For "maximum" covenants (DebtToEquity, DebtToEbitda): actual must be ≤ threshold.
766    /// For "minimum" covenants (InterestCoverage, CurrentRatio, NetWorth, FixedChargeCoverage):
767    /// actual must be ≥ threshold.
768    fn evaluate_compliance(
769        covenant_type: CovenantType,
770        threshold: Decimal,
771        actual_value: Decimal,
772    ) -> (bool, Decimal) {
773        match covenant_type {
774            // Maximum covenants: actual <= threshold means compliant
775            CovenantType::DebtToEquity | CovenantType::DebtToEbitda => {
776                let headroom = (threshold - actual_value).round_dp(4);
777                (actual_value <= threshold, headroom)
778            }
779            // Minimum covenants: actual >= threshold means compliant
780            CovenantType::InterestCoverage
781            | CovenantType::CurrentRatio
782            | CovenantType::NetWorth
783            | CovenantType::FixedChargeCoverage => {
784                let headroom = (actual_value - threshold).round_dp(4);
785                (actual_value >= threshold, headroom)
786            }
787        }
788    }
789
790    /// Recomputes compliance and headroom from current values.
791    pub fn update_compliance(&mut self) {
792        let (compliant, headroom) =
793            Self::evaluate_compliance(self.covenant_type, self.threshold, self.actual_value);
794        self.is_compliant = compliant;
795        self.headroom = headroom;
796    }
797}
798
799/// A debt instrument (loan, bond, credit facility).
800#[derive(Debug, Clone, Serialize, Deserialize)]
801pub struct DebtInstrument {
802    /// Unique instrument identifier
803    pub id: String,
804    /// Legal entity borrower
805    pub entity_id: String,
806    /// Type of debt instrument
807    pub instrument_type: DebtType,
808    /// Lender / creditor name
809    pub lender: String,
810    /// Original principal amount
811    #[serde(with = "rust_decimal::serde::str")]
812    pub principal: Decimal,
813    /// Denomination currency
814    pub currency: String,
815    /// Interest rate (annual, as decimal fraction)
816    #[serde(with = "rust_decimal::serde::str")]
817    pub interest_rate: Decimal,
818    /// Fixed or variable rate
819    pub rate_type: InterestRateType,
820    /// Date the instrument was originated
821    pub origination_date: NaiveDate,
822    /// Contractual maturity date
823    pub maturity_date: NaiveDate,
824    /// Amortization schedule (empty for bullet / revolving)
825    pub amortization_schedule: Vec<AmortizationPayment>,
826    /// Associated financial covenants
827    pub covenants: Vec<DebtCovenant>,
828    /// Current drawn amount (for revolving facilities)
829    #[serde(with = "rust_decimal::serde::str")]
830    pub drawn_amount: Decimal,
831    /// Committed facility limit (for revolving facilities)
832    #[serde(with = "rust_decimal::serde::str")]
833    pub facility_limit: Decimal,
834}
835
836impl DebtInstrument {
837    /// Creates a new debt instrument.
838    #[allow(clippy::too_many_arguments)]
839    pub fn new(
840        id: impl Into<String>,
841        entity_id: impl Into<String>,
842        instrument_type: DebtType,
843        lender: impl Into<String>,
844        principal: Decimal,
845        currency: impl Into<String>,
846        interest_rate: Decimal,
847        rate_type: InterestRateType,
848        origination_date: NaiveDate,
849        maturity_date: NaiveDate,
850    ) -> Self {
851        Self {
852            id: id.into(),
853            entity_id: entity_id.into(),
854            instrument_type,
855            lender: lender.into(),
856            principal,
857            currency: currency.into(),
858            interest_rate,
859            rate_type,
860            origination_date,
861            maturity_date,
862            amortization_schedule: Vec::new(),
863            covenants: Vec::new(),
864            drawn_amount: principal,
865            facility_limit: principal,
866        }
867    }
868
869    /// Sets the amortization schedule.
870    pub fn with_amortization_schedule(mut self, schedule: Vec<AmortizationPayment>) -> Self {
871        self.amortization_schedule = schedule;
872        self
873    }
874
875    /// Adds a covenant.
876    pub fn with_covenant(mut self, covenant: DebtCovenant) -> Self {
877        self.covenants.push(covenant);
878        self
879    }
880
881    /// Sets the drawn amount (for revolving facilities).
882    pub fn with_drawn_amount(mut self, amount: Decimal) -> Self {
883        self.drawn_amount = amount;
884        self
885    }
886
887    /// Sets the facility limit (for revolving facilities).
888    pub fn with_facility_limit(mut self, limit: Decimal) -> Self {
889        self.facility_limit = limit;
890        self
891    }
892
893    /// Returns the total principal payments across the amortization schedule.
894    pub fn total_principal_payments(&self) -> Decimal {
895        self.amortization_schedule
896            .iter()
897            .map(|p| p.principal_payment)
898            .sum::<Decimal>()
899            .round_dp(2)
900    }
901
902    /// Returns the total interest payments across the amortization schedule.
903    pub fn total_interest_payments(&self) -> Decimal {
904        self.amortization_schedule
905            .iter()
906            .map(|p| p.interest_payment)
907            .sum::<Decimal>()
908            .round_dp(2)
909    }
910
911    /// Returns available capacity on a revolving credit facility.
912    pub fn available_capacity(&self) -> Decimal {
913        (self.facility_limit - self.drawn_amount).round_dp(2)
914    }
915
916    /// Returns `true` if all covenants are compliant.
917    pub fn all_covenants_compliant(&self) -> bool {
918        self.covenants
919            .iter()
920            .all(|c| c.is_compliant || c.waiver_obtained)
921    }
922}
923
924/// A bank guarantee or letter of credit.
925#[derive(Debug, Clone, Serialize, Deserialize)]
926pub struct BankGuarantee {
927    /// Unique guarantee identifier
928    pub id: String,
929    /// Legal entity that obtained the guarantee
930    pub entity_id: String,
931    /// Type of guarantee
932    pub guarantee_type: GuaranteeType,
933    /// Face amount of the guarantee
934    #[serde(with = "rust_decimal::serde::str")]
935    pub amount: Decimal,
936    /// Denomination currency
937    pub currency: String,
938    /// Party in whose favour the guarantee is issued
939    pub beneficiary: String,
940    /// Bank that issued the guarantee
941    pub issuing_bank: String,
942    /// Issue date
943    pub issue_date: NaiveDate,
944    /// Expiry date
945    pub expiry_date: NaiveDate,
946    /// Current lifecycle status
947    pub status: GuaranteeStatus,
948    /// Linked procurement contract (if applicable)
949    pub linked_contract_id: Option<String>,
950    /// Linked project (if applicable)
951    pub linked_project_id: Option<String>,
952}
953
954impl BankGuarantee {
955    /// Creates a new bank guarantee.
956    #[allow(clippy::too_many_arguments)]
957    pub fn new(
958        id: impl Into<String>,
959        entity_id: impl Into<String>,
960        guarantee_type: GuaranteeType,
961        amount: Decimal,
962        currency: impl Into<String>,
963        beneficiary: impl Into<String>,
964        issuing_bank: impl Into<String>,
965        issue_date: NaiveDate,
966        expiry_date: NaiveDate,
967    ) -> Self {
968        Self {
969            id: id.into(),
970            entity_id: entity_id.into(),
971            guarantee_type,
972            amount,
973            currency: currency.into(),
974            beneficiary: beneficiary.into(),
975            issuing_bank: issuing_bank.into(),
976            issue_date,
977            expiry_date,
978            status: GuaranteeStatus::Active,
979            linked_contract_id: None,
980            linked_project_id: None,
981        }
982    }
983
984    /// Sets the status.
985    pub fn with_status(mut self, status: GuaranteeStatus) -> Self {
986        self.status = status;
987        self
988    }
989
990    /// Links to a procurement contract.
991    pub fn with_linked_contract(mut self, contract_id: impl Into<String>) -> Self {
992        self.linked_contract_id = Some(contract_id.into());
993        self
994    }
995
996    /// Links to a project.
997    pub fn with_linked_project(mut self, project_id: impl Into<String>) -> Self {
998        self.linked_project_id = Some(project_id.into());
999        self
1000    }
1001
1002    /// Returns `true` if the guarantee is active on the given date.
1003    pub fn is_active_on(&self, date: NaiveDate) -> bool {
1004        self.status == GuaranteeStatus::Active
1005            && date >= self.issue_date
1006            && date <= self.expiry_date
1007    }
1008
1009    /// Returns the remaining validity in days from the given date.
1010    pub fn remaining_days(&self, as_of: NaiveDate) -> i64 {
1011        (self.expiry_date - as_of).num_days().max(0)
1012    }
1013}
1014
1015/// A netting position for a single entity within a netting run.
1016#[derive(Debug, Clone, Serialize, Deserialize)]
1017pub struct NettingPosition {
1018    /// Entity identifier
1019    pub entity_id: String,
1020    /// Gross amount receivable from other entities
1021    #[serde(with = "rust_decimal::serde::str")]
1022    pub gross_receivable: Decimal,
1023    /// Gross amount payable to other entities
1024    #[serde(with = "rust_decimal::serde::str")]
1025    pub gross_payable: Decimal,
1026    /// Net position (receivable - payable)
1027    #[serde(with = "rust_decimal::serde::str")]
1028    pub net_position: Decimal,
1029    /// Whether this entity pays or receives
1030    pub settlement_direction: PayOrReceive,
1031}
1032
1033/// An intercompany netting run.
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1035pub struct NettingRun {
1036    /// Unique netting run identifier
1037    pub id: String,
1038    /// Settlement date
1039    pub netting_date: NaiveDate,
1040    /// Netting cycle frequency
1041    pub cycle: NettingCycle,
1042    /// List of participating entity IDs
1043    pub participating_entities: Vec<String>,
1044    /// Total gross receivables across all entities
1045    #[serde(with = "rust_decimal::serde::str")]
1046    pub gross_receivables: Decimal,
1047    /// Total gross payables across all entities
1048    #[serde(with = "rust_decimal::serde::str")]
1049    pub gross_payables: Decimal,
1050    /// Net settlement amount (sum of absolute net positions / 2)
1051    #[serde(with = "rust_decimal::serde::str")]
1052    pub net_settlement: Decimal,
1053    /// Settlement currency
1054    pub settlement_currency: String,
1055    /// Per-entity positions
1056    pub positions: Vec<NettingPosition>,
1057}
1058
1059impl NettingRun {
1060    /// Creates a new netting run.
1061    #[allow(clippy::too_many_arguments)]
1062    pub fn new(
1063        id: impl Into<String>,
1064        netting_date: NaiveDate,
1065        cycle: NettingCycle,
1066        settlement_currency: impl Into<String>,
1067        positions: Vec<NettingPosition>,
1068    ) -> Self {
1069        let participating_entities: Vec<String> =
1070            positions.iter().map(|p| p.entity_id.clone()).collect();
1071        let gross_receivables = positions
1072            .iter()
1073            .map(|p| p.gross_receivable)
1074            .sum::<Decimal>()
1075            .round_dp(2);
1076        let gross_payables = positions
1077            .iter()
1078            .map(|p| p.gross_payable)
1079            .sum::<Decimal>()
1080            .round_dp(2);
1081        let net_settlement = positions
1082            .iter()
1083            .map(|p| p.net_position.abs())
1084            .sum::<Decimal>()
1085            .round_dp(2)
1086            / Decimal::TWO;
1087        Self {
1088            id: id.into(),
1089            netting_date,
1090            cycle,
1091            participating_entities,
1092            gross_receivables,
1093            gross_payables,
1094            net_settlement: net_settlement.round_dp(2),
1095            settlement_currency: settlement_currency.into(),
1096            positions,
1097        }
1098    }
1099
1100    /// Payment savings from netting: gross flows eliminated.
1101    ///
1102    /// `savings = max(gross_receivables, gross_payables) - net_settlement`
1103    pub fn savings(&self) -> Decimal {
1104        let gross_max = self.gross_receivables.max(self.gross_payables);
1105        (gross_max - self.net_settlement).round_dp(2)
1106    }
1107
1108    /// Savings as a percentage of gross flows.
1109    pub fn savings_pct(&self) -> Decimal {
1110        let gross_max = self.gross_receivables.max(self.gross_payables);
1111        if gross_max.is_zero() {
1112            return Decimal::ZERO;
1113        }
1114        (self.savings() / gross_max * Decimal::ONE_HUNDRED).round_dp(2)
1115    }
1116}
1117
1118// ---------------------------------------------------------------------------
1119// Tests
1120// ---------------------------------------------------------------------------
1121
1122#[cfg(test)]
1123#[allow(clippy::unwrap_used)]
1124mod tests {
1125    use super::*;
1126    use rust_decimal_macros::dec;
1127
1128    #[test]
1129    fn test_cash_position_closing_balance() {
1130        let pos = CashPosition::new(
1131            "CP-001",
1132            "C001",
1133            "BA-001",
1134            "USD",
1135            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1136            dec!(10000),
1137            dec!(5000),
1138            dec!(2000),
1139        );
1140        // closing = 10000 + 5000 - 2000 = 13000
1141        assert_eq!(pos.closing_balance, dec!(13000));
1142        assert_eq!(pos.computed_closing_balance(), dec!(13000));
1143        assert_eq!(pos.available_balance, dec!(13000)); // defaults to closing
1144    }
1145
1146    #[test]
1147    fn test_cash_position_with_overrides() {
1148        let pos = CashPosition::new(
1149            "CP-002",
1150            "C001",
1151            "BA-001",
1152            "USD",
1153            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1154            dec!(10000),
1155            dec!(5000),
1156            dec!(2000),
1157        )
1158        .with_available_balance(dec!(12000))
1159        .with_value_date_balance(dec!(12500));
1160
1161        assert_eq!(pos.closing_balance, dec!(13000));
1162        assert_eq!(pos.available_balance, dec!(12000));
1163        assert_eq!(pos.value_date_balance, dec!(12500));
1164    }
1165
1166    #[test]
1167    fn test_cash_forecast_net_position() {
1168        let items = vec![
1169            CashForecastItem {
1170                id: "CFI-001".to_string(),
1171                date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
1172                category: TreasuryCashFlowCategory::ArCollection,
1173                amount: dec!(50000),
1174                probability: dec!(0.90),
1175                source_document_type: Some("SalesOrder".to_string()),
1176                source_document_id: Some("SO-001".to_string()),
1177            },
1178            CashForecastItem {
1179                id: "CFI-002".to_string(),
1180                date: NaiveDate::from_ymd_opt(2025, 2, 5).unwrap(),
1181                category: TreasuryCashFlowCategory::ApPayment,
1182                amount: dec!(-30000),
1183                probability: dec!(1.00),
1184                source_document_type: Some("PurchaseOrder".to_string()),
1185                source_document_id: Some("PO-001".to_string()),
1186            },
1187            CashForecastItem {
1188                id: "CFI-003".to_string(),
1189                date: NaiveDate::from_ymd_opt(2025, 2, 15).unwrap(),
1190                category: TreasuryCashFlowCategory::TaxPayment,
1191                amount: dec!(-10000),
1192                probability: dec!(1.00),
1193                source_document_type: None,
1194                source_document_id: None,
1195            },
1196        ];
1197        let forecast = CashForecast::new(
1198            "CF-001",
1199            "C001",
1200            "USD",
1201            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
1202            30,
1203            items,
1204            dec!(0.90),
1205        );
1206
1207        // net = (50000 * 0.90) + (-30000 * 1.00) + (-10000 * 1.00)
1208        //     = 45000 - 30000 - 10000 = 5000
1209        assert_eq!(forecast.net_position, dec!(5000));
1210        assert_eq!(forecast.computed_net_position(), dec!(5000));
1211        assert_eq!(forecast.items.len(), 3);
1212    }
1213
1214    #[test]
1215    fn test_cash_pool_total_accounts() {
1216        let pool = CashPool::new(
1217            "POOL-001",
1218            "EUR Cash Pool",
1219            PoolType::ZeroBalancing,
1220            "BA-HEADER",
1221            NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1222        )
1223        .with_participant("BA-001")
1224        .with_participant("BA-002")
1225        .with_participant("BA-003")
1226        .with_interest_rate_benefit(dec!(0.0025));
1227
1228        assert_eq!(pool.total_accounts(), 4); // header + 3 participants
1229        assert_eq!(pool.interest_rate_benefit, dec!(0.0025));
1230        assert_eq!(pool.pool_type, PoolType::ZeroBalancing);
1231    }
1232
1233    #[test]
1234    fn test_hedging_instrument_lifecycle() {
1235        let instr = HedgingInstrument::new(
1236            "HI-001",
1237            HedgeInstrumentType::FxForward,
1238            dec!(1000000),
1239            "EUR",
1240            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1241            NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1242            "Deutsche Bank",
1243        )
1244        .with_currency_pair("EUR/USD")
1245        .with_fixed_rate(dec!(1.0850))
1246        .with_fair_value(dec!(15000));
1247
1248        assert!(instr.is_active());
1249        assert_eq!(
1250            instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
1251            107 // 2025-03-15 to 2025-06-30
1252        );
1253        assert_eq!(instr.currency_pair, Some("EUR/USD".to_string()));
1254        assert_eq!(instr.fixed_rate, Some(dec!(1.0850)));
1255
1256        // Terminate
1257        let terminated = instr.with_status(InstrumentStatus::Terminated);
1258        assert!(!terminated.is_active());
1259    }
1260
1261    #[test]
1262    fn test_hedging_instrument_remaining_tenor_past_maturity() {
1263        let instr = HedgingInstrument::new(
1264            "HI-002",
1265            HedgeInstrumentType::InterestRateSwap,
1266            dec!(5000000),
1267            "USD",
1268            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1269            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1270            "JPMorgan",
1271        );
1272
1273        // Past maturity → 0 days
1274        assert_eq!(
1275            instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()),
1276            0
1277        );
1278    }
1279
1280    #[test]
1281    fn test_hedge_relationship_effectiveness() {
1282        // Effective: ratio = 0.95 (within 80-125%)
1283        let effective = HedgeRelationship::new(
1284            "HR-001",
1285            HedgedItemType::ForecastedTransaction,
1286            "Forecasted EUR revenue Q2 2025",
1287            "HI-001",
1288            HedgeType::CashFlowHedge,
1289            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1290            EffectivenessMethod::Regression,
1291            dec!(0.95),
1292        );
1293        assert!(effective.is_effective);
1294        assert!(HedgeRelationship::check_effectiveness(dec!(0.80))); // boundary
1295        assert!(HedgeRelationship::check_effectiveness(dec!(1.25))); // boundary
1296
1297        // Ineffective: ratio = 0.75 (below 80%)
1298        let ineffective = HedgeRelationship::new(
1299            "HR-002",
1300            HedgedItemType::FirmCommitment,
1301            "Committed USD purchase",
1302            "HI-002",
1303            HedgeType::FairValueHedge,
1304            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1305            EffectivenessMethod::DollarOffset,
1306            dec!(0.75),
1307        )
1308        .with_ineffectiveness_amount(dec!(25000));
1309        assert!(!ineffective.is_effective);
1310        assert_eq!(ineffective.ineffectiveness_amount, dec!(25000));
1311
1312        // Boundaries
1313        assert!(!HedgeRelationship::check_effectiveness(dec!(0.79)));
1314        assert!(!HedgeRelationship::check_effectiveness(dec!(1.26)));
1315    }
1316
1317    #[test]
1318    fn test_debt_covenant_compliance() {
1319        // Maximum covenant (DebtToEbitda): actual 2.8 <= threshold 3.5 → compliant
1320        let compliant = DebtCovenant::new(
1321            "COV-001",
1322            CovenantType::DebtToEbitda,
1323            dec!(3.5),
1324            Frequency::Quarterly,
1325            dec!(2.8),
1326            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1327        );
1328        assert!(compliant.is_compliant);
1329        assert_eq!(compliant.headroom, dec!(0.7)); // 3.5 - 2.8
1330
1331        // Maximum covenant breached: actual 4.0 > threshold 3.5
1332        let breached = DebtCovenant::new(
1333            "COV-002",
1334            CovenantType::DebtToEbitda,
1335            dec!(3.5),
1336            Frequency::Quarterly,
1337            dec!(4.0),
1338            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1339        );
1340        assert!(!breached.is_compliant);
1341        assert_eq!(breached.headroom, dec!(-0.5)); // negative = breach
1342
1343        // Minimum covenant (InterestCoverage): actual 4.5 >= threshold 3.0 → compliant
1344        let min_compliant = DebtCovenant::new(
1345            "COV-003",
1346            CovenantType::InterestCoverage,
1347            dec!(3.0),
1348            Frequency::Quarterly,
1349            dec!(4.5),
1350            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1351        );
1352        assert!(min_compliant.is_compliant);
1353        assert_eq!(min_compliant.headroom, dec!(1.5)); // 4.5 - 3.0
1354
1355        // Minimum covenant breached: actual 2.5 < threshold 3.0
1356        let min_breached = DebtCovenant::new(
1357            "COV-004",
1358            CovenantType::InterestCoverage,
1359            dec!(3.0),
1360            Frequency::Quarterly,
1361            dec!(2.5),
1362            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1363        );
1364        assert!(!min_breached.is_compliant);
1365        assert_eq!(min_breached.headroom, dec!(-0.5));
1366
1367        // With waiver
1368        let waived = DebtCovenant::new(
1369            "COV-005",
1370            CovenantType::DebtToEquity,
1371            dec!(2.0),
1372            Frequency::Annual,
1373            dec!(2.5),
1374            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1375        )
1376        .with_waiver(true);
1377        assert!(!waived.is_compliant); // technically breached
1378        assert!(waived.waiver_obtained); // but waiver obtained
1379    }
1380
1381    #[test]
1382    fn test_debt_instrument_amortization() {
1383        let schedule = vec![
1384            AmortizationPayment {
1385                date: NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1386                principal_payment: dec!(250000),
1387                interest_payment: dec!(68750),
1388                balance_after: dec!(4750000),
1389            },
1390            AmortizationPayment {
1391                date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1392                principal_payment: dec!(250000),
1393                interest_payment: dec!(65312.50),
1394                balance_after: dec!(4500000),
1395            },
1396            AmortizationPayment {
1397                date: NaiveDate::from_ymd_opt(2025, 9, 30).unwrap(),
1398                principal_payment: dec!(250000),
1399                interest_payment: dec!(61875),
1400                balance_after: dec!(4250000),
1401            },
1402            AmortizationPayment {
1403                date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1404                principal_payment: dec!(250000),
1405                interest_payment: dec!(58437.50),
1406                balance_after: dec!(4000000),
1407            },
1408        ];
1409
1410        let debt = DebtInstrument::new(
1411            "DEBT-001",
1412            "C001",
1413            DebtType::TermLoan,
1414            "First National Bank",
1415            dec!(5000000),
1416            "USD",
1417            dec!(0.055),
1418            InterestRateType::Fixed,
1419            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1420            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1421        )
1422        .with_amortization_schedule(schedule);
1423
1424        assert_eq!(debt.total_principal_payments(), dec!(1000000));
1425        assert_eq!(debt.total_interest_payments(), dec!(254375));
1426        assert_eq!(debt.amortization_schedule[0].total_payment(), dec!(318750));
1427    }
1428
1429    #[test]
1430    fn test_debt_instrument_revolving_credit() {
1431        let revolver = DebtInstrument::new(
1432            "DEBT-002",
1433            "C001",
1434            DebtType::RevolvingCredit,
1435            "Wells Fargo",
1436            dec!(0),
1437            "USD",
1438            dec!(0.045),
1439            InterestRateType::Variable,
1440            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1441            NaiveDate::from_ymd_opt(2028, 1, 1).unwrap(),
1442        )
1443        .with_drawn_amount(dec!(800000))
1444        .with_facility_limit(dec!(2000000));
1445
1446        assert_eq!(revolver.available_capacity(), dec!(1200000));
1447    }
1448
1449    #[test]
1450    fn test_debt_instrument_all_covenants_compliant() {
1451        let debt = DebtInstrument::new(
1452            "DEBT-003",
1453            "C001",
1454            DebtType::TermLoan,
1455            "Citibank",
1456            dec!(3000000),
1457            "USD",
1458            dec!(0.05),
1459            InterestRateType::Fixed,
1460            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1461            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1462        )
1463        .with_covenant(DebtCovenant::new(
1464            "COV-A",
1465            CovenantType::DebtToEbitda,
1466            dec!(3.5),
1467            Frequency::Quarterly,
1468            dec!(2.5),
1469            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1470        ))
1471        .with_covenant(DebtCovenant::new(
1472            "COV-B",
1473            CovenantType::InterestCoverage,
1474            dec!(3.0),
1475            Frequency::Quarterly,
1476            dec!(5.0),
1477            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1478        ));
1479
1480        assert!(debt.all_covenants_compliant());
1481
1482        // Add a breached covenant with waiver
1483        let debt_waived = debt.with_covenant(
1484            DebtCovenant::new(
1485                "COV-C",
1486                CovenantType::CurrentRatio,
1487                dec!(1.5),
1488                Frequency::Quarterly,
1489                dec!(1.2), // breached
1490                NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1491            )
1492            .with_waiver(true),
1493        );
1494        assert!(debt_waived.all_covenants_compliant()); // waiver counts
1495    }
1496
1497    #[test]
1498    fn test_bank_guarantee_active_check() {
1499        let guarantee = BankGuarantee::new(
1500            "BG-001",
1501            "C001",
1502            GuaranteeType::PerformanceBond,
1503            dec!(500000),
1504            "USD",
1505            "Construction Corp",
1506            "HSBC",
1507            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1508            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1509        )
1510        .with_linked_project("PROJ-001");
1511
1512        // Active within range
1513        assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1514        // Before issue
1515        assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()));
1516        // After expiry
1517        assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1518        // On expiry (inclusive)
1519        assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1520
1521        // Remaining days
1522        assert_eq!(
1523            guarantee.remaining_days(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()),
1524            199
1525        );
1526        assert_eq!(
1527            guarantee.remaining_days(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()),
1528            0 // past expiry
1529        );
1530
1531        // Drawn status
1532        let drawn = BankGuarantee::new(
1533            "BG-002",
1534            "C001",
1535            GuaranteeType::StandbyLc,
1536            dec!(200000),
1537            "EUR",
1538            "Supplier GmbH",
1539            "Deutsche Bank",
1540            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1541            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1542        )
1543        .with_status(GuaranteeStatus::Drawn);
1544        assert!(!drawn.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1545    }
1546
1547    #[test]
1548    fn test_netting_run_savings() {
1549        let positions = vec![
1550            NettingPosition {
1551                entity_id: "C001".to_string(),
1552                gross_receivable: dec!(100000),
1553                gross_payable: dec!(60000),
1554                net_position: dec!(40000),
1555                settlement_direction: PayOrReceive::Receive,
1556            },
1557            NettingPosition {
1558                entity_id: "C002".to_string(),
1559                gross_receivable: dec!(80000),
1560                gross_payable: dec!(90000),
1561                net_position: dec!(-10000),
1562                settlement_direction: PayOrReceive::Pay,
1563            },
1564            NettingPosition {
1565                entity_id: "C003".to_string(),
1566                gross_receivable: dec!(50000),
1567                gross_payable: dec!(80000),
1568                net_position: dec!(-30000),
1569                settlement_direction: PayOrReceive::Pay,
1570            },
1571        ];
1572
1573        let run = NettingRun::new(
1574            "NR-001",
1575            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
1576            NettingCycle::Monthly,
1577            "USD",
1578            positions,
1579        );
1580
1581        assert_eq!(run.gross_receivables, dec!(230000));
1582        assert_eq!(run.gross_payables, dec!(230000));
1583        // net_settlement = sum(|net_position|) / 2 = (40000 + 10000 + 30000) / 2 = 40000
1584        assert_eq!(run.net_settlement, dec!(40000));
1585        // savings = max(230000, 230000) - 40000 = 190000
1586        assert_eq!(run.savings(), dec!(190000));
1587        assert_eq!(run.participating_entities.len(), 3);
1588    }
1589
1590    #[test]
1591    fn test_netting_run_savings_pct() {
1592        let positions = vec![
1593            NettingPosition {
1594                entity_id: "C001".to_string(),
1595                gross_receivable: dec!(100000),
1596                gross_payable: dec!(0),
1597                net_position: dec!(100000),
1598                settlement_direction: PayOrReceive::Receive,
1599            },
1600            NettingPosition {
1601                entity_id: "C002".to_string(),
1602                gross_receivable: dec!(0),
1603                gross_payable: dec!(100000),
1604                net_position: dec!(-100000),
1605                settlement_direction: PayOrReceive::Pay,
1606            },
1607        ];
1608
1609        let run = NettingRun::new(
1610            "NR-002",
1611            NaiveDate::from_ymd_opt(2025, 2, 28).unwrap(),
1612            NettingCycle::Monthly,
1613            "EUR",
1614            positions,
1615        );
1616
1617        // No savings when perfectly bilateral
1618        assert_eq!(run.net_settlement, dec!(100000));
1619        assert_eq!(run.savings(), dec!(0));
1620        assert_eq!(run.savings_pct(), dec!(0));
1621    }
1622
1623    #[test]
1624    fn test_cash_pool_sweep() {
1625        let sweep = CashPoolSweep {
1626            id: "SWP-001".to_string(),
1627            pool_id: "POOL-001".to_string(),
1628            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1629            from_account_id: "BA-001".to_string(),
1630            to_account_id: "BA-HEADER".to_string(),
1631            amount: dec!(50000),
1632            currency: "EUR".to_string(),
1633        };
1634
1635        assert_eq!(sweep.amount, dec!(50000));
1636        assert_eq!(sweep.pool_id, "POOL-001");
1637    }
1638
1639    #[test]
1640    fn test_serde_roundtrip_cash_position() {
1641        let pos = CashPosition::new(
1642            "CP-SERDE",
1643            "C001",
1644            "BA-001",
1645            "USD",
1646            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1647            dec!(10000.50),
1648            dec!(5000.25),
1649            dec!(2000.75),
1650        );
1651
1652        let json = serde_json::to_string_pretty(&pos).unwrap();
1653        let deserialized: CashPosition = serde_json::from_str(&json).unwrap();
1654
1655        assert_eq!(deserialized.opening_balance, pos.opening_balance);
1656        assert_eq!(deserialized.closing_balance, pos.closing_balance);
1657        assert_eq!(deserialized.date, pos.date);
1658    }
1659
1660    #[test]
1661    fn test_serde_roundtrip_hedging_instrument() {
1662        let instr = HedgingInstrument::new(
1663            "HI-SERDE",
1664            HedgeInstrumentType::InterestRateSwap,
1665            dec!(5000000),
1666            "USD",
1667            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1668            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1669            "JPMorgan",
1670        )
1671        .with_fixed_rate(dec!(0.0425))
1672        .with_floating_index("SOFR")
1673        .with_fair_value(dec!(-35000));
1674
1675        let json = serde_json::to_string_pretty(&instr).unwrap();
1676        let deserialized: HedgingInstrument = serde_json::from_str(&json).unwrap();
1677
1678        assert_eq!(deserialized.fixed_rate, Some(dec!(0.0425)));
1679        assert_eq!(deserialized.floating_index, Some("SOFR".to_string()));
1680        assert_eq!(deserialized.strike_rate, None);
1681        assert_eq!(deserialized.fair_value, dec!(-35000));
1682    }
1683}