1use chrono::{NaiveDate, NaiveTime};
14use rust_decimal::Decimal;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum TreasuryCashFlowCategory {
25 #[default]
27 ArCollection,
28 ApPayment,
30 PayrollDisbursement,
32 TaxPayment,
34 DebtService,
36 CapitalExpenditure,
38 IntercompanySettlement,
40 ProjectMilestone,
42 Other,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum PoolType {
50 #[default]
52 PhysicalPooling,
53 NotionalPooling,
55 ZeroBalancing,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
61#[serde(rename_all = "snake_case")]
62pub enum HedgeInstrumentType {
63 #[default]
65 FxForward,
66 FxOption,
68 InterestRateSwap,
70 CommodityForward,
72 CrossCurrencySwap,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
78#[serde(rename_all = "snake_case")]
79pub enum InstrumentStatus {
80 #[default]
82 Active,
83 Matured,
85 Terminated,
87 Novated,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
93#[serde(rename_all = "snake_case")]
94pub enum HedgedItemType {
95 #[default]
97 ForecastedTransaction,
98 FirmCommitment,
100 RecognizedAsset,
102 NetInvestment,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum HedgeType {
110 #[default]
112 FairValueHedge,
113 CashFlowHedge,
115 NetInvestmentHedge,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
121#[serde(rename_all = "snake_case")]
122pub enum EffectivenessMethod {
123 #[default]
125 DollarOffset,
126 Regression,
128 CriticalTerms,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
134#[serde(rename_all = "snake_case")]
135pub enum DebtType {
136 #[default]
138 TermLoan,
139 RevolvingCredit,
141 Bond,
143 CommercialPaper,
145 BridgeLoan,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
151#[serde(rename_all = "snake_case")]
152pub enum InterestRateType {
153 #[default]
155 Fixed,
156 Variable,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
162#[serde(rename_all = "snake_case")]
163pub enum CovenantType {
164 #[default]
166 DebtToEquity,
167 InterestCoverage,
169 CurrentRatio,
171 NetWorth,
173 DebtToEbitda,
175 FixedChargeCoverage,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
181#[serde(rename_all = "snake_case")]
182pub enum Frequency {
183 Monthly,
185 #[default]
187 Quarterly,
188 Annual,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
194#[serde(rename_all = "snake_case")]
195pub enum GuaranteeType {
196 #[default]
198 CommercialLc,
199 StandbyLc,
201 BankGuarantee,
203 PerformanceBond,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
209#[serde(rename_all = "snake_case")]
210pub enum GuaranteeStatus {
211 #[default]
213 Active,
214 Drawn,
216 Expired,
218 Cancelled,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
224#[serde(rename_all = "snake_case")]
225pub enum NettingCycle {
226 Daily,
228 Weekly,
230 #[default]
232 Monthly,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
237#[serde(rename_all = "snake_case")]
238pub enum PayOrReceive {
239 #[default]
241 Pay,
242 Receive,
244 Flat,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CashPosition {
255 pub id: String,
257 pub entity_id: String,
259 pub bank_account_id: String,
261 pub currency: String,
263 pub date: NaiveDate,
265 #[serde(with = "rust_decimal::serde::str")]
267 pub opening_balance: Decimal,
268 #[serde(with = "rust_decimal::serde::str")]
270 pub inflows: Decimal,
271 #[serde(with = "rust_decimal::serde::str")]
273 pub outflows: Decimal,
274 #[serde(with = "rust_decimal::serde::str")]
276 pub closing_balance: Decimal,
277 #[serde(with = "rust_decimal::serde::str")]
279 pub available_balance: Decimal,
280 #[serde(with = "rust_decimal::serde::str")]
282 pub value_date_balance: Decimal,
283}
284
285impl CashPosition {
286 #[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 pub fn with_available_balance(mut self, balance: Decimal) -> Self {
316 self.available_balance = balance;
317 self
318 }
319
320 pub fn with_value_date_balance(mut self, balance: Decimal) -> Self {
322 self.value_date_balance = balance;
323 self
324 }
325
326 pub fn computed_closing_balance(&self) -> Decimal {
328 (self.opening_balance + self.inflows - self.outflows).round_dp(2)
329 }
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct CashForecastItem {
335 pub id: String,
337 pub date: NaiveDate,
339 pub category: TreasuryCashFlowCategory,
341 #[serde(with = "rust_decimal::serde::str")]
343 pub amount: Decimal,
344 #[serde(with = "rust_decimal::serde::str")]
346 pub probability: Decimal,
347 pub source_document_type: Option<String>,
349 pub source_document_id: Option<String>,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct CashForecast {
356 pub id: String,
358 pub entity_id: String,
360 pub currency: String,
362 pub forecast_date: NaiveDate,
364 pub horizon_days: u32,
366 pub items: Vec<CashForecastItem>,
368 #[serde(with = "rust_decimal::serde::str")]
370 pub net_position: Decimal,
371 #[serde(with = "rust_decimal::serde::str")]
373 pub confidence_level: Decimal,
374}
375
376impl CashForecast {
377 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct CashPool {
417 pub id: String,
419 pub name: String,
421 pub pool_type: PoolType,
423 pub header_account_id: String,
425 pub participant_accounts: Vec<String>,
427 pub sweep_time: NaiveTime,
429 #[serde(with = "rust_decimal::serde::str")]
431 pub interest_rate_benefit: Decimal,
432}
433
434impl CashPool {
435 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 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 pub fn with_interest_rate_benefit(mut self, benefit: Decimal) -> Self {
462 self.interest_rate_benefit = benefit;
463 self
464 }
465
466 pub fn total_accounts(&self) -> usize {
468 1 + self.participant_accounts.len()
469 }
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct CashPoolSweep {
475 pub id: String,
477 pub pool_id: String,
479 pub date: NaiveDate,
481 pub from_account_id: String,
483 pub to_account_id: String,
485 #[serde(with = "rust_decimal::serde::str")]
487 pub amount: Decimal,
488 pub currency: String,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct HedgingInstrument {
495 pub id: String,
497 pub instrument_type: HedgeInstrumentType,
499 #[serde(with = "rust_decimal::serde::str")]
501 pub notional_amount: Decimal,
502 pub currency: String,
504 pub currency_pair: Option<String>,
506 #[serde(default, with = "rust_decimal::serde::str_option")]
508 pub fixed_rate: Option<Decimal>,
509 pub floating_index: Option<String>,
511 #[serde(default, with = "rust_decimal::serde::str_option")]
513 pub strike_rate: Option<Decimal>,
514 pub trade_date: NaiveDate,
516 pub maturity_date: NaiveDate,
518 pub counterparty: String,
520 #[serde(with = "rust_decimal::serde::str")]
522 pub fair_value: Decimal,
523 pub status: InstrumentStatus,
525}
526
527impl HedgingInstrument {
528 #[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 pub fn with_currency_pair(mut self, pair: impl Into<String>) -> Self {
558 self.currency_pair = Some(pair.into());
559 self
560 }
561
562 pub fn with_fixed_rate(mut self, rate: Decimal) -> Self {
564 self.fixed_rate = Some(rate);
565 self
566 }
567
568 pub fn with_floating_index(mut self, index: impl Into<String>) -> Self {
570 self.floating_index = Some(index.into());
571 self
572 }
573
574 pub fn with_strike_rate(mut self, rate: Decimal) -> Self {
576 self.strike_rate = Some(rate);
577 self
578 }
579
580 pub fn with_fair_value(mut self, value: Decimal) -> Self {
582 self.fair_value = value;
583 self
584 }
585
586 pub fn with_status(mut self, status: InstrumentStatus) -> Self {
588 self.status = status;
589 self
590 }
591
592 pub fn is_active(&self) -> bool {
594 self.status == InstrumentStatus::Active
595 }
596
597 pub fn remaining_tenor_days(&self, as_of: NaiveDate) -> i64 {
600 (self.maturity_date - as_of).num_days().max(0)
601 }
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct HedgeRelationship {
607 pub id: String,
609 pub hedged_item_type: HedgedItemType,
611 pub hedged_item_description: String,
613 pub hedging_instrument_id: String,
615 pub hedge_type: HedgeType,
617 pub designation_date: NaiveDate,
619 pub effectiveness_test_method: EffectivenessMethod,
621 #[serde(with = "rust_decimal::serde::str")]
623 pub effectiveness_ratio: Decimal,
624 pub is_effective: bool,
626 #[serde(with = "rust_decimal::serde::str")]
628 pub ineffectiveness_amount: Decimal,
629}
630
631impl HedgeRelationship {
632 #[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 pub fn with_ineffectiveness_amount(mut self, amount: Decimal) -> Self {
661 self.ineffectiveness_amount = amount;
662 self
663 }
664
665 pub fn check_effectiveness(ratio: Decimal) -> bool {
671 let lower = Decimal::new(80, 2); let upper = Decimal::new(125, 2); ratio >= lower && ratio <= upper
674 }
675
676 pub fn update_effectiveness(&mut self) {
678 self.is_effective = Self::check_effectiveness(self.effectiveness_ratio);
679 }
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct AmortizationPayment {
685 pub date: NaiveDate,
687 #[serde(with = "rust_decimal::serde::str")]
689 pub principal_payment: Decimal,
690 #[serde(with = "rust_decimal::serde::str")]
692 pub interest_payment: Decimal,
693 #[serde(with = "rust_decimal::serde::str")]
695 pub balance_after: Decimal,
696}
697
698impl AmortizationPayment {
699 pub fn total_payment(&self) -> Decimal {
701 (self.principal_payment + self.interest_payment).round_dp(2)
702 }
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct DebtCovenant {
708 pub id: String,
710 pub covenant_type: CovenantType,
712 #[serde(with = "rust_decimal::serde::str")]
714 pub threshold: Decimal,
715 pub measurement_frequency: Frequency,
717 #[serde(with = "rust_decimal::serde::str")]
719 pub actual_value: Decimal,
720 pub measurement_date: NaiveDate,
722 pub is_compliant: bool,
724 #[serde(with = "rust_decimal::serde::str")]
726 pub headroom: Decimal,
727 pub waiver_obtained: bool,
729}
730
731impl DebtCovenant {
732 #[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 pub fn with_waiver(mut self, waiver: bool) -> Self {
759 self.waiver_obtained = waiver;
760 self
761 }
762
763 fn evaluate_compliance(
769 covenant_type: CovenantType,
770 threshold: Decimal,
771 actual_value: Decimal,
772 ) -> (bool, Decimal) {
773 match covenant_type {
774 CovenantType::DebtToEquity | CovenantType::DebtToEbitda => {
776 let headroom = (threshold - actual_value).round_dp(4);
777 (actual_value <= threshold, headroom)
778 }
779 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
801pub struct DebtInstrument {
802 pub id: String,
804 pub entity_id: String,
806 pub instrument_type: DebtType,
808 pub lender: String,
810 #[serde(with = "rust_decimal::serde::str")]
812 pub principal: Decimal,
813 pub currency: String,
815 #[serde(with = "rust_decimal::serde::str")]
817 pub interest_rate: Decimal,
818 pub rate_type: InterestRateType,
820 pub origination_date: NaiveDate,
822 pub maturity_date: NaiveDate,
824 pub amortization_schedule: Vec<AmortizationPayment>,
826 pub covenants: Vec<DebtCovenant>,
828 #[serde(with = "rust_decimal::serde::str")]
830 pub drawn_amount: Decimal,
831 #[serde(with = "rust_decimal::serde::str")]
833 pub facility_limit: Decimal,
834}
835
836impl DebtInstrument {
837 #[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 pub fn with_amortization_schedule(mut self, schedule: Vec<AmortizationPayment>) -> Self {
871 self.amortization_schedule = schedule;
872 self
873 }
874
875 pub fn with_covenant(mut self, covenant: DebtCovenant) -> Self {
877 self.covenants.push(covenant);
878 self
879 }
880
881 pub fn with_drawn_amount(mut self, amount: Decimal) -> Self {
883 self.drawn_amount = amount;
884 self
885 }
886
887 pub fn with_facility_limit(mut self, limit: Decimal) -> Self {
889 self.facility_limit = limit;
890 self
891 }
892
893 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 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 pub fn available_capacity(&self) -> Decimal {
913 (self.facility_limit - self.drawn_amount).round_dp(2)
914 }
915
916 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#[derive(Debug, Clone, Serialize, Deserialize)]
926pub struct BankGuarantee {
927 pub id: String,
929 pub entity_id: String,
931 pub guarantee_type: GuaranteeType,
933 #[serde(with = "rust_decimal::serde::str")]
935 pub amount: Decimal,
936 pub currency: String,
938 pub beneficiary: String,
940 pub issuing_bank: String,
942 pub issue_date: NaiveDate,
944 pub expiry_date: NaiveDate,
946 pub status: GuaranteeStatus,
948 pub linked_contract_id: Option<String>,
950 pub linked_project_id: Option<String>,
952}
953
954impl BankGuarantee {
955 #[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 pub fn with_status(mut self, status: GuaranteeStatus) -> Self {
986 self.status = status;
987 self
988 }
989
990 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 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 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 pub fn remaining_days(&self, as_of: NaiveDate) -> i64 {
1011 (self.expiry_date - as_of).num_days().max(0)
1012 }
1013}
1014
1015#[derive(Debug, Clone, Serialize, Deserialize)]
1017pub struct NettingPosition {
1018 pub entity_id: String,
1020 #[serde(with = "rust_decimal::serde::str")]
1022 pub gross_receivable: Decimal,
1023 #[serde(with = "rust_decimal::serde::str")]
1025 pub gross_payable: Decimal,
1026 #[serde(with = "rust_decimal::serde::str")]
1028 pub net_position: Decimal,
1029 pub settlement_direction: PayOrReceive,
1031}
1032
1033#[derive(Debug, Clone, Serialize, Deserialize)]
1035pub struct NettingRun {
1036 pub id: String,
1038 pub netting_date: NaiveDate,
1040 pub cycle: NettingCycle,
1042 pub participating_entities: Vec<String>,
1044 #[serde(with = "rust_decimal::serde::str")]
1046 pub gross_receivables: Decimal,
1047 #[serde(with = "rust_decimal::serde::str")]
1049 pub gross_payables: Decimal,
1050 #[serde(with = "rust_decimal::serde::str")]
1052 pub net_settlement: Decimal,
1053 pub settlement_currency: String,
1055 pub positions: Vec<NettingPosition>,
1057}
1058
1059impl NettingRun {
1060 #[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 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 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#[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 assert_eq!(pos.closing_balance, dec!(13000));
1142 assert_eq!(pos.computed_closing_balance(), dec!(13000));
1143 assert_eq!(pos.available_balance, dec!(13000)); }
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 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); 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 );
1253 assert_eq!(instr.currency_pair, Some("EUR/USD".to_string()));
1254 assert_eq!(instr.fixed_rate, Some(dec!(1.0850)));
1255
1256 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 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 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))); assert!(HedgeRelationship::check_effectiveness(dec!(1.25))); 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 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 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)); 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)); 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)); 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 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); assert!(waived.waiver_obtained); }
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 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), NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1491 )
1492 .with_waiver(true),
1493 );
1494 assert!(debt_waived.all_covenants_compliant()); }
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 assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1514 assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()));
1516 assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1518 assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1520
1521 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 );
1530
1531 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 assert_eq!(run.net_settlement, dec!(40000));
1585 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 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}