use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RelatedPartyType {
#[default]
Subsidiary,
Associate,
JointVenture,
KeyManagement,
CloseFamily,
ShareholderSignificant,
CommonDirector,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RelationshipBasis {
#[default]
Ownership,
Control,
SignificantInfluence,
KeyManagementPersonnel,
CloseFamily,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum IdentificationSource {
#[default]
ManagementDisclosure,
AuditorInquiry,
PublicRecords,
BankConfirmation,
LegalReview,
WhistleblowerTip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RptTransactionType {
#[default]
Sale,
Purchase,
Lease,
Loan,
Guarantee,
ManagementFee,
Dividend,
Transfer,
ServiceAgreement,
LicenseRoyalty,
CapitalContribution,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedParty {
pub party_id: Uuid,
pub party_ref: String,
pub engagement_id: Uuid,
pub party_name: String,
pub party_type: RelatedPartyType,
pub relationship_basis: RelationshipBasis,
pub ownership_percentage: Option<f64>,
pub board_representation: bool,
pub key_management: bool,
pub disclosed_in_financials: bool,
pub disclosure_adequate: Option<bool>,
pub identified_by: IdentificationSource,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl RelatedParty {
pub fn new(
engagement_id: Uuid,
party_name: impl Into<String>,
party_type: RelatedPartyType,
relationship_basis: RelationshipBasis,
) -> Self {
let now = Utc::now();
let id = Uuid::new_v4();
let party_ref = format!("RP-{}", &id.simple().to_string()[..8]);
Self {
party_id: id,
party_ref,
engagement_id,
party_name: party_name.into(),
party_type,
relationship_basis,
ownership_percentage: None,
board_representation: false,
key_management: false,
disclosed_in_financials: true,
disclosure_adequate: None,
identified_by: IdentificationSource::ManagementDisclosure,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedPartyTransaction {
pub transaction_id: Uuid,
pub transaction_ref: String,
pub engagement_id: Uuid,
pub related_party_id: Uuid,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub journal_entry_id: Option<String>,
pub transaction_type: RptTransactionType,
pub description: String,
pub amount: Decimal,
pub currency: String,
pub transaction_date: NaiveDate,
pub terms_description: String,
pub arms_length: Option<bool>,
pub arms_length_evidence: Option<String>,
pub business_rationale: Option<String>,
pub approved_by: Option<String>,
pub disclosed_in_financials: bool,
pub disclosure_adequate: Option<bool>,
pub management_override_risk: bool,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl RelatedPartyTransaction {
#[allow(clippy::too_many_arguments)]
pub fn new(
engagement_id: Uuid,
related_party_id: Uuid,
transaction_type: RptTransactionType,
description: impl Into<String>,
amount: Decimal,
currency: impl Into<String>,
transaction_date: NaiveDate,
) -> Self {
let now = Utc::now();
let id = Uuid::new_v4();
let transaction_ref = format!("RPT-{}", &id.simple().to_string()[..8]);
Self {
transaction_id: id,
transaction_ref,
engagement_id,
related_party_id,
journal_entry_id: None,
transaction_type,
description: description.into(),
amount,
currency: currency.into(),
transaction_date,
terms_description: String::new(),
arms_length: None,
arms_length_evidence: None,
business_rationale: None,
approved_by: None,
disclosed_in_financials: true,
disclosure_adequate: None,
management_override_risk: false,
created_at: now,
updated_at: now,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn sample_date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn test_new_related_party() {
let eng = Uuid::new_v4();
let rp = RelatedParty::new(
eng,
"Acme Holdings Ltd",
RelatedPartyType::Subsidiary,
RelationshipBasis::Ownership,
);
assert_eq!(rp.engagement_id, eng);
assert_eq!(rp.party_name, "Acme Holdings Ltd");
assert_eq!(rp.party_type, RelatedPartyType::Subsidiary);
assert_eq!(rp.relationship_basis, RelationshipBasis::Ownership);
assert!(rp.disclosed_in_financials);
assert!(!rp.board_representation);
assert!(!rp.key_management);
assert!(rp.ownership_percentage.is_none());
assert!(rp.disclosure_adequate.is_none());
assert_eq!(rp.identified_by, IdentificationSource::ManagementDisclosure);
assert!(rp.party_ref.starts_with("RP-"));
assert_eq!(rp.party_ref.len(), 11); }
#[test]
fn test_new_rpt() {
let eng = Uuid::new_v4();
let party = Uuid::new_v4();
let rpt = RelatedPartyTransaction::new(
eng,
party,
RptTransactionType::ManagementFee,
"Annual management fee for shared services",
dec!(250_000),
"USD",
sample_date(2025, 6, 30),
);
assert_eq!(rpt.engagement_id, eng);
assert_eq!(rpt.related_party_id, party);
assert_eq!(rpt.transaction_type, RptTransactionType::ManagementFee);
assert_eq!(rpt.amount, dec!(250_000));
assert_eq!(rpt.currency, "USD");
assert!(rpt.disclosed_in_financials);
assert!(!rpt.management_override_risk);
assert!(rpt.arms_length.is_none());
assert!(rpt.terms_description.is_empty());
assert!(rpt.transaction_ref.starts_with("RPT-"));
assert_eq!(rpt.transaction_ref.len(), 12); }
#[test]
fn test_related_party_type_serde() {
let variants = [
RelatedPartyType::Subsidiary,
RelatedPartyType::Associate,
RelatedPartyType::JointVenture,
RelatedPartyType::KeyManagement,
RelatedPartyType::CloseFamily,
RelatedPartyType::ShareholderSignificant,
RelatedPartyType::CommonDirector,
RelatedPartyType::Other,
];
for v in variants {
let json = serde_json::to_string(&v).unwrap();
let rt: RelatedPartyType = serde_json::from_str(&json).unwrap();
assert_eq!(v, rt);
}
assert_eq!(
serde_json::to_string(&RelatedPartyType::JointVenture).unwrap(),
"\"joint_venture\""
);
assert_eq!(
serde_json::to_string(&RelatedPartyType::ShareholderSignificant).unwrap(),
"\"shareholder_significant\""
);
assert_eq!(
serde_json::to_string(&RelatedPartyType::CommonDirector).unwrap(),
"\"common_director\""
);
}
#[test]
fn test_relationship_basis_serde() {
let variants = [
RelationshipBasis::Ownership,
RelationshipBasis::Control,
RelationshipBasis::SignificantInfluence,
RelationshipBasis::KeyManagementPersonnel,
RelationshipBasis::CloseFamily,
RelationshipBasis::Other,
];
for v in variants {
let json = serde_json::to_string(&v).unwrap();
let rt: RelationshipBasis = serde_json::from_str(&json).unwrap();
assert_eq!(v, rt);
}
assert_eq!(
serde_json::to_string(&RelationshipBasis::SignificantInfluence).unwrap(),
"\"significant_influence\""
);
assert_eq!(
serde_json::to_string(&RelationshipBasis::KeyManagementPersonnel).unwrap(),
"\"key_management_personnel\""
);
}
#[test]
fn test_identification_source_serde() {
let variants = [
IdentificationSource::ManagementDisclosure,
IdentificationSource::AuditorInquiry,
IdentificationSource::PublicRecords,
IdentificationSource::BankConfirmation,
IdentificationSource::LegalReview,
IdentificationSource::WhistleblowerTip,
];
for v in variants {
let json = serde_json::to_string(&v).unwrap();
let rt: IdentificationSource = serde_json::from_str(&json).unwrap();
assert_eq!(v, rt);
}
assert_eq!(
serde_json::to_string(&IdentificationSource::ManagementDisclosure).unwrap(),
"\"management_disclosure\""
);
assert_eq!(
serde_json::to_string(&IdentificationSource::WhistleblowerTip).unwrap(),
"\"whistleblower_tip\""
);
}
#[test]
fn test_rpt_transaction_type_serde() {
let variants = [
RptTransactionType::Sale,
RptTransactionType::Purchase,
RptTransactionType::Lease,
RptTransactionType::Loan,
RptTransactionType::Guarantee,
RptTransactionType::ManagementFee,
RptTransactionType::Dividend,
RptTransactionType::Transfer,
RptTransactionType::ServiceAgreement,
RptTransactionType::LicenseRoyalty,
RptTransactionType::CapitalContribution,
RptTransactionType::Other,
];
for v in variants {
let json = serde_json::to_string(&v).unwrap();
let rt: RptTransactionType = serde_json::from_str(&json).unwrap();
assert_eq!(v, rt);
}
assert_eq!(
serde_json::to_string(&RptTransactionType::Sale).unwrap(),
"\"sale\""
);
assert_eq!(
serde_json::to_string(&RptTransactionType::ManagementFee).unwrap(),
"\"management_fee\""
);
assert_eq!(
serde_json::to_string(&RptTransactionType::ServiceAgreement).unwrap(),
"\"service_agreement\""
);
assert_eq!(
serde_json::to_string(&RptTransactionType::LicenseRoyalty).unwrap(),
"\"license_royalty\""
);
assert_eq!(
serde_json::to_string(&RptTransactionType::CapitalContribution).unwrap(),
"\"capital_contribution\""
);
}
#[test]
fn test_rpt_all_12_transaction_types() {
let eng = Uuid::new_v4();
let party = Uuid::new_v4();
let date = sample_date(2025, 1, 15);
let all_types = [
RptTransactionType::Sale,
RptTransactionType::Purchase,
RptTransactionType::Lease,
RptTransactionType::Loan,
RptTransactionType::Guarantee,
RptTransactionType::ManagementFee,
RptTransactionType::Dividend,
RptTransactionType::Transfer,
RptTransactionType::ServiceAgreement,
RptTransactionType::LicenseRoyalty,
RptTransactionType::CapitalContribution,
RptTransactionType::Other,
];
assert_eq!(
all_types.len(),
12,
"must have exactly 12 transaction types"
);
for txn_type in all_types {
let rpt = RelatedPartyTransaction::new(
eng,
party,
txn_type,
"Test transaction",
dec!(1_000),
"USD",
date,
);
let json = serde_json::to_string(&rpt.transaction_type).unwrap();
let rt: RptTransactionType = serde_json::from_str(&json).unwrap();
assert_eq!(rpt.transaction_type, rt);
}
}
#[test]
fn test_management_override_risk_default() {
let eng = Uuid::new_v4();
let party = Uuid::new_v4();
let rpt = RelatedPartyTransaction::new(
eng,
party,
RptTransactionType::Loan,
"Intercompany loan",
dec!(1_000_000),
"GBP",
sample_date(2025, 3, 31),
);
assert!(!rpt.management_override_risk);
}
}