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 = "rust_decimal::serde::str")]
272 pub opening_balance: Decimal,
273 #[serde(with = "rust_decimal::serde::str")]
275 pub inflows: Decimal,
276 #[serde(with = "rust_decimal::serde::str")]
278 pub outflows: Decimal,
279 #[serde(with = "rust_decimal::serde::str")]
281 pub closing_balance: Decimal,
282 #[serde(with = "rust_decimal::serde::str")]
284 pub available_balance: Decimal,
285 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
348 pub amount: Decimal,
349 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
375 pub net_position: Decimal,
376 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
506 pub notional_amount: Decimal,
507 pub currency: String,
509 pub currency_pair: Option<String>,
511 #[serde(default, with = "rust_decimal::serde::str_option")]
513 pub fixed_rate: Option<Decimal>,
514 pub floating_index: Option<String>,
516 #[serde(default, with = "rust_decimal::serde::str_option")]
518 pub strike_rate: Option<Decimal>,
519 pub trade_date: NaiveDate,
521 pub maturity_date: NaiveDate,
523 pub counterparty: String,
525 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
628 pub effectiveness_ratio: Decimal,
629 pub is_effective: bool,
631 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
694 pub principal_payment: Decimal,
695 #[serde(with = "rust_decimal::serde::str")]
697 pub interest_payment: Decimal,
698 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
719 pub threshold: Decimal,
720 pub measurement_frequency: Frequency,
722 #[serde(with = "rust_decimal::serde::str")]
724 pub actual_value: Decimal,
725 pub measurement_date: NaiveDate,
727 pub is_compliant: bool,
729 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
862 pub principal: Decimal,
863 pub currency: String,
865 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
880 pub drawn_amount: Decimal,
881 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
1072 pub gross_receivable: Decimal,
1073 #[serde(with = "rust_decimal::serde::str")]
1075 pub gross_payable: Decimal,
1076 #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
1096 pub gross_receivables: Decimal,
1097 #[serde(with = "rust_decimal::serde::str")]
1099 pub gross_payables: Decimal,
1100 #[serde(with = "rust_decimal::serde::str")]
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)]
1556#[allow(clippy::unwrap_used)]
1557mod tests {
1558 use super::*;
1559 use rust_decimal_macros::dec;
1560
1561 #[test]
1562 fn test_cash_position_closing_balance() {
1563 let pos = CashPosition::new(
1564 "CP-001",
1565 "C001",
1566 "BA-001",
1567 "USD",
1568 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1569 dec!(10000),
1570 dec!(5000),
1571 dec!(2000),
1572 );
1573 assert_eq!(pos.closing_balance, dec!(13000));
1575 assert_eq!(pos.computed_closing_balance(), dec!(13000));
1576 assert_eq!(pos.available_balance, dec!(13000)); }
1578
1579 #[test]
1580 fn test_cash_position_with_overrides() {
1581 let pos = CashPosition::new(
1582 "CP-002",
1583 "C001",
1584 "BA-001",
1585 "USD",
1586 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1587 dec!(10000),
1588 dec!(5000),
1589 dec!(2000),
1590 )
1591 .with_available_balance(dec!(12000))
1592 .with_value_date_balance(dec!(12500));
1593
1594 assert_eq!(pos.closing_balance, dec!(13000));
1595 assert_eq!(pos.available_balance, dec!(12000));
1596 assert_eq!(pos.value_date_balance, dec!(12500));
1597 }
1598
1599 #[test]
1600 fn test_cash_forecast_net_position() {
1601 let items = vec![
1602 CashForecastItem {
1603 id: "CFI-001".to_string(),
1604 date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
1605 category: TreasuryCashFlowCategory::ArCollection,
1606 amount: dec!(50000),
1607 probability: dec!(0.90),
1608 source_document_type: Some("SalesOrder".to_string()),
1609 source_document_id: Some("SO-001".to_string()),
1610 },
1611 CashForecastItem {
1612 id: "CFI-002".to_string(),
1613 date: NaiveDate::from_ymd_opt(2025, 2, 5).unwrap(),
1614 category: TreasuryCashFlowCategory::ApPayment,
1615 amount: dec!(-30000),
1616 probability: dec!(1.00),
1617 source_document_type: Some("PurchaseOrder".to_string()),
1618 source_document_id: Some("PO-001".to_string()),
1619 },
1620 CashForecastItem {
1621 id: "CFI-003".to_string(),
1622 date: NaiveDate::from_ymd_opt(2025, 2, 15).unwrap(),
1623 category: TreasuryCashFlowCategory::TaxPayment,
1624 amount: dec!(-10000),
1625 probability: dec!(1.00),
1626 source_document_type: None,
1627 source_document_id: None,
1628 },
1629 ];
1630 let forecast = CashForecast::new(
1631 "CF-001",
1632 "C001",
1633 "USD",
1634 NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
1635 30,
1636 items,
1637 dec!(0.90),
1638 );
1639
1640 assert_eq!(forecast.net_position, dec!(5000));
1643 assert_eq!(forecast.computed_net_position(), dec!(5000));
1644 assert_eq!(forecast.items.len(), 3);
1645 }
1646
1647 #[test]
1648 fn test_cash_pool_total_accounts() {
1649 let pool = CashPool::new(
1650 "POOL-001",
1651 "EUR Cash Pool",
1652 PoolType::ZeroBalancing,
1653 "BA-HEADER",
1654 NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1655 )
1656 .with_participant("BA-001")
1657 .with_participant("BA-002")
1658 .with_participant("BA-003")
1659 .with_interest_rate_benefit(dec!(0.0025));
1660
1661 assert_eq!(pool.total_accounts(), 4); assert_eq!(pool.interest_rate_benefit, dec!(0.0025));
1663 assert_eq!(pool.pool_type, PoolType::ZeroBalancing);
1664 }
1665
1666 #[test]
1667 fn test_hedging_instrument_lifecycle() {
1668 let instr = HedgingInstrument::new(
1669 "HI-001",
1670 HedgeInstrumentType::FxForward,
1671 dec!(1000000),
1672 "EUR",
1673 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1674 NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1675 "Deutsche Bank",
1676 )
1677 .with_currency_pair("EUR/USD")
1678 .with_fixed_rate(dec!(1.0850))
1679 .with_fair_value(dec!(15000));
1680
1681 assert!(instr.is_active());
1682 assert_eq!(
1683 instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
1684 107 );
1686 assert_eq!(instr.currency_pair, Some("EUR/USD".to_string()));
1687 assert_eq!(instr.fixed_rate, Some(dec!(1.0850)));
1688
1689 let terminated = instr.with_status(InstrumentStatus::Terminated);
1691 assert!(!terminated.is_active());
1692 }
1693
1694 #[test]
1695 fn test_hedging_instrument_remaining_tenor_past_maturity() {
1696 let instr = HedgingInstrument::new(
1697 "HI-002",
1698 HedgeInstrumentType::InterestRateSwap,
1699 dec!(5000000),
1700 "USD",
1701 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1702 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1703 "JPMorgan",
1704 );
1705
1706 assert_eq!(
1708 instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()),
1709 0
1710 );
1711 }
1712
1713 #[test]
1714 fn test_hedge_relationship_effectiveness() {
1715 let effective = HedgeRelationship::new(
1717 "HR-001",
1718 HedgedItemType::ForecastedTransaction,
1719 "Forecasted EUR revenue Q2 2025",
1720 "HI-001",
1721 HedgeType::CashFlowHedge,
1722 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1723 EffectivenessMethod::Regression,
1724 dec!(0.95),
1725 );
1726 assert!(effective.is_effective);
1727 assert!(HedgeRelationship::check_effectiveness(dec!(0.80))); assert!(HedgeRelationship::check_effectiveness(dec!(1.25))); let ineffective = HedgeRelationship::new(
1732 "HR-002",
1733 HedgedItemType::FirmCommitment,
1734 "Committed USD purchase",
1735 "HI-002",
1736 HedgeType::FairValueHedge,
1737 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1738 EffectivenessMethod::DollarOffset,
1739 dec!(0.75),
1740 )
1741 .with_ineffectiveness_amount(dec!(25000));
1742 assert!(!ineffective.is_effective);
1743 assert_eq!(ineffective.ineffectiveness_amount, dec!(25000));
1744
1745 assert!(!HedgeRelationship::check_effectiveness(dec!(0.79)));
1747 assert!(!HedgeRelationship::check_effectiveness(dec!(1.26)));
1748 }
1749
1750 #[test]
1751 fn test_debt_covenant_compliance() {
1752 let compliant = DebtCovenant::new(
1754 "COV-001",
1755 CovenantType::DebtToEbitda,
1756 dec!(3.5),
1757 Frequency::Quarterly,
1758 dec!(2.8),
1759 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1760 );
1761 assert!(compliant.is_compliant);
1762 assert_eq!(compliant.headroom, dec!(0.7)); let breached = DebtCovenant::new(
1766 "COV-002",
1767 CovenantType::DebtToEbitda,
1768 dec!(3.5),
1769 Frequency::Quarterly,
1770 dec!(4.0),
1771 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1772 );
1773 assert!(!breached.is_compliant);
1774 assert_eq!(breached.headroom, dec!(-0.5)); let min_compliant = DebtCovenant::new(
1778 "COV-003",
1779 CovenantType::InterestCoverage,
1780 dec!(3.0),
1781 Frequency::Quarterly,
1782 dec!(4.5),
1783 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1784 );
1785 assert!(min_compliant.is_compliant);
1786 assert_eq!(min_compliant.headroom, dec!(1.5)); let min_breached = DebtCovenant::new(
1790 "COV-004",
1791 CovenantType::InterestCoverage,
1792 dec!(3.0),
1793 Frequency::Quarterly,
1794 dec!(2.5),
1795 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1796 );
1797 assert!(!min_breached.is_compliant);
1798 assert_eq!(min_breached.headroom, dec!(-0.5));
1799
1800 let waived = DebtCovenant::new(
1802 "COV-005",
1803 CovenantType::DebtToEquity,
1804 dec!(2.0),
1805 Frequency::Annual,
1806 dec!(2.5),
1807 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1808 )
1809 .with_waiver(true);
1810 assert!(!waived.is_compliant); assert!(waived.waiver_obtained); }
1813
1814 #[test]
1815 fn test_debt_instrument_amortization() {
1816 let schedule = vec![
1817 AmortizationPayment {
1818 date: NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1819 principal_payment: dec!(250000),
1820 interest_payment: dec!(68750),
1821 balance_after: dec!(4750000),
1822 },
1823 AmortizationPayment {
1824 date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1825 principal_payment: dec!(250000),
1826 interest_payment: dec!(65312.50),
1827 balance_after: dec!(4500000),
1828 },
1829 AmortizationPayment {
1830 date: NaiveDate::from_ymd_opt(2025, 9, 30).unwrap(),
1831 principal_payment: dec!(250000),
1832 interest_payment: dec!(61875),
1833 balance_after: dec!(4250000),
1834 },
1835 AmortizationPayment {
1836 date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1837 principal_payment: dec!(250000),
1838 interest_payment: dec!(58437.50),
1839 balance_after: dec!(4000000),
1840 },
1841 ];
1842
1843 let debt = DebtInstrument::new(
1844 "DEBT-001",
1845 "C001",
1846 DebtType::TermLoan,
1847 "First National Bank",
1848 dec!(5000000),
1849 "USD",
1850 dec!(0.055),
1851 InterestRateType::Fixed,
1852 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1853 NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1854 )
1855 .with_amortization_schedule(schedule);
1856
1857 assert_eq!(debt.total_principal_payments(), dec!(1000000));
1858 assert_eq!(debt.total_interest_payments(), dec!(254375));
1859 assert_eq!(debt.amortization_schedule[0].total_payment(), dec!(318750));
1860 }
1861
1862 #[test]
1863 fn test_debt_instrument_revolving_credit() {
1864 let revolver = DebtInstrument::new(
1865 "DEBT-002",
1866 "C001",
1867 DebtType::RevolvingCredit,
1868 "Wells Fargo",
1869 dec!(0),
1870 "USD",
1871 dec!(0.045),
1872 InterestRateType::Variable,
1873 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1874 NaiveDate::from_ymd_opt(2028, 1, 1).unwrap(),
1875 )
1876 .with_drawn_amount(dec!(800000))
1877 .with_facility_limit(dec!(2000000));
1878
1879 assert_eq!(revolver.available_capacity(), dec!(1200000));
1880 }
1881
1882 #[test]
1883 fn test_debt_instrument_all_covenants_compliant() {
1884 let debt = DebtInstrument::new(
1885 "DEBT-003",
1886 "C001",
1887 DebtType::TermLoan,
1888 "Citibank",
1889 dec!(3000000),
1890 "USD",
1891 dec!(0.05),
1892 InterestRateType::Fixed,
1893 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1894 NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1895 )
1896 .with_covenant(DebtCovenant::new(
1897 "COV-A",
1898 CovenantType::DebtToEbitda,
1899 dec!(3.5),
1900 Frequency::Quarterly,
1901 dec!(2.5),
1902 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1903 ))
1904 .with_covenant(DebtCovenant::new(
1905 "COV-B",
1906 CovenantType::InterestCoverage,
1907 dec!(3.0),
1908 Frequency::Quarterly,
1909 dec!(5.0),
1910 NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1911 ));
1912
1913 assert!(debt.all_covenants_compliant());
1914
1915 let debt_waived = debt.with_covenant(
1917 DebtCovenant::new(
1918 "COV-C",
1919 CovenantType::CurrentRatio,
1920 dec!(1.5),
1921 Frequency::Quarterly,
1922 dec!(1.2), NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1924 )
1925 .with_waiver(true),
1926 );
1927 assert!(debt_waived.all_covenants_compliant()); }
1929
1930 #[test]
1931 fn test_bank_guarantee_active_check() {
1932 let guarantee = BankGuarantee::new(
1933 "BG-001",
1934 "C001",
1935 GuaranteeType::PerformanceBond,
1936 dec!(500000),
1937 "USD",
1938 "Construction Corp",
1939 "HSBC",
1940 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1941 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1942 )
1943 .with_linked_project("PROJ-001");
1944
1945 assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1947 assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()));
1949 assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1951 assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1953
1954 assert_eq!(
1956 guarantee.remaining_days(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()),
1957 199
1958 );
1959 assert_eq!(
1960 guarantee.remaining_days(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()),
1961 0 );
1963
1964 let drawn = BankGuarantee::new(
1966 "BG-002",
1967 "C001",
1968 GuaranteeType::StandbyLc,
1969 dec!(200000),
1970 "EUR",
1971 "Supplier GmbH",
1972 "Deutsche Bank",
1973 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1974 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1975 )
1976 .with_status(GuaranteeStatus::Drawn);
1977 assert!(!drawn.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1978 }
1979
1980 #[test]
1981 fn test_netting_run_savings() {
1982 let positions = vec![
1983 NettingPosition {
1984 entity_id: "C001".to_string(),
1985 gross_receivable: dec!(100000),
1986 gross_payable: dec!(60000),
1987 net_position: dec!(40000),
1988 settlement_direction: PayOrReceive::Receive,
1989 },
1990 NettingPosition {
1991 entity_id: "C002".to_string(),
1992 gross_receivable: dec!(80000),
1993 gross_payable: dec!(90000),
1994 net_position: dec!(-10000),
1995 settlement_direction: PayOrReceive::Pay,
1996 },
1997 NettingPosition {
1998 entity_id: "C003".to_string(),
1999 gross_receivable: dec!(50000),
2000 gross_payable: dec!(80000),
2001 net_position: dec!(-30000),
2002 settlement_direction: PayOrReceive::Pay,
2003 },
2004 ];
2005
2006 let run = NettingRun::new(
2007 "NR-001",
2008 NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
2009 NettingCycle::Monthly,
2010 "USD",
2011 positions,
2012 );
2013
2014 assert_eq!(run.gross_receivables, dec!(230000));
2015 assert_eq!(run.gross_payables, dec!(230000));
2016 assert_eq!(run.net_settlement, dec!(40000));
2018 assert_eq!(run.savings(), dec!(190000));
2020 assert_eq!(run.participating_entities.len(), 3);
2021 }
2022
2023 #[test]
2024 fn test_netting_run_savings_pct() {
2025 let positions = vec![
2026 NettingPosition {
2027 entity_id: "C001".to_string(),
2028 gross_receivable: dec!(100000),
2029 gross_payable: dec!(0),
2030 net_position: dec!(100000),
2031 settlement_direction: PayOrReceive::Receive,
2032 },
2033 NettingPosition {
2034 entity_id: "C002".to_string(),
2035 gross_receivable: dec!(0),
2036 gross_payable: dec!(100000),
2037 net_position: dec!(-100000),
2038 settlement_direction: PayOrReceive::Pay,
2039 },
2040 ];
2041
2042 let run = NettingRun::new(
2043 "NR-002",
2044 NaiveDate::from_ymd_opt(2025, 2, 28).unwrap(),
2045 NettingCycle::Monthly,
2046 "EUR",
2047 positions,
2048 );
2049
2050 assert_eq!(run.net_settlement, dec!(100000));
2052 assert_eq!(run.savings(), dec!(0));
2053 assert_eq!(run.savings_pct(), dec!(0));
2054 }
2055
2056 #[test]
2057 fn test_cash_pool_sweep() {
2058 let sweep = CashPoolSweep {
2059 id: "SWP-001".to_string(),
2060 pool_id: "POOL-001".to_string(),
2061 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2062 from_account_id: "BA-001".to_string(),
2063 to_account_id: "BA-HEADER".to_string(),
2064 amount: dec!(50000),
2065 currency: "EUR".to_string(),
2066 };
2067
2068 assert_eq!(sweep.amount, dec!(50000));
2069 assert_eq!(sweep.pool_id, "POOL-001");
2070 }
2071
2072 #[test]
2073 fn test_serde_roundtrip_cash_position() {
2074 let pos = CashPosition::new(
2075 "CP-SERDE",
2076 "C001",
2077 "BA-001",
2078 "USD",
2079 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2080 dec!(10000.50),
2081 dec!(5000.25),
2082 dec!(2000.75),
2083 );
2084
2085 let json = serde_json::to_string_pretty(&pos).unwrap();
2086 let deserialized: CashPosition = serde_json::from_str(&json).unwrap();
2087
2088 assert_eq!(deserialized.opening_balance, pos.opening_balance);
2089 assert_eq!(deserialized.closing_balance, pos.closing_balance);
2090 assert_eq!(deserialized.date, pos.date);
2091 }
2092
2093 #[test]
2094 fn test_serde_roundtrip_hedging_instrument() {
2095 let instr = HedgingInstrument::new(
2096 "HI-SERDE",
2097 HedgeInstrumentType::InterestRateSwap,
2098 dec!(5000000),
2099 "USD",
2100 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2101 NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
2102 "JPMorgan",
2103 )
2104 .with_fixed_rate(dec!(0.0425))
2105 .with_floating_index("SOFR")
2106 .with_fair_value(dec!(-35000));
2107
2108 let json = serde_json::to_string_pretty(&instr).unwrap();
2109 let deserialized: HedgingInstrument = serde_json::from_str(&json).unwrap();
2110
2111 assert_eq!(deserialized.fixed_rate, Some(dec!(0.0425)));
2112 assert_eq!(deserialized.floating_index, Some("SOFR".to_string()));
2113 assert_eq!(deserialized.strike_rate, None);
2114 assert_eq!(deserialized.fair_value, dec!(-35000));
2115 }
2116}