use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmationType {
#[default]
BankBalance,
AccountsReceivable,
AccountsPayable,
Investment,
Loan,
Legal,
Insurance,
Inventory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmationForm {
#[default]
Positive,
Negative,
Blank,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmationStatus {
#[default]
Draft,
Sent,
Received,
NoResponse,
AlternativeProcedures,
Completed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RecipientType {
#[default]
Bank,
Customer,
Supplier,
LegalCounsel,
Insurer,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseType {
#[default]
Confirmed,
ConfirmedWithException,
Denied,
NoReply,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalConfirmation {
pub confirmation_id: Uuid,
pub confirmation_ref: String,
pub engagement_id: Uuid,
pub workpaper_id: Option<Uuid>,
pub confirmation_type: ConfirmationType,
pub recipient_name: String,
pub recipient_type: RecipientType,
pub account_id: Option<String>,
pub book_balance: Decimal,
pub confirmation_date: NaiveDate,
pub sent_date: Option<NaiveDate>,
pub response_deadline: Option<NaiveDate>,
pub status: ConfirmationStatus,
pub positive_negative: ConfirmationForm,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl ExternalConfirmation {
pub fn new(
engagement_id: Uuid,
confirmation_type: ConfirmationType,
recipient_name: &str,
recipient_type: RecipientType,
book_balance: Decimal,
confirmation_date: NaiveDate,
) -> Self {
let id = Uuid::new_v4();
let now = Utc::now();
Self {
confirmation_id: id,
confirmation_ref: format!("CONF-{}", &id.to_string()[..8]),
engagement_id,
workpaper_id: None,
confirmation_type,
recipient_name: recipient_name.into(),
recipient_type,
account_id: None,
book_balance,
confirmation_date,
sent_date: None,
response_deadline: None,
status: ConfirmationStatus::Draft,
positive_negative: ConfirmationForm::Positive,
created_at: now,
updated_at: now,
}
}
pub fn with_workpaper(mut self, workpaper_id: Uuid) -> Self {
self.workpaper_id = Some(workpaper_id);
self
}
pub fn with_account(mut self, account_id: &str) -> Self {
self.account_id = Some(account_id.into());
self
}
pub fn send(&mut self, sent_date: NaiveDate, deadline: NaiveDate) {
self.sent_date = Some(sent_date);
self.response_deadline = Some(deadline);
self.status = ConfirmationStatus::Sent;
self.updated_at = Utc::now();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfirmationResponse {
pub response_id: Uuid,
pub response_ref: String,
pub confirmation_id: Uuid,
pub engagement_id: Uuid,
pub response_date: NaiveDate,
pub confirmed_balance: Option<Decimal>,
pub response_type: ResponseType,
pub has_exception: bool,
pub exception_amount: Option<Decimal>,
pub exception_description: Option<String>,
pub reconciled: bool,
pub reconciliation_explanation: Option<String>,
#[serde(with = "crate::serde_timestamp::utc")]
pub created_at: DateTime<Utc>,
#[serde(with = "crate::serde_timestamp::utc")]
pub updated_at: DateTime<Utc>,
}
impl ConfirmationResponse {
pub fn new(
confirmation_id: Uuid,
engagement_id: Uuid,
response_date: NaiveDate,
response_type: ResponseType,
) -> Self {
let id = Uuid::new_v4();
let now = Utc::now();
Self {
response_id: id,
response_ref: format!("RESP-{}", &id.to_string()[..8]),
confirmation_id,
engagement_id,
response_date,
confirmed_balance: None,
response_type,
has_exception: false,
exception_amount: None,
exception_description: None,
reconciled: false,
reconciliation_explanation: None,
created_at: now,
updated_at: now,
}
}
pub fn with_confirmed_balance(mut self, balance: Decimal) -> Self {
self.confirmed_balance = Some(balance);
self
}
pub fn with_exception(mut self, amount: Decimal, description: &str) -> Self {
self.has_exception = true;
self.exception_amount = Some(amount);
self.exception_description = Some(description.into());
self
}
pub fn reconcile(&mut self, explanation: &str) {
self.reconciled = true;
self.reconciliation_explanation = Some(explanation.into());
self.updated_at = Utc::now();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn sample_confirmation() -> ExternalConfirmation {
ExternalConfirmation::new(
Uuid::new_v4(),
ConfirmationType::BankBalance,
"First National Bank",
RecipientType::Bank,
dec!(125_000.00),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
}
fn sample_response(confirmation_id: Uuid, engagement_id: Uuid) -> ConfirmationResponse {
ConfirmationResponse::new(
confirmation_id,
engagement_id,
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
ResponseType::Confirmed,
)
}
#[test]
fn test_new_confirmation() {
let conf = sample_confirmation();
assert_eq!(conf.status, ConfirmationStatus::Draft);
assert_eq!(conf.positive_negative, ConfirmationForm::Positive);
assert!(conf.workpaper_id.is_none());
assert!(conf.account_id.is_none());
assert!(conf.sent_date.is_none());
assert!(conf.response_deadline.is_none());
}
#[test]
fn test_send_updates_status() {
let mut conf = sample_confirmation();
let sent = NaiveDate::from_ymd_opt(2026, 1, 5).unwrap();
let deadline = NaiveDate::from_ymd_opt(2026, 1, 20).unwrap();
conf.send(sent, deadline);
assert_eq!(conf.status, ConfirmationStatus::Sent);
assert_eq!(conf.sent_date, Some(sent));
assert_eq!(conf.response_deadline, Some(deadline));
}
#[test]
fn test_with_workpaper() {
let wp_id = Uuid::new_v4();
let conf = sample_confirmation().with_workpaper(wp_id);
assert_eq!(conf.workpaper_id, Some(wp_id));
}
#[test]
fn test_with_account() {
let conf = sample_confirmation().with_account("ACC-001");
assert_eq!(conf.account_id, Some("ACC-001".to_string()));
}
#[test]
fn test_new_response() {
let conf = sample_confirmation();
let resp = sample_response(conf.confirmation_id, conf.engagement_id);
assert!(!resp.has_exception);
assert!(!resp.reconciled);
assert!(resp.confirmed_balance.is_none());
assert!(resp.exception_amount.is_none());
assert!(resp.reconciliation_explanation.is_none());
}
#[test]
fn test_with_confirmed_balance() {
let conf = sample_confirmation();
let resp = sample_response(conf.confirmation_id, conf.engagement_id)
.with_confirmed_balance(dec!(125_000.00));
assert_eq!(resp.confirmed_balance, Some(dec!(125_000.00)));
}
#[test]
fn test_with_exception() {
let conf = sample_confirmation();
let resp = sample_response(conf.confirmation_id, conf.engagement_id)
.with_confirmed_balance(dec!(123_500.00))
.with_exception(dec!(1_500.00), "Unrecorded credit note dated 30 Dec 2025");
assert!(resp.has_exception);
assert_eq!(resp.exception_amount, Some(dec!(1_500.00)));
assert!(resp.exception_description.is_some());
}
#[test]
fn test_reconcile() {
let conf = sample_confirmation();
let mut resp = sample_response(conf.confirmation_id, conf.engagement_id)
.with_exception(dec!(1_500.00), "Timing difference");
assert!(!resp.reconciled);
resp.reconcile("Credit note received and posted on 2 Jan 2026 — timing difference only.");
assert!(resp.reconciled);
assert!(resp.reconciliation_explanation.is_some());
}
#[test]
fn test_confirmation_status_serde() {
let val = serde_json::to_value(ConfirmationStatus::AlternativeProcedures).unwrap();
assert_eq!(val, serde_json::json!("alternative_procedures"));
for status in [
ConfirmationStatus::Draft,
ConfirmationStatus::Sent,
ConfirmationStatus::Received,
ConfirmationStatus::NoResponse,
ConfirmationStatus::AlternativeProcedures,
ConfirmationStatus::Completed,
] {
let serialised = serde_json::to_string(&status).unwrap();
let deserialised: ConfirmationStatus = serde_json::from_str(&serialised).unwrap();
assert_eq!(status, deserialised);
}
}
#[test]
fn test_confirmation_type_serde() {
for ct in [
ConfirmationType::BankBalance,
ConfirmationType::AccountsReceivable,
ConfirmationType::AccountsPayable,
ConfirmationType::Investment,
ConfirmationType::Loan,
ConfirmationType::Legal,
ConfirmationType::Insurance,
ConfirmationType::Inventory,
] {
let serialised = serde_json::to_string(&ct).unwrap();
let deserialised: ConfirmationType = serde_json::from_str(&serialised).unwrap();
assert_eq!(ct, deserialised);
}
}
#[test]
fn test_response_type_serde() {
for rt in [
ResponseType::Confirmed,
ResponseType::ConfirmedWithException,
ResponseType::Denied,
ResponseType::NoReply,
] {
let serialised = serde_json::to_string(&rt).unwrap();
let deserialised: ResponseType = serde_json::from_str(&serialised).unwrap();
assert_eq!(rt, deserialised);
}
}
}