taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

ach.rs (5535B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::{fmt::Display, str::FromStr};
     18 
     19 use compact_str::CompactString;
     20 use serde_with::{DeserializeFromStr, SerializeDisplay};
     21 
     22 use crate::types::utils::{InlineAscii, InlineStr};
     23 
     24 const ROUTING_NB_SIZE: usize = 9;
     25 const MAX_ACCOUNT_NB_SIZE: usize = 17;
     26 
     27 /// ACH routing number
     28 #[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
     29 pub struct RoutingNumber(InlineAscii<ROUTING_NB_SIZE>);
     30 
     31 impl RoutingNumber {
     32     #[inline]
     33     pub fn checksum(s: &[u8; 9]) -> u8 {
     34         // Sum raw ASCII values directly using 16-bit registers
     35         let sum0 = s[0] as u16 + s[3] as u16 + s[6] as u16;
     36         let sum1 = s[1] as u16 + s[4] as u16 + s[7] as u16;
     37         let sum2 = s[2] as u16 + s[5] as u16 + s[8] as u16;
     38 
     39         //  Fold all 9 `b'0'` (48) ASCII subtractions into a single constant offset:
     40         // (3 * 3 + 7 * 3 + 1 * 3) * 48 = 33 * 48 = 1584
     41         let total = 3 * sum0 + 7 * sum1 + sum2 - 1584;
     42         (total % 10) as u8
     43     }
     44 
     45     pub fn federal_reserve_routine_symbol(&self) -> &str {
     46         // SAFETY len = 9
     47         unsafe { self.as_ref().get_unchecked(0..4) }
     48     }
     49 
     50     pub fn financial_institution_identifier(&self) -> &str {
     51         // SAFETY len = 8
     52         unsafe { self.as_ref().get_unchecked(4..8) }
     53     }
     54 }
     55 
     56 impl AsRef<str> for RoutingNumber {
     57     fn as_ref(&self) -> &str {
     58         self.0.as_ref()
     59     }
     60 }
     61 
     62 #[derive(Debug, PartialEq, Eq, thiserror::Error)]
     63 pub enum RoutineNumberErrKind {
     64     #[error("contains illegal characters (only 0-9 allowed)")]
     65     Invalid,
     66     #[error("invalid check digit")]
     67     Checksum,
     68     #[error("bad size expected {ROUTING_NB_SIZE} chars got {0}")]
     69     Size(usize),
     70 }
     71 
     72 #[derive(Debug, thiserror::Error)]
     73 #[error("ACH routing number '{routing_number}' {kind}")]
     74 pub struct ParseRoutingNumberErr {
     75     routing_number: CompactString,
     76     pub kind: RoutineNumberErrKind,
     77 }
     78 
     79 impl FromStr for RoutingNumber {
     80     type Err = ParseRoutingNumberErr;
     81 
     82     fn from_str(s: &str) -> Result<Self, Self::Err> {
     83         let bytes: &[u8] = s.as_bytes();
     84         if let Ok(array) = <&[u8] as TryInto<[u8; ROUTING_NB_SIZE]>>::try_into(bytes) {
     85             if !array.iter().all(u8::is_ascii_digit) {
     86                 Err(RoutineNumberErrKind::Invalid)
     87             } else if Self::checksum(&array) != 0 {
     88                 Err(RoutineNumberErrKind::Checksum)
     89             } else {
     90                 Ok(Self(InlineAscii::from(array)))
     91             }
     92         } else {
     93             Err(RoutineNumberErrKind::Size(bytes.len()))
     94         }
     95         .map_err(|kind| ParseRoutingNumberErr {
     96             routing_number: s.into(),
     97             kind,
     98         })
     99     }
    100 }
    101 
    102 impl Display for RoutingNumber {
    103     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    104         Display::fmt(&self.as_ref(), f)
    105     }
    106 }
    107 
    108 impl std::fmt::Debug for RoutingNumber {
    109     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    110         Display::fmt(&self, f)
    111     }
    112 }
    113 
    114 /// ACH account number
    115 #[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
    116 pub struct AccountNumber(InlineStr<MAX_ACCOUNT_NB_SIZE>);
    117 
    118 impl AsRef<str> for AccountNumber {
    119     fn as_ref(&self) -> &str {
    120         self.0.as_ref()
    121     }
    122 }
    123 
    124 #[derive(Debug, PartialEq, Eq, thiserror::Error)]
    125 pub enum AccountNumberErrKind {
    126     #[error("contains illegal characters (only 0-9A-Z allowed)")]
    127     Invalid,
    128     #[error("bad size expected max {MAX_ACCOUNT_NB_SIZE} chars got {0}")]
    129     Size(usize),
    130 }
    131 
    132 #[derive(Debug, thiserror::Error)]
    133 #[error("ACH account number '{account_number}' {kind}")]
    134 pub struct ParseAccountNumberErr {
    135     account_number: CompactString,
    136     pub kind: AccountNumberErrKind,
    137 }
    138 impl FromStr for AccountNumber {
    139     type Err = ParseAccountNumberErr;
    140 
    141     fn from_str(s: &str) -> Result<Self, Self::Err> {
    142         let bytes: &[u8] = s.as_bytes();
    143         let len = bytes.len();
    144         if len > MAX_ACCOUNT_NB_SIZE {
    145             Err(AccountNumberErrKind::Size(len))
    146         } else if !bytes.iter().all(u8::is_ascii_alphanumeric) {
    147             Err(AccountNumberErrKind::Invalid)
    148         } else {
    149             Ok(Self(
    150                 InlineStr::try_from_iter(bytes.iter().copied().map(|b| b.to_ascii_uppercase()))
    151                     .unwrap(),
    152             ))
    153         }
    154         .map_err(|kind| ParseAccountNumberErr {
    155             account_number: s.into(),
    156             kind,
    157         })
    158     }
    159 }
    160 
    161 impl Display for AccountNumber {
    162     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    163         Display::fmt(&self.as_ref(), f)
    164     }
    165 }
    166 
    167 impl std::fmt::Debug for AccountNumber {
    168     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    169         Display::fmt(&self, f)
    170     }
    171 }
    172 
    173 #[test]
    174 fn ach() {
    175     for nb in ["111000038", "021000021"] {
    176         let parsed = RoutingNumber::from_str(nb).unwrap();
    177         assert_eq!(parsed.to_string(), nb)
    178     }
    179 }