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    pub created_at: DateTime<Utc>,
136    pub updated_at: DateTime<Utc>,
137}
138
139impl ExternalConfirmation {
140    /// Create a new external confirmation request.
141    pub fn new(
142        engagement_id: Uuid,
143        confirmation_type: ConfirmationType,
144        recipient_name: &str,
145        recipient_type: RecipientType,
146        book_balance: Decimal,
147        confirmation_date: NaiveDate,
148    ) -> Self {
149        let id = Uuid::new_v4();
150        let now = Utc::now();
151        Self {
152            confirmation_id: id,
153            confirmation_ref: format!("CONF-{}", &id.to_string()[..8]),
154            engagement_id,
155            workpaper_id: None,
156            confirmation_type,
157            recipient_name: recipient_name.into(),
158            recipient_type,
159            account_id: None,
160            book_balance,
161            confirmation_date,
162            sent_date: None,
163            response_deadline: None,
164            status: ConfirmationStatus::Draft,
165            positive_negative: ConfirmationForm::Positive,
166            created_at: now,
167            updated_at: now,
168        }
169    }
170
171    /// Link to a workpaper.
172    pub fn with_workpaper(mut self, workpaper_id: Uuid) -> Self {
173        self.workpaper_id = Some(workpaper_id);
174        self
175    }
176
177    /// Set the account or reference number at the confirming party.
178    pub fn with_account(mut self, account_id: &str) -> Self {
179        self.account_id = Some(account_id.into());
180        self
181    }
182
183    /// Mark the confirmation as sent and record the dispatch date and deadline.
184    pub fn send(&mut self, sent_date: NaiveDate, deadline: NaiveDate) {
185        self.sent_date = Some(sent_date);
186        self.response_deadline = Some(deadline);
187        self.status = ConfirmationStatus::Sent;
188        self.updated_at = Utc::now();
189    }
190}
191
192/// Response received from a confirming party per ISA 505.
193///
194/// Records the details of what the confirming party stated, any exceptions,
195/// and whether the auditor has reconciled differences to the book balance.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ConfirmationResponse {
198    /// Unique response ID
199    pub response_id: Uuid,
200    /// Human-readable reference (e.g. "RESP-a1b2c3d4")
201    pub response_ref: String,
202    /// The confirmation this response relates to
203    pub confirmation_id: Uuid,
204    /// Engagement this response belongs to
205    pub engagement_id: Uuid,
206    /// Date the response was received
207    pub response_date: NaiveDate,
208    /// Balance stated by the confirming party (None for blank forms not filled in)
209    pub confirmed_balance: Option<Decimal>,
210    /// Nature of the response
211    pub response_type: ResponseType,
212    /// Whether the confirming party noted any exceptions
213    pub has_exception: bool,
214    /// Monetary value of the noted exception, if any
215    pub exception_amount: Option<Decimal>,
216    /// Description of the exception
217    pub exception_description: Option<String>,
218    /// Whether differences have been reconciled
219    pub reconciled: bool,
220    /// Explanation of the reconciliation
221    pub reconciliation_explanation: Option<String>,
222    pub created_at: DateTime<Utc>,
223    pub updated_at: DateTime<Utc>,
224}
225
226impl ConfirmationResponse {
227    /// Create a new confirmation response.
228    pub fn new(
229        confirmation_id: Uuid,
230        engagement_id: Uuid,
231        response_date: NaiveDate,
232        response_type: ResponseType,
233    ) -> Self {
234        let id = Uuid::new_v4();
235        let now = Utc::now();
236        Self {
237            response_id: id,
238            response_ref: format!("RESP-{}", &id.to_string()[..8]),
239            confirmation_id,
240            engagement_id,
241            response_date,
242            confirmed_balance: None,
243            response_type,
244            has_exception: false,
245            exception_amount: None,
246            exception_description: None,
247            reconciled: false,
248            reconciliation_explanation: None,
249            created_at: now,
250            updated_at: now,
251        }
252    }
253
254    /// Record the balance confirmed by the third party.
255    pub fn with_confirmed_balance(mut self, balance: Decimal) -> Self {
256        self.confirmed_balance = Some(balance);
257        self
258    }
259
260    /// Record an exception noted by the confirming party.
261    pub fn with_exception(mut self, amount: Decimal, description: &str) -> Self {
262        self.has_exception = true;
263        self.exception_amount = Some(amount);
264        self.exception_description = Some(description.into());
265        self
266    }
267
268    /// Mark the response as reconciled and record the explanation.
269    pub fn reconcile(&mut self, explanation: &str) {
270        self.reconciled = true;
271        self.reconciliation_explanation = Some(explanation.into());
272        self.updated_at = Utc::now();
273    }
274}
275
276#[cfg(test)]
277#[allow(clippy::unwrap_used)]
278mod tests {
279    use super::*;
280    use rust_decimal_macros::dec;
281
282    fn sample_confirmation() -> ExternalConfirmation {
283        ExternalConfirmation::new(
284            Uuid::new_v4(),
285            ConfirmationType::BankBalance,
286            "First National Bank",
287            RecipientType::Bank,
288            dec!(125_000.00),
289            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
290        )
291    }
292
293    fn sample_response(confirmation_id: Uuid, engagement_id: Uuid) -> ConfirmationResponse {
294        ConfirmationResponse::new(
295            confirmation_id,
296            engagement_id,
297            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
298            ResponseType::Confirmed,
299        )
300    }
301
302    // --- ExternalConfirmation tests ---
303
304    #[test]
305    fn test_new_confirmation() {
306        let conf = sample_confirmation();
307        assert_eq!(conf.status, ConfirmationStatus::Draft);
308        assert_eq!(conf.positive_negative, ConfirmationForm::Positive);
309        assert!(conf.workpaper_id.is_none());
310        assert!(conf.account_id.is_none());
311        assert!(conf.sent_date.is_none());
312        assert!(conf.response_deadline.is_none());
313    }
314
315    #[test]
316    fn test_send_updates_status() {
317        let mut conf = sample_confirmation();
318        let sent = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
319        let deadline = NaiveDate::from_ymd_opt(2026, 1, 20).unwrap();
320        conf.send(sent, deadline);
321        assert_eq!(conf.status, ConfirmationStatus::Sent);
322        assert_eq!(conf.sent_date, Some(sent));
323        assert_eq!(conf.response_deadline, Some(deadline));
324    }
325
326    #[test]
327    fn test_with_workpaper() {
328        let wp_id = Uuid::new_v4();
329        let conf = sample_confirmation().with_workpaper(wp_id);
330        assert_eq!(conf.workpaper_id, Some(wp_id));
331    }
332
333    #[test]
334    fn test_with_account() {
335        let conf = sample_confirmation().with_account("ACC-001");
336        assert_eq!(conf.account_id, Some("ACC-001".to_string()));
337    }
338
339    // --- ConfirmationResponse tests ---
340
341    #[test]
342    fn test_new_response() {
343        let conf = sample_confirmation();
344        let resp = sample_response(conf.confirmation_id, conf.engagement_id);
345        assert!(!resp.has_exception);
346        assert!(!resp.reconciled);
347        assert!(resp.confirmed_balance.is_none());
348        assert!(resp.exception_amount.is_none());
349        assert!(resp.reconciliation_explanation.is_none());
350    }
351
352    #[test]
353    fn test_with_confirmed_balance() {
354        let conf = sample_confirmation();
355        let resp = sample_response(conf.confirmation_id, conf.engagement_id)
356            .with_confirmed_balance(dec!(125_000.00));
357        assert_eq!(resp.confirmed_balance, Some(dec!(125_000.00)));
358    }
359
360    #[test]
361    fn test_with_exception() {
362        let conf = sample_confirmation();
363        let resp = sample_response(conf.confirmation_id, conf.engagement_id)
364            .with_confirmed_balance(dec!(123_500.00))
365            .with_exception(dec!(1_500.00), "Unrecorded credit note dated 30 Dec 2025");
366        assert!(resp.has_exception);
367        assert_eq!(resp.exception_amount, Some(dec!(1_500.00)));
368        assert!(resp.exception_description.is_some());
369    }
370
371    #[test]
372    fn test_reconcile() {
373        let conf = sample_confirmation();
374        let mut resp = sample_response(conf.confirmation_id, conf.engagement_id)
375            .with_exception(dec!(1_500.00), "Timing difference");
376        assert!(!resp.reconciled);
377        resp.reconcile("Credit note received and posted on 2 Jan 2026 — timing difference only.");
378        assert!(resp.reconciled);
379        assert!(resp.reconciliation_explanation.is_some());
380    }
381
382    // --- Serde tests ---
383
384    #[test]
385    fn test_confirmation_status_serde() {
386        // CRITICAL: AlternativeProcedures must serialise as "alternative_procedures"
387        let val = serde_json::to_value(ConfirmationStatus::AlternativeProcedures).unwrap();
388        assert_eq!(val, serde_json::json!("alternative_procedures"));
389
390        // Round-trip all variants
391        for status in [
392            ConfirmationStatus::Draft,
393            ConfirmationStatus::Sent,
394            ConfirmationStatus::Received,
395            ConfirmationStatus::NoResponse,
396            ConfirmationStatus::AlternativeProcedures,
397            ConfirmationStatus::Completed,
398        ] {
399            let serialised = serde_json::to_string(&status).unwrap();
400            let deserialised: ConfirmationStatus = serde_json::from_str(&serialised).unwrap();
401            assert_eq!(status, deserialised);
402        }
403    }
404
405    #[test]
406    fn test_confirmation_type_serde() {
407        for ct in [
408            ConfirmationType::BankBalance,
409            ConfirmationType::AccountsReceivable,
410            ConfirmationType::AccountsPayable,
411            ConfirmationType::Investment,
412            ConfirmationType::Loan,
413            ConfirmationType::Legal,
414            ConfirmationType::Insurance,
415            ConfirmationType::Inventory,
416        ] {
417            let serialised = serde_json::to_string(&ct).unwrap();
418            let deserialised: ConfirmationType = serde_json::from_str(&serialised).unwrap();
419            assert_eq!(ct, deserialised);
420        }
421    }
422
423    #[test]
424    fn test_response_type_serde() {
425        for rt in [
426            ResponseType::Confirmed,
427            ResponseType::ConfirmedWithException,
428            ResponseType::Denied,
429            ResponseType::NoReply,
430        ] {
431            let serialised = serde_json::to_string(&rt).unwrap();
432            let deserialised: ResponseType = serde_json::from_str(&serialised).unwrap();
433            assert_eq!(rt, deserialised);
434        }
435    }
436}