use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, PaymentObligationId};
use strum::{Display, EnumString};
use uuid::Uuid;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum PaymentObligationStatus {
#[default]
Pending,
Scheduled,
PartiallyPaid,
Paid,
Cancelled,
}
impl PaymentObligationStatus {
#[must_use]
pub const fn is_terminal(&self) -> bool {
matches!(self, Self::Paid | Self::Cancelled)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentObligation {
pub id: PaymentObligationId,
pub number: String,
pub supplier_id: Uuid,
pub purchase_order_id: Option<Uuid>,
pub amount: Decimal,
pub amount_paid: Decimal,
pub currency: CurrencyCode,
pub due_date: NaiveDate,
pub status: PaymentObligationStatus,
pub linked_bill_ids: Vec<Uuid>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl PaymentObligation {
#[must_use]
pub fn outstanding(&self) -> Decimal {
(self.amount - self.amount_paid).max(Decimal::ZERO)
}
#[must_use]
pub fn is_overdue(&self, today: NaiveDate) -> bool {
!self.status.is_terminal() && self.outstanding() > Decimal::ZERO && self.due_date < today
}
#[must_use]
pub fn derive_status(&self) -> PaymentObligationStatus {
if self.status == PaymentObligationStatus::Cancelled {
return PaymentObligationStatus::Cancelled;
}
if self.amount_paid <= Decimal::ZERO {
self.status
} else if self.amount_paid >= self.amount {
PaymentObligationStatus::Paid
} else {
PaymentObligationStatus::PartiallyPaid
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePaymentObligation {
pub supplier_id: Uuid,
pub purchase_order_id: Option<Uuid>,
pub amount: Decimal,
pub currency: Option<CurrencyCode>,
pub due_date: NaiveDate,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PaymentObligationFilter {
pub supplier_id: Option<Uuid>,
pub status: Option<PaymentObligationStatus>,
pub due_before: Option<NaiveDate>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PaymentObligationDashboard {
pub open_count: u64,
pub total_outstanding: Decimal,
pub overdue_count: u64,
pub overdue_amount: Decimal,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use rust_decimal_macros::dec;
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn make(
amount: Decimal,
paid: Decimal,
due: NaiveDate,
status: PaymentObligationStatus,
) -> PaymentObligation {
PaymentObligation {
id: PaymentObligationId::new(),
number: "PO-OBL-1".into(),
supplier_id: Uuid::nil(),
purchase_order_id: None,
amount,
amount_paid: paid,
currency: CurrencyCode::USD,
due_date: due,
status,
linked_bill_ids: vec![],
notes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn outstanding_never_negative() {
assert_eq!(
make(dec!(100), dec!(40), day(2026, 1, 1), PaymentObligationStatus::Pending)
.outstanding(),
dec!(60)
);
assert_eq!(
make(dec!(100), dec!(120), day(2026, 1, 1), PaymentObligationStatus::Paid)
.outstanding(),
dec!(0)
);
}
#[test]
fn overdue_logic() {
let today = day(2026, 6, 15);
assert!(
make(dec!(100), dec!(0), day(2026, 6, 1), PaymentObligationStatus::Pending)
.is_overdue(today)
);
assert!(
!make(dec!(100), dec!(0), day(2026, 7, 1), PaymentObligationStatus::Pending)
.is_overdue(today)
);
assert!(
!make(dec!(100), dec!(100), day(2026, 6, 1), PaymentObligationStatus::Paid)
.is_overdue(today)
);
}
#[test]
fn derive_status_progression() {
let due = day(2026, 6, 1);
assert_eq!(
make(dec!(100), dec!(0), due, PaymentObligationStatus::Pending).derive_status(),
PaymentObligationStatus::Pending
);
assert_eq!(
make(dec!(100), dec!(50), due, PaymentObligationStatus::Pending).derive_status(),
PaymentObligationStatus::PartiallyPaid
);
assert_eq!(
make(dec!(100), dec!(100), due, PaymentObligationStatus::Pending).derive_status(),
PaymentObligationStatus::Paid
);
assert_eq!(
make(dec!(100), dec!(100), due, PaymentObligationStatus::Cancelled).derive_status(),
PaymentObligationStatus::Cancelled
);
}
#[test]
fn status_roundtrip() {
for s in [
PaymentObligationStatus::Pending,
PaymentObligationStatus::Scheduled,
PaymentObligationStatus::PartiallyPaid,
PaymentObligationStatus::Paid,
PaymentObligationStatus::Cancelled,
] {
assert_eq!(s.to_string().parse::<PaymentObligationStatus>().unwrap(), s);
}
}
}