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)]
281#[allow(clippy::unwrap_used)]
282mod tests {
283    use super::*;
284    use rust_decimal_macros::dec;
285
286    fn sample_confirmation() -> ExternalConfirmation {
287        ExternalConfirmation::new(
288            Uuid::new_v4(),
289            ConfirmationType::BankBalance,
290            "First National Bank",
291            RecipientType::Bank,
292            dec!(125_000.00),
293            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
294        )
295    }
296
297    fn sample_response(confirmation_id: Uuid, engagement_id: Uuid) -> ConfirmationResponse {
298        ConfirmationResponse::new(
299            confirmation_id,
300            engagement_id,
301            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
302            ResponseType::Confirmed,
303        )
304    }
305
306    // --- ExternalConfirmation tests ---
307
308    #[test]
309    fn test_new_confirmation() {
310        let conf = sample_confirmation();
311        assert_eq!(conf.status, ConfirmationStatus::Draft);
312        assert_eq!(conf.positive_negative, ConfirmationForm::Positive);
313        assert!(conf.workpaper_id.is_none());
314        assert!(conf.account_id.is_none());
315        assert!(conf.sent_date.is_none());
316        assert!(conf.response_deadline.is_none());
317    }
318
319    #[test]
320    fn test_send_updates_status() {
321        let mut conf = sample_confirmation();
322        let sent = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
323        let deadline = NaiveDate::from_ymd_opt(2026, 1, 20).unwrap();
324        conf.send(sent, deadline);
325        assert_eq!(conf.status, ConfirmationStatus::Sent);
326        assert_eq!(conf.sent_date, Some(sent));
327        assert_eq!(conf.response_deadline, Some(deadline));
328    }
329
330    #[test]
331    fn test_with_workpaper() {
332        let wp_id = Uuid::new_v4();
333        let conf = sample_confirmation().with_workpaper(wp_id);
334        assert_eq!(conf.workpaper_id, Some(wp_id));
335    }
336
337    #[test]
338    fn test_with_account() {
339        let conf = sample_confirmation().with_account("ACC-001");
340        assert_eq!(conf.account_id, Some("ACC-001".to_string()));
341    }
342
343    // --- ConfirmationResponse tests ---
344
345    #[test]
346    fn test_new_response() {
347        let conf = sample_confirmation();
348        let resp = sample_response(conf.confirmation_id, conf.engagement_id);
349        assert!(!resp.has_exception);
350        assert!(!resp.reconciled);
351        assert!(resp.confirmed_balance.is_none());
352        assert!(resp.exception_amount.is_none());
353        assert!(resp.reconciliation_explanation.is_none());
354    }
355
356    #[test]
357    fn test_with_confirmed_balance() {
358        let conf = sample_confirmation();
359        let resp = sample_response(conf.confirmation_id, conf.engagement_id)
360            .with_confirmed_balance(dec!(125_000.00));
361        assert_eq!(resp.confirmed_balance, Some(dec!(125_000.00)));
362    }
363
364    #[test]
365    fn test_with_exception() {
366        let conf = sample_confirmation();
367        let resp = sample_response(conf.confirmation_id, conf.engagement_id)
368            .with_confirmed_balance(dec!(123_500.00))
369            .with_exception(dec!(1_500.00), "Unrecorded credit note dated 30 Dec 2025");
370        assert!(resp.has_exception);
371        assert_eq!(resp.exception_amount, Some(dec!(1_500.00)));
372        assert!(resp.exception_description.is_some());
373    }
374
375    #[test]
376    fn test_reconcile() {
377        let conf = sample_confirmation();
378        let mut resp = sample_response(conf.confirmation_id, conf.engagement_id)
379            .with_exception(dec!(1_500.00), "Timing difference");
380        assert!(!resp.reconciled);
381        resp.reconcile("Credit note received and posted on 2 Jan 2026 — timing difference only.");
382        assert!(resp.reconciled);
383        assert!(resp.reconciliation_explanation.is_some());
384    }
385
386    // --- Serde tests ---
387
388    #[test]
389    fn test_confirmation_status_serde() {
390        // CRITICAL: AlternativeProcedures must serialise as "alternative_procedures"
391        let val = serde_json::to_value(ConfirmationStatus::AlternativeProcedures).unwrap();
392        assert_eq!(val, serde_json::json!("alternative_procedures"));
393
394        // Round-trip all variants
395        for status in [
396            ConfirmationStatus::Draft,
397            ConfirmationStatus::Sent,
398            ConfirmationStatus::Received,
399            ConfirmationStatus::NoResponse,
400            ConfirmationStatus::AlternativeProcedures,
401            ConfirmationStatus::Completed,
402        ] {
403            let serialised = serde_json::to_string(&status).unwrap();
404            let deserialised: ConfirmationStatus = serde_json::from_str(&serialised).unwrap();
405            assert_eq!(status, deserialised);
406        }
407    }
408
409    #[test]
410    fn test_confirmation_type_serde() {
411        for ct in [
412            ConfirmationType::BankBalance,
413            ConfirmationType::AccountsReceivable,
414            ConfirmationType::AccountsPayable,
415            ConfirmationType::Investment,
416            ConfirmationType::Loan,
417            ConfirmationType::Legal,
418            ConfirmationType::Insurance,
419            ConfirmationType::Inventory,
420        ] {
421            let serialised = serde_json::to_string(&ct).unwrap();
422            let deserialised: ConfirmationType = serde_json::from_str(&serialised).unwrap();
423            assert_eq!(ct, deserialised);
424        }
425    }
426
427    #[test]
428    fn test_response_type_serde() {
429        for rt in [
430            ResponseType::Confirmed,
431            ResponseType::ConfirmedWithException,
432            ResponseType::Denied,
433            ResponseType::NoReply,
434        ] {
435            let serialised = serde_json::to_string(&rt).unwrap();
436            let deserialised: ResponseType = serde_json::from_str(&serialised).unwrap();
437            assert_eq!(rt, deserialised);
438        }
439    }
440}