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
9pub mod prelude {
11 pub use crate::{
12 EffectiveDate, PostedDate, Transaction, TransactionDate, TransactionDirection,
13 TransactionError, TransactionId, TransactionStatus,
14 };
15}
16
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct TransactionId(String);
20
21impl TransactionId {
22 pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
28 non_empty_text(value, TransactionError::EmptyIdentifier).map(Self)
29 }
30
31 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct TransactionDate(String);
55
56impl TransactionDate {
57 pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
63 iso_date_text(value).map(Self)
64 }
65
66 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct PostedDate(String);
82
83impl PostedDate {
84 pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
90 iso_date_text(value).map(Self)
91 }
92
93 #[must_use]
95 pub fn as_str(&self) -> &str {
96 &self.0
97 }
98}
99
100#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct EffectiveDate(String);
103
104impl EffectiveDate {
105 pub fn new(value: impl AsRef<str>) -> Result<Self, TransactionError> {
111 iso_date_text(value).map(Self)
112 }
113
114 #[must_use]
116 pub fn as_str(&self) -> &str {
117 &self.0
118 }
119}
120
121#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
123pub enum TransactionStatus {
124 Pending,
126 Posted,
128 Settled,
130 Voided,
132 Reversed,
134}
135
136#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub enum TransactionDirection {
139 Inflow,
141 Outflow,
143}
144
145#[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 #[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 #[must_use]
181 pub const fn id(&self) -> &TransactionId {
182 &self.id
183 }
184
185 #[must_use]
187 pub const fn amount(&self) -> Amount {
188 self.amount
189 }
190
191 #[must_use]
193 pub const fn transaction_date(&self) -> &TransactionDate {
194 &self.date
195 }
196
197 #[must_use]
199 pub const fn posted_date(&self) -> Option<&PostedDate> {
200 self.posted_date.as_ref()
201 }
202
203 #[must_use]
205 pub const fn effective_date(&self) -> Option<&EffectiveDate> {
206 self.effective_date.as_ref()
207 }
208
209 #[must_use]
211 pub const fn status(&self) -> TransactionStatus {
212 self.status
213 }
214
215 #[must_use]
217 pub const fn direction(&self) -> TransactionDirection {
218 self.direction
219 }
220
221 #[must_use]
223 pub fn description(&self) -> Option<&str> {
224 self.description.as_deref()
225 }
226
227 #[must_use]
229 pub const fn with_status(mut self, status: TransactionStatus) -> Self {
230 self.status = status;
231 self
232 }
233
234 #[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 #[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
267pub enum TransactionError {
268 EmptyIdentifier,
270 InvalidDate,
272 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}