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