1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
9 pub use crate::{
10 AchAccountType, AchAddendaIndicator, AchCompanyId, AchEntry, AchEntryDirection, AchError,
11 AchIndividualId, AchStandardEntryClass, AchTraceNumber, AchTransactionCode,
12 };
13}
14
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub enum AchStandardEntryClass {
18 Ccd,
20 Ctx,
22 Ppd,
24 Tel,
26 Web,
28 Arc,
30 Boc,
32 Pop,
34 Rck,
36 Iat,
38}
39
40impl AchStandardEntryClass {
41 #[must_use]
43 pub const fn as_str(self) -> &'static str {
44 match self {
45 Self::Ccd => "CCD",
46 Self::Ctx => "CTX",
47 Self::Ppd => "PPD",
48 Self::Tel => "TEL",
49 Self::Web => "WEB",
50 Self::Arc => "ARC",
51 Self::Boc => "BOC",
52 Self::Pop => "POP",
53 Self::Rck => "RCK",
54 Self::Iat => "IAT",
55 }
56 }
57
58 pub fn from_code(value: impl AsRef<str>) -> Result<Self, AchError> {
65 let value = value.as_ref().trim();
66 if value.eq_ignore_ascii_case("CCD") {
67 Ok(Self::Ccd)
68 } else if value.eq_ignore_ascii_case("CTX") {
69 Ok(Self::Ctx)
70 } else if value.eq_ignore_ascii_case("PPD") {
71 Ok(Self::Ppd)
72 } else if value.eq_ignore_ascii_case("TEL") {
73 Ok(Self::Tel)
74 } else if value.eq_ignore_ascii_case("WEB") {
75 Ok(Self::Web)
76 } else if value.eq_ignore_ascii_case("ARC") {
77 Ok(Self::Arc)
78 } else if value.eq_ignore_ascii_case("BOC") {
79 Ok(Self::Boc)
80 } else if value.eq_ignore_ascii_case("POP") {
81 Ok(Self::Pop)
82 } else if value.eq_ignore_ascii_case("RCK") {
83 Ok(Self::Rck)
84 } else if value.eq_ignore_ascii_case("IAT") {
85 Ok(Self::Iat)
86 } else {
87 Err(AchError::InvalidStandardEntryClass)
88 }
89 }
90}
91
92impl fmt::Display for AchStandardEntryClass {
93 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94 formatter.write_str(self.as_str())
95 }
96}
97
98impl FromStr for AchStandardEntryClass {
99 type Err = AchError;
100
101 fn from_str(value: &str) -> Result<Self, Self::Err> {
102 Self::from_code(value)
103 }
104}
105
106#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub enum AchAccountType {
109 Checking,
111 Savings,
113 Loan,
115}
116
117impl AchAccountType {
118 #[must_use]
120 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::Checking => "checking",
123 Self::Savings => "savings",
124 Self::Loan => "loan",
125 }
126 }
127}
128
129impl fmt::Display for AchAccountType {
130 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131 formatter.write_str(self.as_str())
132 }
133}
134
135#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
137pub enum AchEntryDirection {
138 Credit,
140 Debit,
142}
143
144impl AchEntryDirection {
145 #[must_use]
147 pub const fn as_str(self) -> &'static str {
148 match self {
149 Self::Credit => "credit",
150 Self::Debit => "debit",
151 }
152 }
153}
154
155impl fmt::Display for AchEntryDirection {
156 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
157 formatter.write_str(self.as_str())
158 }
159}
160
161#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
163pub enum AchTransactionCode {
164 CheckingCredit,
166 CheckingPrenoteCredit,
168 CheckingDebit,
170 CheckingPrenoteDebit,
172 SavingsCredit,
174 SavingsPrenoteCredit,
176 SavingsDebit,
178 SavingsPrenoteDebit,
180 LoanCredit,
182 LoanPrenoteCredit,
184}
185
186impl AchTransactionCode {
187 #[must_use]
189 pub const fn code(self) -> u8 {
190 match self {
191 Self::CheckingCredit => 22,
192 Self::CheckingPrenoteCredit => 23,
193 Self::CheckingDebit => 27,
194 Self::CheckingPrenoteDebit => 28,
195 Self::SavingsCredit => 32,
196 Self::SavingsPrenoteCredit => 33,
197 Self::SavingsDebit => 37,
198 Self::SavingsPrenoteDebit => 38,
199 Self::LoanCredit => 52,
200 Self::LoanPrenoteCredit => 53,
201 }
202 }
203
204 pub const fn from_code(code: u8) -> Result<Self, AchError> {
211 match code {
212 22 => Ok(Self::CheckingCredit),
213 23 => Ok(Self::CheckingPrenoteCredit),
214 27 => Ok(Self::CheckingDebit),
215 28 => Ok(Self::CheckingPrenoteDebit),
216 32 => Ok(Self::SavingsCredit),
217 33 => Ok(Self::SavingsPrenoteCredit),
218 37 => Ok(Self::SavingsDebit),
219 38 => Ok(Self::SavingsPrenoteDebit),
220 52 => Ok(Self::LoanCredit),
221 53 => Ok(Self::LoanPrenoteCredit),
222 _ => Err(AchError::InvalidTransactionCode),
223 }
224 }
225
226 #[must_use]
228 pub const fn account_type(self) -> AchAccountType {
229 match self {
230 Self::CheckingCredit
231 | Self::CheckingPrenoteCredit
232 | Self::CheckingDebit
233 | Self::CheckingPrenoteDebit => AchAccountType::Checking,
234 Self::SavingsCredit
235 | Self::SavingsPrenoteCredit
236 | Self::SavingsDebit
237 | Self::SavingsPrenoteDebit => AchAccountType::Savings,
238 Self::LoanCredit | Self::LoanPrenoteCredit => AchAccountType::Loan,
239 }
240 }
241
242 #[must_use]
244 pub const fn direction(self) -> AchEntryDirection {
245 match self {
246 Self::CheckingCredit
247 | Self::CheckingPrenoteCredit
248 | Self::SavingsCredit
249 | Self::SavingsPrenoteCredit
250 | Self::LoanCredit
251 | Self::LoanPrenoteCredit => AchEntryDirection::Credit,
252 Self::CheckingDebit
253 | Self::CheckingPrenoteDebit
254 | Self::SavingsDebit
255 | Self::SavingsPrenoteDebit => AchEntryDirection::Debit,
256 }
257 }
258
259 #[must_use]
261 pub const fn is_prenote(self) -> bool {
262 matches!(
263 self,
264 Self::CheckingPrenoteCredit
265 | Self::CheckingPrenoteDebit
266 | Self::SavingsPrenoteCredit
267 | Self::SavingsPrenoteDebit
268 | Self::LoanPrenoteCredit
269 )
270 }
271}
272
273impl fmt::Display for AchTransactionCode {
274 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
275 write!(formatter, "{:02}", self.code())
276 }
277}
278
279impl FromStr for AchTransactionCode {
280 type Err = AchError;
281
282 fn from_str(value: &str) -> Result<Self, Self::Err> {
283 let value = value.trim();
284 let bytes = value.as_bytes();
285 if bytes.len() != 2 || !bytes.iter().all(u8::is_ascii_digit) {
286 return Err(AchError::InvalidTransactionCode);
287 }
288
289 let code = ((bytes[0] - b'0') * 10) + (bytes[1] - b'0');
290 Self::from_code(code)
291 }
292}
293
294#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
296pub enum AchAddendaIndicator {
297 NoAddenda,
299 Addenda,
301}
302
303impl AchAddendaIndicator {
304 #[must_use]
306 pub const fn as_str(self) -> &'static str {
307 match self {
308 Self::NoAddenda => "0",
309 Self::Addenda => "1",
310 }
311 }
312
313 #[must_use]
315 pub const fn has_addenda(self) -> bool {
316 matches!(self, Self::Addenda)
317 }
318
319 pub fn from_code(value: impl AsRef<str>) -> Result<Self, AchError> {
325 match value.as_ref().trim() {
326 "0" => Ok(Self::NoAddenda),
327 "1" => Ok(Self::Addenda),
328 _ => Err(AchError::InvalidAddendaIndicator),
329 }
330 }
331}
332
333impl fmt::Display for AchAddendaIndicator {
334 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
335 formatter.write_str(self.as_str())
336 }
337}
338
339impl FromStr for AchAddendaIndicator {
340 type Err = AchError;
341
342 fn from_str(value: &str) -> Result<Self, Self::Err> {
343 Self::from_code(value)
344 }
345}
346
347#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
349pub struct AchTraceNumber(String);
350
351impl AchTraceNumber {
352 pub fn new(value: impl AsRef<str>) -> Result<Self, AchError> {
359 let value = value.as_ref().trim();
360 if value.len() != 15 {
361 return Err(AchError::InvalidTraceNumberLength);
362 }
363
364 if !value.bytes().all(|byte| byte.is_ascii_digit()) {
365 return Err(AchError::InvalidTraceNumberCharacter);
366 }
367
368 Ok(Self(value.to_owned()))
369 }
370
371 #[must_use]
373 pub fn as_str(&self) -> &str {
374 &self.0
375 }
376
377 #[must_use]
379 pub fn odfi_identification(&self) -> &str {
380 &self.0[..8]
381 }
382
383 #[must_use]
385 pub fn sequence_number(&self) -> &str {
386 &self.0[8..]
387 }
388}
389
390impl AsRef<str> for AchTraceNumber {
391 fn as_ref(&self) -> &str {
392 self.as_str()
393 }
394}
395
396impl fmt::Display for AchTraceNumber {
397 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
398 formatter.write_str(self.as_str())
399 }
400}
401
402impl FromStr for AchTraceNumber {
403 type Err = AchError;
404
405 fn from_str(value: &str) -> Result<Self, Self::Err> {
406 Self::new(value)
407 }
408}
409
410#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
412pub struct AchCompanyId(String);
413
414impl AchCompanyId {
415 pub fn new(value: impl AsRef<str>) -> Result<Self, AchError> {
423 validate_identifier(
424 value.as_ref(),
425 10,
426 AchError::EmptyCompanyId,
427 AchError::CompanyIdTooLong,
428 AchError::InvalidCompanyIdCharacter,
429 )
430 .map(Self)
431 }
432
433 #[must_use]
435 pub fn as_str(&self) -> &str {
436 &self.0
437 }
438}
439
440impl AsRef<str> for AchCompanyId {
441 fn as_ref(&self) -> &str {
442 self.as_str()
443 }
444}
445
446impl fmt::Display for AchCompanyId {
447 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
448 formatter.write_str(self.as_str())
449 }
450}
451
452impl FromStr for AchCompanyId {
453 type Err = AchError;
454
455 fn from_str(value: &str) -> Result<Self, Self::Err> {
456 Self::new(value)
457 }
458}
459
460#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
462pub struct AchIndividualId(String);
463
464impl AchIndividualId {
465 pub fn new(value: impl AsRef<str>) -> Result<Self, AchError> {
473 validate_identifier(
474 value.as_ref(),
475 15,
476 AchError::EmptyIndividualId,
477 AchError::IndividualIdTooLong,
478 AchError::InvalidIndividualIdCharacter,
479 )
480 .map(Self)
481 }
482
483 #[must_use]
485 pub fn as_str(&self) -> &str {
486 &self.0
487 }
488}
489
490impl AsRef<str> for AchIndividualId {
491 fn as_ref(&self) -> &str {
492 self.as_str()
493 }
494}
495
496impl fmt::Display for AchIndividualId {
497 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
498 formatter.write_str(self.as_str())
499 }
500}
501
502impl FromStr for AchIndividualId {
503 type Err = AchError;
504
505 fn from_str(value: &str) -> Result<Self, Self::Err> {
506 Self::new(value)
507 }
508}
509
510#[derive(Clone, Debug, Eq, PartialEq)]
512pub struct AchEntry {
513 standard_entry_class: AchStandardEntryClass,
514 transaction_code: AchTransactionCode,
515 trace_number: AchTraceNumber,
516 company_id: AchCompanyId,
517 individual_id: AchIndividualId,
518 addenda_indicator: AchAddendaIndicator,
519}
520
521impl AchEntry {
522 #[must_use]
524 pub const fn new(
525 standard_entry_class: AchStandardEntryClass,
526 transaction_code: AchTransactionCode,
527 trace_number: AchTraceNumber,
528 company_id: AchCompanyId,
529 individual_id: AchIndividualId,
530 ) -> Self {
531 Self {
532 standard_entry_class,
533 transaction_code,
534 trace_number,
535 company_id,
536 individual_id,
537 addenda_indicator: AchAddendaIndicator::NoAddenda,
538 }
539 }
540
541 #[must_use]
543 pub const fn standard_entry_class(&self) -> AchStandardEntryClass {
544 self.standard_entry_class
545 }
546
547 #[must_use]
549 pub const fn transaction_code(&self) -> AchTransactionCode {
550 self.transaction_code
551 }
552
553 #[must_use]
555 pub const fn trace_number(&self) -> &AchTraceNumber {
556 &self.trace_number
557 }
558
559 #[must_use]
561 pub const fn company_id(&self) -> &AchCompanyId {
562 &self.company_id
563 }
564
565 #[must_use]
567 pub const fn individual_id(&self) -> &AchIndividualId {
568 &self.individual_id
569 }
570
571 #[must_use]
573 pub const fn addenda_indicator(&self) -> AchAddendaIndicator {
574 self.addenda_indicator
575 }
576
577 #[must_use]
579 pub const fn with_addenda_indicator(mut self, addenda_indicator: AchAddendaIndicator) -> Self {
580 self.addenda_indicator = addenda_indicator;
581 self
582 }
583}
584
585#[derive(Clone, Copy, Debug, Eq, PartialEq)]
587pub enum AchError {
588 InvalidStandardEntryClass,
590 InvalidTransactionCode,
592 InvalidAddendaIndicator,
594 InvalidTraceNumberLength,
596 InvalidTraceNumberCharacter,
598 EmptyCompanyId,
600 CompanyIdTooLong,
602 InvalidCompanyIdCharacter,
604 EmptyIndividualId,
606 IndividualIdTooLong,
608 InvalidIndividualIdCharacter,
610}
611
612impl fmt::Display for AchError {
613 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
614 match self {
615 Self::InvalidStandardEntryClass => {
616 formatter.write_str("ACH standard entry class is unsupported")
617 },
618 Self::InvalidTransactionCode => {
619 formatter.write_str("ACH transaction code is unsupported")
620 },
621 Self::InvalidAddendaIndicator => {
622 formatter.write_str("ACH addenda indicator must be 0 or 1")
623 },
624 Self::InvalidTraceNumberLength => {
625 formatter.write_str("ACH trace number must be exactly 15 digits")
626 },
627 Self::InvalidTraceNumberCharacter => {
628 formatter.write_str("ACH trace number must contain only digits")
629 },
630 Self::EmptyCompanyId => formatter.write_str("ACH company identifier cannot be empty"),
631 Self::CompanyIdTooLong => {
632 formatter.write_str("ACH company identifier cannot exceed 10 bytes")
633 },
634 Self::InvalidCompanyIdCharacter => {
635 formatter.write_str("ACH company identifier contains an unsupported character")
636 },
637 Self::EmptyIndividualId => {
638 formatter.write_str("ACH individual identifier cannot be empty")
639 },
640 Self::IndividualIdTooLong => {
641 formatter.write_str("ACH individual identifier cannot exceed 15 bytes")
642 },
643 Self::InvalidIndividualIdCharacter => {
644 formatter.write_str("ACH individual identifier contains an unsupported character")
645 },
646 }
647 }
648}
649
650impl Error for AchError {}
651
652fn validate_identifier(
653 value: &str,
654 max_len: usize,
655 empty_error: AchError,
656 too_long_error: AchError,
657 invalid_character_error: AchError,
658) -> Result<String, AchError> {
659 let value = value.trim();
660 if value.is_empty() {
661 return Err(empty_error);
662 }
663
664 if value.len() > max_len {
665 return Err(too_long_error);
666 }
667
668 if !value.bytes().all(is_identifier_byte) {
669 return Err(invalid_character_error);
670 }
671
672 Ok(value.to_owned())
673}
674
675const fn is_identifier_byte(byte: u8) -> bool {
676 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
677}
678
679#[cfg(test)]
680mod tests {
681 use core::str::FromStr;
682
683 use super::{
684 AchAccountType, AchAddendaIndicator, AchCompanyId, AchEntry, AchEntryDirection, AchError,
685 AchIndividualId, AchStandardEntryClass, AchTraceNumber, AchTransactionCode,
686 };
687
688 #[test]
689 fn parses_and_displays_standard_entry_classes() -> Result<(), AchError> {
690 assert_eq!(
691 AchStandardEntryClass::from_code("ppd")?,
692 AchStandardEntryClass::Ppd
693 );
694 assert_eq!(AchStandardEntryClass::Web.as_str(), "WEB");
695 assert_eq!(AchStandardEntryClass::Ccd.to_string(), "CCD");
696 assert_eq!(
697 AchStandardEntryClass::from_code("XYZ"),
698 Err(AchError::InvalidStandardEntryClass)
699 );
700 Ok(())
701 }
702
703 #[test]
704 fn exposes_transaction_code_behavior() -> Result<(), AchError> {
705 let credit = AchTransactionCode::from_code(22)?;
706 let debit = AchTransactionCode::from_str("38")?;
707
708 assert_eq!(credit.code(), 22);
709 assert_eq!(credit.account_type(), AchAccountType::Checking);
710 assert_eq!(credit.direction(), AchEntryDirection::Credit);
711 assert!(!credit.is_prenote());
712 assert_eq!(debit.account_type(), AchAccountType::Savings);
713 assert_eq!(debit.direction(), AchEntryDirection::Debit);
714 assert!(debit.is_prenote());
715 assert_eq!(
716 AchTransactionCode::from_code(99),
717 Err(AchError::InvalidTransactionCode)
718 );
719 Ok(())
720 }
721
722 #[test]
723 fn validates_trace_numbers() -> Result<(), AchError> {
724 let trace = AchTraceNumber::new("123456780000001")?;
725
726 assert_eq!(trace.as_str(), "123456780000001");
727 assert_eq!(trace.odfi_identification(), "12345678");
728 assert_eq!(trace.sequence_number(), "0000001");
729 assert_eq!(
730 AchTraceNumber::new("12345678000001"),
731 Err(AchError::InvalidTraceNumberLength)
732 );
733 assert_eq!(
734 AchTraceNumber::new("12345678000000A"),
735 Err(AchError::InvalidTraceNumberCharacter)
736 );
737 Ok(())
738 }
739
740 #[test]
741 fn validates_identifiers() -> Result<(), AchError> {
742 let company_id = AchCompanyId::new(" 1234567890 ")?;
743 let individual_id = AchIndividualId::new("EMPLOYEE-001")?;
744
745 assert_eq!(company_id.as_str(), "1234567890");
746 assert_eq!(individual_id.as_str(), "EMPLOYEE-001");
747 assert_eq!(AchCompanyId::new(""), Err(AchError::EmptyCompanyId));
748 assert_eq!(
749 AchCompanyId::new("12345678901"),
750 Err(AchError::CompanyIdTooLong)
751 );
752 assert_eq!(
753 AchIndividualId::new("employee 001"),
754 Err(AchError::InvalidIndividualIdCharacter)
755 );
756 Ok(())
757 }
758
759 #[test]
760 fn supports_addenda_indicator() -> Result<(), AchError> {
761 assert_eq!(
762 AchAddendaIndicator::from_code("0")?,
763 AchAddendaIndicator::NoAddenda
764 );
765 assert_eq!(
766 AchAddendaIndicator::from_code("1")?,
767 AchAddendaIndicator::Addenda
768 );
769 assert_eq!(AchAddendaIndicator::Addenda.as_str(), "1");
770 assert!(AchAddendaIndicator::Addenda.has_addenda());
771 assert_eq!(
772 AchAddendaIndicator::from_code("2"),
773 Err(AchError::InvalidAddendaIndicator)
774 );
775 Ok(())
776 }
777
778 #[test]
779 fn creates_entry_metadata() -> Result<(), AchError> {
780 let entry = AchEntry::new(
781 AchStandardEntryClass::Ppd,
782 AchTransactionCode::CheckingCredit,
783 AchTraceNumber::new("123456780000001")?,
784 AchCompanyId::new("1234567890")?,
785 AchIndividualId::new("EMPLOYEE001")?,
786 )
787 .with_addenda_indicator(AchAddendaIndicator::Addenda);
788
789 assert_eq!(entry.standard_entry_class(), AchStandardEntryClass::Ppd);
790 assert_eq!(entry.transaction_code(), AchTransactionCode::CheckingCredit);
791 assert_eq!(entry.trace_number().as_str(), "123456780000001");
792 assert_eq!(entry.company_id().as_str(), "1234567890");
793 assert_eq!(entry.individual_id().as_str(), "EMPLOYEE001");
794 assert_eq!(entry.addenda_indicator(), AchAddendaIndicator::Addenda);
795 Ok(())
796 }
797}