Skip to main content

stateset_core/models/
invoice.rs

1//! Invoice domain models
2//!
3//! Handles invoice generation, tracking, and payment reconciliation.
4
5use 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/// Invoice status
12#[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    /// Draft - not yet sent
18    #[default]
19    Draft,
20    /// Sent to customer
21    Sent,
22    /// Viewed by customer
23    Viewed,
24    /// Partially paid
25    PartiallyPaid,
26    /// Fully paid
27    Paid,
28    /// Past due
29    Overdue,
30    /// Voided/cancelled
31    Voided,
32    /// Written off as uncollectible
33    WrittenOff,
34    /// In dispute
35    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/// Invoice type
58#[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    /// Standard invoice
64    #[default]
65    Standard,
66    /// Credit memo/note
67    CreditMemo,
68    /// Debit memo/note
69    DebitMemo,
70    /// Proforma invoice
71    Proforma,
72    /// Recurring invoice
73    Recurring,
74    /// Final invoice
75    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/// An invoice
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Invoice {
97    /// Unique ID
98    pub id: InvoiceId,
99    /// Human-readable invoice number
100    pub invoice_number: String,
101    /// Customer ID
102    pub customer_id: CustomerId,
103    /// Associated order ID (optional)
104    pub order_id: Option<OrderId>,
105    /// Invoice status
106    pub status: InvoiceStatus,
107    /// Invoice type
108    pub invoice_type: InvoiceType,
109    /// Invoice date
110    pub invoice_date: DateTime<Utc>,
111    /// Due date
112    pub due_date: DateTime<Utc>,
113    /// Payment terms description
114    pub payment_terms: Option<String>,
115    /// Currency code
116    pub currency: CurrencyCode,
117
118    // Billing information
119    /// Billing name
120    pub billing_name: Option<String>,
121    /// Billing email
122    pub billing_email: Option<String>,
123    /// Billing address
124    pub billing_address: Option<String>,
125    /// Billing city
126    pub billing_city: Option<String>,
127    /// Billing state
128    pub billing_state: Option<String>,
129    /// Billing postal code
130    pub billing_postal_code: Option<String>,
131    /// Billing country
132    pub billing_country: Option<String>,
133
134    // Amounts
135    /// Subtotal (before tax/discounts)
136    pub subtotal: Decimal,
137    /// Discount amount
138    pub discount_amount: Decimal,
139    /// Discount percentage (if applicable)
140    pub discount_percent: Option<Decimal>,
141    /// Tax amount
142    pub tax_amount: Decimal,
143    /// Tax rate (percentage)
144    pub tax_rate: Option<Decimal>,
145    /// Shipping/handling charges
146    pub shipping_amount: Decimal,
147    /// Total amount due
148    pub total: Decimal,
149    /// Amount paid
150    pub amount_paid: Decimal,
151    /// Balance due
152    pub balance_due: Decimal,
153
154    /// Purchase order reference
155    pub po_number: Option<String>,
156    /// Internal notes
157    pub notes: Option<String>,
158    /// Terms and conditions
159    pub terms: Option<String>,
160    /// Footer text
161    pub footer: Option<String>,
162
163    /// When invoice was sent
164    pub sent_at: Option<DateTime<Utc>>,
165    /// When invoice was viewed
166    pub viewed_at: Option<DateTime<Utc>>,
167    /// When invoice was paid in full
168    pub paid_at: Option<DateTime<Utc>>,
169    /// When invoice was voided
170    pub voided_at: Option<DateTime<Utc>>,
171
172    /// Line items
173    pub items: Vec<InvoiceItem>,
174
175    /// When created
176    pub created_at: DateTime<Utc>,
177    /// When last updated
178    pub updated_at: DateTime<Utc>,
179}
180
181impl Invoice {
182    /// Check if the invoice is overdue
183    #[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    /// Get days until due (negative if overdue)
192    #[must_use]
193    pub fn days_until_due(&self) -> i64 {
194        (self.due_date - Utc::now()).num_days()
195    }
196
197    /// Calculate the balance due
198    #[must_use]
199    pub fn calculate_balance(&self) -> Decimal {
200        self.total - self.amount_paid
201    }
202}
203
204/// A line item on an invoice
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct InvoiceItem {
207    /// Unique ID
208    pub id: Uuid,
209    /// Parent invoice ID
210    pub invoice_id: InvoiceId,
211    /// Associated order item ID
212    pub order_item_id: Option<OrderItemId>,
213    /// Product ID
214    pub product_id: Option<ProductId>,
215    /// SKU
216    pub sku: Option<String>,
217    /// Item description
218    pub description: String,
219    /// Quantity
220    pub quantity: Decimal,
221    /// Unit of measure
222    pub unit_of_measure: Option<String>,
223    /// Unit price
224    pub unit_price: Decimal,
225    /// Discount amount for this line
226    pub discount_amount: Decimal,
227    /// Tax amount for this line
228    pub tax_amount: Decimal,
229    /// Line total (quantity * `unit_price` - discount + tax)
230    pub line_total: Decimal,
231    /// Sort order
232    pub sort_order: i32,
233    /// When created
234    pub created_at: DateTime<Utc>,
235    /// When last updated
236    pub updated_at: DateTime<Utc>,
237}
238
239/// Input for creating an invoice
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct CreateInvoice {
242    /// Customer ID
243    pub customer_id: CustomerId,
244    /// Order ID (optional)
245    pub order_id: Option<OrderId>,
246    /// Invoice type
247    pub invoice_type: Option<InvoiceType>,
248    /// Invoice date (defaults to now)
249    pub invoice_date: Option<DateTime<Utc>>,
250    /// Due date (defaults to invoice date + payment terms)
251    pub due_date: Option<DateTime<Utc>>,
252    /// Days until due (used if `due_date` not provided)
253    pub days_until_due: Option<i32>,
254    /// Payment terms description
255    pub payment_terms: Option<String>,
256    /// Currency (defaults to USD)
257    pub currency: Option<CurrencyCode>,
258
259    // Billing info
260    /// Billing name
261    pub billing_name: Option<String>,
262    /// Billing email
263    pub billing_email: Option<String>,
264    /// Billing address
265    pub billing_address: Option<String>,
266    /// Billing city
267    pub billing_city: Option<String>,
268    /// Billing state
269    pub billing_state: Option<String>,
270    /// Billing postal code
271    pub billing_postal_code: Option<String>,
272    /// Billing country
273    pub billing_country: Option<String>,
274
275    /// Discount amount
276    pub discount_amount: Option<Decimal>,
277    /// Discount percentage
278    pub discount_percent: Option<Decimal>,
279    /// Tax amount (or calculated from items)
280    pub tax_amount: Option<Decimal>,
281    /// Tax rate
282    pub tax_rate: Option<Decimal>,
283    /// Shipping amount
284    pub shipping_amount: Option<Decimal>,
285
286    /// PO number reference
287    pub po_number: Option<String>,
288    /// Notes
289    pub notes: Option<String>,
290    /// Terms and conditions
291    pub terms: Option<String>,
292    /// Footer text
293    pub footer: Option<String>,
294
295    /// Line items
296    pub items: Vec<CreateInvoiceItem>,
297}
298
299/// Input for creating an invoice line item
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301pub struct CreateInvoiceItem {
302    /// Order item ID
303    pub order_item_id: Option<OrderItemId>,
304    /// Product ID
305    pub product_id: Option<ProductId>,
306    /// SKU
307    pub sku: Option<String>,
308    /// Description
309    pub description: String,
310    /// Quantity
311    pub quantity: Decimal,
312    /// Unit of measure
313    pub unit_of_measure: Option<String>,
314    /// Unit price
315    pub unit_price: Decimal,
316    /// Discount amount
317    pub discount_amount: Option<Decimal>,
318    /// Tax amount
319    pub tax_amount: Option<Decimal>,
320    /// Sort order
321    pub sort_order: Option<i32>,
322}
323
324/// Input for updating an invoice
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct UpdateInvoice {
327    /// Update due date
328    pub due_date: Option<DateTime<Utc>>,
329    /// Update payment terms
330    pub payment_terms: Option<String>,
331    /// Update billing name
332    pub billing_name: Option<String>,
333    /// Update billing email
334    pub billing_email: Option<String>,
335    /// Update billing address
336    pub billing_address: Option<String>,
337    /// Update billing city
338    pub billing_city: Option<String>,
339    /// Update billing state
340    pub billing_state: Option<String>,
341    /// Update billing postal code
342    pub billing_postal_code: Option<String>,
343    /// Update billing country
344    pub billing_country: Option<String>,
345    /// Update discount amount
346    pub discount_amount: Option<Decimal>,
347    /// Update discount percent
348    pub discount_percent: Option<Decimal>,
349    /// Update tax amount
350    pub tax_amount: Option<Decimal>,
351    /// Update tax rate
352    pub tax_rate: Option<Decimal>,
353    /// Update shipping amount
354    pub shipping_amount: Option<Decimal>,
355    /// Update PO number
356    pub po_number: Option<String>,
357    /// Update notes
358    pub notes: Option<String>,
359    /// Update terms
360    pub terms: Option<String>,
361    /// Update footer
362    pub footer: Option<String>,
363}
364
365/// Input for recording a payment on an invoice
366#[derive(Debug, Clone, Default, Serialize, Deserialize)]
367pub struct RecordInvoicePayment {
368    /// Amount being paid
369    pub amount: Decimal,
370    /// Payment ID (if linked to a payment record)
371    pub payment_id: Option<Uuid>,
372    /// Payment method description
373    pub payment_method: Option<String>,
374    /// Payment reference/check number
375    pub reference: Option<String>,
376    /// Notes
377    pub notes: Option<String>,
378}
379
380/// Filter for listing invoices
381#[derive(Debug, Clone, Default, Serialize, Deserialize)]
382pub struct InvoiceFilter {
383    /// Filter by customer ID
384    pub customer_id: Option<CustomerId>,
385    /// Filter by order ID
386    pub order_id: Option<OrderId>,
387    /// Filter by status
388    pub status: Option<InvoiceStatus>,
389    /// Filter by invoice type
390    pub invoice_type: Option<InvoiceType>,
391    /// Filter overdue only
392    pub overdue_only: Option<bool>,
393    /// Filter by date range start (invoice date)
394    pub from_date: Option<DateTime<Utc>>,
395    /// Filter by date range end (invoice date)
396    pub to_date: Option<DateTime<Utc>>,
397    /// Filter by due date range start
398    pub due_from: Option<DateTime<Utc>>,
399    /// Filter by due date range end
400    pub due_to: Option<DateTime<Utc>>,
401    /// Filter by minimum total
402    pub min_total: Option<Decimal>,
403    /// Filter by maximum total
404    pub max_total: Option<Decimal>,
405    /// Filter by minimum balance due
406    pub min_balance: Option<Decimal>,
407    /// Search by invoice number
408    pub invoice_number: Option<String>,
409    /// Limit results
410    pub limit: Option<u32>,
411    /// Offset for pagination
412    pub offset: Option<u32>,
413}
414
415/// Generate a unique invoice number
416#[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    // ========================================================================
430    // Test Helpers
431    // ========================================================================
432
433    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    // ========================================================================
477    // Number Generation
478    // ========================================================================
479
480    #[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    // ========================================================================
491    // Balance Math
492    // ========================================================================
493
494    #[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    // ========================================================================
522    // Overdue Logic
523    // ========================================================================
524
525    #[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    // ========================================================================
571    // Enum Round-Trips
572    // ========================================================================
573
574    #[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}