Skip to main content

datasynth_core/models/audit/
confirmation.rs

1//! External confirmation models per ISA 505.
2//!
3//! External confirmations are audit evidence obtained as a direct written
4//! response to the auditor from a third party (the confirming party), in paper
5//! form or by electronic or other medium.
6
7use chrono::{DateTime, NaiveDate, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12/// Type of external confirmation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum ConfirmationType {
16    /// Bank balance confirmation
17    #[default]
18    BankBalance,
19    /// Accounts receivable confirmation
20    AccountsReceivable,
21    /// Accounts payable confirmation
22    AccountsPayable,
23    /// Investment confirmation
24    Investment,
25    /// Loan confirmation
26    Loan,
27    /// Legal letter confirmation
28    Legal,
29    /// Insurance confirmation
30    Insurance,
31    /// Inventory confirmation
32    Inventory,
33}
34
35/// Form of external confirmation (ISA 505.A6).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
37#[serde(rename_all = "snake_case")]
38pub enum ConfirmationForm {
39    /// Positive confirmation — recipient asked to respond in all cases
40    #[default]
41    Positive,
42    /// Negative confirmation — recipient asked to respond only if they disagree
43    Negative,
44    /// Blank confirmation — recipient asked to fill in the balance
45    Blank,
46}
47
48/// Lifecycle status of an external confirmation request.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum ConfirmationStatus {
52    /// Confirmation drafted but not yet sent
53    #[default]
54    Draft,
55    /// Confirmation has been sent to the confirming party
56    Sent,
57    /// Response has been received
58    Received,
59    /// No response received by the deadline
60    NoResponse,
61    /// Alternative procedures performed in lieu of confirmation
62    AlternativeProcedures,
63    /// Confirmation process completed
64    Completed,
65}
66
67/// Type of confirming party (recipient).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum RecipientType {
71    /// Financial institution / bank
72    #[default]
73    Bank,
74    /// Customer (debtor)
75    Customer,
76    /// Supplier (creditor)
77    Supplier,
78    /// Legal counsel
79    LegalCounsel,
80    /// Insurance company
81    Insurer,
82    /// Other third party
83    Other,
84}
85
86/// Type of response received from the confirming party.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
88#[serde(rename_all = "snake_case")]
89pub enum ResponseType {
90    /// Balance confirmed without exception
91    #[default]
92    Confirmed,
93    /// Balance confirmed but with one or more exceptions noted
94    ConfirmedWithException,
95    /// Confirming party denies the recorded balance
96    Denied,
97    /// No reply received
98    NoReply,
99}
100
101/// External confirmation request per ISA 505.
102///
103/// Tracks the full lifecycle of a confirmation from draft through to completion,
104/// including the balance under confirmation and key dates.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ExternalConfirmation {
107    /// Unique confirmation ID
108    pub confirmation_id: Uuid,
109    /// Human-readable reference (e.g. "CONF-a1b2c3d4")
110    pub confirmation_ref: String,
111    /// Engagement this confirmation belongs to
112    pub engagement_id: Uuid,
113    /// Optional linked workpaper
114    pub workpaper_id: Option<Uuid>,
115    /// Type of balance or matter being confirmed
116    pub confirmation_type: ConfirmationType,
117    /// Name of the confirming party
118    pub recipient_name: String,
119    /// Category of confirming party
120    pub recipient_type: RecipientType,
121    /// Account or reference number at the confirming party
122    pub account_id: Option<String>,
123    /// Balance per the client's books
124    pub book_balance: Decimal,
125    /// Date the balance relates to
126    pub confirmation_date: NaiveDate,
127    /// Date the request was dispatched
128    pub sent_date: Option<NaiveDate>,
129    /// Deadline by which a response is required
130    pub response_deadline: Option<NaiveDate>,
131    /// Current lifecycle status
132    pub status: ConfirmationStatus,
133    /// Positive, negative, or blank form
134    pub positive_negative: ConfirmationForm,
135    #[serde(with = "crate::serde_timestamp::utc")]
136    pub created_at: DateTime<Utc>,
137    #[serde(with = "crate::serde_timestamp::utc")]
138    pub updated_at: DateTime<Utc>,
139}
140
141impl ExternalConfirmation {
142    /// Create a new external confirmation request.
143    pub fn new(
144        engagement_id: Uuid,
145        confirmation_type: ConfirmationType,
146        recipient_name: &str,
147        recipient_type: RecipientType,
148        book_balance: Decimal,
149        confirmation_date: NaiveDate,
150    ) -> Self {
151        let id = Uuid::new_v4();
152        let now = Utc::now();
153        Self {
154            confirmation_id: id,
155            confirmation_ref: format!("CONF-{}", &id.to_string()[..8]),
156            engagement_id,
157            workpaper_id: None,
158            confirmation_type,
159            recipient_name: recipient_name.into(),
160            recipient_type,
161            account_id: None,
162            book_balance,
163            confirmation_date,
164            sent_date: None,
165            response_deadline: None,
166            status: ConfirmationStatus::Draft,
167            positive_negative: ConfirmationForm::Positive,
168            created_at: now,
169            updated_at: now,
170        }
171    }
172
173    /// Link to a workpaper.
174    pub fn with_workpaper(mut self, workpaper_id: Uuid) -> Self {
175        self.workpaper_id = Some(workpaper_id);
176        self
177    }
178
179    /// Set the account or reference number at the confirming party.
180    pub fn with_account(mut self, account_id: &str) -> Self {
181        self.account_id = Some(account_id.into());
182        self
183    }
184
185    /// Mark the confirmation as sent and record the dispatch date and deadline.
186    pub fn send(&mut self, sent_date: NaiveDate, deadline: NaiveDate) {
187        self.sent_date = Some(sent_date);
188        self.response_deadline = Some(deadline);
189        self.status = ConfirmationStatus::Sent;
190        self.updated_at = Utc::now();
191    }
192}
193
194/// Response received from a confirming party per ISA 505.
195///
196/// Records the details of what the confirming party stated, any exceptions,
197/// and whether the auditor has reconciled differences to the book balance.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ConfirmationResponse {
200    /// Unique response ID
201    pub response_id: Uuid,
202    /// Human-readable reference (e.g. "RESP-a1b2c3d4")
203    pub response_ref: String,
204    /// The confirmation this response relates to
205    pub confirmation_id: Uuid,
206    /// Engagement this response belongs to
207    pub engagement_id: Uuid,
208    /// Date the response was received
209    pub response_date: NaiveDate,
210    /// Balance stated by the confirming party (None for blank forms not filled in)
211    pub confirmed_balance: Option<Decimal>,
212    /// Nature of the response
213    pub response_type: ResponseType,
214    /// Whether the confirming party noted any exceptions
215    pub has_exception: bool,
216    /// Monetary value of the noted exception, if any
217    pub exception_amount: Option<Decimal>,
218    /// Description of the exception
219    pub exception_description: Option<String>,
220    /// Whether differences have been reconciled
221    pub reconciled: bool,
222    /// Explanation of the reconciliation
223    pub reconciliation_explanation: Option<String>,
224    #[serde(with = "crate::serde_timestamp::utc")]
225    pub created_at: DateTime<Utc>,
226    #[serde(with = "crate::serde_timestamp::utc")]
227    pub updated_at: DateTime<Utc>,
228}
229
230impl ConfirmationResponse {
231    /// Create a new confirmation response.
232    pub fn new(
233        confirmation_id: Uuid,
234        engagement_id: Uuid,
235        response_date: NaiveDate,
236        response_type: ResponseType,
237    ) -> Self {
238        let id = Uuid::new_v4();
239        let now = Utc::now();
240        Self {
241            response_id: id,
242            response_ref: format!("RESP-{}", &id.to_string()[..8]),
243            confirmation_id,
244            engagement_id,
245            response_date,
246            confirmed_balance: None,
247            response_type,
248            has_exception: false,
249            exception_amount: None,
250            exception_description: None,
251            reconciled: false,
252            reconciliation_explanation: None,
253            created_at: now,
254            updated_at: now,
255        }
256    }
257
258    /// Record the balance confirmed by the third party.
259    pub fn with_confirmed_balance(mut self, balance: Decimal) -> Self {
260        self.confirmed_balance = Some(balance);
261        self
262    }
263
264    /// Record an exception noted by the confirming party.
265    pub fn with_exception(mut self, amount: Decimal, description: &str) -> Self {
266        self.has_exception = true;
267        self.exception_amount = Some(amount);
268        self.exception_description = Some(description.into());
269        self
270    }
271
272    /// Mark the response as reconciled and record the explanation.
273    pub fn reconcile(&mut self, explanation: &str) {
274        self.reconciled = true;
275        self.reconciliation_explanation = Some(explanation.into());
276        self.updated_at = Utc::now();
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use rust_decimal_macros::dec;
284
285    fn sample_confirmation() -> ExternalConfirmation {
286        ExternalConfirmation::new(
287            Uuid::new_v4(),
288            ConfirmationType::BankBalance,
289            "First National Bank",
290            RecipientType::Bank,
291            dec!(125_000.00),
292            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
293        )
294    }
295
296    fn sample_response(confirmation_id: Uuid, engagement_id: Uuid) -> ConfirmationResponse {
297        ConfirmationResponse::new(
298            confirmation_id,
299            engagement_id,
300            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
301            ResponseType::Confirmed,
302        )
303    }
304
305    // --- ExternalConfirmation tests ---
306
307    #[test]
308    fn test_new_confirmation() {
309        let conf = sample_confirmation();
310        assert_eq!(conf.status, ConfirmationStatus::Draft);
311        assert_eq!(conf.positive_negative, ConfirmationForm::Positive);
312        assert!(conf.workpaper_id.is_none());
313        assert!(conf.account_id.is_none());
314        assert!(conf.sent_date.is_none());
315        assert!(conf.response_deadline.is_none());
316    }
317
318    #[test]
319    fn test_send_updates_status() {
320        let mut conf = sample_confirmation();
321        let sent = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
322        let deadline = NaiveDate::from_ymd_opt(2026, 1, 20).unwrap();
323        conf.send(sent, deadline);
324        assert_eq!(conf.status, ConfirmationStatus::Sent);
325        assert_eq!(conf.sent_date, Some(sent));
326        assert_eq!(conf.response_deadline, Some(deadline));
327    }
328
329    #[test]
330    fn test_with_workpaper() {
331        let wp_id = Uuid::new_v4();
332        let conf = sample_confirmation().with_workpaper(wp_id);
333        assert_eq!(conf.workpaper_id, Some(wp_id));
334    }
335
336    #[test]
337    fn test_with_account() {
338        let conf = sample_confirmation().with_account("ACC-001");
339        assert_eq!(conf.account_id, Some("ACC-001".to_string()));
340    }
341
342    // --- ConfirmationResponse tests ---
343
344    #[test]
345    fn test_new_response() {
346        let conf = sample_confirmation();
347        let resp = sample_response(conf.confirmation_id, conf.engagement_id);
348        assert!(!resp.has_exception);
349        assert!(!resp.reconciled);
350        assert!(resp.confirmed_balance.is_none());
351        assert!(resp.exception_amount.is_none());
352        assert!(resp.reconciliation_explanation.is_none());
353    }
354
355    #[test]
356    fn test_with_confirmed_balance() {
357        let conf = sample_confirmation();
358        let resp = sample_response(conf.confirmation_id, conf.engagement_id)
359            .with_confirmed_balance(dec!(125_000.00));
360        assert_eq!(resp.confirmed_balance, Some(dec!(125_000.00)));
361    }
362
363    #[test]
364    fn test_with_exception() {
365        let conf = sample_confirmation();
366        let resp = sample_response(conf.confirmation_id, conf.engagement_id)
367            .with_confirmed_balance(dec!(123_500.00))
368            .with_exception(dec!(1_500.00), "Unrecorded credit note dated 30 Dec 2025");
369        assert!(resp.has_exception);
370        assert_eq!(resp.exception_amount, Some(dec!(1_500.00)));
371        assert!(resp.exception_description.is_some());
372    }
373
374    #[test]
375    fn test_reconcile() {
376        let conf = sample_confirmation();
377        let mut resp = sample_response(conf.confirmation_id, conf.engagement_id)
378            .with_exception(dec!(1_500.00), "Timing difference");
379        assert!(!resp.reconciled);
380        resp.reconcile("Credit note received and posted on 2 Jan 2026 — timing difference only.");
381        assert!(resp.reconciled);
382        assert!(resp.reconciliation_explanation.is_some());
383    }
384
385    // --- Serde tests ---
386
387    #[test]
388    fn test_confirmation_status_serde() {
389        // CRITICAL: AlternativeProcedures must serialise as "alternative_procedures"
390        let val = serde_json::to_value(ConfirmationStatus::AlternativeProcedures).unwrap();
391        assert_eq!(val, serde_json::json!("alternative_procedures"));
392
393        // Round-trip all variants
394        for status in [
395            ConfirmationStatus::Draft,
396            ConfirmationStatus::Sent,
397            ConfirmationStatus::Received,
398            ConfirmationStatus::NoResponse,
399            ConfirmationStatus::AlternativeProcedures,
400            ConfirmationStatus::Completed,
401        ] {
402            let serialised = serde_json::to_string(&status).unwrap();
403            let deserialised: ConfirmationStatus = serde_json::from_str(&serialised).unwrap();
404            assert_eq!(status, deserialised);
405        }
406    }
407
408    #[test]
409    fn test_confirmation_type_serde() {
410        for ct in [
411            ConfirmationType::BankBalance,
412            ConfirmationType::AccountsReceivable,
413            ConfirmationType::AccountsPayable,
414            ConfirmationType::Investment,
415            ConfirmationType::Loan,
416            ConfirmationType::Legal,
417            ConfirmationType::Insurance,
418            ConfirmationType::Inventory,
419        ] {
420            let serialised = serde_json::to_string(&ct).unwrap();
421            let deserialised: ConfirmationType = serde_json::from_str(&serialised).unwrap();
422            assert_eq!(ct, deserialised);
423        }
424    }
425
426    #[test]
427    fn test_response_type_serde() {
428        for rt in [
429            ResponseType::Confirmed,
430            ResponseType::ConfirmedWithException,
431            ResponseType::Denied,
432            ResponseType::NoReply,
433        ] {
434            let serialised = serde_json::to_string(&rt).unwrap();
435            let deserialised: ResponseType = serde_json::from_str(&serialised).unwrap();
436            assert_eq!(rt, deserialised);
437        }
438    }
439}