Skip to main content

datasynth_core/models/audit/
workpaper.rs

1//! Workpaper models per ISA 230.
2//!
3//! Workpapers document audit procedures performed, evidence obtained,
4//! and conclusions reached.
5
6use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use super::engagement::RiskLevel;
11
12/// Audit workpaper representing documented audit work.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Workpaper {
15    /// Unique workpaper ID
16    pub workpaper_id: Uuid,
17    /// Workpaper reference (e.g., "A-100", "B-200")
18    pub workpaper_ref: String,
19    /// Engagement ID this workpaper belongs to
20    pub engagement_id: Uuid,
21    /// Workpaper title
22    pub title: String,
23    /// Section/area of the audit
24    pub section: WorkpaperSection,
25    /// Audit objective addressed
26    pub objective: String,
27    /// Financial statement assertions tested
28    pub assertions_tested: Vec<Assertion>,
29    /// Procedure performed
30    pub procedure_performed: String,
31    /// Procedure type
32    pub procedure_type: ProcedureType,
33
34    // === Scope ===
35    /// Testing scope
36    pub scope: WorkpaperScope,
37    /// Population size (total items)
38    pub population_size: u64,
39    /// Sample size (items tested)
40    pub sample_size: u32,
41    /// Sampling method used
42    pub sampling_method: SamplingMethod,
43
44    // === Results ===
45    /// Summary of results
46    pub results_summary: String,
47    /// Number of exceptions found
48    pub exceptions_found: u32,
49    /// Exception rate
50    pub exception_rate: f64,
51    /// Conclusion reached
52    pub conclusion: WorkpaperConclusion,
53    /// Risk level addressed
54    pub risk_level_addressed: RiskLevel,
55
56    // === References ===
57    /// Evidence reference IDs
58    pub evidence_refs: Vec<Uuid>,
59    /// Cross-references to other workpapers
60    pub cross_references: Vec<String>,
61    /// Related account IDs
62    pub account_ids: Vec<String>,
63
64    // === Sign-offs ===
65    /// Preparer user ID
66    pub preparer_id: String,
67    /// Preparer name
68    pub preparer_name: String,
69    /// Date prepared
70    pub preparer_date: NaiveDate,
71    /// First reviewer ID
72    pub reviewer_id: Option<String>,
73    /// First reviewer name
74    pub reviewer_name: Option<String>,
75    /// First review date
76    pub reviewer_date: Option<NaiveDate>,
77    /// Second reviewer (manager) ID
78    pub second_reviewer_id: Option<String>,
79    /// Second reviewer name
80    pub second_reviewer_name: Option<String>,
81    /// Second review date
82    pub second_reviewer_date: Option<NaiveDate>,
83
84    // === Status ===
85    /// Workpaper status
86    pub status: WorkpaperStatus,
87    /// Version number
88    pub version: u32,
89    /// Review notes
90    pub review_notes: Vec<ReviewNote>,
91
92    // === Timestamps ===
93    #[serde(with = "crate::serde_timestamp::utc")]
94    pub created_at: DateTime<Utc>,
95    #[serde(with = "crate::serde_timestamp::utc")]
96    pub updated_at: DateTime<Utc>,
97}
98
99impl Workpaper {
100    /// Create a new workpaper.
101    pub fn new(
102        engagement_id: Uuid,
103        workpaper_ref: &str,
104        title: &str,
105        section: WorkpaperSection,
106    ) -> Self {
107        let now = Utc::now();
108        Self {
109            workpaper_id: Uuid::new_v4(),
110            workpaper_ref: workpaper_ref.into(),
111            engagement_id,
112            title: title.into(),
113            section,
114            objective: String::new(),
115            assertions_tested: Vec::new(),
116            procedure_performed: String::new(),
117            procedure_type: ProcedureType::InquiryObservation,
118            scope: WorkpaperScope::default(),
119            population_size: 0,
120            sample_size: 0,
121            sampling_method: SamplingMethod::Judgmental,
122            results_summary: String::new(),
123            exceptions_found: 0,
124            exception_rate: 0.0,
125            conclusion: WorkpaperConclusion::Satisfactory,
126            risk_level_addressed: RiskLevel::Medium,
127            evidence_refs: Vec::new(),
128            cross_references: Vec::new(),
129            account_ids: Vec::new(),
130            preparer_id: String::new(),
131            preparer_name: String::new(),
132            preparer_date: now.date_naive(),
133            reviewer_id: None,
134            reviewer_name: None,
135            reviewer_date: None,
136            second_reviewer_id: None,
137            second_reviewer_name: None,
138            second_reviewer_date: None,
139            status: WorkpaperStatus::Draft,
140            version: 1,
141            review_notes: Vec::new(),
142            created_at: now,
143            updated_at: now,
144        }
145    }
146
147    /// Set the objective and assertions.
148    pub fn with_objective(mut self, objective: &str, assertions: Vec<Assertion>) -> Self {
149        self.objective = objective.into();
150        self.assertions_tested = assertions;
151        self
152    }
153
154    /// Set the procedure.
155    pub fn with_procedure(mut self, procedure: &str, procedure_type: ProcedureType) -> Self {
156        self.procedure_performed = procedure.into();
157        self.procedure_type = procedure_type;
158        self
159    }
160
161    /// Set the scope and sampling.
162    pub fn with_scope(
163        mut self,
164        scope: WorkpaperScope,
165        population: u64,
166        sample: u32,
167        method: SamplingMethod,
168    ) -> Self {
169        self.scope = scope;
170        self.population_size = population;
171        self.sample_size = sample;
172        self.sampling_method = method;
173        self
174    }
175
176    /// Set the results.
177    pub fn with_results(
178        mut self,
179        summary: &str,
180        exceptions: u32,
181        conclusion: WorkpaperConclusion,
182    ) -> Self {
183        self.results_summary = summary.into();
184        self.exceptions_found = exceptions;
185        self.exception_rate = if self.sample_size > 0 {
186            exceptions as f64 / self.sample_size as f64
187        } else {
188            0.0
189        };
190        self.conclusion = conclusion;
191        self
192    }
193
194    /// Set the preparer.
195    pub fn with_preparer(mut self, id: &str, name: &str, date: NaiveDate) -> Self {
196        self.preparer_id = id.into();
197        self.preparer_name = name.into();
198        self.preparer_date = date;
199        self
200    }
201
202    /// Add first reviewer sign-off.
203    pub fn add_first_review(&mut self, id: &str, name: &str, date: NaiveDate) {
204        self.reviewer_id = Some(id.into());
205        self.reviewer_name = Some(name.into());
206        self.reviewer_date = Some(date);
207        self.status = WorkpaperStatus::FirstReviewComplete;
208        self.updated_at = Utc::now();
209    }
210
211    /// Add second reviewer sign-off.
212    pub fn add_second_review(&mut self, id: &str, name: &str, date: NaiveDate) {
213        self.second_reviewer_id = Some(id.into());
214        self.second_reviewer_name = Some(name.into());
215        self.second_reviewer_date = Some(date);
216        self.status = WorkpaperStatus::Complete;
217        self.updated_at = Utc::now();
218    }
219
220    /// Add a review note.
221    pub fn add_review_note(&mut self, reviewer: &str, note: &str) {
222        self.review_notes.push(ReviewNote {
223            note_id: Uuid::new_v4(),
224            reviewer_id: reviewer.into(),
225            note: note.into(),
226            status: ReviewNoteStatus::Open,
227            created_at: Utc::now(),
228            resolved_at: None,
229        });
230        self.updated_at = Utc::now();
231    }
232
233    /// Check if the workpaper is complete.
234    pub fn is_complete(&self) -> bool {
235        matches!(self.status, WorkpaperStatus::Complete)
236    }
237
238    /// Check if all review notes are resolved.
239    pub fn all_notes_resolved(&self) -> bool {
240        self.review_notes.iter().all(|n| {
241            matches!(
242                n.status,
243                ReviewNoteStatus::Resolved | ReviewNoteStatus::NotApplicable
244            )
245        })
246    }
247}
248
249/// Workpaper section/area.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
251#[serde(rename_all = "snake_case")]
252pub enum WorkpaperSection {
253    /// Planning documentation
254    #[default]
255    Planning,
256    /// Risk assessment procedures
257    RiskAssessment,
258    /// Internal control testing
259    ControlTesting,
260    /// Substantive testing
261    SubstantiveTesting,
262    /// Completion procedures
263    Completion,
264    /// Reporting
265    Reporting,
266    /// Permanent file
267    PermanentFile,
268}
269
270impl WorkpaperSection {
271    /// Get the typical workpaper reference prefix for this section.
272    pub fn reference_prefix(&self) -> &'static str {
273        match self {
274            Self::Planning => "A",
275            Self::RiskAssessment => "B",
276            Self::ControlTesting => "C",
277            Self::SubstantiveTesting => "D",
278            Self::Completion => "E",
279            Self::Reporting => "F",
280            Self::PermanentFile => "P",
281        }
282    }
283}
284
285/// Financial statement assertions per ISA 315.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
287#[serde(rename_all = "snake_case")]
288pub enum Assertion {
289    // Transaction assertions
290    /// Transactions occurred and relate to the entity
291    Occurrence,
292    /// All transactions that should have been recorded have been recorded
293    Completeness,
294    /// Amounts and data relating to transactions have been recorded appropriately
295    Accuracy,
296    /// Transactions have been recorded in the correct accounting period
297    Cutoff,
298    /// Transactions have been recorded in the proper accounts
299    Classification,
300
301    // Balance assertions
302    /// Assets, liabilities, and equity interests exist
303    Existence,
304    /// The entity holds rights to assets and liabilities are obligations
305    RightsAndObligations,
306    /// Assets, liabilities, and equity interests are included at appropriate amounts
307    ValuationAndAllocation,
308
309    // Presentation assertions
310    /// Financial information is appropriately presented and described
311    PresentationAndDisclosure,
312}
313
314impl Assertion {
315    /// Get all transaction-level assertions.
316    pub fn transaction_assertions() -> Vec<Self> {
317        vec![
318            Self::Occurrence,
319            Self::Completeness,
320            Self::Accuracy,
321            Self::Cutoff,
322            Self::Classification,
323        ]
324    }
325
326    /// Get all balance-level assertions.
327    pub fn balance_assertions() -> Vec<Self> {
328        vec![
329            Self::Existence,
330            Self::Completeness,
331            Self::RightsAndObligations,
332            Self::ValuationAndAllocation,
333        ]
334    }
335
336    /// Get a human-readable description.
337    pub fn description(&self) -> &'static str {
338        match self {
339            Self::Occurrence => "Transactions and events have occurred and pertain to the entity",
340            Self::Completeness => {
341                "All transactions and events that should have been recorded have been recorded"
342            }
343            Self::Accuracy => "Amounts and other data have been recorded appropriately",
344            Self::Cutoff => "Transactions and events have been recorded in the correct period",
345            Self::Classification => {
346                "Transactions and events have been recorded in the proper accounts"
347            }
348            Self::Existence => "Assets, liabilities, and equity interests exist",
349            Self::RightsAndObligations => {
350                "The entity holds rights to assets and liabilities are obligations of the entity"
351            }
352            Self::ValuationAndAllocation => {
353                "Assets, liabilities, and equity interests are included at appropriate amounts"
354            }
355            Self::PresentationAndDisclosure => {
356                "Financial information is appropriately presented and described"
357            }
358        }
359    }
360}
361
362/// Type of audit procedure.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
364#[serde(rename_all = "snake_case")]
365pub enum ProcedureType {
366    /// Inquiry and observation
367    #[default]
368    InquiryObservation,
369    /// Inspection of records
370    Inspection,
371    /// External confirmation
372    Confirmation,
373    /// Recalculation
374    Recalculation,
375    /// Reperformance
376    Reperformance,
377    /// Analytical procedures
378    AnalyticalProcedures,
379    /// Test of controls
380    TestOfControls,
381    /// Substantive test of details
382    SubstantiveTest,
383    /// Combined approach
384    Combined,
385}
386
387/// Testing scope.
388#[derive(Debug, Clone, Serialize, Deserialize, Default)]
389pub struct WorkpaperScope {
390    /// Coverage percentage
391    pub coverage_percentage: f64,
392    /// Period covered start
393    pub period_start: Option<NaiveDate>,
394    /// Period covered end
395    pub period_end: Option<NaiveDate>,
396    /// Scope limitations
397    pub limitations: Vec<String>,
398}
399
400/// Sampling method used.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
402#[serde(rename_all = "snake_case")]
403pub enum SamplingMethod {
404    /// Statistical random sampling
405    StatisticalRandom,
406    /// Monetary unit sampling
407    MonetaryUnit,
408    /// Judgmental selection
409    #[default]
410    Judgmental,
411    /// Haphazard selection
412    Haphazard,
413    /// Block selection
414    Block,
415    /// All items (100% testing)
416    AllItems,
417}
418
419/// Workpaper conclusion.
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
421#[serde(rename_all = "snake_case")]
422pub enum WorkpaperConclusion {
423    /// Satisfactory - no exceptions or immaterial exceptions
424    #[default]
425    Satisfactory,
426    /// Satisfactory with exceptions noted
427    SatisfactoryWithExceptions,
428    /// Unsatisfactory - material exceptions
429    Unsatisfactory,
430    /// Unable to conclude - scope limitation
431    UnableToConclude,
432    /// Additional procedures required
433    AdditionalProceduresRequired,
434}
435
436/// Workpaper status.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
438#[serde(rename_all = "snake_case")]
439pub enum WorkpaperStatus {
440    /// Draft
441    #[default]
442    Draft,
443    /// Pending review
444    PendingReview,
445    /// First review complete
446    FirstReviewComplete,
447    /// Pending second review
448    PendingSecondReview,
449    /// Complete
450    Complete,
451    /// Superseded
452    Superseded,
453}
454
455/// Review note on a workpaper.
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct ReviewNote {
458    /// Note ID
459    pub note_id: Uuid,
460    /// Reviewer who added the note
461    pub reviewer_id: String,
462    /// Note content
463    pub note: String,
464    /// Note status
465    pub status: ReviewNoteStatus,
466    /// When the note was created
467    #[serde(with = "crate::serde_timestamp::utc")]
468    pub created_at: DateTime<Utc>,
469    /// When the note was resolved
470    #[serde(default, with = "crate::serde_timestamp::utc::option")]
471    pub resolved_at: Option<DateTime<Utc>>,
472}
473
474/// Review note status.
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
476#[serde(rename_all = "snake_case")]
477pub enum ReviewNoteStatus {
478    /// Open - needs action
479    #[default]
480    Open,
481    /// In progress
482    InProgress,
483    /// Resolved
484    Resolved,
485    /// Not applicable
486    NotApplicable,
487}
488
489#[cfg(test)]
490#[allow(clippy::unwrap_used)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn test_workpaper_creation() {
496        let wp = Workpaper::new(
497            Uuid::new_v4(),
498            "C-100",
499            "Revenue Recognition Testing",
500            WorkpaperSection::SubstantiveTesting,
501        );
502
503        assert_eq!(wp.workpaper_ref, "C-100");
504        assert_eq!(wp.section, WorkpaperSection::SubstantiveTesting);
505        assert_eq!(wp.status, WorkpaperStatus::Draft);
506    }
507
508    #[test]
509    fn test_workpaper_with_results() {
510        let wp = Workpaper::new(
511            Uuid::new_v4(),
512            "D-100",
513            "Accounts Receivable Confirmation",
514            WorkpaperSection::SubstantiveTesting,
515        )
516        .with_scope(
517            WorkpaperScope::default(),
518            1000,
519            50,
520            SamplingMethod::StatisticalRandom,
521        )
522        .with_results(
523            "Confirmed 50 balances with 2 exceptions",
524            2,
525            WorkpaperConclusion::SatisfactoryWithExceptions,
526        );
527
528        assert_eq!(wp.exception_rate, 0.04);
529        assert_eq!(
530            wp.conclusion,
531            WorkpaperConclusion::SatisfactoryWithExceptions
532        );
533    }
534
535    #[test]
536    fn test_review_signoff() {
537        let mut wp = Workpaper::new(
538            Uuid::new_v4(),
539            "A-100",
540            "Planning Memo",
541            WorkpaperSection::Planning,
542        );
543
544        wp.add_first_review(
545            "reviewer1",
546            "John Smith",
547            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
548        );
549        assert_eq!(wp.status, WorkpaperStatus::FirstReviewComplete);
550
551        wp.add_second_review(
552            "manager1",
553            "Jane Doe",
554            NaiveDate::from_ymd_opt(2025, 1, 16).unwrap(),
555        );
556        assert_eq!(wp.status, WorkpaperStatus::Complete);
557        assert!(wp.is_complete());
558    }
559
560    #[test]
561    fn test_assertions() {
562        let txn_assertions = Assertion::transaction_assertions();
563        assert_eq!(txn_assertions.len(), 5);
564        assert!(txn_assertions.contains(&Assertion::Occurrence));
565
566        let bal_assertions = Assertion::balance_assertions();
567        assert_eq!(bal_assertions.len(), 4);
568        assert!(bal_assertions.contains(&Assertion::Existence));
569    }
570}