Skip to main content

datasynth_core/models/audit/
related_party.rs

1//! Related party models per ISA 550.
2//!
3//! ISA 550 requires auditors to identify related party relationships and transactions
4//! and assess their impact on the financial statements. Related party transactions
5//! are inherently higher risk because they may not be conducted on arm's length terms
6//! and can be used as a vehicle for management override or fraud.
7
8use chrono::{DateTime, NaiveDate, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// Classification of the related party's relationship to the entity.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum RelatedPartyType {
17    /// Subsidiary — the entity controls the related party
18    #[default]
19    Subsidiary,
20    /// Associate — the entity has significant influence over the related party
21    Associate,
22    /// Joint venture — the entity and one or more other parties control the related party jointly
23    JointVenture,
24    /// Key management personnel — those with authority and responsibility for planning,
25    /// directing and controlling the activities of the entity
26    KeyManagement,
27    /// Close family members of key management personnel
28    CloseFamily,
29    /// Shareholder with significant influence (but not control) over the entity
30    ShareholderSignificant,
31    /// Entity controlled by a common director or key management person
32    CommonDirector,
33    /// Other related party relationship
34    Other,
35}
36
37/// Legal or economic basis for the related party relationship.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[serde(rename_all = "snake_case")]
40pub enum RelationshipBasis {
41    /// Related through direct or indirect ownership
42    #[default]
43    Ownership,
44    /// Related through the ability to control operating and financial policies
45    Control,
46    /// Related through significant influence without control
47    SignificantInfluence,
48    /// Related through key management personnel status
49    KeyManagementPersonnel,
50    /// Related through close family ties
51    CloseFamily,
52    /// Other basis for the relationship
53    Other,
54}
55
56/// How the related party was identified during the audit.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum IdentificationSource {
60    /// Disclosed by management in response to auditor inquiry
61    #[default]
62    ManagementDisclosure,
63    /// Identified directly by the auditor through enquiry procedures
64    AuditorInquiry,
65    /// Identified through review of public registers or records
66    PublicRecords,
67    /// Identified through bank confirmation procedures
68    BankConfirmation,
69    /// Identified through review of legal agreements or correspondence
70    LegalReview,
71    /// Identified through a whistleblower tip or anonymous allegation
72    WhistleblowerTip,
73}
74
75/// Type of related party transaction per ISA 550.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
77#[serde(rename_all = "snake_case")]
78pub enum RptTransactionType {
79    /// Sale of goods or services to a related party
80    #[default]
81    Sale,
82    /// Purchase of goods or services from a related party
83    Purchase,
84    /// Lease of property or equipment to/from a related party
85    Lease,
86    /// Loan extended to or received from a related party
87    Loan,
88    /// Guarantee provided to or by a related party
89    Guarantee,
90    /// Management fee charged to or received from a related party
91    ManagementFee,
92    /// Dividend paid or received
93    Dividend,
94    /// Transfer of assets or liabilities
95    Transfer,
96    /// Ongoing service agreement with a related party
97    ServiceAgreement,
98    /// License or royalty arrangement
99    LicenseRoyalty,
100    /// Capital contribution made to or received from a related party
101    CapitalContribution,
102    /// Other type of related party transaction
103    Other,
104}
105
106/// A related party identified during the audit per ISA 550.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct RelatedParty {
109    /// Unique party ID
110    pub party_id: Uuid,
111    /// Human-readable reference (format: "RP-{first 8 hex chars of party_id}")
112    pub party_ref: String,
113    /// Engagement this related party is associated with
114    pub engagement_id: Uuid,
115
116    // === Identity ===
117    /// Name of the related party
118    pub party_name: String,
119    /// Classification of the related party type
120    pub party_type: RelatedPartyType,
121    /// Legal or economic basis for the relationship
122    pub relationship_basis: RelationshipBasis,
123
124    // === Relationship Details ===
125    /// Ownership percentage, if applicable
126    pub ownership_percentage: Option<f64>,
127    /// Whether the related party has board representation
128    pub board_representation: bool,
129    /// Whether the related party is or controls key management personnel
130    pub key_management: bool,
131
132    // === Disclosure Assessment ===
133    /// Whether the related party has been disclosed in the financial statements
134    pub disclosed_in_financials: bool,
135    /// Whether the disclosure is adequate per the applicable financial reporting framework
136    pub disclosure_adequate: Option<bool>,
137
138    // === Identification ===
139    /// How this related party was identified
140    pub identified_by: IdentificationSource,
141
142    // === Timestamps ===
143    #[serde(with = "crate::serde_timestamp::utc")]
144    pub created_at: DateTime<Utc>,
145    #[serde(with = "crate::serde_timestamp::utc")]
146    pub updated_at: DateTime<Utc>,
147}
148
149impl RelatedParty {
150    /// Create a new related party record.
151    pub fn new(
152        engagement_id: Uuid,
153        party_name: impl Into<String>,
154        party_type: RelatedPartyType,
155        relationship_basis: RelationshipBasis,
156    ) -> Self {
157        let now = Utc::now();
158        let id = Uuid::new_v4();
159        let party_ref = format!("RP-{}", &id.simple().to_string()[..8]);
160        Self {
161            party_id: id,
162            party_ref,
163            engagement_id,
164            party_name: party_name.into(),
165            party_type,
166            relationship_basis,
167            ownership_percentage: None,
168            board_representation: false,
169            key_management: false,
170            disclosed_in_financials: true,
171            disclosure_adequate: None,
172            identified_by: IdentificationSource::ManagementDisclosure,
173            created_at: now,
174            updated_at: now,
175        }
176    }
177}
178
179/// A transaction with a related party identified during the audit per ISA 550.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct RelatedPartyTransaction {
182    /// Unique transaction ID
183    pub transaction_id: Uuid,
184    /// Human-readable reference (format: "RPT-{first 8 hex chars of transaction_id}")
185    pub transaction_ref: String,
186    /// Engagement this transaction belongs to
187    pub engagement_id: Uuid,
188    /// The related party involved in this transaction
189    pub related_party_id: Uuid,
190
191    // === Transaction Details ===
192    /// FK → `JournalEntry.document_id` — the journal entry that records this transaction.
193    /// `None` when the transaction has not yet been posted to the general ledger.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub journal_entry_id: Option<String>,
196    /// Type of related party transaction
197    pub transaction_type: RptTransactionType,
198    /// Description of the transaction
199    pub description: String,
200    /// Transaction amount
201    pub amount: Decimal,
202    /// Currency of the transaction (ISO 4217, e.g., "USD")
203    pub currency: String,
204    /// Date the transaction was entered into
205    pub transaction_date: NaiveDate,
206    /// Description of the terms and conditions
207    pub terms_description: String,
208
209    // === Arm's Length Assessment ===
210    /// Whether the transaction was conducted on arm's length terms
211    pub arms_length: Option<bool>,
212    /// Evidence supporting the arm's length determination
213    pub arms_length_evidence: Option<String>,
214    /// Business rationale for the transaction
215    pub business_rationale: Option<String>,
216    /// Who approved the transaction (e.g., audit committee, board)
217    pub approved_by: Option<String>,
218
219    // === Disclosure Assessment ===
220    /// Whether this transaction has been disclosed in the financial statements
221    pub disclosed_in_financials: bool,
222    /// Whether the disclosure is adequate per the applicable framework
223    pub disclosure_adequate: Option<bool>,
224
225    // === Risk Assessment ===
226    /// Whether this transaction poses a management override risk
227    pub management_override_risk: bool,
228
229    // === Timestamps ===
230    #[serde(with = "crate::serde_timestamp::utc")]
231    pub created_at: DateTime<Utc>,
232    #[serde(with = "crate::serde_timestamp::utc")]
233    pub updated_at: DateTime<Utc>,
234}
235
236impl RelatedPartyTransaction {
237    /// Create a new related party transaction record.
238    #[allow(clippy::too_many_arguments)]
239    pub fn new(
240        engagement_id: Uuid,
241        related_party_id: Uuid,
242        transaction_type: RptTransactionType,
243        description: impl Into<String>,
244        amount: Decimal,
245        currency: impl Into<String>,
246        transaction_date: NaiveDate,
247    ) -> Self {
248        let now = Utc::now();
249        let id = Uuid::new_v4();
250        let transaction_ref = format!("RPT-{}", &id.simple().to_string()[..8]);
251        Self {
252            transaction_id: id,
253            transaction_ref,
254            engagement_id,
255            related_party_id,
256            journal_entry_id: None,
257            transaction_type,
258            description: description.into(),
259            amount,
260            currency: currency.into(),
261            transaction_date,
262            terms_description: String::new(),
263            arms_length: None,
264            arms_length_evidence: None,
265            business_rationale: None,
266            approved_by: None,
267            disclosed_in_financials: true,
268            disclosure_adequate: None,
269            management_override_risk: false,
270            created_at: now,
271            updated_at: now,
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use rust_decimal_macros::dec;
280
281    fn sample_date(year: i32, month: u32, day: u32) -> NaiveDate {
282        NaiveDate::from_ymd_opt(year, month, day).unwrap()
283    }
284
285    #[test]
286    fn test_new_related_party() {
287        let eng = Uuid::new_v4();
288        let rp = RelatedParty::new(
289            eng,
290            "Acme Holdings Ltd",
291            RelatedPartyType::Subsidiary,
292            RelationshipBasis::Ownership,
293        );
294
295        assert_eq!(rp.engagement_id, eng);
296        assert_eq!(rp.party_name, "Acme Holdings Ltd");
297        assert_eq!(rp.party_type, RelatedPartyType::Subsidiary);
298        assert_eq!(rp.relationship_basis, RelationshipBasis::Ownership);
299        assert!(rp.disclosed_in_financials);
300        assert!(!rp.board_representation);
301        assert!(!rp.key_management);
302        assert!(rp.ownership_percentage.is_none());
303        assert!(rp.disclosure_adequate.is_none());
304        assert_eq!(rp.identified_by, IdentificationSource::ManagementDisclosure);
305        assert!(rp.party_ref.starts_with("RP-"));
306        assert_eq!(rp.party_ref.len(), 11); // "RP-" + 8 hex chars
307    }
308
309    #[test]
310    fn test_new_rpt() {
311        let eng = Uuid::new_v4();
312        let party = Uuid::new_v4();
313        let rpt = RelatedPartyTransaction::new(
314            eng,
315            party,
316            RptTransactionType::ManagementFee,
317            "Annual management fee for shared services",
318            dec!(250_000),
319            "USD",
320            sample_date(2025, 6, 30),
321        );
322
323        assert_eq!(rpt.engagement_id, eng);
324        assert_eq!(rpt.related_party_id, party);
325        assert_eq!(rpt.transaction_type, RptTransactionType::ManagementFee);
326        assert_eq!(rpt.amount, dec!(250_000));
327        assert_eq!(rpt.currency, "USD");
328        assert!(rpt.disclosed_in_financials);
329        assert!(!rpt.management_override_risk);
330        assert!(rpt.arms_length.is_none());
331        assert!(rpt.terms_description.is_empty());
332        assert!(rpt.transaction_ref.starts_with("RPT-"));
333        assert_eq!(rpt.transaction_ref.len(), 12); // "RPT-" + 8 hex chars
334    }
335
336    #[test]
337    fn test_related_party_type_serde() {
338        let variants = [
339            RelatedPartyType::Subsidiary,
340            RelatedPartyType::Associate,
341            RelatedPartyType::JointVenture,
342            RelatedPartyType::KeyManagement,
343            RelatedPartyType::CloseFamily,
344            RelatedPartyType::ShareholderSignificant,
345            RelatedPartyType::CommonDirector,
346            RelatedPartyType::Other,
347        ];
348        for v in variants {
349            let json = serde_json::to_string(&v).unwrap();
350            let rt: RelatedPartyType = serde_json::from_str(&json).unwrap();
351            assert_eq!(v, rt);
352        }
353        assert_eq!(
354            serde_json::to_string(&RelatedPartyType::JointVenture).unwrap(),
355            "\"joint_venture\""
356        );
357        assert_eq!(
358            serde_json::to_string(&RelatedPartyType::ShareholderSignificant).unwrap(),
359            "\"shareholder_significant\""
360        );
361        assert_eq!(
362            serde_json::to_string(&RelatedPartyType::CommonDirector).unwrap(),
363            "\"common_director\""
364        );
365    }
366
367    #[test]
368    fn test_relationship_basis_serde() {
369        let variants = [
370            RelationshipBasis::Ownership,
371            RelationshipBasis::Control,
372            RelationshipBasis::SignificantInfluence,
373            RelationshipBasis::KeyManagementPersonnel,
374            RelationshipBasis::CloseFamily,
375            RelationshipBasis::Other,
376        ];
377        for v in variants {
378            let json = serde_json::to_string(&v).unwrap();
379            let rt: RelationshipBasis = serde_json::from_str(&json).unwrap();
380            assert_eq!(v, rt);
381        }
382        assert_eq!(
383            serde_json::to_string(&RelationshipBasis::SignificantInfluence).unwrap(),
384            "\"significant_influence\""
385        );
386        assert_eq!(
387            serde_json::to_string(&RelationshipBasis::KeyManagementPersonnel).unwrap(),
388            "\"key_management_personnel\""
389        );
390    }
391
392    #[test]
393    fn test_identification_source_serde() {
394        let variants = [
395            IdentificationSource::ManagementDisclosure,
396            IdentificationSource::AuditorInquiry,
397            IdentificationSource::PublicRecords,
398            IdentificationSource::BankConfirmation,
399            IdentificationSource::LegalReview,
400            IdentificationSource::WhistleblowerTip,
401        ];
402        for v in variants {
403            let json = serde_json::to_string(&v).unwrap();
404            let rt: IdentificationSource = serde_json::from_str(&json).unwrap();
405            assert_eq!(v, rt);
406        }
407        assert_eq!(
408            serde_json::to_string(&IdentificationSource::ManagementDisclosure).unwrap(),
409            "\"management_disclosure\""
410        );
411        assert_eq!(
412            serde_json::to_string(&IdentificationSource::WhistleblowerTip).unwrap(),
413            "\"whistleblower_tip\""
414        );
415    }
416
417    #[test]
418    fn test_rpt_transaction_type_serde() {
419        // Test round-trip for all 12 variants
420        let variants = [
421            RptTransactionType::Sale,
422            RptTransactionType::Purchase,
423            RptTransactionType::Lease,
424            RptTransactionType::Loan,
425            RptTransactionType::Guarantee,
426            RptTransactionType::ManagementFee,
427            RptTransactionType::Dividend,
428            RptTransactionType::Transfer,
429            RptTransactionType::ServiceAgreement,
430            RptTransactionType::LicenseRoyalty,
431            RptTransactionType::CapitalContribution,
432            RptTransactionType::Other,
433        ];
434        for v in variants {
435            let json = serde_json::to_string(&v).unwrap();
436            let rt: RptTransactionType = serde_json::from_str(&json).unwrap();
437            assert_eq!(v, rt);
438        }
439        assert_eq!(
440            serde_json::to_string(&RptTransactionType::Sale).unwrap(),
441            "\"sale\""
442        );
443        assert_eq!(
444            serde_json::to_string(&RptTransactionType::ManagementFee).unwrap(),
445            "\"management_fee\""
446        );
447        assert_eq!(
448            serde_json::to_string(&RptTransactionType::ServiceAgreement).unwrap(),
449            "\"service_agreement\""
450        );
451        assert_eq!(
452            serde_json::to_string(&RptTransactionType::LicenseRoyalty).unwrap(),
453            "\"license_royalty\""
454        );
455        assert_eq!(
456            serde_json::to_string(&RptTransactionType::CapitalContribution).unwrap(),
457            "\"capital_contribution\""
458        );
459    }
460
461    #[test]
462    fn test_rpt_all_12_transaction_types() {
463        let eng = Uuid::new_v4();
464        let party = Uuid::new_v4();
465        let date = sample_date(2025, 1, 15);
466
467        let all_types = [
468            RptTransactionType::Sale,
469            RptTransactionType::Purchase,
470            RptTransactionType::Lease,
471            RptTransactionType::Loan,
472            RptTransactionType::Guarantee,
473            RptTransactionType::ManagementFee,
474            RptTransactionType::Dividend,
475            RptTransactionType::Transfer,
476            RptTransactionType::ServiceAgreement,
477            RptTransactionType::LicenseRoyalty,
478            RptTransactionType::CapitalContribution,
479            RptTransactionType::Other,
480        ];
481
482        assert_eq!(
483            all_types.len(),
484            12,
485            "must have exactly 12 transaction types"
486        );
487
488        for txn_type in all_types {
489            let rpt = RelatedPartyTransaction::new(
490                eng,
491                party,
492                txn_type,
493                "Test transaction",
494                dec!(1_000),
495                "USD",
496                date,
497            );
498            // Verify each variant serializes and deserialises correctly
499            let json = serde_json::to_string(&rpt.transaction_type).unwrap();
500            let rt: RptTransactionType = serde_json::from_str(&json).unwrap();
501            assert_eq!(rpt.transaction_type, rt);
502        }
503    }
504
505    #[test]
506    fn test_management_override_risk_default() {
507        let eng = Uuid::new_v4();
508        let party = Uuid::new_v4();
509        let rpt = RelatedPartyTransaction::new(
510            eng,
511            party,
512            RptTransactionType::Loan,
513            "Intercompany loan",
514            dec!(1_000_000),
515            "GBP",
516            sample_date(2025, 3, 31),
517        );
518        // Default must be false per the spec
519        assert!(!rpt.management_override_risk);
520    }
521}