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 #[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 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 pub fn with_workpaper(mut self, workpaper_id: Uuid) -> Self {
175 self.workpaper_id = Some(workpaper_id);
176 self
177 }
178
179 pub fn with_account(mut self, account_id: &str) -> Self {
181 self.account_id = Some(account_id.into());
182 self
183 }
184
185 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#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct ConfirmationResponse {
200 pub response_id: Uuid,
202 pub response_ref: String,
204 pub confirmation_id: Uuid,
206 pub engagement_id: Uuid,
208 pub response_date: NaiveDate,
210 pub confirmed_balance: Option<Decimal>,
212 pub response_type: ResponseType,
214 pub has_exception: bool,
216 pub exception_amount: Option<Decimal>,
218 pub exception_description: Option<String>,
220 pub reconciled: bool,
222 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 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 pub fn with_confirmed_balance(mut self, balance: Decimal) -> Self {
260 self.confirmed_balance = Some(balance);
261 self
262 }
263
264 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 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 #[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 #[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 #[test]
389 fn test_confirmation_status_serde() {
390 let val = serde_json::to_value(ConfirmationStatus::AlternativeProcedures).unwrap();
392 assert_eq!(val, serde_json::json!("alternative_procedures"));
393
394 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}