#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
use use_amount::Amount;
pub mod prelude {
pub use crate::{
EffectiveDate, PostedDate, Transaction, TransactionDate, TransactionDirection,
TransactionError, TransactionId, TransactionStatus,
};
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TransactionId(String);
impl TransactionId {
pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
non_empty_text(value, TransactionError::EmptyIdentifier).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for TransactionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for TransactionId {
type Err = TransactionError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TransactionDate(String);
impl TransactionDate {
pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
iso_date_text(value).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for TransactionDate {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PostedDate(String);
impl PostedDate {
pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
iso_date_text(value).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct EffectiveDate(String);
impl EffectiveDate {
pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
iso_date_text(value).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TransactionStatus {
Pending,
Posted,
Settled,
Voided,
Reversed,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TransactionDirection {
Inflow,
Outflow,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Transaction {
id: TransactionId,
amount: Amount,
date: TransactionDate,
posted_date: Option<PostedDate>,
effective_date: Option<EffectiveDate>,
status: TransactionStatus,
direction: TransactionDirection,
description: Option<String>,
}
impl Transaction {
#[must_use]
pub const fn new(
id: TransactionId,
amount: Amount,
transaction_date: TransactionDate,
direction: TransactionDirection,
) -> Self {
Self {
id,
amount,
date: transaction_date,
posted_date: None,
effective_date: None,
status: TransactionStatus::Pending,
direction,
description: None,
}
}
#[must_use]
pub const fn id(&self) -> &TransactionId {
&self.id
}
#[must_use]
pub const fn amount(&self) -> Amount {
self.amount
}
#[must_use]
pub const fn transaction_date(&self) -> &TransactionDate {
&self.date
}
#[must_use]
pub const fn posted_date(&self) -> Option<&PostedDate> {
self.posted_date.as_ref()
}
#[must_use]
pub const fn effective_date(&self) -> Option<&EffectiveDate> {
self.effective_date.as_ref()
}
#[must_use]
pub const fn status(&self) -> TransactionStatus {
self.status
}
#[must_use]
pub const fn direction(&self) -> TransactionDirection {
self.direction
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
#[must_use]
pub const fn with_status(mut self, status: TransactionStatus) -> Self {
self.status = status;
self
}
#[must_use]
pub fn with_posted_date(mut self, posted_date: PostedDate) -> Self {
self.posted_date = Some(posted_date);
self
}
#[must_use]
pub fn with_effective_date(mut self, effective_date: EffectiveDate) -> Self {
self.effective_date = Some(effective_date);
self
}
pub fn with_description(
mut self,
description: impl AsRef<str>,
) -> Result<Self, TransactionError> {
self.description = Some(non_empty_text(
description,
TransactionError::EmptyDescription,
)?);
Ok(self)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransactionError {
EmptyIdentifier,
InvalidDate,
EmptyDescription,
}
impl fmt::Display for TransactionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyIdentifier => formatter.write_str("transaction identifier cannot be empty"),
Self::InvalidDate => formatter.write_str("transaction date must use YYYY-MM-DD shape"),
Self::EmptyDescription => {
formatter.write_str("transaction description cannot be empty")
},
}
}
}
impl Error for TransactionError {}
fn non_empty_text(
value: impl AsRef<str>,
error: TransactionError,
) -> Result<String, TransactionError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(error)
} else {
Ok(trimmed.to_string())
}
}
fn iso_date_text(value: impl AsRef<str>) -> Result<String, TransactionError> {
let trimmed = value.as_ref().trim();
let bytes = trimmed.as_bytes();
if bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes[..4].iter().all(u8::is_ascii_digit)
&& bytes[5..7].iter().all(u8::is_ascii_digit)
&& bytes[8..].iter().all(u8::is_ascii_digit)
{
Ok(trimmed.to_string())
} else {
Err(TransactionError::InvalidDate)
}
}
#[cfg(test)]
mod tests {
use use_amount::Amount;
use super::{
EffectiveDate, PostedDate, Transaction, TransactionDate, TransactionDirection,
TransactionError, TransactionId, TransactionStatus,
};
#[test]
fn creates_transaction() -> Result<(), Box<dyn std::error::Error>> {
let transaction = Transaction::new(
TransactionId::new("txn-1001")?,
Amount::from_minor_units(12_345, 2)?,
TransactionDate::new("2026-06-07")?,
TransactionDirection::Inflow,
)
.with_status(TransactionStatus::Posted)
.with_posted_date(PostedDate::new("2026-06-08")?)
.with_effective_date(EffectiveDate::new("2026-06-07")?)
.with_description("customer payment")?;
assert_eq!(transaction.id().as_str(), "txn-1001");
assert_eq!(transaction.status(), TransactionStatus::Posted);
assert_eq!(transaction.description(), Some("customer payment"));
Ok(())
}
#[test]
fn rejects_empty_identifier_and_bad_date() {
assert_eq!(
TransactionId::new(""),
Err(TransactionError::EmptyIdentifier)
);
assert_eq!(
TransactionDate::new("06/07/2026"),
Err(TransactionError::InvalidDate)
);
}
}