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
9pub mod prelude {
11 pub use crate::{
12 AppliedAmount, Receipt, ReceiptError, ReceiptNumber, ReceiptStatus, ReceivedAt,
13 UnappliedAmount,
14 };
15}
16
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct ReceiptNumber(String);
20
21impl ReceiptNumber {
22 pub fn new(value: impl AsRef<str>) -> Result<Self, ReceiptError> {
28 non_empty(value, ReceiptError::EmptyReceiptNumber).map(Self)
29 }
30
31 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct ReceivedAt(String);
55
56impl ReceivedAt {
57 pub fn new(value: impl AsRef<str>) -> Result<Self, ReceiptError> {
63 non_empty(value, ReceiptError::EmptyReceivedAt).map(Self)
64 }
65
66 #[must_use]
68 pub fn as_str(&self) -> &str {
69 &self.0
70 }
71}
72
73#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
75pub enum ReceiptStatus {
76 Received,
78 PartiallyApplied,
80 Applied,
82 Voided,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct AppliedAmount(Money);
89
90impl AppliedAmount {
91 #[must_use]
93 pub const fn new(amount: Money) -> Self {
94 Self(amount)
95 }
96
97 #[must_use]
99 pub const fn amount(&self) -> &Money {
100 &self.0
101 }
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct UnappliedAmount(Money);
107
108impl UnappliedAmount {
109 #[must_use]
111 pub const fn new(amount: Money) -> Self {
112 Self(amount)
113 }
114
115 #[must_use]
117 pub const fn amount(&self) -> &Money {
118 &self.0
119 }
120}
121
122#[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 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 #[must_use]
168 pub const fn number(&self) -> &ReceiptNumber {
169 &self.number
170 }
171
172 #[must_use]
174 pub const fn received_at(&self) -> &ReceivedAt {
175 &self.received_at
176 }
177
178 #[must_use]
180 pub const fn applied_amount(&self) -> &AppliedAmount {
181 &self.applied_amount
182 }
183
184 #[must_use]
186 pub const fn unapplied_amount(&self) -> &UnappliedAmount {
187 &self.unapplied_amount
188 }
189
190 #[must_use]
192 pub const fn status(&self) -> ReceiptStatus {
193 self.status
194 }
195
196 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#[derive(Clone, Debug, Eq, PartialEq)]
211pub enum ReceiptError {
212 EmptyReceiptNumber,
214 EmptyReceivedAt,
216 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}