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 AccountHolderName, AccountNumber, AccountType, BankAccount, BankAccountError,
11 MaskedAccountNumber,
12 };
13}
14
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct AccountNumber(String);
18
19impl AccountNumber {
20 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 #[must_use]
46 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49
50 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
79pub struct MaskedAccountNumber(String);
80
81impl MaskedAccountNumber {
82 #[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
109pub enum AccountType {
110 Checking,
112 Savings,
114 MoneyMarket,
116 Loan,
118 Credit,
120 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
139pub struct AccountHolderName(String);
140
141impl AccountHolderName {
142 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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct BankAccount {
172 number: AccountNumber,
173 account_type: AccountType,
174 holder_name: AccountHolderName,
175}
176
177impl BankAccount {
178 #[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 #[must_use]
194 pub const fn number(&self) -> &AccountNumber {
195 &self.number
196 }
197
198 #[must_use]
200 pub fn masked_number(&self) -> MaskedAccountNumber {
201 self.number.masked()
202 }
203
204 #[must_use]
206 pub const fn account_type(&self) -> AccountType {
207 self.account_type
208 }
209
210 #[must_use]
212 pub const fn holder_name(&self) -> &AccountHolderName {
213 &self.holder_name
214 }
215}
216
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub enum BankAccountError {
220 EmptyAccountNumber,
222 AccountNumberTooLong,
224 InvalidAccountNumberCharacter,
226 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}