Skip to main content

use_bank_account/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Common bank account primitives.
8pub mod prelude {
9    pub use crate::{
10        AccountHolderName, AccountNumber, AccountType, BankAccount, BankAccountError,
11        MaskedAccountNumber,
12    };
13}
14
15/// A conservatively validated bank account number.
16#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct AccountNumber(String);
18
19impl AccountNumber {
20    /// Creates an account number from 1 to 34 ASCII alphanumeric characters.
21    ///
22    /// # Errors
23    ///
24    /// Returns [`BankAccountError::EmptyAccountNumber`] for empty input,
25    /// [`BankAccountError::AccountNumberTooLong`] for values longer than 34 characters, and
26    /// [`BankAccountError::InvalidAccountNumberCharacter`] for non-alphanumeric characters.
27    pub fn new(value: impl AsRef<str>) -> Result<Self, BankAccountError> {
28        let value = value.as_ref().trim();
29        if value.is_empty() {
30            return Err(BankAccountError::EmptyAccountNumber);
31        }
32
33        if value.len() > 34 {
34            return Err(BankAccountError::AccountNumberTooLong);
35        }
36
37        if !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
38            return Err(BankAccountError::InvalidAccountNumberCharacter);
39        }
40
41        Ok(Self(value.to_string()))
42    }
43
44    /// Returns the account number.
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49
50    /// Returns a masked account number with the last four characters visible.
51    #[must_use]
52    pub fn masked(&self) -> MaskedAccountNumber {
53        MaskedAccountNumber::from_account_number(self, 4)
54    }
55}
56
57impl AsRef<str> for AccountNumber {
58    fn as_ref(&self) -> &str {
59        self.as_str()
60    }
61}
62
63impl fmt::Display for AccountNumber {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        formatter.write_str(self.as_str())
66    }
67}
68
69impl FromStr for AccountNumber {
70    type Err = BankAccountError;
71
72    fn from_str(value: &str) -> Result<Self, Self::Err> {
73        Self::new(value)
74    }
75}
76
77/// A masked bank account number intended for display.
78#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub struct MaskedAccountNumber(String);
80
81impl MaskedAccountNumber {
82    /// Masks an account number, keeping at most `visible_suffix` trailing characters visible.
83    #[must_use]
84    pub fn from_account_number(account_number: &AccountNumber, visible_suffix: usize) -> Self {
85        let value = account_number.as_str();
86        let visible = visible_suffix.min(value.len());
87        let hidden = value.len() - visible;
88        let suffix_start = value.len() - visible;
89        let mut masked = "*".repeat(hidden);
90        masked.push_str(&value[suffix_start..]);
91        Self(masked)
92    }
93
94    /// Returns the masked account number.
95    #[must_use]
96    pub fn as_str(&self) -> &str {
97        &self.0
98    }
99}
100
101impl fmt::Display for MaskedAccountNumber {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        formatter.write_str(self.as_str())
104    }
105}
106
107/// Broad account type vocabulary.
108#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
109pub enum AccountType {
110    /// Checking or current account.
111    Checking,
112    /// Savings account.
113    Savings,
114    /// Money market deposit account.
115    MoneyMarket,
116    /// Loan account.
117    Loan,
118    /// Credit account.
119    Credit,
120    /// Other account type.
121    Other,
122}
123
124impl fmt::Display for AccountType {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter.write_str(match self {
127            Self::Checking => "checking",
128            Self::Savings => "savings",
129            Self::MoneyMarket => "money-market",
130            Self::Loan => "loan",
131            Self::Credit => "credit",
132            Self::Other => "other",
133        })
134    }
135}
136
137/// A non-empty account holder name.
138#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
139pub struct AccountHolderName(String);
140
141impl AccountHolderName {
142    /// Creates an account holder name from non-empty text.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`BankAccountError::EmptyAccountHolderName`] when the trimmed input is empty.
147    pub fn new(value: impl AsRef<str>) -> Result<Self, BankAccountError> {
148        let value = value.as_ref().trim();
149        if value.is_empty() {
150            return Err(BankAccountError::EmptyAccountHolderName);
151        }
152
153        Ok(Self(value.to_string()))
154    }
155
156    /// Returns the account holder name.
157    #[must_use]
158    pub fn as_str(&self) -> &str {
159        &self.0
160    }
161}
162
163impl fmt::Display for AccountHolderName {
164    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165        formatter.write_str(self.as_str())
166    }
167}
168
169/// A bank account with a number, type, and holder name.
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct BankAccount {
172    number: AccountNumber,
173    account_type: AccountType,
174    holder_name: AccountHolderName,
175}
176
177impl BankAccount {
178    /// Creates a bank account from validated parts.
179    #[must_use]
180    pub const fn new(
181        number: AccountNumber,
182        account_type: AccountType,
183        holder_name: AccountHolderName,
184    ) -> Self {
185        Self {
186            number,
187            account_type,
188            holder_name,
189        }
190    }
191
192    /// Returns the account number.
193    #[must_use]
194    pub const fn number(&self) -> &AccountNumber {
195        &self.number
196    }
197
198    /// Returns the masked account number.
199    #[must_use]
200    pub fn masked_number(&self) -> MaskedAccountNumber {
201        self.number.masked()
202    }
203
204    /// Returns the account type.
205    #[must_use]
206    pub const fn account_type(&self) -> AccountType {
207        self.account_type
208    }
209
210    /// Returns the account holder name.
211    #[must_use]
212    pub const fn holder_name(&self) -> &AccountHolderName {
213        &self.holder_name
214    }
215}
216
217/// Errors returned by bank account primitives.
218#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub enum BankAccountError {
220    /// The account number was empty after trimming whitespace.
221    EmptyAccountNumber,
222    /// The account number was longer than 34 characters.
223    AccountNumberTooLong,
224    /// The account number contained a non-alphanumeric character.
225    InvalidAccountNumberCharacter,
226    /// The account holder name was empty after trimming whitespace.
227    EmptyAccountHolderName,
228}
229
230impl fmt::Display for BankAccountError {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::EmptyAccountNumber => formatter.write_str("account number cannot be empty"),
234            Self::AccountNumberTooLong => {
235                formatter.write_str("account number cannot exceed 34 characters")
236            },
237            Self::InvalidAccountNumberCharacter => {
238                formatter.write_str("account number must be ASCII alphanumeric")
239            },
240            Self::EmptyAccountHolderName => {
241                formatter.write_str("account holder name cannot be empty")
242            },
243        }
244    }
245}
246
247impl Error for BankAccountError {}
248
249#[cfg(test)]
250mod tests {
251    use super::{
252        AccountHolderName, AccountNumber, AccountType, BankAccount, BankAccountError,
253        MaskedAccountNumber,
254    };
255
256    #[test]
257    fn creates_bank_account_and_mask() -> Result<(), BankAccountError> {
258        let account = BankAccount::new(
259            AccountNumber::new("1234567890")?,
260            AccountType::Checking,
261            AccountHolderName::new("Example LLC")?,
262        );
263
264        assert_eq!(account.number().as_str(), "1234567890");
265        assert_eq!(account.masked_number().as_str(), "******7890");
266        assert_eq!(account.account_type(), AccountType::Checking);
267        assert_eq!(account.holder_name().as_str(), "Example LLC");
268        Ok(())
269    }
270
271    #[test]
272    fn rejects_empty_or_symbolic_account_numbers() {
273        assert_eq!(
274            AccountNumber::new(""),
275            Err(BankAccountError::EmptyAccountNumber)
276        );
277        assert_eq!(
278            AccountNumber::new("123-456"),
279            Err(BankAccountError::InvalidAccountNumberCharacter)
280        );
281    }
282
283    #[test]
284    fn supports_custom_mask_width() -> Result<(), BankAccountError> {
285        let number = AccountNumber::new("ABCD1234")?;
286        assert_eq!(
287            MaskedAccountNumber::from_account_number(&number, 2).as_str(),
288            "******34"
289        );
290        Ok(())
291    }
292}