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    pub created_at: DateTime<Utc>,
144    pub updated_at: DateTime<Utc>,
145}
146
147impl RelatedParty {
148    /// Create a new related party record.
149    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/// A transaction with a related party identified during the audit per ISA 550.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct RelatedPartyTransaction {
180    /// Unique transaction ID
181    pub transaction_id: Uuid,
182    /// Human-readable reference (format: "RPT-{first 8 hex chars of transaction_id}")
183    pub transaction_ref: String,
184    /// Engagement this transaction belongs to
185    pub engagement_id: Uuid,
186    /// The related party involved in this transaction
187    pub related_party_id: Uuid,
188
189    // === Transaction Details ===
190    /// FK → `JournalEntry.document_id` — the journal entry that records this transaction.
191    /// `None` when the transaction has not yet been posted to the general ledger.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub journal_entry_id: Option<String>,
194    /// Type of related party transaction
195    pub transaction_type: RptTransactionType,
196    /// Description of the transaction
197    pub description: String,
198    /// Transaction amount
199    pub amount: Decimal,
200    /// Currency of the transaction (ISO 4217, e.g., "USD")
201    pub currency: String,
202    /// Date the transaction was entered into
203    pub transaction_date: NaiveDate,
204    /// Description of the terms and conditions
205    pub terms_description: String,
206
207    // === Arm's Length Assessment ===
208    /// Whether the transaction was conducted on arm's length terms
209    pub arms_length: Option<bool>,
210    /// Evidence supporting the arm's length determination
211    pub arms_length_evidence: Option<String>,
212    /// Business rationale for the transaction
213    pub business_rationale: Option<String>,
214    /// Who approved the transaction (e.g., audit committee, board)
215    pub approved_by: Option<String>,
216
217    // === Disclosure Assessment ===
218    /// Whether this transaction has been disclosed in the financial statements
219    pub disclosed_in_financials: bool,
220    /// Whether the disclosure is adequate per the applicable framework
221    pub disclosure_adequate: Option<bool>,
222
223    // === Risk Assessment ===
224    /// Whether this transaction poses a management override risk
225    pub management_override_risk: bool,
226
227    // === Timestamps ===
228    pub created_at: DateTime<Utc>,
229    pub updated_at: DateTime<Utc>,
230}
231
232impl RelatedPartyTransaction {
233    /// Create a new related party transaction record.
234    #[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); // "RP-" + 8 hex chars
304    }
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); // "RPT-" + 8 hex chars
331    }
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        // Test round-trip for all 12 variants
417        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            // Verify each variant serializes and deserialises correctly
496            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        // Default must be false per the spec
516        assert!(!rpt.management_override_risk);
517    }
518}