1use chrono::{DateTime, NaiveDate, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum ConfirmationType {
16 #[default]
18 BankBalance,
19 AccountsReceivable,
21 AccountsPayable,
23 Investment,
25 Loan,
27 Legal,
29 Insurance,
31 Inventory,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
37#[serde(rename_all = "snake_case")]
38pub enum ConfirmationForm {
39 #[default]
41 Positive,
42 Negative,
44 Blank,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum ConfirmationStatus {
52 #[default]
54 Draft,
55 Sent,
57 Received,
59 NoResponse,
61 AlternativeProcedures,
63 Completed,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum RecipientType {
71 #[default]
73 Bank,
74 Customer,
76 Supplier,
78 LegalCounsel,
80 Insurer,
82 Other,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
88#[serde(rename_all = "snake_case")]
89pub enum ResponseType {
90 #[default]
92 Confirmed,
93 ConfirmedWithException,
95 Denied,
97 NoReply,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ExternalConfirmation {
107 pub confirmation_id: Uuid,
109 pub confirmation_ref: String,
111 pub engagement_id: Uuid,
113 pub workpaper_id: Option<Uuid>,
115 pub confirmation_type: ConfirmationType,
117 pub recipient_name: String,
119 pub recipient_type: RecipientType,
121 pub account_id: Option<String>,
123 pub book_balance: Decimal,
125 pub confirmation_date: NaiveDate,
127 pub sent_date: Option<NaiveDate>,
129 pub response_deadline: Option<NaiveDate>,
131 pub status: ConfirmationStatus,
133 pub positive_negative: ConfirmationForm,
135 pub created_at: DateTime<Utc>,
136 pub updated_at: DateTime<Utc>,
137}
138
139impl ExternalConfirmation {
140 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 pub fn with_workpaper(mut self, workpaper_id: Uuid) -> Self {
173 self.workpaper_id = Some(workpaper_id);
174 self
175 }
176
177 pub fn with_account(mut self, account_id: &str) -> Self {
179 self.account_id = Some(account_id.into());
180 self
181 }
182
183 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#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ConfirmationResponse {
198 pub response_id: Uuid,
200 pub response_ref: String,
202 pub confirmation_id: Uuid,
204 pub engagement_id: Uuid,
206 pub response_date: NaiveDate,
208 pub confirmed_balance: Option<Decimal>,
210 pub response_type: ResponseType,
212 pub has_exception: bool,
214 pub exception_amount: Option<Decimal>,
216 pub exception_description: Option<String>,
218 pub reconciled: bool,
220 pub reconciliation_explanation: Option<String>,
222 pub created_at: DateTime<Utc>,
223 pub updated_at: DateTime<Utc>,
224}
225
226impl ConfirmationResponse {
227 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 pub fn with_confirmed_balance(mut self, balance: Decimal) -> Self {
256 self.confirmed_balance = Some(balance);
257 self
258 }
259
260 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 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 #[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 #[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 #[test]
385 fn test_confirmation_status_serde() {
386 let val = serde_json::to_value(ConfirmationStatus::AlternativeProcedures).unwrap();
388 assert_eq!(val, serde_json::json!("alternative_procedures"));
389
390 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}