1use chrono::{DateTime, NaiveDate, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum RelatedPartyType {
17 #[default]
19 Subsidiary,
20 Associate,
22 JointVenture,
24 KeyManagement,
27 CloseFamily,
29 ShareholderSignificant,
31 CommonDirector,
33 Other,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum RelationshipBasis {
41 #[default]
43 Ownership,
44 Control,
46 SignificantInfluence,
48 KeyManagementPersonnel,
50 CloseFamily,
52 Other,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum IdentificationSource {
60 #[default]
62 ManagementDisclosure,
63 AuditorInquiry,
65 PublicRecords,
67 BankConfirmation,
69 LegalReview,
71 WhistleblowerTip,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
77#[serde(rename_all = "snake_case")]
78pub enum RptTransactionType {
79 #[default]
81 Sale,
82 Purchase,
84 Lease,
86 Loan,
88 Guarantee,
90 ManagementFee,
92 Dividend,
94 Transfer,
96 ServiceAgreement,
98 LicenseRoyalty,
100 CapitalContribution,
102 Other,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct RelatedParty {
109 pub party_id: Uuid,
111 pub party_ref: String,
113 pub engagement_id: Uuid,
115
116 pub party_name: String,
119 pub party_type: RelatedPartyType,
121 pub relationship_basis: RelationshipBasis,
123
124 pub ownership_percentage: Option<f64>,
127 pub board_representation: bool,
129 pub key_management: bool,
131
132 pub disclosed_in_financials: bool,
135 pub disclosure_adequate: Option<bool>,
137
138 pub identified_by: IdentificationSource,
141
142 pub created_at: DateTime<Utc>,
144 pub updated_at: DateTime<Utc>,
145}
146
147impl RelatedParty {
148 pub fn new(
150 engagement_id: Uuid,
151 party_name: impl Into<String>,
152 party_type: RelatedPartyType,
153 relationship_basis: RelationshipBasis,
154 ) -> Self {
155 let now = Utc::now();
156 let id = Uuid::new_v4();
157 let party_ref = format!("RP-{}", &id.simple().to_string()[..8]);
158 Self {
159 party_id: id,
160 party_ref,
161 engagement_id,
162 party_name: party_name.into(),
163 party_type,
164 relationship_basis,
165 ownership_percentage: None,
166 board_representation: false,
167 key_management: false,
168 disclosed_in_financials: true,
169 disclosure_adequate: None,
170 identified_by: IdentificationSource::ManagementDisclosure,
171 created_at: now,
172 updated_at: now,
173 }
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct RelatedPartyTransaction {
180 pub transaction_id: Uuid,
182 pub transaction_ref: String,
184 pub engagement_id: Uuid,
186 pub related_party_id: Uuid,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub journal_entry_id: Option<String>,
194 pub transaction_type: RptTransactionType,
196 pub description: String,
198 pub amount: Decimal,
200 pub currency: String,
202 pub transaction_date: NaiveDate,
204 pub terms_description: String,
206
207 pub arms_length: Option<bool>,
210 pub arms_length_evidence: Option<String>,
212 pub business_rationale: Option<String>,
214 pub approved_by: Option<String>,
216
217 pub disclosed_in_financials: bool,
220 pub disclosure_adequate: Option<bool>,
222
223 pub management_override_risk: bool,
226
227 pub created_at: DateTime<Utc>,
229 pub updated_at: DateTime<Utc>,
230}
231
232impl RelatedPartyTransaction {
233 #[allow(clippy::too_many_arguments)]
235 pub fn new(
236 engagement_id: Uuid,
237 related_party_id: Uuid,
238 transaction_type: RptTransactionType,
239 description: impl Into<String>,
240 amount: Decimal,
241 currency: impl Into<String>,
242 transaction_date: NaiveDate,
243 ) -> Self {
244 let now = Utc::now();
245 let id = Uuid::new_v4();
246 let transaction_ref = format!("RPT-{}", &id.simple().to_string()[..8]);
247 Self {
248 transaction_id: id,
249 transaction_ref,
250 engagement_id,
251 related_party_id,
252 journal_entry_id: None,
253 transaction_type,
254 description: description.into(),
255 amount,
256 currency: currency.into(),
257 transaction_date,
258 terms_description: String::new(),
259 arms_length: None,
260 arms_length_evidence: None,
261 business_rationale: None,
262 approved_by: None,
263 disclosed_in_financials: true,
264 disclosure_adequate: None,
265 management_override_risk: false,
266 created_at: now,
267 updated_at: now,
268 }
269 }
270}
271
272#[cfg(test)]
273#[allow(clippy::unwrap_used)]
274mod tests {
275 use super::*;
276 use rust_decimal_macros::dec;
277
278 fn sample_date(year: i32, month: u32, day: u32) -> NaiveDate {
279 NaiveDate::from_ymd_opt(year, month, day).unwrap()
280 }
281
282 #[test]
283 fn test_new_related_party() {
284 let eng = Uuid::new_v4();
285 let rp = RelatedParty::new(
286 eng,
287 "Acme Holdings Ltd",
288 RelatedPartyType::Subsidiary,
289 RelationshipBasis::Ownership,
290 );
291
292 assert_eq!(rp.engagement_id, eng);
293 assert_eq!(rp.party_name, "Acme Holdings Ltd");
294 assert_eq!(rp.party_type, RelatedPartyType::Subsidiary);
295 assert_eq!(rp.relationship_basis, RelationshipBasis::Ownership);
296 assert!(rp.disclosed_in_financials);
297 assert!(!rp.board_representation);
298 assert!(!rp.key_management);
299 assert!(rp.ownership_percentage.is_none());
300 assert!(rp.disclosure_adequate.is_none());
301 assert_eq!(rp.identified_by, IdentificationSource::ManagementDisclosure);
302 assert!(rp.party_ref.starts_with("RP-"));
303 assert_eq!(rp.party_ref.len(), 11); }
305
306 #[test]
307 fn test_new_rpt() {
308 let eng = Uuid::new_v4();
309 let party = Uuid::new_v4();
310 let rpt = RelatedPartyTransaction::new(
311 eng,
312 party,
313 RptTransactionType::ManagementFee,
314 "Annual management fee for shared services",
315 dec!(250_000),
316 "USD",
317 sample_date(2025, 6, 30),
318 );
319
320 assert_eq!(rpt.engagement_id, eng);
321 assert_eq!(rpt.related_party_id, party);
322 assert_eq!(rpt.transaction_type, RptTransactionType::ManagementFee);
323 assert_eq!(rpt.amount, dec!(250_000));
324 assert_eq!(rpt.currency, "USD");
325 assert!(rpt.disclosed_in_financials);
326 assert!(!rpt.management_override_risk);
327 assert!(rpt.arms_length.is_none());
328 assert!(rpt.terms_description.is_empty());
329 assert!(rpt.transaction_ref.starts_with("RPT-"));
330 assert_eq!(rpt.transaction_ref.len(), 12); }
332
333 #[test]
334 fn test_related_party_type_serde() {
335 let variants = [
336 RelatedPartyType::Subsidiary,
337 RelatedPartyType::Associate,
338 RelatedPartyType::JointVenture,
339 RelatedPartyType::KeyManagement,
340 RelatedPartyType::CloseFamily,
341 RelatedPartyType::ShareholderSignificant,
342 RelatedPartyType::CommonDirector,
343 RelatedPartyType::Other,
344 ];
345 for v in variants {
346 let json = serde_json::to_string(&v).unwrap();
347 let rt: RelatedPartyType = serde_json::from_str(&json).unwrap();
348 assert_eq!(v, rt);
349 }
350 assert_eq!(
351 serde_json::to_string(&RelatedPartyType::JointVenture).unwrap(),
352 "\"joint_venture\""
353 );
354 assert_eq!(
355 serde_json::to_string(&RelatedPartyType::ShareholderSignificant).unwrap(),
356 "\"shareholder_significant\""
357 );
358 assert_eq!(
359 serde_json::to_string(&RelatedPartyType::CommonDirector).unwrap(),
360 "\"common_director\""
361 );
362 }
363
364 #[test]
365 fn test_relationship_basis_serde() {
366 let variants = [
367 RelationshipBasis::Ownership,
368 RelationshipBasis::Control,
369 RelationshipBasis::SignificantInfluence,
370 RelationshipBasis::KeyManagementPersonnel,
371 RelationshipBasis::CloseFamily,
372 RelationshipBasis::Other,
373 ];
374 for v in variants {
375 let json = serde_json::to_string(&v).unwrap();
376 let rt: RelationshipBasis = serde_json::from_str(&json).unwrap();
377 assert_eq!(v, rt);
378 }
379 assert_eq!(
380 serde_json::to_string(&RelationshipBasis::SignificantInfluence).unwrap(),
381 "\"significant_influence\""
382 );
383 assert_eq!(
384 serde_json::to_string(&RelationshipBasis::KeyManagementPersonnel).unwrap(),
385 "\"key_management_personnel\""
386 );
387 }
388
389 #[test]
390 fn test_identification_source_serde() {
391 let variants = [
392 IdentificationSource::ManagementDisclosure,
393 IdentificationSource::AuditorInquiry,
394 IdentificationSource::PublicRecords,
395 IdentificationSource::BankConfirmation,
396 IdentificationSource::LegalReview,
397 IdentificationSource::WhistleblowerTip,
398 ];
399 for v in variants {
400 let json = serde_json::to_string(&v).unwrap();
401 let rt: IdentificationSource = serde_json::from_str(&json).unwrap();
402 assert_eq!(v, rt);
403 }
404 assert_eq!(
405 serde_json::to_string(&IdentificationSource::ManagementDisclosure).unwrap(),
406 "\"management_disclosure\""
407 );
408 assert_eq!(
409 serde_json::to_string(&IdentificationSource::WhistleblowerTip).unwrap(),
410 "\"whistleblower_tip\""
411 );
412 }
413
414 #[test]
415 fn test_rpt_transaction_type_serde() {
416 let variants = [
418 RptTransactionType::Sale,
419 RptTransactionType::Purchase,
420 RptTransactionType::Lease,
421 RptTransactionType::Loan,
422 RptTransactionType::Guarantee,
423 RptTransactionType::ManagementFee,
424 RptTransactionType::Dividend,
425 RptTransactionType::Transfer,
426 RptTransactionType::ServiceAgreement,
427 RptTransactionType::LicenseRoyalty,
428 RptTransactionType::CapitalContribution,
429 RptTransactionType::Other,
430 ];
431 for v in variants {
432 let json = serde_json::to_string(&v).unwrap();
433 let rt: RptTransactionType = serde_json::from_str(&json).unwrap();
434 assert_eq!(v, rt);
435 }
436 assert_eq!(
437 serde_json::to_string(&RptTransactionType::Sale).unwrap(),
438 "\"sale\""
439 );
440 assert_eq!(
441 serde_json::to_string(&RptTransactionType::ManagementFee).unwrap(),
442 "\"management_fee\""
443 );
444 assert_eq!(
445 serde_json::to_string(&RptTransactionType::ServiceAgreement).unwrap(),
446 "\"service_agreement\""
447 );
448 assert_eq!(
449 serde_json::to_string(&RptTransactionType::LicenseRoyalty).unwrap(),
450 "\"license_royalty\""
451 );
452 assert_eq!(
453 serde_json::to_string(&RptTransactionType::CapitalContribution).unwrap(),
454 "\"capital_contribution\""
455 );
456 }
457
458 #[test]
459 fn test_rpt_all_12_transaction_types() {
460 let eng = Uuid::new_v4();
461 let party = Uuid::new_v4();
462 let date = sample_date(2025, 1, 15);
463
464 let all_types = [
465 RptTransactionType::Sale,
466 RptTransactionType::Purchase,
467 RptTransactionType::Lease,
468 RptTransactionType::Loan,
469 RptTransactionType::Guarantee,
470 RptTransactionType::ManagementFee,
471 RptTransactionType::Dividend,
472 RptTransactionType::Transfer,
473 RptTransactionType::ServiceAgreement,
474 RptTransactionType::LicenseRoyalty,
475 RptTransactionType::CapitalContribution,
476 RptTransactionType::Other,
477 ];
478
479 assert_eq!(
480 all_types.len(),
481 12,
482 "must have exactly 12 transaction types"
483 );
484
485 for txn_type in all_types {
486 let rpt = RelatedPartyTransaction::new(
487 eng,
488 party,
489 txn_type,
490 "Test transaction",
491 dec!(1_000),
492 "USD",
493 date,
494 );
495 let json = serde_json::to_string(&rpt.transaction_type).unwrap();
497 let rt: RptTransactionType = serde_json::from_str(&json).unwrap();
498 assert_eq!(rpt.transaction_type, rt);
499 }
500 }
501
502 #[test]
503 fn test_management_override_risk_default() {
504 let eng = Uuid::new_v4();
505 let party = Uuid::new_v4();
506 let rpt = RelatedPartyTransaction::new(
507 eng,
508 party,
509 RptTransactionType::Loan,
510 "Intercompany loan",
511 dec!(1_000_000),
512 "GBP",
513 sample_date(2025, 3, 31),
514 );
515 assert!(!rpt.management_override_risk);
517 }
518}