Skip to main content

use_transaction/
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_amount::Amount;
8
9/// Common transaction primitives.
10pub mod prelude {
11    pub use crate::{
12        EffectiveDate, PostedDate, Transaction, TransactionDate, TransactionDirection,
13        TransactionError, TransactionId, TransactionStatus,
14    };
15}
16
17/// A non-empty transaction identifier.
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct TransactionId(String);
20
21impl TransactionId {
22    /// Creates a transaction identifier from non-empty text.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`TransactionError::EmptyIdentifier`] when the trimmed input is empty.
27    pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
28        non_empty_text(value, TransactionError::EmptyIdentifier).map(Self)
29    }
30
31    /// Returns the transaction identifier.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Display for TransactionId {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str(self.as_str())
41    }
42}
43
44impl FromStr for TransactionId {
45    type Err = TransactionError;
46
47    fn from_str(value: &str) -> Result<Self, Self::Err> {
48        Self::new(value)
49    }
50}
51
52/// A transaction date in `YYYY-MM-DD` shape.
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct TransactionDate(String);
55
56impl TransactionDate {
57    /// Creates a transaction date from `YYYY-MM-DD` shaped text.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`TransactionError::InvalidDate`] when the input is not in `YYYY-MM-DD` shape.
62    pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
63        iso_date_text(value).map(Self)
64    }
65
66    /// Returns the transaction date.
67    #[must_use]
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73impl fmt::Display for TransactionDate {
74    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75        formatter.write_str(self.as_str())
76    }
77}
78
79/// A posted date in `YYYY-MM-DD` shape.
80#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct PostedDate(String);
82
83impl PostedDate {
84    /// Creates a posted date from `YYYY-MM-DD` shaped text.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`TransactionError::InvalidDate`] when the input is not in `YYYY-MM-DD` shape.
89    pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
90        iso_date_text(value).map(Self)
91    }
92
93    /// Returns the posted date.
94    #[must_use]
95    pub fn as_str(&self) -> &str {
96        &self.0
97    }
98}
99
100/// An effective date in `YYYY-MM-DD` shape.
101#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct EffectiveDate(String);
103
104impl EffectiveDate {
105    /// Creates an effective date from `YYYY-MM-DD` shaped text.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`TransactionError::InvalidDate`] when the input is not in `YYYY-MM-DD` shape.
110    pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
111        iso_date_text(value).map(Self)
112    }
113
114    /// Returns the effective date.
115    #[must_use]
116    pub fn as_str(&self) -> &str {
117        &self.0
118    }
119}
120
121/// Generic transaction status vocabulary.
122#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
123pub enum TransactionStatus {
124    /// Transaction has been recorded but not posted.
125    Pending,
126    /// Transaction has posted.
127    Posted,
128    /// Transaction has settled.
129    Settled,
130    /// Transaction was voided.
131    Voided,
132    /// Transaction was reversed.
133    Reversed,
134}
135
136/// Generic transaction direction vocabulary.
137#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub enum TransactionDirection {
139    /// Money or value moving in.
140    Inflow,
141    /// Money or value moving out.
142    Outflow,
143}
144
145/// A generic financial transaction.
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct Transaction {
148    id: TransactionId,
149    amount: Amount,
150    date: TransactionDate,
151    posted_date: Option<PostedDate>,
152    effective_date: Option<EffectiveDate>,
153    status: TransactionStatus,
154    direction: TransactionDirection,
155    description: Option<String>,
156}
157
158impl Transaction {
159    /// Creates a pending transaction from required fields.
160    #[must_use]
161    pub const fn new(
162        id: TransactionId,
163        amount: Amount,
164        transaction_date: TransactionDate,
165        direction: TransactionDirection,
166    ) -> Self {
167        Self {
168            id,
169            amount,
170            date: transaction_date,
171            posted_date: None,
172            effective_date: None,
173            status: TransactionStatus::Pending,
174            direction,
175            description: None,
176        }
177    }
178
179    /// Returns the transaction identifier.
180    #[must_use]
181    pub const fn id(&self) -> &TransactionId {
182        &self.id
183    }
184
185    /// Returns the transaction amount.
186    #[must_use]
187    pub const fn amount(&self) -> Amount {
188        self.amount
189    }
190
191    /// Returns the transaction date.
192    #[must_use]
193    pub const fn transaction_date(&self) -> &TransactionDate {
194        &self.date
195    }
196
197    /// Returns the posted date.
198    #[must_use]
199    pub const fn posted_date(&self) -> Option<&PostedDate> {
200        self.posted_date.as_ref()
201    }
202
203    /// Returns the effective date.
204    #[must_use]
205    pub const fn effective_date(&self) -> Option<&EffectiveDate> {
206        self.effective_date.as_ref()
207    }
208
209    /// Returns the transaction status.
210    #[must_use]
211    pub const fn status(&self) -> TransactionStatus {
212        self.status
213    }
214
215    /// Returns the transaction direction.
216    #[must_use]
217    pub const fn direction(&self) -> TransactionDirection {
218        self.direction
219    }
220
221    /// Returns the optional transaction description.
222    #[must_use]
223    pub fn description(&self) -> Option<&str> {
224        self.description.as_deref()
225    }
226
227    /// Sets the transaction status.
228    #[must_use]
229    pub const fn with_status(mut self, status: TransactionStatus) -> Self {
230        self.status = status;
231        self
232    }
233
234    /// Sets the posted date.
235    #[must_use]
236    pub fn with_posted_date(mut self, posted_date: PostedDate) -> Self {
237        self.posted_date = Some(posted_date);
238        self
239    }
240
241    /// Sets the effective date.
242    #[must_use]
243    pub fn with_effective_date(mut self, effective_date: EffectiveDate) -> Self {
244        self.effective_date = Some(effective_date);
245        self
246    }
247
248    /// Sets a non-empty transaction description.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`TransactionError::EmptyDescription`] when the trimmed input is empty.
253    pub fn with_description(
254        mut self,
255        description: impl AsRef<str>,
256    ) -> Result<Self, TransactionError> {
257        self.description = Some(non_empty_text(
258            description,
259            TransactionError::EmptyDescription,
260        )?);
261        Ok(self)
262    }
263}
264
265/// Errors returned by transaction primitives.
266#[derive(Clone, Copy, Debug, Eq, PartialEq)]
267pub enum TransactionError {
268    /// The identifier was empty after trimming whitespace.
269    EmptyIdentifier,
270    /// The date was not in `YYYY-MM-DD` shape.
271    InvalidDate,
272    /// The description was empty after trimming whitespace.
273    EmptyDescription,
274}
275
276impl fmt::Display for TransactionError {
277    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278        match self {
279            Self::EmptyIdentifier => formatter.write_str("transaction identifier cannot be empty"),
280            Self::InvalidDate => formatter.write_str("transaction date must use YYYY-MM-DD shape"),
281            Self::EmptyDescription => {
282                formatter.write_str("transaction description cannot be empty")
283            },
284        }
285    }
286}
287
288impl Error for TransactionError {}
289
290fn non_empty_text(
291    value: impl AsRef<str>,
292    error: TransactionError,
293) -> Result<String, TransactionError> {
294    let trimmed = value.as_ref().trim();
295    if trimmed.is_empty() {
296        Err(error)
297    } else {
298        Ok(trimmed.to_string())
299    }
300}
301
302fn iso_date_text(value: impl AsRef<str>) -> Result<String, TransactionError> {
303    let trimmed = value.as_ref().trim();
304    let bytes = trimmed.as_bytes();
305    if bytes.len() == 10
306        && bytes[4] == b'-'
307        && bytes[7] == b'-'
308        && bytes[..4].iter().all(u8::is_ascii_digit)
309        && bytes[5..7].iter().all(u8::is_ascii_digit)
310        && bytes[8..].iter().all(u8::is_ascii_digit)
311    {
312        Ok(trimmed.to_string())
313    } else {
314        Err(TransactionError::InvalidDate)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use use_amount::Amount;
321
322    use super::{
323        EffectiveDate, PostedDate, Transaction, TransactionDate, TransactionDirection,
324        TransactionError, TransactionId, TransactionStatus,
325    };
326
327    #[test]
328    fn creates_transaction() -> Result<(), Box<dyn std::error::Error>> {
329        let transaction = Transaction::new(
330            TransactionId::new("txn-1001")?,
331            Amount::from_minor_units(12_345, 2)?,
332            TransactionDate::new("2026-06-07")?,
333            TransactionDirection::Inflow,
334        )
335        .with_status(TransactionStatus::Posted)
336        .with_posted_date(PostedDate::new("2026-06-08")?)
337        .with_effective_date(EffectiveDate::new("2026-06-07")?)
338        .with_description("customer payment")?;
339
340        assert_eq!(transaction.id().as_str(), "txn-1001");
341        assert_eq!(transaction.status(), TransactionStatus::Posted);
342        assert_eq!(transaction.description(), Some("customer payment"));
343        Ok(())
344    }
345
346    #[test]
347    fn rejects_empty_identifier_and_bad_date() {
348        assert_eq!(
349            TransactionId::new(""),
350            Err(TransactionError::EmptyIdentifier)
351        );
352        assert_eq!(
353            TransactionDate::new("06/07/2026"),
354            Err(TransactionError::InvalidDate)
355        );
356    }
357}