#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
use use_money::{Money, MoneyError};
pub mod prelude {
pub use crate::{
AppliedAmount, Receipt, ReceiptError, ReceiptNumber, ReceiptStatus, ReceivedAt,
UnappliedAmount,
};
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ReceiptNumber(String);
impl ReceiptNumber {
pub fn new(value: impl AsRef<str>) -> Result<Self, ReceiptError> {
non_empty(value, ReceiptError::EmptyReceiptNumber).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ReceiptNumber {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for ReceiptNumber {
type Err = ReceiptError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ReceivedAt(String);
impl ReceivedAt {
pub fn new(value: impl AsRef<str>) -> Result<Self, ReceiptError> {
non_empty(value, ReceiptError::EmptyReceivedAt).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ReceiptStatus {
Received,
PartiallyApplied,
Applied,
Voided,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppliedAmount(Money);
impl AppliedAmount {
#[must_use]
pub const fn new(amount: Money) -> Self {
Self(amount)
}
#[must_use]
pub const fn amount(&self) -> &Money {
&self.0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnappliedAmount(Money);
impl UnappliedAmount {
#[must_use]
pub const fn new(amount: Money) -> Self {
Self(amount)
}
#[must_use]
pub const fn amount(&self) -> &Money {
&self.0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Receipt {
number: ReceiptNumber,
received_at: ReceivedAt,
applied_amount: AppliedAmount,
unapplied_amount: UnappliedAmount,
status: ReceiptStatus,
}
impl Receipt {
pub fn new(
number: ReceiptNumber,
received_at: ReceivedAt,
applied_amount: AppliedAmount,
unapplied_amount: UnappliedAmount,
) -> Result<Self, ReceiptError> {
applied_amount
.amount()
.checked_add(unapplied_amount.amount())
.map_err(ReceiptError::Money)?;
let status = if unapplied_amount.amount().is_zero() {
ReceiptStatus::Applied
} else if applied_amount.amount().is_zero() {
ReceiptStatus::Received
} else {
ReceiptStatus::PartiallyApplied
};
Ok(Self {
number,
received_at,
applied_amount,
unapplied_amount,
status,
})
}
#[must_use]
pub const fn number(&self) -> &ReceiptNumber {
&self.number
}
#[must_use]
pub const fn received_at(&self) -> &ReceivedAt {
&self.received_at
}
#[must_use]
pub const fn applied_amount(&self) -> &AppliedAmount {
&self.applied_amount
}
#[must_use]
pub const fn unapplied_amount(&self) -> &UnappliedAmount {
&self.unapplied_amount
}
#[must_use]
pub const fn status(&self) -> ReceiptStatus {
self.status
}
pub fn total_received(&self) -> Result<Money, ReceiptError> {
self.applied_amount
.amount()
.checked_add(self.unapplied_amount.amount())
.map_err(ReceiptError::Money)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ReceiptError {
EmptyReceiptNumber,
EmptyReceivedAt,
Money(MoneyError),
}
impl fmt::Display for ReceiptError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyReceiptNumber => formatter.write_str("receipt number cannot be empty"),
Self::EmptyReceivedAt => formatter.write_str("received timestamp cannot be empty"),
Self::Money(error) => error.fmt(formatter),
}
}
}
impl Error for ReceiptError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Money(error) => Some(error),
Self::EmptyReceiptNumber | Self::EmptyReceivedAt => None,
}
}
}
fn non_empty(value: impl AsRef<str>, error: ReceiptError) -> Result<String, ReceiptError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(error)
} else {
Ok(trimmed.to_string())
}
}
#[cfg(test)]
mod tests {
use use_amount::Amount;
use use_currency::CurrencyCode;
use use_money::Money;
use super::{
AppliedAmount, Receipt, ReceiptError, ReceiptNumber, ReceiptStatus, ReceivedAt,
UnappliedAmount,
};
fn money(code: &str, minor_units: i128) -> Result<Money, Box<dyn std::error::Error>> {
Ok(Money::new(
Amount::from_minor_units(minor_units, 2)?,
CurrencyCode::new(code)?,
))
}
#[test]
fn creates_applied_receipt() -> Result<(), Box<dyn std::error::Error>> {
let receipt = Receipt::new(
ReceiptNumber::new("rcpt-1001")?,
ReceivedAt::new("2026-06-07T10:00:00Z")?,
AppliedAmount::new(money("USD", 10_000)?),
UnappliedAmount::new(money("USD", 0)?),
)?;
assert_eq!(receipt.status(), ReceiptStatus::Applied);
assert_eq!(receipt.total_received()?.amount().minor_units(), 10_000);
Ok(())
}
#[test]
fn rejects_currency_mismatch() -> Result<(), Box<dyn std::error::Error>> {
let receipt = Receipt::new(
ReceiptNumber::new("rcpt-1002")?,
ReceivedAt::new("2026-06-07T10:00:00Z")?,
AppliedAmount::new(money("USD", 10_000)?),
UnappliedAmount::new(money("EUR", 100)?),
);
assert!(matches!(receipt, Err(ReceiptError::Money(_))));
Ok(())
}
}