1use std::collections::HashMap;
14
15use chrono::{NaiveDate, NaiveTime};
16use rust_decimal::prelude::ToPrimitive;
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19
20use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum TreasuryCashFlowCategory {
30 #[default]
32 ArCollection,
33 ApPayment,
35 PayrollDisbursement,
37 TaxPayment,
39 DebtService,
41 CapitalExpenditure,
43 IntercompanySettlement,
45 ProjectMilestone,
47 Other,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum PoolType {
55 #[default]
57 PhysicalPooling,
58 NotionalPooling,
60 ZeroBalancing,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum HedgeInstrumentType {
68 #[default]
70 FxForward,
71 FxOption,
73 InterestRateSwap,
75 CommodityForward,
77 CrossCurrencySwap,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
83#[serde(rename_all = "snake_case")]
84pub enum InstrumentStatus {
85 #[default]
87 Active,
88 Matured,
90 Terminated,
92 Novated,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
98#[serde(rename_all = "snake_case")]
99pub enum HedgedItemType {
100 #[default]
102 ForecastedTransaction,
103 FirmCommitment,
105 RecognizedAsset,
107 NetInvestment,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum HedgeType {
115 #[default]
117 FairValueHedge,
118 CashFlowHedge,
120 NetInvestmentHedge,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
126#[serde(rename_all = "snake_case")]
127pub enum EffectivenessMethod {
128 #[default]
130 DollarOffset,
131 Regression,
133 CriticalTerms,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
139#[serde(rename_all = "snake_case")]
140pub enum DebtType {
141 #[default]
143 TermLoan,
144 RevolvingCredit,
146 Bond,
148 CommercialPaper,
150 BridgeLoan,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
156#[serde(rename_all = "snake_case")]
157pub enum InterestRateType {
158 #[default]
160 Fixed,
161 Variable,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
167#[serde(rename_all = "snake_case")]
168pub enum CovenantType {
169 #[default]
171 DebtToEquity,
172 InterestCoverage,
174 CurrentRatio,
176 NetWorth,
178 DebtToEbitda,
180 FixedChargeCoverage,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
186#[serde(rename_all = "snake_case")]
187pub enum Frequency {
188 Monthly,
190 #[default]
192 Quarterly,
193 Annual,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
199#[serde(rename_all = "snake_case")]
200pub enum GuaranteeType {
201 #[default]
203 CommercialLc,
204 StandbyLc,
206 BankGuarantee,
208 PerformanceBond,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
214#[serde(rename_all = "snake_case")]
215pub enum GuaranteeStatus {
216 #[default]
218 Active,
219 Drawn,
221 Expired,
223 Cancelled,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
229#[serde(rename_all = "snake_case")]
230pub enum NettingCycle {
231 Daily,
233 Weekly,
235 #[default]
237 Monthly,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
242#[serde(rename_all = "snake_case")]
243pub enum PayOrReceive {
244 #[default]
246 Pay,
247 Receive,
249 Flat,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct CashPosition {
260 pub id: String,
262 pub entity_id: String,
264 pub bank_account_id: String,
266 pub currency: String,
268 pub date: NaiveDate,
270 #[serde(with = "crate::serde_decimal")]
272 pub opening_balance: Decimal,
273 #[serde(with = "crate::serde_decimal")]
275 pub inflows: Decimal,
276 #[serde(with = "crate::serde_decimal")]
278 pub outflows: Decimal,
279 #[serde(with = "crate::serde_decimal")]
281 pub closing_balance: Decimal,
282 #[serde(with = "crate::serde_decimal")]
284 pub available_balance: Decimal,
285 #[serde(with = "crate::serde_decimal")]
287 pub value_date_balance: Decimal,
288}
289
290impl CashPosition {
291 #[allow(clippy::too_many_arguments)]
293 pub fn new(
294 id: impl Into<String>,
295 entity_id: impl Into<String>,
296 bank_account_id: impl Into<String>,
297 currency: impl Into<String>,
298 date: NaiveDate,
299 opening_balance: Decimal,
300 inflows: Decimal,
301 outflows: Decimal,
302 ) -> Self {
303 let closing = (opening_balance + inflows - outflows).round_dp(2);
304 Self {
305 id: id.into(),
306 entity_id: entity_id.into(),
307 bank_account_id: bank_account_id.into(),
308 currency: currency.into(),
309 date,
310 opening_balance,
311 inflows,
312 outflows,
313 closing_balance: closing,
314 available_balance: closing,
315 value_date_balance: closing,
316 }
317 }
318
319 pub fn with_available_balance(mut self, balance: Decimal) -> Self {
321 self.available_balance = balance;
322 self
323 }
324
325 pub fn with_value_date_balance(mut self, balance: Decimal) -> Self {
327 self.value_date_balance = balance;
328 self
329 }
330
331 pub fn computed_closing_balance(&self) -> Decimal {
333 (self.opening_balance + self.inflows - self.outflows).round_dp(2)
334 }
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct CashForecastItem {
340 pub id: String,
342 pub date: NaiveDate,
344 pub category: TreasuryCashFlowCategory,
346 #[serde(with = "crate::serde_decimal")]
348 pub amount: Decimal,
349 #[serde(with = "crate::serde_decimal")]
351 pub probability: Decimal,
352 pub source_document_type: Option<String>,
354 pub source_document_id: Option<String>,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct CashForecast {
361 pub id: String,
363 pub entity_id: String,
365 pub currency: String,
367 pub forecast_date: NaiveDate,
369 pub horizon_days: u32,
371 pub items: Vec<CashForecastItem>,
373 #[serde(with = "crate::serde_decimal")]
375 pub net_position: Decimal,
376 #[serde(with = "crate::serde_decimal")]
378 pub confidence_level: Decimal,
379}
380
381impl CashForecast {
382 pub fn new(
384 id: impl Into<String>,
385 entity_id: impl Into<String>,
386 currency: impl Into<String>,
387 forecast_date: NaiveDate,
388 horizon_days: u32,
389 items: Vec<CashForecastItem>,
390 confidence_level: Decimal,
391 ) -> Self {
392 let net_position = items
393 .iter()
394 .map(|item| (item.amount * item.probability).round_dp(2))
395 .sum::<Decimal>()
396 .round_dp(2);
397 Self {
398 id: id.into(),
399 entity_id: entity_id.into(),
400 currency: currency.into(),
401 forecast_date,
402 horizon_days,
403 items,
404 net_position,
405 confidence_level,
406 }
407 }
408
409 pub fn computed_net_position(&self) -> Decimal {
411 self.items
412 .iter()
413 .map(|item| (item.amount * item.probability).round_dp(2))
414 .sum::<Decimal>()
415 .round_dp(2)
416 }
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct CashPool {
422 pub id: String,
424 pub name: String,
426 pub pool_type: PoolType,
428 pub header_account_id: String,
430 pub participant_accounts: Vec<String>,
432 pub sweep_time: NaiveTime,
434 #[serde(with = "crate::serde_decimal")]
436 pub interest_rate_benefit: Decimal,
437}
438
439impl CashPool {
440 pub fn new(
442 id: impl Into<String>,
443 name: impl Into<String>,
444 pool_type: PoolType,
445 header_account_id: impl Into<String>,
446 sweep_time: NaiveTime,
447 ) -> Self {
448 Self {
449 id: id.into(),
450 name: name.into(),
451 pool_type,
452 header_account_id: header_account_id.into(),
453 participant_accounts: Vec::new(),
454 sweep_time,
455 interest_rate_benefit: Decimal::ZERO,
456 }
457 }
458
459 pub fn with_participant(mut self, account_id: impl Into<String>) -> Self {
461 self.participant_accounts.push(account_id.into());
462 self
463 }
464
465 pub fn with_interest_rate_benefit(mut self, benefit: Decimal) -> Self {
467 self.interest_rate_benefit = benefit;
468 self
469 }
470
471 pub fn total_accounts(&self) -> usize {
473 1 + self.participant_accounts.len()
474 }
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct CashPoolSweep {
480 pub id: String,
482 pub pool_id: String,
484 pub date: NaiveDate,
486 pub from_account_id: String,
488 pub to_account_id: String,
490 #[serde(with = "crate::serde_decimal")]
492 pub amount: Decimal,
493 pub currency: String,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct HedgingInstrument {
500 pub id: String,
502 pub instrument_type: HedgeInstrumentType,
504 #[serde(with = "crate::serde_decimal")]
506 pub notional_amount: Decimal,
507 pub currency: String,
509 pub currency_pair: Option<String>,
511 #[serde(default, with = "crate::serde_decimal::option")]
513 pub fixed_rate: Option<Decimal>,
514 pub floating_index: Option<String>,
516 #[serde(default, with = "crate::serde_decimal::option")]
518 pub strike_rate: Option<Decimal>,
519 pub trade_date: NaiveDate,
521 pub maturity_date: NaiveDate,
523 pub counterparty: String,
525 #[serde(with = "crate::serde_decimal")]
527 pub fair_value: Decimal,
528 pub status: InstrumentStatus,
530}
531
532impl HedgingInstrument {
533 #[allow(clippy::too_many_arguments)]
535 pub fn new(
536 id: impl Into<String>,
537 instrument_type: HedgeInstrumentType,
538 notional_amount: Decimal,
539 currency: impl Into<String>,
540 trade_date: NaiveDate,
541 maturity_date: NaiveDate,
542 counterparty: impl Into<String>,
543 ) -> Self {
544 Self {
545 id: id.into(),
546 instrument_type,
547 notional_amount,
548 currency: currency.into(),
549 currency_pair: None,
550 fixed_rate: None,
551 floating_index: None,
552 strike_rate: None,
553 trade_date,
554 maturity_date,
555 counterparty: counterparty.into(),
556 fair_value: Decimal::ZERO,
557 status: InstrumentStatus::Active,
558 }
559 }
560
561 pub fn with_currency_pair(mut self, pair: impl Into<String>) -> Self {
563 self.currency_pair = Some(pair.into());
564 self
565 }
566
567 pub fn with_fixed_rate(mut self, rate: Decimal) -> Self {
569 self.fixed_rate = Some(rate);
570 self
571 }
572
573 pub fn with_floating_index(mut self, index: impl Into<String>) -> Self {
575 self.floating_index = Some(index.into());
576 self
577 }
578
579 pub fn with_strike_rate(mut self, rate: Decimal) -> Self {
581 self.strike_rate = Some(rate);
582 self
583 }
584
585 pub fn with_fair_value(mut self, value: Decimal) -> Self {
587 self.fair_value = value;
588 self
589 }
590
591 pub fn with_status(mut self, status: InstrumentStatus) -> Self {
593 self.status = status;
594 self
595 }
596
597 pub fn is_active(&self) -> bool {
599 self.status == InstrumentStatus::Active
600 }
601
602 pub fn remaining_tenor_days(&self, as_of: NaiveDate) -> i64 {
605 (self.maturity_date - as_of).num_days().max(0)
606 }
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct HedgeRelationship {
612 pub id: String,
614 pub hedged_item_type: HedgedItemType,
616 pub hedged_item_description: String,
618 pub hedging_instrument_id: String,
620 pub hedge_type: HedgeType,
622 pub designation_date: NaiveDate,
624 pub effectiveness_test_method: EffectivenessMethod,
626 #[serde(with = "crate::serde_decimal")]
628 pub effectiveness_ratio: Decimal,
629 pub is_effective: bool,
631 #[serde(with = "crate::serde_decimal")]
633 pub ineffectiveness_amount: Decimal,
634}
635
636impl HedgeRelationship {
637 #[allow(clippy::too_many_arguments)]
639 pub fn new(
640 id: impl Into<String>,
641 hedged_item_type: HedgedItemType,
642 hedged_item_description: impl Into<String>,
643 hedging_instrument_id: impl Into<String>,
644 hedge_type: HedgeType,
645 designation_date: NaiveDate,
646 effectiveness_test_method: EffectivenessMethod,
647 effectiveness_ratio: Decimal,
648 ) -> Self {
649 let is_effective = Self::check_effectiveness(effectiveness_ratio);
650 Self {
651 id: id.into(),
652 hedged_item_type,
653 hedged_item_description: hedged_item_description.into(),
654 hedging_instrument_id: hedging_instrument_id.into(),
655 hedge_type,
656 designation_date,
657 effectiveness_test_method,
658 effectiveness_ratio,
659 is_effective,
660 ineffectiveness_amount: Decimal::ZERO,
661 }
662 }
663
664 pub fn with_ineffectiveness_amount(mut self, amount: Decimal) -> Self {
666 self.ineffectiveness_amount = amount;
667 self
668 }
669
670 pub fn check_effectiveness(ratio: Decimal) -> bool {
676 let lower = Decimal::new(80, 2); let upper = Decimal::new(125, 2); ratio >= lower && ratio <= upper
679 }
680
681 pub fn update_effectiveness(&mut self) {
683 self.is_effective = Self::check_effectiveness(self.effectiveness_ratio);
684 }
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct AmortizationPayment {
690 pub date: NaiveDate,
692 #[serde(with = "crate::serde_decimal")]
694 pub principal_payment: Decimal,
695 #[serde(with = "crate::serde_decimal")]
697 pub interest_payment: Decimal,
698 #[serde(with = "crate::serde_decimal")]
700 pub balance_after: Decimal,
701}
702
703impl AmortizationPayment {
704 pub fn total_payment(&self) -> Decimal {
706 (self.principal_payment + self.interest_payment).round_dp(2)
707 }
708}
709
710#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct DebtCovenant {
713 pub id: String,
715 pub covenant_type: CovenantType,
717 #[serde(with = "crate::serde_decimal")]
719 pub threshold: Decimal,
720 pub measurement_frequency: Frequency,
722 #[serde(with = "crate::serde_decimal")]
724 pub actual_value: Decimal,
725 pub measurement_date: NaiveDate,
727 pub is_compliant: bool,
729 #[serde(with = "crate::serde_decimal")]
731 pub headroom: Decimal,
732 pub waiver_obtained: bool,
734
735 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub facility_id: Option<String>,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub entity_code: Option<String>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub facility_name: Option<String>,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub outstanding_principal: Option<Decimal>,
748 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub currency: Option<String>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub period: Option<String>,
754}
755
756impl DebtCovenant {
757 #[allow(clippy::too_many_arguments)]
759 pub fn new(
760 id: impl Into<String>,
761 covenant_type: CovenantType,
762 threshold: Decimal,
763 measurement_frequency: Frequency,
764 actual_value: Decimal,
765 measurement_date: NaiveDate,
766 ) -> Self {
767 let (is_compliant, headroom) =
768 Self::evaluate_compliance(covenant_type, threshold, actual_value);
769 Self {
770 id: id.into(),
771 covenant_type,
772 threshold,
773 measurement_frequency,
774 actual_value,
775 measurement_date,
776 is_compliant,
777 headroom,
778 waiver_obtained: false,
779 facility_id: None,
780 entity_code: None,
781 facility_name: None,
782 outstanding_principal: None,
783 currency: None,
784 period: None,
785 }
786 }
787
788 pub fn with_waiver(mut self, waiver: bool) -> Self {
790 self.waiver_obtained = waiver;
791 self
792 }
793
794 pub fn with_facility(
796 mut self,
797 facility_id: impl Into<String>,
798 entity_code: impl Into<String>,
799 facility_name: impl Into<String>,
800 outstanding_principal: Decimal,
801 currency: impl Into<String>,
802 period: impl Into<String>,
803 ) -> Self {
804 self.facility_id = Some(facility_id.into());
805 self.entity_code = Some(entity_code.into());
806 self.facility_name = Some(facility_name.into());
807 self.outstanding_principal = Some(outstanding_principal);
808 self.currency = Some(currency.into());
809 self.period = Some(period.into());
810 self
811 }
812
813 fn evaluate_compliance(
819 covenant_type: CovenantType,
820 threshold: Decimal,
821 actual_value: Decimal,
822 ) -> (bool, Decimal) {
823 match covenant_type {
824 CovenantType::DebtToEquity | CovenantType::DebtToEbitda => {
826 let headroom = (threshold - actual_value).round_dp(4);
827 (actual_value <= threshold, headroom)
828 }
829 CovenantType::InterestCoverage
831 | CovenantType::CurrentRatio
832 | CovenantType::NetWorth
833 | CovenantType::FixedChargeCoverage => {
834 let headroom = (actual_value - threshold).round_dp(4);
835 (actual_value >= threshold, headroom)
836 }
837 }
838 }
839
840 pub fn update_compliance(&mut self) {
842 let (compliant, headroom) =
843 Self::evaluate_compliance(self.covenant_type, self.threshold, self.actual_value);
844 self.is_compliant = compliant;
845 self.headroom = headroom;
846 }
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct DebtInstrument {
852 pub id: String,
854 pub entity_id: String,
856 pub instrument_type: DebtType,
858 pub lender: String,
860 #[serde(with = "crate::serde_decimal")]
862 pub principal: Decimal,
863 pub currency: String,
865 #[serde(with = "crate::serde_decimal")]
867 pub interest_rate: Decimal,
868 pub rate_type: InterestRateType,
870 pub origination_date: NaiveDate,
872 pub maturity_date: NaiveDate,
874 pub amortization_schedule: Vec<AmortizationPayment>,
876 pub covenants: Vec<DebtCovenant>,
878 #[serde(with = "crate::serde_decimal")]
880 pub drawn_amount: Decimal,
881 #[serde(with = "crate::serde_decimal")]
883 pub facility_limit: Decimal,
884}
885
886impl DebtInstrument {
887 #[allow(clippy::too_many_arguments)]
889 pub fn new(
890 id: impl Into<String>,
891 entity_id: impl Into<String>,
892 instrument_type: DebtType,
893 lender: impl Into<String>,
894 principal: Decimal,
895 currency: impl Into<String>,
896 interest_rate: Decimal,
897 rate_type: InterestRateType,
898 origination_date: NaiveDate,
899 maturity_date: NaiveDate,
900 ) -> Self {
901 Self {
902 id: id.into(),
903 entity_id: entity_id.into(),
904 instrument_type,
905 lender: lender.into(),
906 principal,
907 currency: currency.into(),
908 interest_rate,
909 rate_type,
910 origination_date,
911 maturity_date,
912 amortization_schedule: Vec::new(),
913 covenants: Vec::new(),
914 drawn_amount: principal,
915 facility_limit: principal,
916 }
917 }
918
919 pub fn with_amortization_schedule(mut self, schedule: Vec<AmortizationPayment>) -> Self {
921 self.amortization_schedule = schedule;
922 self
923 }
924
925 pub fn with_covenant(mut self, covenant: DebtCovenant) -> Self {
927 self.covenants.push(covenant);
928 self
929 }
930
931 pub fn with_drawn_amount(mut self, amount: Decimal) -> Self {
933 self.drawn_amount = amount;
934 self
935 }
936
937 pub fn with_facility_limit(mut self, limit: Decimal) -> Self {
939 self.facility_limit = limit;
940 self
941 }
942
943 pub fn total_principal_payments(&self) -> Decimal {
945 self.amortization_schedule
946 .iter()
947 .map(|p| p.principal_payment)
948 .sum::<Decimal>()
949 .round_dp(2)
950 }
951
952 pub fn total_interest_payments(&self) -> Decimal {
954 self.amortization_schedule
955 .iter()
956 .map(|p| p.interest_payment)
957 .sum::<Decimal>()
958 .round_dp(2)
959 }
960
961 pub fn available_capacity(&self) -> Decimal {
963 (self.facility_limit - self.drawn_amount).round_dp(2)
964 }
965
966 pub fn all_covenants_compliant(&self) -> bool {
968 self.covenants
969 .iter()
970 .all(|c| c.is_compliant || c.waiver_obtained)
971 }
972}
973
974#[derive(Debug, Clone, Serialize, Deserialize)]
976pub struct BankGuarantee {
977 pub id: String,
979 pub entity_id: String,
981 pub guarantee_type: GuaranteeType,
983 #[serde(with = "crate::serde_decimal")]
985 pub amount: Decimal,
986 pub currency: String,
988 pub beneficiary: String,
990 pub issuing_bank: String,
992 pub issue_date: NaiveDate,
994 pub expiry_date: NaiveDate,
996 pub status: GuaranteeStatus,
998 pub linked_contract_id: Option<String>,
1000 pub linked_project_id: Option<String>,
1002}
1003
1004impl BankGuarantee {
1005 #[allow(clippy::too_many_arguments)]
1007 pub fn new(
1008 id: impl Into<String>,
1009 entity_id: impl Into<String>,
1010 guarantee_type: GuaranteeType,
1011 amount: Decimal,
1012 currency: impl Into<String>,
1013 beneficiary: impl Into<String>,
1014 issuing_bank: impl Into<String>,
1015 issue_date: NaiveDate,
1016 expiry_date: NaiveDate,
1017 ) -> Self {
1018 Self {
1019 id: id.into(),
1020 entity_id: entity_id.into(),
1021 guarantee_type,
1022 amount,
1023 currency: currency.into(),
1024 beneficiary: beneficiary.into(),
1025 issuing_bank: issuing_bank.into(),
1026 issue_date,
1027 expiry_date,
1028 status: GuaranteeStatus::Active,
1029 linked_contract_id: None,
1030 linked_project_id: None,
1031 }
1032 }
1033
1034 pub fn with_status(mut self, status: GuaranteeStatus) -> Self {
1036 self.status = status;
1037 self
1038 }
1039
1040 pub fn with_linked_contract(mut self, contract_id: impl Into<String>) -> Self {
1042 self.linked_contract_id = Some(contract_id.into());
1043 self
1044 }
1045
1046 pub fn with_linked_project(mut self, project_id: impl Into<String>) -> Self {
1048 self.linked_project_id = Some(project_id.into());
1049 self
1050 }
1051
1052 pub fn is_active_on(&self, date: NaiveDate) -> bool {
1054 self.status == GuaranteeStatus::Active
1055 && date >= self.issue_date
1056 && date <= self.expiry_date
1057 }
1058
1059 pub fn remaining_days(&self, as_of: NaiveDate) -> i64 {
1061 (self.expiry_date - as_of).num_days().max(0)
1062 }
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize)]
1067pub struct NettingPosition {
1068 pub entity_id: String,
1070 #[serde(with = "crate::serde_decimal")]
1072 pub gross_receivable: Decimal,
1073 #[serde(with = "crate::serde_decimal")]
1075 pub gross_payable: Decimal,
1076 #[serde(with = "crate::serde_decimal")]
1078 pub net_position: Decimal,
1079 pub settlement_direction: PayOrReceive,
1081}
1082
1083#[derive(Debug, Clone, Serialize, Deserialize)]
1085pub struct NettingRun {
1086 pub id: String,
1088 pub netting_date: NaiveDate,
1090 pub cycle: NettingCycle,
1092 pub participating_entities: Vec<String>,
1094 #[serde(with = "crate::serde_decimal")]
1096 pub gross_receivables: Decimal,
1097 #[serde(with = "crate::serde_decimal")]
1099 pub gross_payables: Decimal,
1100 #[serde(with = "crate::serde_decimal")]
1102 pub net_settlement: Decimal,
1103 pub settlement_currency: String,
1105 pub positions: Vec<NettingPosition>,
1107}
1108
1109impl NettingRun {
1110 #[allow(clippy::too_many_arguments)]
1112 pub fn new(
1113 id: impl Into<String>,
1114 netting_date: NaiveDate,
1115 cycle: NettingCycle,
1116 settlement_currency: impl Into<String>,
1117 positions: Vec<NettingPosition>,
1118 ) -> Self {
1119 let participating_entities: Vec<String> =
1120 positions.iter().map(|p| p.entity_id.clone()).collect();
1121 let gross_receivables = positions
1122 .iter()
1123 .map(|p| p.gross_receivable)
1124 .sum::<Decimal>()
1125 .round_dp(2);
1126 let gross_payables = positions
1127 .iter()
1128 .map(|p| p.gross_payable)
1129 .sum::<Decimal>()
1130 .round_dp(2);
1131 let net_settlement = positions
1132 .iter()
1133 .map(|p| p.net_position.abs())
1134 .sum::<Decimal>()
1135 .round_dp(2)
1136 / Decimal::TWO;
1137 Self {
1138 id: id.into(),
1139 netting_date,
1140 cycle,
1141 participating_entities,
1142 gross_receivables,
1143 gross_payables,
1144 net_settlement: net_settlement.round_dp(2),
1145 settlement_currency: settlement_currency.into(),
1146 positions,
1147 }
1148 }
1149
1150 pub fn savings(&self) -> Decimal {
1154 let gross_max = self.gross_receivables.max(self.gross_payables);
1155 (gross_max - self.net_settlement).round_dp(2)
1156 }
1157
1158 pub fn savings_pct(&self) -> Decimal {
1160 let gross_max = self.gross_receivables.max(self.gross_payables);
1161 if gross_max.is_zero() {
1162 return Decimal::ZERO;
1163 }
1164 (self.savings() / gross_max * Decimal::ONE_HUNDRED).round_dp(2)
1165 }
1166}
1167
1168impl ToNodeProperties for CashPosition {
1173 fn node_type_name(&self) -> &'static str {
1174 "cash_position"
1175 }
1176 fn node_type_code(&self) -> u16 {
1177 420
1178 }
1179 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1180 let mut p = HashMap::new();
1181 p.insert(
1182 "entityCode".into(),
1183 GraphPropertyValue::String(self.entity_id.clone()),
1184 );
1185 p.insert(
1186 "bankId".into(),
1187 GraphPropertyValue::String(self.bank_account_id.clone()),
1188 );
1189 p.insert(
1190 "currency".into(),
1191 GraphPropertyValue::String(self.currency.clone()),
1192 );
1193 p.insert("asOfDate".into(), GraphPropertyValue::Date(self.date));
1194 p.insert(
1195 "openingBalance".into(),
1196 GraphPropertyValue::Decimal(self.opening_balance),
1197 );
1198 p.insert("inflows".into(), GraphPropertyValue::Decimal(self.inflows));
1199 p.insert(
1200 "outflows".into(),
1201 GraphPropertyValue::Decimal(self.outflows),
1202 );
1203 p.insert(
1204 "balance".into(),
1205 GraphPropertyValue::Decimal(self.closing_balance),
1206 );
1207 p.insert(
1208 "availableBalance".into(),
1209 GraphPropertyValue::Decimal(self.available_balance),
1210 );
1211 p.insert(
1212 "valueDateBalance".into(),
1213 GraphPropertyValue::Decimal(self.value_date_balance),
1214 );
1215 p
1216 }
1217}
1218
1219impl ToNodeProperties for CashForecast {
1220 fn node_type_name(&self) -> &'static str {
1221 "cash_forecast"
1222 }
1223 fn node_type_code(&self) -> u16 {
1224 421
1225 }
1226 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1227 let mut p = HashMap::new();
1228 p.insert(
1229 "entityCode".into(),
1230 GraphPropertyValue::String(self.entity_id.clone()),
1231 );
1232 p.insert(
1233 "currency".into(),
1234 GraphPropertyValue::String(self.currency.clone()),
1235 );
1236 p.insert(
1237 "forecastDate".into(),
1238 GraphPropertyValue::Date(self.forecast_date),
1239 );
1240 p.insert(
1241 "horizonDays".into(),
1242 GraphPropertyValue::Int(self.horizon_days as i64),
1243 );
1244 p.insert(
1245 "itemCount".into(),
1246 GraphPropertyValue::Int(self.items.len() as i64),
1247 );
1248 p.insert(
1249 "netPosition".into(),
1250 GraphPropertyValue::Decimal(self.net_position),
1251 );
1252 p.insert(
1253 "certaintyLevel".into(),
1254 GraphPropertyValue::Float(self.confidence_level.to_f64().unwrap_or(0.0)),
1255 );
1256 p
1257 }
1258}
1259
1260impl ToNodeProperties for CashPool {
1261 fn node_type_name(&self) -> &'static str {
1262 "cash_pool"
1263 }
1264 fn node_type_code(&self) -> u16 {
1265 422
1266 }
1267 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1268 let mut p = HashMap::new();
1269 p.insert("name".into(), GraphPropertyValue::String(self.name.clone()));
1270 p.insert(
1271 "poolType".into(),
1272 GraphPropertyValue::String(format!("{:?}", self.pool_type)),
1273 );
1274 p.insert(
1275 "headerAccount".into(),
1276 GraphPropertyValue::String(self.header_account_id.clone()),
1277 );
1278 p.insert(
1279 "participantCount".into(),
1280 GraphPropertyValue::Int(self.participant_accounts.len() as i64),
1281 );
1282 p.insert(
1283 "interestBenefit".into(),
1284 GraphPropertyValue::Decimal(self.interest_rate_benefit),
1285 );
1286 p
1287 }
1288}
1289
1290impl ToNodeProperties for CashPoolSweep {
1291 fn node_type_name(&self) -> &'static str {
1292 "cash_pool_sweep"
1293 }
1294 fn node_type_code(&self) -> u16 {
1295 423
1296 }
1297 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1298 let mut p = HashMap::new();
1299 p.insert(
1300 "poolId".into(),
1301 GraphPropertyValue::String(self.pool_id.clone()),
1302 );
1303 p.insert("date".into(), GraphPropertyValue::Date(self.date));
1304 p.insert(
1305 "fromAccount".into(),
1306 GraphPropertyValue::String(self.from_account_id.clone()),
1307 );
1308 p.insert(
1309 "toAccount".into(),
1310 GraphPropertyValue::String(self.to_account_id.clone()),
1311 );
1312 p.insert("amount".into(), GraphPropertyValue::Decimal(self.amount));
1313 p.insert(
1314 "currency".into(),
1315 GraphPropertyValue::String(self.currency.clone()),
1316 );
1317 p
1318 }
1319}
1320
1321impl ToNodeProperties for HedgingInstrument {
1322 fn node_type_name(&self) -> &'static str {
1323 "hedging_instrument"
1324 }
1325 fn node_type_code(&self) -> u16 {
1326 424
1327 }
1328 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1329 let mut p = HashMap::new();
1330 p.insert(
1331 "hedgeType".into(),
1332 GraphPropertyValue::String(format!("{:?}", self.instrument_type)),
1333 );
1334 p.insert(
1335 "notionalAmount".into(),
1336 GraphPropertyValue::Decimal(self.notional_amount),
1337 );
1338 p.insert(
1339 "currency".into(),
1340 GraphPropertyValue::String(self.currency.clone()),
1341 );
1342 if let Some(ref cp) = self.currency_pair {
1343 p.insert(
1344 "currencyPair".into(),
1345 GraphPropertyValue::String(cp.clone()),
1346 );
1347 }
1348 p.insert(
1349 "tradeDate".into(),
1350 GraphPropertyValue::Date(self.trade_date),
1351 );
1352 p.insert(
1353 "maturityDate".into(),
1354 GraphPropertyValue::Date(self.maturity_date),
1355 );
1356 p.insert(
1357 "counterparty".into(),
1358 GraphPropertyValue::String(self.counterparty.clone()),
1359 );
1360 p.insert(
1361 "fairValue".into(),
1362 GraphPropertyValue::Decimal(self.fair_value),
1363 );
1364 p.insert(
1365 "status".into(),
1366 GraphPropertyValue::String(format!("{:?}", self.status)),
1367 );
1368 p
1369 }
1370}
1371
1372impl ToNodeProperties for HedgeRelationship {
1373 fn node_type_name(&self) -> &'static str {
1374 "hedge_relationship"
1375 }
1376 fn node_type_code(&self) -> u16 {
1377 425
1378 }
1379 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1380 let mut p = HashMap::new();
1381 p.insert(
1382 "hedgedItemType".into(),
1383 GraphPropertyValue::String(format!("{:?}", self.hedged_item_type)),
1384 );
1385 p.insert(
1386 "hedgedItemDescription".into(),
1387 GraphPropertyValue::String(self.hedged_item_description.clone()),
1388 );
1389 p.insert(
1390 "instrumentId".into(),
1391 GraphPropertyValue::String(self.hedging_instrument_id.clone()),
1392 );
1393 p.insert(
1394 "hedgeType".into(),
1395 GraphPropertyValue::String(format!("{:?}", self.hedge_type)),
1396 );
1397 p.insert(
1398 "designationDate".into(),
1399 GraphPropertyValue::Date(self.designation_date),
1400 );
1401 p.insert(
1402 "effectivenessMethod".into(),
1403 GraphPropertyValue::String(format!("{:?}", self.effectiveness_test_method)),
1404 );
1405 p.insert(
1406 "effectivenessRatio".into(),
1407 GraphPropertyValue::Float(self.effectiveness_ratio.to_f64().unwrap_or(0.0)),
1408 );
1409 p.insert(
1410 "isEffective".into(),
1411 GraphPropertyValue::Bool(self.is_effective),
1412 );
1413 p.insert(
1414 "ineffectivenessAmount".into(),
1415 GraphPropertyValue::Decimal(self.ineffectiveness_amount),
1416 );
1417 p
1418 }
1419}
1420
1421impl ToNodeProperties for DebtInstrument {
1422 fn node_type_name(&self) -> &'static str {
1423 "debt_instrument"
1424 }
1425 fn node_type_code(&self) -> u16 {
1426 426
1427 }
1428 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1429 let mut p = HashMap::new();
1430 p.insert(
1431 "entityCode".into(),
1432 GraphPropertyValue::String(self.entity_id.clone()),
1433 );
1434 p.insert(
1435 "instrumentType".into(),
1436 GraphPropertyValue::String(format!("{:?}", self.instrument_type)),
1437 );
1438 p.insert(
1439 "lender".into(),
1440 GraphPropertyValue::String(self.lender.clone()),
1441 );
1442 p.insert(
1443 "principal".into(),
1444 GraphPropertyValue::Decimal(self.principal),
1445 );
1446 p.insert(
1447 "currency".into(),
1448 GraphPropertyValue::String(self.currency.clone()),
1449 );
1450 p.insert(
1451 "interestRate".into(),
1452 GraphPropertyValue::Decimal(self.interest_rate),
1453 );
1454 p.insert(
1455 "rateType".into(),
1456 GraphPropertyValue::String(format!("{:?}", self.rate_type)),
1457 );
1458 p.insert(
1459 "originationDate".into(),
1460 GraphPropertyValue::Date(self.origination_date),
1461 );
1462 p.insert(
1463 "maturityDate".into(),
1464 GraphPropertyValue::Date(self.maturity_date),
1465 );
1466 p.insert(
1467 "drawnAmount".into(),
1468 GraphPropertyValue::Decimal(self.drawn_amount),
1469 );
1470 p.insert(
1471 "facilityLimit".into(),
1472 GraphPropertyValue::Decimal(self.facility_limit),
1473 );
1474 p.insert(
1475 "covenantCount".into(),
1476 GraphPropertyValue::Int(self.covenants.len() as i64),
1477 );
1478 p
1479 }
1480}
1481
1482impl ToNodeProperties for DebtCovenant {
1483 fn node_type_name(&self) -> &'static str {
1484 "debt_covenant"
1485 }
1486 fn node_type_code(&self) -> u16 {
1487 427
1488 }
1489 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1490 let mut p = HashMap::new();
1491 p.insert(
1492 "covenantType".into(),
1493 GraphPropertyValue::String(format!("{:?}", self.covenant_type)),
1494 );
1495 p.insert(
1496 "threshold".into(),
1497 GraphPropertyValue::Decimal(self.threshold),
1498 );
1499 p.insert(
1500 "frequency".into(),
1501 GraphPropertyValue::String(format!("{:?}", self.measurement_frequency)),
1502 );
1503 p.insert(
1504 "actualValue".into(),
1505 GraphPropertyValue::Decimal(self.actual_value),
1506 );
1507 p.insert(
1508 "testDate".into(),
1509 GraphPropertyValue::Date(self.measurement_date),
1510 );
1511 p.insert(
1512 "complianceStatus".into(),
1513 GraphPropertyValue::Bool(self.is_compliant),
1514 );
1515 p.insert(
1516 "headroom".into(),
1517 GraphPropertyValue::Decimal(self.headroom),
1518 );
1519 p.insert(
1520 "waiverObtained".into(),
1521 GraphPropertyValue::Bool(self.waiver_obtained),
1522 );
1523 if let Some(ref fid) = self.facility_id {
1524 p.insert("facilityId".into(), GraphPropertyValue::String(fid.clone()));
1525 }
1526 if let Some(ref ec) = self.entity_code {
1527 p.insert("entityCode".into(), GraphPropertyValue::String(ec.clone()));
1528 }
1529 if let Some(ref fn_) = self.facility_name {
1530 p.insert(
1531 "facilityName".into(),
1532 GraphPropertyValue::String(fn_.clone()),
1533 );
1534 }
1535 if let Some(op) = self.outstanding_principal {
1536 p.insert(
1537 "outstandingPrincipal".into(),
1538 GraphPropertyValue::Decimal(op),
1539 );
1540 }
1541 if let Some(ref cur) = self.currency {
1542 p.insert("currency".into(), GraphPropertyValue::String(cur.clone()));
1543 }
1544 if let Some(ref per) = self.period {
1545 p.insert("period".into(), GraphPropertyValue::String(per.clone()));
1546 }
1547 p
1548 }
1549}
1550
1551#[cfg(test)]
1556mod tests {
1557 use super::*;
1558 use rust_decimal_macros::dec;
1559
1560 #[test]
1561 fn test_cash_position_closing_balance() {
1562 let pos = CashPosition::new(
1563 "CP-001",
1564 "C001",
1565 "BA-001",
1566 "USD",
1567 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1568 dec!(10000),
1569 dec!(5000),
1570 dec!(2000),
1571 );
1572 assert_eq!(pos.closing_balance, dec!(13000));
1574 assert_eq!(pos.computed_closing_balance(), dec!(13000));
1575 assert_eq!(pos.available_balance, dec!(13000)); }
1577
1578 #[test]
1579 fn test_cash_position_with_overrides() {
1580 let pos = CashPosition::new(
1581 "CP-002",
1582 "C001",
1583 "BA-001",
1584 "USD",
1585 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1586 dec!(10000),
1587 dec!(5000),
1588 dec!(2000),
1589 )
1590 .with_available_balance(dec!(12000))
1591 .with_value_date_balance(dec!(12500));
1592
1593 assert_eq!(pos.closing_balance, dec!(13000));
1594 assert_eq!(pos.available_balance, dec!(12000));
1595 assert_eq!(pos.value_date_balance, dec!(12500));
1596 }
1597
1598 #[test]
1599 fn test_cash_forecast_net_position() {
1600 let items = vec![
1601 CashForecastItem {
1602 id: "CFI-001".to_string(),
1603 date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
1604 category: TreasuryCashFlowCategory::ArCollection,
1605 amount: dec!(50000),
1606 probability: dec!(0.90),
1607 source_document_type: Some("SalesOrder".to_string()),
1608 source_document_id: Some("SO-001".to_string()),
1609 },
1610 CashForecastItem {
1611 id: "CFI-002".to_string(),
1612 date: NaiveDate::from_ymd_opt(2025, 2, 5).unwrap(),
1613 category: TreasuryCashFlowCategory::ApPayment,
1614 amount: dec!(-30000),
1615 probability: dec!(1.00),
1616 source_document_type: Some("PurchaseOrder".to_string()),
1617 source_document_id: Some("PO-001".to_string()),
1618 },
1619 CashForecastItem {
1620 id: "CFI-003".to_string(),
1621 date: NaiveDate::from_ymd_opt(2025, 2, 15).unwrap(),
1622 category: TreasuryCashFlowCategory::TaxPayment,
1623 amount: dec!(-10000),
1624 probability: dec!(1.00),
1625 source_document_type: None,
1626 source_document_id: None,
1627 },
1628 ];
1629 let forecast = CashForecast::new(
1630 "CF-001",
1631 "C001",
1632 "USD",
1633 NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
1634 30,
1635 items,
1636 dec!(0.90),
1637 );
1638
1639 assert_eq!(forecast.net_position, dec!(5000));
1642 assert_eq!(forecast.computed_net_position(), dec!(5000));
1643 assert_eq!(forecast.items.len(), 3);
1644 }
1645
1646 #[test]
1647 fn test_cash_pool_total_accounts() {
1648 let pool = CashPool::new(
1649 "POOL-001",
1650 "EUR Cash Pool",
1651 PoolType::ZeroBalancing,
1652 "BA-HEADER",
1653 NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1654 )
1655 .with_participant("BA-001")
1656 .with_participant("BA-002")
1657 .with_participant("BA-003")
1658 .with_interest_rate_benefit(dec!(0.0025));
1659
1660 assert_eq!(pool.total_accounts(), 4); assert_eq!(pool.interest_rate_benefit, dec!(0.0025));
1662 assert_eq!(pool.pool_type, PoolType::ZeroBalancing);
1663 }
1664
1665 #[test]
1666 fn test_hedging_instrument_lifecycle() {
1667 let instr = HedgingInstrument::new(
1668 "HI-001",
1669 HedgeInstrumentType::FxForward,
1670 dec!(1000000),
1671 "EUR",
1672 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1673 NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1674 "Deutsche Bank",
1675 )
1676 .with_currency_pair("EUR/USD")
1677 .with_fixed_rate(dec!(1.0850))
1678 .with_fair_value(dec!(15000));
1679
1680 assert!(instr.is_active());
1681 assert_eq!(
1682 instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
1683 107 );
1685 assert_eq!(instr.currency_pair, Some("EUR/USD".to_string()));
1686 assert_eq!(instr.fixed_rate, Some(dec!(1.0850)));
1687
1688 let terminated = instr.with_status(InstrumentStatus::Terminated);
1690 assert!(!terminated.is_active());
1691 }
1692
1693 #[test]
1694 fn test_hedging_instrument_remaining_tenor_past_maturity() {
1695 let instr = HedgingInstrument::new(
1696 "HI-002",
1697 HedgeInstrumentType::InterestRateSwap,
1698 dec!(5000000),
1699 "USD",
1700 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1701 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1702 "JPMorgan",
1703 );
1704
1705 assert_eq!(
1707 instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()),
1708 0
1709 );
1710 }
1711
1712 #[test]
1713 fn test_hedge_relationship_effectiveness() {
1714 let effective = HedgeRelationship::new(
1716 "HR-001",
1717 HedgedItemType::ForecastedTransaction,
1718 "Forecasted EUR revenue Q2 2025",
1719 "HI-001",
1720 HedgeType::CashFlowHedge,
1721 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1722 EffectivenessMethod::Regression,
1723 dec!(0.95),
1724 );
1725 assert!(effective.is_effective);
1726 assert!(HedgeRelationship::check_effectiveness(dec!(0.80))); assert!(HedgeRelationship::check_effectiveness(dec!(1.25))); let ineffective = HedgeRelationship::new(
1731 "HR-002",
1732 HedgedItemType::FirmCommitment,
1733 "Committed USD purchase",
1734 "HI-002",
1735 HedgeType::FairValueHedge,
1736 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1737 EffectivenessMethod::DollarOffset,
1738 dec!(0.75),
1739 )
1740 .with_ineffectiveness_amount(dec!(25000));
1741 assert!(!ineffective.is_effective);
1742 assert_eq!(ineffective.ineffectiveness_amount, dec!(25000));
1743
1744 assert!(!HedgeRelationship::check_effectiveness(dec!(0.79)));
1746 assert!(!HedgeRelationship::check_effectiveness(dec!(1.26)));
1747 }
1748
1749 #[test]
1750 fn test_debt_covenant_compliance() {
1751 let compliant = DebtCovenant::new(
1753 "COV-001",
1754 CovenantType::DebtToEbitda,
1755 dec!(3.5),
1756 Frequency::Quarterly,
1757 dec!(2.8),
1758 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1759 );
1760 assert!(compliant.is_compliant);
1761 assert_eq!(compliant.headroom, dec!(0.7)); let breached = DebtCovenant::new(
1765 "COV-002",
1766 CovenantType::DebtToEbitda,
1767 dec!(3.5),
1768 Frequency::Quarterly,
1769 dec!(4.0),
1770 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1771 );
1772 assert!(!breached.is_compliant);
1773 assert_eq!(breached.headroom, dec!(-0.5)); let min_compliant = DebtCovenant::new(
1777 "COV-003",
1778 CovenantType::InterestCoverage,
1779 dec!(3.0),
1780 Frequency::Quarterly,
1781 dec!(4.5),
1782 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1783 );
1784 assert!(min_compliant.is_compliant);
1785 assert_eq!(min_compliant.headroom, dec!(1.5)); let min_breached = DebtCovenant::new(
1789 "COV-004",
1790 CovenantType::InterestCoverage,
1791 dec!(3.0),
1792 Frequency::Quarterly,
1793 dec!(2.5),
1794 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1795 );
1796 assert!(!min_breached.is_compliant);
1797 assert_eq!(min_breached.headroom, dec!(-0.5));
1798
1799 let waived = DebtCovenant::new(
1801 "COV-005",
1802 CovenantType::DebtToEquity,
1803 dec!(2.0),
1804 Frequency::Annual,
1805 dec!(2.5),
1806 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1807 )
1808 .with_waiver(true);
1809 assert!(!waived.is_compliant); assert!(waived.waiver_obtained); }
1812
1813 #[test]
1814 fn test_debt_instrument_amortization() {
1815 let schedule = vec![
1816 AmortizationPayment {
1817 date: NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1818 principal_payment: dec!(250000),
1819 interest_payment: dec!(68750),
1820 balance_after: dec!(4750000),
1821 },
1822 AmortizationPayment {
1823 date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1824 principal_payment: dec!(250000),
1825 interest_payment: dec!(65312.50),
1826 balance_after: dec!(4500000),
1827 },
1828 AmortizationPayment {
1829 date: NaiveDate::from_ymd_opt(2025, 9, 30).unwrap(),
1830 principal_payment: dec!(250000),
1831 interest_payment: dec!(61875),
1832 balance_after: dec!(4250000),
1833 },
1834 AmortizationPayment {
1835 date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1836 principal_payment: dec!(250000),
1837 interest_payment: dec!(58437.50),
1838 balance_after: dec!(4000000),
1839 },
1840 ];
1841
1842 let debt = DebtInstrument::new(
1843 "DEBT-001",
1844 "C001",
1845 DebtType::TermLoan,
1846 "First National Bank",
1847 dec!(5000000),
1848 "USD",
1849 dec!(0.055),
1850 InterestRateType::Fixed,
1851 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1852 NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1853 )
1854 .with_amortization_schedule(schedule);
1855
1856 assert_eq!(debt.total_principal_payments(), dec!(1000000));
1857 assert_eq!(debt.total_interest_payments(), dec!(254375));
1858 assert_eq!(debt.amortization_schedule[0].total_payment(), dec!(318750));
1859 }
1860
1861 #[test]
1862 fn test_debt_instrument_revolving_credit() {
1863 let revolver = DebtInstrument::new(
1864 "DEBT-002",
1865 "C001",
1866 DebtType::RevolvingCredit,
1867 "Wells Fargo",
1868 dec!(0),
1869 "USD",
1870 dec!(0.045),
1871 InterestRateType::Variable,
1872 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1873 NaiveDate::from_ymd_opt(2028, 1, 1).unwrap(),
1874 )
1875 .with_drawn_amount(dec!(800000))
1876 .with_facility_limit(dec!(2000000));
1877
1878 assert_eq!(revolver.available_capacity(), dec!(1200000));
1879 }
1880
1881 #[test]
1882 fn test_debt_instrument_all_covenants_compliant() {
1883 let debt = DebtInstrument::new(
1884 "DEBT-003",
1885 "C001",
1886 DebtType::TermLoan,
1887 "Citibank",
1888 dec!(3000000),
1889 "USD",
1890 dec!(0.05),
1891 InterestRateType::Fixed,
1892 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1893 NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1894 )
1895 .with_covenant(DebtCovenant::new(
1896 "COV-A",
1897 CovenantType::DebtToEbitda,
1898 dec!(3.5),
1899 Frequency::Quarterly,
1900 dec!(2.5),
1901 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1902 ))
1903 .with_covenant(DebtCovenant::new(
1904 "COV-B",
1905 CovenantType::InterestCoverage,
1906 dec!(3.0),
1907 Frequency::Quarterly,
1908 dec!(5.0),
1909 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1910 ));
1911
1912 assert!(debt.all_covenants_compliant());
1913
1914 let debt_waived = debt.with_covenant(
1916 DebtCovenant::new(
1917 "COV-C",
1918 CovenantType::CurrentRatio,
1919 dec!(1.5),
1920 Frequency::Quarterly,
1921 dec!(1.2), NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1923 )
1924 .with_waiver(true),
1925 );
1926 assert!(debt_waived.all_covenants_compliant()); }
1928
1929 #[test]
1930 fn test_bank_guarantee_active_check() {
1931 let guarantee = BankGuarantee::new(
1932 "BG-001",
1933 "C001",
1934 GuaranteeType::PerformanceBond,
1935 dec!(500000),
1936 "USD",
1937 "Construction Corp",
1938 "HSBC",
1939 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1940 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1941 )
1942 .with_linked_project("PROJ-001");
1943
1944 assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1946 assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()));
1948 assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1950 assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1952
1953 assert_eq!(
1955 guarantee.remaining_days(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()),
1956 199
1957 );
1958 assert_eq!(
1959 guarantee.remaining_days(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()),
1960 0 );
1962
1963 let drawn = BankGuarantee::new(
1965 "BG-002",
1966 "C001",
1967 GuaranteeType::StandbyLc,
1968 dec!(200000),
1969 "EUR",
1970 "Supplier GmbH",
1971 "Deutsche Bank",
1972 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1973 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1974 )
1975 .with_status(GuaranteeStatus::Drawn);
1976 assert!(!drawn.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1977 }
1978
1979 #[test]
1980 fn test_netting_run_savings() {
1981 let positions = vec![
1982 NettingPosition {
1983 entity_id: "C001".to_string(),
1984 gross_receivable: dec!(100000),
1985 gross_payable: dec!(60000),
1986 net_position: dec!(40000),
1987 settlement_direction: PayOrReceive::Receive,
1988 },
1989 NettingPosition {
1990 entity_id: "C002".to_string(),
1991 gross_receivable: dec!(80000),
1992 gross_payable: dec!(90000),
1993 net_position: dec!(-10000),
1994 settlement_direction: PayOrReceive::Pay,
1995 },
1996 NettingPosition {
1997 entity_id: "C003".to_string(),
1998 gross_receivable: dec!(50000),
1999 gross_payable: dec!(80000),
2000 net_position: dec!(-30000),
2001 settlement_direction: PayOrReceive::Pay,
2002 },
2003 ];
2004
2005 let run = NettingRun::new(
2006 "NR-001",
2007 NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
2008 NettingCycle::Monthly,
2009 "USD",
2010 positions,
2011 );
2012
2013 assert_eq!(run.gross_receivables, dec!(230000));
2014 assert_eq!(run.gross_payables, dec!(230000));
2015 assert_eq!(run.net_settlement, dec!(40000));
2017 assert_eq!(run.savings(), dec!(190000));
2019 assert_eq!(run.participating_entities.len(), 3);
2020 }
2021
2022 #[test]
2023 fn test_netting_run_savings_pct() {
2024 let positions = vec![
2025 NettingPosition {
2026 entity_id: "C001".to_string(),
2027 gross_receivable: dec!(100000),
2028 gross_payable: dec!(0),
2029 net_position: dec!(100000),
2030 settlement_direction: PayOrReceive::Receive,
2031 },
2032 NettingPosition {
2033 entity_id: "C002".to_string(),
2034 gross_receivable: dec!(0),
2035 gross_payable: dec!(100000),
2036 net_position: dec!(-100000),
2037 settlement_direction: PayOrReceive::Pay,
2038 },
2039 ];
2040
2041 let run = NettingRun::new(
2042 "NR-002",
2043 NaiveDate::from_ymd_opt(2025, 2, 28).unwrap(),
2044 NettingCycle::Monthly,
2045 "EUR",
2046 positions,
2047 );
2048
2049 assert_eq!(run.net_settlement, dec!(100000));
2051 assert_eq!(run.savings(), dec!(0));
2052 assert_eq!(run.savings_pct(), dec!(0));
2053 }
2054
2055 #[test]
2056 fn test_cash_pool_sweep() {
2057 let sweep = CashPoolSweep {
2058 id: "SWP-001".to_string(),
2059 pool_id: "POOL-001".to_string(),
2060 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2061 from_account_id: "BA-001".to_string(),
2062 to_account_id: "BA-HEADER".to_string(),
2063 amount: dec!(50000),
2064 currency: "EUR".to_string(),
2065 };
2066
2067 assert_eq!(sweep.amount, dec!(50000));
2068 assert_eq!(sweep.pool_id, "POOL-001");
2069 }
2070
2071 #[test]
2072 fn test_serde_roundtrip_cash_position() {
2073 let pos = CashPosition::new(
2074 "CP-SERDE",
2075 "C001",
2076 "BA-001",
2077 "USD",
2078 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2079 dec!(10000.50),
2080 dec!(5000.25),
2081 dec!(2000.75),
2082 );
2083
2084 let json = serde_json::to_string_pretty(&pos).unwrap();
2085 let deserialized: CashPosition = serde_json::from_str(&json).unwrap();
2086
2087 assert_eq!(deserialized.opening_balance, pos.opening_balance);
2088 assert_eq!(deserialized.closing_balance, pos.closing_balance);
2089 assert_eq!(deserialized.date, pos.date);
2090 }
2091
2092 #[test]
2093 fn test_serde_roundtrip_hedging_instrument() {
2094 let instr = HedgingInstrument::new(
2095 "HI-SERDE",
2096 HedgeInstrumentType::InterestRateSwap,
2097 dec!(5000000),
2098 "USD",
2099 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2100 NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
2101 "JPMorgan",
2102 )
2103 .with_fixed_rate(dec!(0.0425))
2104 .with_floating_index("SOFR")
2105 .with_fair_value(dec!(-35000));
2106
2107 let json = serde_json::to_string_pretty(&instr).unwrap();
2108 let deserialized: HedgingInstrument = serde_json::from_str(&json).unwrap();
2109
2110 assert_eq!(deserialized.fixed_rate, Some(dec!(0.0425)));
2111 assert_eq!(deserialized.floating_index, Some("SOFR".to_string()));
2112 assert_eq!(deserialized.strike_rate, None);
2113 assert_eq!(deserialized.fair_value, dec!(-35000));
2114 }
2115}