Skip to main content

use_invoice/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::{error::Error, slice};
6
7use use_money::{Money, MoneyError};
8
9/// Common invoice primitives.
10pub mod prelude {
11    pub use crate::{
12        BalanceDue, DueDate, Invoice, InvoiceError, InvoiceLine, InvoiceNumber, InvoiceStatus,
13        Subtotal, Total,
14    };
15}
16
17/// A non-empty invoice number.
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct InvoiceNumber(String);
20
21impl InvoiceNumber {
22    /// Creates an invoice number from non-empty text.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`InvoiceError::EmptyInvoiceNumber`] when the trimmed input is empty.
27    pub fn new(value: impl AsRef<str>) -> Result<Self, InvoiceError> {
28        non_empty(value, InvoiceError::EmptyInvoiceNumber).map(Self)
29    }
30
31    /// Returns the invoice number.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Display for InvoiceNumber {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str(self.as_str())
41    }
42}
43
44impl FromStr for InvoiceNumber {
45    type Err = InvoiceError;
46
47    fn from_str(value: &str) -> Result<Self, Self::Err> {
48        Self::new(value)
49    }
50}
51
52/// A due date in `YYYY-MM-DD` shape.
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct DueDate(String);
55
56impl DueDate {
57    /// Creates a due date from `YYYY-MM-DD` shaped text.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`InvoiceError::InvalidDueDate`] when the input is not in `YYYY-MM-DD` shape.
62    pub fn new(value: impl AsRef<str>) -> Result<Self, InvoiceError> {
63        let value = value.as_ref().trim();
64        let bytes = value.as_bytes();
65        if bytes.len() == 10
66            && bytes[4] == b'-'
67            && bytes[7] == b'-'
68            && bytes[..4].iter().all(u8::is_ascii_digit)
69            && bytes[5..7].iter().all(u8::is_ascii_digit)
70            && bytes[8..].iter().all(u8::is_ascii_digit)
71        {
72            Ok(Self(value.to_string()))
73        } else {
74            Err(InvoiceError::InvalidDueDate)
75        }
76    }
77
78    /// Returns the due date.
79    #[must_use]
80    pub fn as_str(&self) -> &str {
81        &self.0
82    }
83}
84
85/// Invoice lifecycle status.
86#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
87pub enum InvoiceStatus {
88    /// Draft invoice.
89    Draft,
90    /// Open invoice.
91    Open,
92    /// Partially paid invoice.
93    PartiallyPaid,
94    /// Paid invoice.
95    Paid,
96    /// Void invoice.
97    Void,
98}
99
100/// A single invoice line.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct InvoiceLine {
103    description: String,
104    amount: Money,
105}
106
107impl InvoiceLine {
108    /// Creates an invoice line with a non-empty description.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`InvoiceError::EmptyLineDescription`] when the trimmed description is empty.
113    pub fn new(description: impl AsRef<str>, amount: Money) -> Result<Self, InvoiceError> {
114        Ok(Self {
115            description: non_empty(description, InvoiceError::EmptyLineDescription)?,
116            amount,
117        })
118    }
119
120    /// Returns the line description.
121    #[must_use]
122    pub fn description(&self) -> &str {
123        &self.description
124    }
125
126    /// Returns the line amount.
127    #[must_use]
128    pub const fn amount(&self) -> &Money {
129        &self.amount
130    }
131}
132
133/// Invoice subtotal.
134#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct Subtotal(Money);
136
137impl Subtotal {
138    /// Creates a subtotal.
139    #[must_use]
140    pub const fn new(amount: Money) -> Self {
141        Self(amount)
142    }
143
144    /// Returns the subtotal amount.
145    #[must_use]
146    pub const fn amount(&self) -> &Money {
147        &self.0
148    }
149}
150
151/// Invoice total.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct Total(Money);
154
155impl Total {
156    /// Creates a total.
157    #[must_use]
158    pub const fn new(amount: Money) -> Self {
159        Self(amount)
160    }
161
162    /// Returns the total amount.
163    #[must_use]
164    pub const fn amount(&self) -> &Money {
165        &self.0
166    }
167}
168
169/// Invoice balance due.
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct BalanceDue(Money);
172
173impl BalanceDue {
174    /// Creates a balance due.
175    #[must_use]
176    pub const fn new(amount: Money) -> Self {
177        Self(amount)
178    }
179
180    /// Returns the balance-due amount.
181    #[must_use]
182    pub const fn amount(&self) -> &Money {
183        &self.0
184    }
185}
186
187/// A general invoice with same-currency line totals.
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct Invoice {
190    number: InvoiceNumber,
191    status: InvoiceStatus,
192    due_date: Option<DueDate>,
193    lines: Vec<InvoiceLine>,
194    subtotal: Subtotal,
195    total: Total,
196    balance_due: BalanceDue,
197}
198
199impl Invoice {
200    /// Creates an open invoice from same-currency lines.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`InvoiceError::NoLines`] when no lines are supplied and [`InvoiceError::Money`]
205    /// when line totals cannot be added.
206    pub fn from_lines(
207        number: InvoiceNumber,
208        lines: Vec<InvoiceLine>,
209    ) -> Result<Self, InvoiceError> {
210        Self::new(number, InvoiceStatus::Open, None, lines)
211    }
212
213    /// Creates an invoice from same-currency lines.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`InvoiceError::NoLines`] when no lines are supplied and [`InvoiceError::Money`]
218    /// when line totals cannot be added.
219    pub fn new(
220        number: InvoiceNumber,
221        status: InvoiceStatus,
222        due_date: Option<DueDate>,
223        lines: Vec<InvoiceLine>,
224    ) -> Result<Self, InvoiceError> {
225        let subtotal = sum_lines(&lines)?;
226        Ok(Self {
227            number,
228            status,
229            due_date,
230            lines,
231            subtotal: Subtotal::new(subtotal.clone()),
232            total: Total::new(subtotal.clone()),
233            balance_due: BalanceDue::new(subtotal),
234        })
235    }
236
237    /// Returns a copy of this invoice with a due date.
238    #[must_use]
239    pub fn with_due_date(mut self, due_date: DueDate) -> Self {
240        self.due_date = Some(due_date);
241        self
242    }
243
244    /// Returns a copy of this invoice with an amount paid applied to the balance due.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`InvoiceError::Money`] when the payment currency or amount scale is incompatible.
249    pub fn with_amount_paid(mut self, amount_paid: &Money) -> Result<Self, InvoiceError> {
250        self.balance_due = BalanceDue::new(
251            self.total
252                .amount()
253                .checked_sub(amount_paid)
254                .map_err(InvoiceError::Money)?,
255        );
256        self.status = if self.balance_due.amount().is_zero() {
257            InvoiceStatus::Paid
258        } else {
259            InvoiceStatus::PartiallyPaid
260        };
261        Ok(self)
262    }
263
264    /// Returns the invoice number.
265    #[must_use]
266    pub const fn number(&self) -> &InvoiceNumber {
267        &self.number
268    }
269
270    /// Returns the invoice status.
271    #[must_use]
272    pub const fn status(&self) -> InvoiceStatus {
273        self.status
274    }
275
276    /// Returns the optional due date.
277    #[must_use]
278    pub const fn due_date(&self) -> Option<&DueDate> {
279        self.due_date.as_ref()
280    }
281
282    /// Returns the invoice lines.
283    #[must_use]
284    pub fn lines(&self) -> &[InvoiceLine] {
285        &self.lines
286    }
287
288    /// Iterates over invoice lines.
289    pub fn iter(&self) -> slice::Iter<'_, InvoiceLine> {
290        self.lines.iter()
291    }
292
293    /// Returns the subtotal.
294    #[must_use]
295    pub const fn subtotal(&self) -> &Subtotal {
296        &self.subtotal
297    }
298
299    /// Returns the total.
300    #[must_use]
301    pub const fn total(&self) -> &Total {
302        &self.total
303    }
304
305    /// Returns the balance due.
306    #[must_use]
307    pub const fn balance_due(&self) -> &BalanceDue {
308        &self.balance_due
309    }
310}
311
312impl<'a> IntoIterator for &'a Invoice {
313    type Item = &'a InvoiceLine;
314    type IntoIter = slice::Iter<'a, InvoiceLine>;
315
316    fn into_iter(self) -> Self::IntoIter {
317        self.iter()
318    }
319}
320
321/// Errors returned by invoice primitives.
322#[derive(Clone, Debug, Eq, PartialEq)]
323pub enum InvoiceError {
324    /// Invoice number must not be empty.
325    EmptyInvoiceNumber,
326    /// Line description must not be empty.
327    EmptyLineDescription,
328    /// Due date must use `YYYY-MM-DD` shape.
329    InvalidDueDate,
330    /// Invoices require at least one line.
331    NoLines,
332    /// Money arithmetic failed.
333    Money(MoneyError),
334}
335
336impl fmt::Display for InvoiceError {
337    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
338        match self {
339            Self::EmptyInvoiceNumber => formatter.write_str("invoice number cannot be empty"),
340            Self::EmptyLineDescription => {
341                formatter.write_str("invoice line description cannot be empty")
342            },
343            Self::InvalidDueDate => formatter.write_str("due date must use YYYY-MM-DD shape"),
344            Self::NoLines => formatter.write_str("invoice requires at least one line"),
345            Self::Money(error) => error.fmt(formatter),
346        }
347    }
348}
349
350impl Error for InvoiceError {
351    fn source(&self) -> Option<&(dyn Error + 'static)> {
352        match self {
353            Self::Money(error) => Some(error),
354            Self::EmptyInvoiceNumber
355            | Self::EmptyLineDescription
356            | Self::InvalidDueDate
357            | Self::NoLines => None,
358        }
359    }
360}
361
362fn non_empty(value: impl AsRef<str>, error: InvoiceError) -> Result<String, InvoiceError> {
363    let trimmed = value.as_ref().trim();
364    if trimmed.is_empty() {
365        Err(error)
366    } else {
367        Ok(trimmed.to_string())
368    }
369}
370
371fn sum_lines(lines: &[InvoiceLine]) -> Result<Money, InvoiceError> {
372    let Some(first) = lines.first() else {
373        return Err(InvoiceError::NoLines);
374    };
375
376    let mut total = first.amount().clone();
377    for line in &lines[1..] {
378        total = total
379            .checked_add(line.amount())
380            .map_err(InvoiceError::Money)?;
381    }
382    Ok(total)
383}
384
385#[cfg(test)]
386mod tests {
387    use use_amount::Amount;
388    use use_currency::CurrencyCode;
389    use use_money::Money;
390
391    use super::{DueDate, Invoice, InvoiceError, InvoiceLine, InvoiceNumber, InvoiceStatus};
392
393    fn money(code: &str, minor_units: i128) -> Result<Money, Box<dyn std::error::Error>> {
394        Ok(Money::new(
395            Amount::from_minor_units(minor_units, 2)?,
396            CurrencyCode::new(code)?,
397        ))
398    }
399
400    #[test]
401    fn totals_invoice_lines() -> Result<(), Box<dyn std::error::Error>> {
402        let invoice = Invoice::from_lines(
403            InvoiceNumber::new("inv-1001")?,
404            vec![
405                InvoiceLine::new("consulting", money("USD", 20_000)?)?,
406                InvoiceLine::new("support", money("USD", 5_000)?)?,
407            ],
408        )?
409        .with_due_date(DueDate::new("2026-07-01")?)
410        .with_amount_paid(&money("USD", 10_000)?)?;
411
412        assert_eq!(invoice.status(), InvoiceStatus::PartiallyPaid);
413        assert_eq!(invoice.total().amount().amount().minor_units(), 25_000);
414        assert_eq!(
415            invoice.balance_due().amount().amount().minor_units(),
416            15_000
417        );
418        assert_eq!(invoice.due_date().map(DueDate::as_str), Some("2026-07-01"));
419        Ok(())
420    }
421
422    #[test]
423    fn rejects_empty_lines_and_mixed_currencies() -> Result<(), Box<dyn std::error::Error>> {
424        assert_eq!(
425            Invoice::from_lines(InvoiceNumber::new("inv-empty")?, Vec::new()),
426            Err(InvoiceError::NoLines)
427        );
428
429        let invoice = Invoice::from_lines(
430            InvoiceNumber::new("inv-mixed")?,
431            vec![
432                InvoiceLine::new("usd", money("USD", 100)?)?,
433                InvoiceLine::new("eur", money("EUR", 100)?)?,
434            ],
435        );
436        assert!(matches!(invoice, Err(InvoiceError::Money(_))));
437        Ok(())
438    }
439}