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
9pub mod prelude {
11 pub use crate::{
12 BalanceDue, DueDate, Invoice, InvoiceError, InvoiceLine, InvoiceNumber, InvoiceStatus,
13 Subtotal, Total,
14 };
15}
16
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct InvoiceNumber(String);
20
21impl InvoiceNumber {
22 pub fn new(value: impl AsRef<str>) -> Result<Self, InvoiceError> {
28 non_empty(value, InvoiceError::EmptyInvoiceNumber).map(Self)
29 }
30
31 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct DueDate(String);
55
56impl DueDate {
57 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 #[must_use]
80 pub fn as_str(&self) -> &str {
81 &self.0
82 }
83}
84
85#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
87pub enum InvoiceStatus {
88 Draft,
90 Open,
92 PartiallyPaid,
94 Paid,
96 Void,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct InvoiceLine {
103 description: String,
104 amount: Money,
105}
106
107impl InvoiceLine {
108 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 #[must_use]
122 pub fn description(&self) -> &str {
123 &self.description
124 }
125
126 #[must_use]
128 pub const fn amount(&self) -> &Money {
129 &self.amount
130 }
131}
132
133#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct Subtotal(Money);
136
137impl Subtotal {
138 #[must_use]
140 pub const fn new(amount: Money) -> Self {
141 Self(amount)
142 }
143
144 #[must_use]
146 pub const fn amount(&self) -> &Money {
147 &self.0
148 }
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct Total(Money);
154
155impl Total {
156 #[must_use]
158 pub const fn new(amount: Money) -> Self {
159 Self(amount)
160 }
161
162 #[must_use]
164 pub const fn amount(&self) -> &Money {
165 &self.0
166 }
167}
168
169#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct BalanceDue(Money);
172
173impl BalanceDue {
174 #[must_use]
176 pub const fn new(amount: Money) -> Self {
177 Self(amount)
178 }
179
180 #[must_use]
182 pub const fn amount(&self) -> &Money {
183 &self.0
184 }
185}
186
187#[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 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 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 #[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 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 #[must_use]
266 pub const fn number(&self) -> &InvoiceNumber {
267 &self.number
268 }
269
270 #[must_use]
272 pub const fn status(&self) -> InvoiceStatus {
273 self.status
274 }
275
276 #[must_use]
278 pub const fn due_date(&self) -> Option<&DueDate> {
279 self.due_date.as_ref()
280 }
281
282 #[must_use]
284 pub fn lines(&self) -> &[InvoiceLine] {
285 &self.lines
286 }
287
288 pub fn iter(&self) -> slice::Iter<'_, InvoiceLine> {
290 self.lines.iter()
291 }
292
293 #[must_use]
295 pub const fn subtotal(&self) -> &Subtotal {
296 &self.subtotal
297 }
298
299 #[must_use]
301 pub const fn total(&self) -> &Total {
302 &self.total
303 }
304
305 #[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#[derive(Clone, Debug, Eq, PartialEq)]
323pub enum InvoiceError {
324 EmptyInvoiceNumber,
326 EmptyLineDescription,
328 InvalidDueDate,
330 NoLines,
332 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}