Skip to main content

use_receipt/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_money::{Money, MoneyError};
8
9/// Common receipt primitives.
10pub mod prelude {
11    pub use crate::{
12        AppliedAmount, Receipt, ReceiptError, ReceiptNumber, ReceiptStatus, ReceivedAt,
13        UnappliedAmount,
14    };
15}
16
17/// A non-empty receipt number.
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct ReceiptNumber(String);
20
21impl ReceiptNumber {
22    /// Creates a receipt number from non-empty text.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`ReceiptError::EmptyReceiptNumber`] when the trimmed input is empty.
27    pub fn new(value: impl AsRef<str>) -> Result<Self, ReceiptError> {
28        non_empty(value, ReceiptError::EmptyReceiptNumber).map(Self)
29    }
30
31    /// Returns the receipt number.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Display for ReceiptNumber {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str(self.as_str())
41    }
42}
43
44impl FromStr for ReceiptNumber {
45    type Err = ReceiptError;
46
47    fn from_str(value: &str) -> Result<Self, Self::Err> {
48        Self::new(value)
49    }
50}
51
52/// A received timestamp stored as non-empty caller-provided text.
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct ReceivedAt(String);
55
56impl ReceivedAt {
57    /// Creates a received timestamp from non-empty text.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`ReceiptError::EmptyReceivedAt`] when the trimmed input is empty.
62    pub fn new(value: impl AsRef<str>) -> Result<Self, ReceiptError> {
63        non_empty(value, ReceiptError::EmptyReceivedAt).map(Self)
64    }
65
66    /// Returns the received timestamp text.
67    #[must_use]
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73/// Receipt lifecycle status.
74#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
75pub enum ReceiptStatus {
76    /// Receipt has been recorded.
77    Received,
78    /// Receipt has been partially applied.
79    PartiallyApplied,
80    /// Receipt has been fully applied.
81    Applied,
82    /// Receipt was voided.
83    Voided,
84}
85
86/// A receipt amount applied to open items.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct AppliedAmount(Money);
89
90impl AppliedAmount {
91    /// Creates an applied amount.
92    #[must_use]
93    pub const fn new(amount: Money) -> Self {
94        Self(amount)
95    }
96
97    /// Returns the applied money amount.
98    #[must_use]
99    pub const fn amount(&self) -> &Money {
100        &self.0
101    }
102}
103
104/// A receipt amount not yet applied to open items.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct UnappliedAmount(Money);
107
108impl UnappliedAmount {
109    /// Creates an unapplied amount.
110    #[must_use]
111    pub const fn new(amount: Money) -> Self {
112        Self(amount)
113    }
114
115    /// Returns the unapplied money amount.
116    #[must_use]
117    pub const fn amount(&self) -> &Money {
118        &self.0
119    }
120}
121
122/// A receipt with applied and unapplied amounts.
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct Receipt {
125    number: ReceiptNumber,
126    received_at: ReceivedAt,
127    applied_amount: AppliedAmount,
128    unapplied_amount: UnappliedAmount,
129    status: ReceiptStatus,
130}
131
132impl Receipt {
133    /// Creates a receipt and validates that applied and unapplied amounts use the same currency.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`ReceiptError::Money`] when applied and unapplied amounts cannot be added.
138    pub fn new(
139        number: ReceiptNumber,
140        received_at: ReceivedAt,
141        applied_amount: AppliedAmount,
142        unapplied_amount: UnappliedAmount,
143    ) -> Result<Self, ReceiptError> {
144        applied_amount
145            .amount()
146            .checked_add(unapplied_amount.amount())
147            .map_err(ReceiptError::Money)?;
148
149        let status = if unapplied_amount.amount().is_zero() {
150            ReceiptStatus::Applied
151        } else if applied_amount.amount().is_zero() {
152            ReceiptStatus::Received
153        } else {
154            ReceiptStatus::PartiallyApplied
155        };
156
157        Ok(Self {
158            number,
159            received_at,
160            applied_amount,
161            unapplied_amount,
162            status,
163        })
164    }
165
166    /// Returns the receipt number.
167    #[must_use]
168    pub const fn number(&self) -> &ReceiptNumber {
169        &self.number
170    }
171
172    /// Returns the received timestamp.
173    #[must_use]
174    pub const fn received_at(&self) -> &ReceivedAt {
175        &self.received_at
176    }
177
178    /// Returns the applied amount.
179    #[must_use]
180    pub const fn applied_amount(&self) -> &AppliedAmount {
181        &self.applied_amount
182    }
183
184    /// Returns the unapplied amount.
185    #[must_use]
186    pub const fn unapplied_amount(&self) -> &UnappliedAmount {
187        &self.unapplied_amount
188    }
189
190    /// Returns the receipt status.
191    #[must_use]
192    pub const fn status(&self) -> ReceiptStatus {
193        self.status
194    }
195
196    /// Returns the total received amount.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`ReceiptError::Money`] when applied and unapplied amounts cannot be added.
201    pub fn total_received(&self) -> Result<Money, ReceiptError> {
202        self.applied_amount
203            .amount()
204            .checked_add(self.unapplied_amount.amount())
205            .map_err(ReceiptError::Money)
206    }
207}
208
209/// Errors returned by receipt primitives.
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub enum ReceiptError {
212    /// Receipt number must not be empty.
213    EmptyReceiptNumber,
214    /// Received timestamp must not be empty.
215    EmptyReceivedAt,
216    /// Money arithmetic failed.
217    Money(MoneyError),
218}
219
220impl fmt::Display for ReceiptError {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        match self {
223            Self::EmptyReceiptNumber => formatter.write_str("receipt number cannot be empty"),
224            Self::EmptyReceivedAt => formatter.write_str("received timestamp cannot be empty"),
225            Self::Money(error) => error.fmt(formatter),
226        }
227    }
228}
229
230impl Error for ReceiptError {
231    fn source(&self) -> Option<&(dyn Error + 'static)> {
232        match self {
233            Self::Money(error) => Some(error),
234            Self::EmptyReceiptNumber | Self::EmptyReceivedAt => None,
235        }
236    }
237}
238
239fn non_empty(value: impl AsRef<str>, error: ReceiptError) -> Result<String, ReceiptError> {
240    let trimmed = value.as_ref().trim();
241    if trimmed.is_empty() {
242        Err(error)
243    } else {
244        Ok(trimmed.to_string())
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use use_amount::Amount;
251    use use_currency::CurrencyCode;
252    use use_money::Money;
253
254    use super::{
255        AppliedAmount, Receipt, ReceiptError, ReceiptNumber, ReceiptStatus, ReceivedAt,
256        UnappliedAmount,
257    };
258
259    fn money(code: &str, minor_units: i128) -> Result<Money, Box<dyn std::error::Error>> {
260        Ok(Money::new(
261            Amount::from_minor_units(minor_units, 2)?,
262            CurrencyCode::new(code)?,
263        ))
264    }
265
266    #[test]
267    fn creates_applied_receipt() -> Result<(), Box<dyn std::error::Error>> {
268        let receipt = Receipt::new(
269            ReceiptNumber::new("rcpt-1001")?,
270            ReceivedAt::new("2026-06-07T10:00:00Z")?,
271            AppliedAmount::new(money("USD", 10_000)?),
272            UnappliedAmount::new(money("USD", 0)?),
273        )?;
274
275        assert_eq!(receipt.status(), ReceiptStatus::Applied);
276        assert_eq!(receipt.total_received()?.amount().minor_units(), 10_000);
277        Ok(())
278    }
279
280    #[test]
281    fn rejects_currency_mismatch() -> Result<(), Box<dyn std::error::Error>> {
282        let receipt = Receipt::new(
283            ReceiptNumber::new("rcpt-1002")?,
284            ReceivedAt::new("2026-06-07T10:00:00Z")?,
285            AppliedAmount::new(money("USD", 10_000)?),
286            UnappliedAmount::new(money("EUR", 100)?),
287        );
288
289        assert!(matches!(receipt, Err(ReceiptError::Money(_))));
290        Ok(())
291    }
292}