use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, CustomerId, InvoiceId, OrderId, OrderItemId, ProductId};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum InvoiceStatus {
#[default]
Draft,
Sent,
Viewed,
PartiallyPaid,
Paid,
Overdue,
Voided,
WrittenOff,
Disputed,
}
impl std::str::FromStr for InvoiceStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"draft" => Ok(Self::Draft),
"sent" => Ok(Self::Sent),
"viewed" => Ok(Self::Viewed),
"partially_paid" => Ok(Self::PartiallyPaid),
"paid" => Ok(Self::Paid),
"overdue" => Ok(Self::Overdue),
"voided" => Ok(Self::Voided),
"written_off" => Ok(Self::WrittenOff),
"disputed" => Ok(Self::Disputed),
_ => Err(format!("Unknown invoice status: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum InvoiceType {
#[default]
Standard,
CreditMemo,
DebitMemo,
Proforma,
Recurring,
Final,
}
impl std::str::FromStr for InvoiceType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"standard" => Ok(Self::Standard),
"credit_memo" | "credit_note" => Ok(Self::CreditMemo),
"debit_memo" | "debit_note" => Ok(Self::DebitMemo),
"proforma" => Ok(Self::Proforma),
"recurring" => Ok(Self::Recurring),
"final" => Ok(Self::Final),
_ => Err(format!("Unknown invoice type: {s}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invoice {
pub id: InvoiceId,
pub invoice_number: String,
pub customer_id: CustomerId,
pub order_id: Option<OrderId>,
pub status: InvoiceStatus,
pub invoice_type: InvoiceType,
pub invoice_date: DateTime<Utc>,
pub due_date: DateTime<Utc>,
pub payment_terms: Option<String>,
pub currency: CurrencyCode,
pub billing_name: Option<String>,
pub billing_email: Option<String>,
pub billing_address: Option<String>,
pub billing_city: Option<String>,
pub billing_state: Option<String>,
pub billing_postal_code: Option<String>,
pub billing_country: Option<String>,
pub subtotal: Decimal,
pub discount_amount: Decimal,
pub discount_percent: Option<Decimal>,
pub tax_amount: Decimal,
pub tax_rate: Option<Decimal>,
pub shipping_amount: Decimal,
pub total: Decimal,
pub amount_paid: Decimal,
pub balance_due: Decimal,
pub po_number: Option<String>,
pub notes: Option<String>,
pub terms: Option<String>,
pub footer: Option<String>,
pub sent_at: Option<DateTime<Utc>>,
pub viewed_at: Option<DateTime<Utc>>,
pub paid_at: Option<DateTime<Utc>>,
pub voided_at: Option<DateTime<Utc>>,
pub items: Vec<InvoiceItem>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Invoice {
#[must_use]
pub fn is_overdue(&self) -> bool {
if self.status == InvoiceStatus::Paid || self.status == InvoiceStatus::Voided {
return false;
}
Utc::now() > self.due_date
}
#[must_use]
pub fn days_until_due(&self) -> i64 {
(self.due_date - Utc::now()).num_days()
}
#[must_use]
pub fn calculate_balance(&self) -> Decimal {
self.total - self.amount_paid
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvoiceItem {
pub id: Uuid,
pub invoice_id: InvoiceId,
pub order_item_id: Option<OrderItemId>,
pub product_id: Option<ProductId>,
pub sku: Option<String>,
pub description: String,
pub quantity: Decimal,
pub unit_of_measure: Option<String>,
pub unit_price: Decimal,
pub discount_amount: Decimal,
pub tax_amount: Decimal,
pub line_total: Decimal,
pub sort_order: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateInvoice {
pub customer_id: CustomerId,
pub order_id: Option<OrderId>,
pub invoice_type: Option<InvoiceType>,
pub invoice_date: Option<DateTime<Utc>>,
pub due_date: Option<DateTime<Utc>>,
pub days_until_due: Option<i32>,
pub payment_terms: Option<String>,
pub currency: Option<CurrencyCode>,
pub billing_name: Option<String>,
pub billing_email: Option<String>,
pub billing_address: Option<String>,
pub billing_city: Option<String>,
pub billing_state: Option<String>,
pub billing_postal_code: Option<String>,
pub billing_country: Option<String>,
pub discount_amount: Option<Decimal>,
pub discount_percent: Option<Decimal>,
pub tax_amount: Option<Decimal>,
pub tax_rate: Option<Decimal>,
pub shipping_amount: Option<Decimal>,
pub po_number: Option<String>,
pub notes: Option<String>,
pub terms: Option<String>,
pub footer: Option<String>,
pub items: Vec<CreateInvoiceItem>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateInvoiceItem {
pub order_item_id: Option<OrderItemId>,
pub product_id: Option<ProductId>,
pub sku: Option<String>,
pub description: String,
pub quantity: Decimal,
pub unit_of_measure: Option<String>,
pub unit_price: Decimal,
pub discount_amount: Option<Decimal>,
pub tax_amount: Option<Decimal>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateInvoice {
pub due_date: Option<DateTime<Utc>>,
pub payment_terms: Option<String>,
pub billing_name: Option<String>,
pub billing_email: Option<String>,
pub billing_address: Option<String>,
pub billing_city: Option<String>,
pub billing_state: Option<String>,
pub billing_postal_code: Option<String>,
pub billing_country: Option<String>,
pub discount_amount: Option<Decimal>,
pub discount_percent: Option<Decimal>,
pub tax_amount: Option<Decimal>,
pub tax_rate: Option<Decimal>,
pub shipping_amount: Option<Decimal>,
pub po_number: Option<String>,
pub notes: Option<String>,
pub terms: Option<String>,
pub footer: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecordInvoicePayment {
pub amount: Decimal,
pub payment_id: Option<Uuid>,
pub payment_method: Option<String>,
pub reference: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InvoiceFilter {
pub customer_id: Option<CustomerId>,
pub order_id: Option<OrderId>,
pub status: Option<InvoiceStatus>,
pub invoice_type: Option<InvoiceType>,
pub overdue_only: Option<bool>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub due_from: Option<DateTime<Utc>>,
pub due_to: Option<DateTime<Utc>>,
pub min_total: Option<Decimal>,
pub max_total: Option<Decimal>,
pub min_balance: Option<Decimal>,
pub invoice_number: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[must_use]
pub fn generate_invoice_number() -> String {
let now = chrono::Utc::now();
let short_id = &uuid::Uuid::new_v4().simple().to_string()[..8];
format!("INV-{}-{short_id}", now.format("%Y%m%d%H%M%S%3f"))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use rust_decimal_macros::dec;
fn create_test_invoice(status: InvoiceStatus, due_in_days: i64) -> Invoice {
let now = Utc::now();
Invoice {
id: InvoiceId::new(),
invoice_number: generate_invoice_number(),
customer_id: CustomerId::new(),
order_id: None,
status,
invoice_type: InvoiceType::Standard,
invoice_date: now,
due_date: now + Duration::days(due_in_days),
payment_terms: None,
currency: CurrencyCode::USD,
billing_name: None,
billing_email: None,
billing_address: None,
billing_city: None,
billing_state: None,
billing_postal_code: None,
billing_country: None,
subtotal: dec!(100.00),
discount_amount: Decimal::ZERO,
discount_percent: None,
tax_amount: dec!(8.00),
tax_rate: None,
shipping_amount: Decimal::ZERO,
total: dec!(108.00),
amount_paid: Decimal::ZERO,
balance_due: dec!(108.00),
po_number: None,
notes: None,
terms: None,
footer: None,
sent_at: None,
viewed_at: None,
paid_at: None,
voided_at: None,
items: vec![],
created_at: now,
updated_at: now,
}
}
#[test]
fn generated_invoice_numbers_include_entropy_suffix() {
let first = generate_invoice_number();
let second = generate_invoice_number();
assert!(first.starts_with("INV-"));
assert!(first.len() > "INV-20260101120000000".len());
assert_ne!(first, second);
}
#[test]
fn calculate_balance_with_no_payments_equals_total() {
let invoice = create_test_invoice(InvoiceStatus::Sent, 30);
assert_eq!(invoice.calculate_balance(), dec!(108.00));
}
#[test]
fn calculate_balance_after_partial_payment() {
let mut invoice = create_test_invoice(InvoiceStatus::PartiallyPaid, 30);
invoice.amount_paid = dec!(50.00);
assert_eq!(invoice.calculate_balance(), dec!(58.00));
}
#[test]
fn calculate_balance_when_fully_paid_is_zero() {
let mut invoice = create_test_invoice(InvoiceStatus::Paid, 30);
invoice.amount_paid = dec!(108.00);
assert_eq!(invoice.calculate_balance(), Decimal::ZERO);
}
#[test]
fn calculate_balance_on_overpayment_is_negative() {
let mut invoice = create_test_invoice(InvoiceStatus::Paid, 30);
invoice.amount_paid = dec!(120.00);
assert_eq!(invoice.calculate_balance(), dec!(-12.00));
}
#[test]
fn unpaid_invoice_past_due_date_is_overdue() {
let invoice = create_test_invoice(InvoiceStatus::Sent, -1);
assert!(invoice.is_overdue());
}
#[test]
fn unpaid_invoice_before_due_date_is_not_overdue() {
let invoice = create_test_invoice(InvoiceStatus::Sent, 30);
assert!(!invoice.is_overdue());
}
#[test]
fn paid_invoice_is_never_overdue() {
let invoice = create_test_invoice(InvoiceStatus::Paid, -90);
assert!(!invoice.is_overdue());
}
#[test]
fn voided_invoice_is_never_overdue() {
let invoice = create_test_invoice(InvoiceStatus::Voided, -90);
assert!(!invoice.is_overdue());
}
#[test]
fn partially_paid_invoice_past_due_is_overdue() {
let mut invoice = create_test_invoice(InvoiceStatus::PartiallyPaid, -5);
invoice.amount_paid = dec!(50.00);
assert!(invoice.is_overdue());
}
#[test]
fn days_until_due_is_positive_before_due_date() {
let invoice = create_test_invoice(InvoiceStatus::Sent, 30);
let days = invoice.days_until_due();
assert!((29..=30).contains(&days), "expected ~30 days, got {days}");
}
#[test]
fn days_until_due_is_negative_when_overdue() {
let invoice = create_test_invoice(InvoiceStatus::Sent, -10);
let days = invoice.days_until_due();
assert!(days <= -10, "expected <= -10 days, got {days}");
}
#[test]
fn invoice_status_round_trips_through_strings() {
for status in [
InvoiceStatus::Draft,
InvoiceStatus::Sent,
InvoiceStatus::Viewed,
InvoiceStatus::PartiallyPaid,
InvoiceStatus::Paid,
InvoiceStatus::Overdue,
InvoiceStatus::Voided,
InvoiceStatus::WrittenOff,
InvoiceStatus::Disputed,
] {
let parsed: InvoiceStatus =
status.to_string().parse().expect("status should round-trip");
assert_eq!(parsed, status);
}
}
#[test]
fn invoice_status_parse_is_case_insensitive_and_rejects_unknown() {
assert_eq!("PAID".parse::<InvoiceStatus>(), Ok(InvoiceStatus::Paid));
assert_eq!("Partially_Paid".parse::<InvoiceStatus>(), Ok(InvoiceStatus::PartiallyPaid));
assert!("bogus".parse::<InvoiceStatus>().is_err());
}
#[test]
fn invoice_type_round_trips_through_strings() {
for invoice_type in [
InvoiceType::Standard,
InvoiceType::CreditMemo,
InvoiceType::DebitMemo,
InvoiceType::Proforma,
InvoiceType::Recurring,
InvoiceType::Final,
] {
let parsed: InvoiceType =
invoice_type.to_string().parse().expect("type should round-trip");
assert_eq!(parsed, invoice_type);
}
}
#[test]
fn invoice_type_accepts_note_aliases() {
assert_eq!("credit_note".parse::<InvoiceType>(), Ok(InvoiceType::CreditMemo));
assert_eq!("debit_note".parse::<InvoiceType>(), Ok(InvoiceType::DebitMemo));
assert!("unknown".parse::<InvoiceType>().is_err());
}
#[test]
fn invoice_status_serde_round_trips_snake_case() {
let json = serde_json::to_string(&InvoiceStatus::PartiallyPaid).expect("serialize");
assert_eq!(json, "\"partially_paid\"");
let back: InvoiceStatus = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, InvoiceStatus::PartiallyPaid);
}
}