1use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use stateset_primitives::{CurrencyCode, CustomerId, InvoiceId, OrderId, OrderItemId, ProductId};
9use uuid::Uuid;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
13#[strum(serialize_all = "snake_case")]
14#[serde(rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum InvoiceStatus {
17 #[default]
19 Draft,
20 Sent,
22 Viewed,
24 PartiallyPaid,
26 Paid,
28 Overdue,
30 Voided,
32 WrittenOff,
34 Disputed,
36}
37
38impl std::str::FromStr for InvoiceStatus {
39 type Err = String;
40
41 fn from_str(s: &str) -> Result<Self, Self::Err> {
42 match s.to_lowercase().as_str() {
43 "draft" => Ok(Self::Draft),
44 "sent" => Ok(Self::Sent),
45 "viewed" => Ok(Self::Viewed),
46 "partially_paid" => Ok(Self::PartiallyPaid),
47 "paid" => Ok(Self::Paid),
48 "overdue" => Ok(Self::Overdue),
49 "voided" => Ok(Self::Voided),
50 "written_off" => Ok(Self::WrittenOff),
51 "disputed" => Ok(Self::Disputed),
52 _ => Err(format!("Unknown invoice status: {s}")),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
59#[strum(serialize_all = "snake_case")]
60#[serde(rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum InvoiceType {
63 #[default]
65 Standard,
66 CreditMemo,
68 DebitMemo,
70 Proforma,
72 Recurring,
74 Final,
76}
77
78impl std::str::FromStr for InvoiceType {
79 type Err = String;
80
81 fn from_str(s: &str) -> Result<Self, Self::Err> {
82 match s.to_lowercase().as_str() {
83 "standard" => Ok(Self::Standard),
84 "credit_memo" | "credit_note" => Ok(Self::CreditMemo),
85 "debit_memo" | "debit_note" => Ok(Self::DebitMemo),
86 "proforma" => Ok(Self::Proforma),
87 "recurring" => Ok(Self::Recurring),
88 "final" => Ok(Self::Final),
89 _ => Err(format!("Unknown invoice type: {s}")),
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Invoice {
97 pub id: InvoiceId,
99 pub invoice_number: String,
101 pub customer_id: CustomerId,
103 pub order_id: Option<OrderId>,
105 pub status: InvoiceStatus,
107 pub invoice_type: InvoiceType,
109 pub invoice_date: DateTime<Utc>,
111 pub due_date: DateTime<Utc>,
113 pub payment_terms: Option<String>,
115 pub currency: CurrencyCode,
117
118 pub billing_name: Option<String>,
121 pub billing_email: Option<String>,
123 pub billing_address: Option<String>,
125 pub billing_city: Option<String>,
127 pub billing_state: Option<String>,
129 pub billing_postal_code: Option<String>,
131 pub billing_country: Option<String>,
133
134 pub subtotal: Decimal,
137 pub discount_amount: Decimal,
139 pub discount_percent: Option<Decimal>,
141 pub tax_amount: Decimal,
143 pub tax_rate: Option<Decimal>,
145 pub shipping_amount: Decimal,
147 pub total: Decimal,
149 pub amount_paid: Decimal,
151 pub balance_due: Decimal,
153
154 pub po_number: Option<String>,
156 pub notes: Option<String>,
158 pub terms: Option<String>,
160 pub footer: Option<String>,
162
163 pub sent_at: Option<DateTime<Utc>>,
165 pub viewed_at: Option<DateTime<Utc>>,
167 pub paid_at: Option<DateTime<Utc>>,
169 pub voided_at: Option<DateTime<Utc>>,
171
172 pub items: Vec<InvoiceItem>,
174
175 pub created_at: DateTime<Utc>,
177 pub updated_at: DateTime<Utc>,
179}
180
181impl Invoice {
182 #[must_use]
184 pub fn is_overdue(&self) -> bool {
185 if self.status == InvoiceStatus::Paid || self.status == InvoiceStatus::Voided {
186 return false;
187 }
188 Utc::now() > self.due_date
189 }
190
191 #[must_use]
193 pub fn days_until_due(&self) -> i64 {
194 (self.due_date - Utc::now()).num_days()
195 }
196
197 #[must_use]
199 pub fn calculate_balance(&self) -> Decimal {
200 self.total - self.amount_paid
201 }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct InvoiceItem {
207 pub id: Uuid,
209 pub invoice_id: InvoiceId,
211 pub order_item_id: Option<OrderItemId>,
213 pub product_id: Option<ProductId>,
215 pub sku: Option<String>,
217 pub description: String,
219 pub quantity: Decimal,
221 pub unit_of_measure: Option<String>,
223 pub unit_price: Decimal,
225 pub discount_amount: Decimal,
227 pub tax_amount: Decimal,
229 pub line_total: Decimal,
231 pub sort_order: i32,
233 pub created_at: DateTime<Utc>,
235 pub updated_at: DateTime<Utc>,
237}
238
239#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct CreateInvoice {
242 pub customer_id: CustomerId,
244 pub order_id: Option<OrderId>,
246 pub invoice_type: Option<InvoiceType>,
248 pub invoice_date: Option<DateTime<Utc>>,
250 pub due_date: Option<DateTime<Utc>>,
252 pub days_until_due: Option<i32>,
254 pub payment_terms: Option<String>,
256 pub currency: Option<CurrencyCode>,
258
259 pub billing_name: Option<String>,
262 pub billing_email: Option<String>,
264 pub billing_address: Option<String>,
266 pub billing_city: Option<String>,
268 pub billing_state: Option<String>,
270 pub billing_postal_code: Option<String>,
272 pub billing_country: Option<String>,
274
275 pub discount_amount: Option<Decimal>,
277 pub discount_percent: Option<Decimal>,
279 pub tax_amount: Option<Decimal>,
281 pub tax_rate: Option<Decimal>,
283 pub shipping_amount: Option<Decimal>,
285
286 pub po_number: Option<String>,
288 pub notes: Option<String>,
290 pub terms: Option<String>,
292 pub footer: Option<String>,
294
295 pub items: Vec<CreateInvoiceItem>,
297}
298
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301pub struct CreateInvoiceItem {
302 pub order_item_id: Option<OrderItemId>,
304 pub product_id: Option<ProductId>,
306 pub sku: Option<String>,
308 pub description: String,
310 pub quantity: Decimal,
312 pub unit_of_measure: Option<String>,
314 pub unit_price: Decimal,
316 pub discount_amount: Option<Decimal>,
318 pub tax_amount: Option<Decimal>,
320 pub sort_order: Option<i32>,
322}
323
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct UpdateInvoice {
327 pub due_date: Option<DateTime<Utc>>,
329 pub payment_terms: Option<String>,
331 pub billing_name: Option<String>,
333 pub billing_email: Option<String>,
335 pub billing_address: Option<String>,
337 pub billing_city: Option<String>,
339 pub billing_state: Option<String>,
341 pub billing_postal_code: Option<String>,
343 pub billing_country: Option<String>,
345 pub discount_amount: Option<Decimal>,
347 pub discount_percent: Option<Decimal>,
349 pub tax_amount: Option<Decimal>,
351 pub tax_rate: Option<Decimal>,
353 pub shipping_amount: Option<Decimal>,
355 pub po_number: Option<String>,
357 pub notes: Option<String>,
359 pub terms: Option<String>,
361 pub footer: Option<String>,
363}
364
365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
367pub struct RecordInvoicePayment {
368 pub amount: Decimal,
370 pub payment_id: Option<Uuid>,
372 pub payment_method: Option<String>,
374 pub reference: Option<String>,
376 pub notes: Option<String>,
378}
379
380#[derive(Debug, Clone, Default, Serialize, Deserialize)]
382pub struct InvoiceFilter {
383 pub customer_id: Option<CustomerId>,
385 pub order_id: Option<OrderId>,
387 pub status: Option<InvoiceStatus>,
389 pub invoice_type: Option<InvoiceType>,
391 pub overdue_only: Option<bool>,
393 pub from_date: Option<DateTime<Utc>>,
395 pub to_date: Option<DateTime<Utc>>,
397 pub due_from: Option<DateTime<Utc>>,
399 pub due_to: Option<DateTime<Utc>>,
401 pub min_total: Option<Decimal>,
403 pub max_total: Option<Decimal>,
405 pub min_balance: Option<Decimal>,
407 pub invoice_number: Option<String>,
409 pub limit: Option<u32>,
411 pub offset: Option<u32>,
413}
414
415#[must_use]
417pub fn generate_invoice_number() -> String {
418 let now = chrono::Utc::now();
419 let short_id = &uuid::Uuid::new_v4().simple().to_string()[..8];
420 format!("INV-{}-{short_id}", now.format("%Y%m%d%H%M%S%3f"))
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use chrono::Duration;
427 use rust_decimal_macros::dec;
428
429 fn create_test_invoice(status: InvoiceStatus, due_in_days: i64) -> Invoice {
434 let now = Utc::now();
435 Invoice {
436 id: InvoiceId::new(),
437 invoice_number: generate_invoice_number(),
438 customer_id: CustomerId::new(),
439 order_id: None,
440 status,
441 invoice_type: InvoiceType::Standard,
442 invoice_date: now,
443 due_date: now + Duration::days(due_in_days),
444 payment_terms: None,
445 currency: CurrencyCode::USD,
446 billing_name: None,
447 billing_email: None,
448 billing_address: None,
449 billing_city: None,
450 billing_state: None,
451 billing_postal_code: None,
452 billing_country: None,
453 subtotal: dec!(100.00),
454 discount_amount: Decimal::ZERO,
455 discount_percent: None,
456 tax_amount: dec!(8.00),
457 tax_rate: None,
458 shipping_amount: Decimal::ZERO,
459 total: dec!(108.00),
460 amount_paid: Decimal::ZERO,
461 balance_due: dec!(108.00),
462 po_number: None,
463 notes: None,
464 terms: None,
465 footer: None,
466 sent_at: None,
467 viewed_at: None,
468 paid_at: None,
469 voided_at: None,
470 items: vec![],
471 created_at: now,
472 updated_at: now,
473 }
474 }
475
476 #[test]
481 fn generated_invoice_numbers_include_entropy_suffix() {
482 let first = generate_invoice_number();
483 let second = generate_invoice_number();
484
485 assert!(first.starts_with("INV-"));
486 assert!(first.len() > "INV-20260101120000000".len());
487 assert_ne!(first, second);
488 }
489
490 #[test]
495 fn calculate_balance_with_no_payments_equals_total() {
496 let invoice = create_test_invoice(InvoiceStatus::Sent, 30);
497 assert_eq!(invoice.calculate_balance(), dec!(108.00));
498 }
499
500 #[test]
501 fn calculate_balance_after_partial_payment() {
502 let mut invoice = create_test_invoice(InvoiceStatus::PartiallyPaid, 30);
503 invoice.amount_paid = dec!(50.00);
504 assert_eq!(invoice.calculate_balance(), dec!(58.00));
505 }
506
507 #[test]
508 fn calculate_balance_when_fully_paid_is_zero() {
509 let mut invoice = create_test_invoice(InvoiceStatus::Paid, 30);
510 invoice.amount_paid = dec!(108.00);
511 assert_eq!(invoice.calculate_balance(), Decimal::ZERO);
512 }
513
514 #[test]
515 fn calculate_balance_on_overpayment_is_negative() {
516 let mut invoice = create_test_invoice(InvoiceStatus::Paid, 30);
517 invoice.amount_paid = dec!(120.00);
518 assert_eq!(invoice.calculate_balance(), dec!(-12.00));
519 }
520
521 #[test]
526 fn unpaid_invoice_past_due_date_is_overdue() {
527 let invoice = create_test_invoice(InvoiceStatus::Sent, -1);
528 assert!(invoice.is_overdue());
529 }
530
531 #[test]
532 fn unpaid_invoice_before_due_date_is_not_overdue() {
533 let invoice = create_test_invoice(InvoiceStatus::Sent, 30);
534 assert!(!invoice.is_overdue());
535 }
536
537 #[test]
538 fn paid_invoice_is_never_overdue() {
539 let invoice = create_test_invoice(InvoiceStatus::Paid, -90);
540 assert!(!invoice.is_overdue());
541 }
542
543 #[test]
544 fn voided_invoice_is_never_overdue() {
545 let invoice = create_test_invoice(InvoiceStatus::Voided, -90);
546 assert!(!invoice.is_overdue());
547 }
548
549 #[test]
550 fn partially_paid_invoice_past_due_is_overdue() {
551 let mut invoice = create_test_invoice(InvoiceStatus::PartiallyPaid, -5);
552 invoice.amount_paid = dec!(50.00);
553 assert!(invoice.is_overdue());
554 }
555
556 #[test]
557 fn days_until_due_is_positive_before_due_date() {
558 let invoice = create_test_invoice(InvoiceStatus::Sent, 30);
559 let days = invoice.days_until_due();
560 assert!((29..=30).contains(&days), "expected ~30 days, got {days}");
561 }
562
563 #[test]
564 fn days_until_due_is_negative_when_overdue() {
565 let invoice = create_test_invoice(InvoiceStatus::Sent, -10);
566 let days = invoice.days_until_due();
567 assert!(days <= -10, "expected <= -10 days, got {days}");
568 }
569
570 #[test]
575 fn invoice_status_round_trips_through_strings() {
576 for status in [
577 InvoiceStatus::Draft,
578 InvoiceStatus::Sent,
579 InvoiceStatus::Viewed,
580 InvoiceStatus::PartiallyPaid,
581 InvoiceStatus::Paid,
582 InvoiceStatus::Overdue,
583 InvoiceStatus::Voided,
584 InvoiceStatus::WrittenOff,
585 InvoiceStatus::Disputed,
586 ] {
587 let parsed: InvoiceStatus =
588 status.to_string().parse().expect("status should round-trip");
589 assert_eq!(parsed, status);
590 }
591 }
592
593 #[test]
594 fn invoice_status_parse_is_case_insensitive_and_rejects_unknown() {
595 assert_eq!("PAID".parse::<InvoiceStatus>(), Ok(InvoiceStatus::Paid));
596 assert_eq!("Partially_Paid".parse::<InvoiceStatus>(), Ok(InvoiceStatus::PartiallyPaid));
597 assert!("bogus".parse::<InvoiceStatus>().is_err());
598 }
599
600 #[test]
601 fn invoice_type_round_trips_through_strings() {
602 for invoice_type in [
603 InvoiceType::Standard,
604 InvoiceType::CreditMemo,
605 InvoiceType::DebitMemo,
606 InvoiceType::Proforma,
607 InvoiceType::Recurring,
608 InvoiceType::Final,
609 ] {
610 let parsed: InvoiceType =
611 invoice_type.to_string().parse().expect("type should round-trip");
612 assert_eq!(parsed, invoice_type);
613 }
614 }
615
616 #[test]
617 fn invoice_type_accepts_note_aliases() {
618 assert_eq!("credit_note".parse::<InvoiceType>(), Ok(InvoiceType::CreditMemo));
619 assert_eq!("debit_note".parse::<InvoiceType>(), Ok(InvoiceType::DebitMemo));
620 assert!("unknown".parse::<InvoiceType>().is_err());
621 }
622
623 #[test]
624 fn invoice_status_serde_round_trips_snake_case() {
625 let json = serde_json::to_string(&InvoiceStatus::PartiallyPaid).expect("serialize");
626 assert_eq!(json, "\"partially_paid\"");
627 let back: InvoiceStatus = serde_json::from_str(&json).expect("deserialize");
628 assert_eq!(back, InvoiceStatus::PartiallyPaid);
629 }
630}