1use serde::{Deserialize, Serialize};
4use std::convert::TryFrom;
5use std::str::FromStr;
6
7#[derive(Debug, Clone, thiserror::Error)]
9#[error("Invalid NZ bank account number: '{0}' (expected format: XX-XXXX-XXXXXXX-XXX)")]
10pub struct InvalidBankAccountError(pub String);
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[repr(u8)]
18pub enum BankPrefix {
19 Anz = 1,
21 Bnz = 2,
23 Westpac = 3,
25 AnzWise = 4,
27 ChinaConstruction = 5,
29 AnzNational = 6,
31 Nab = 8,
33 Icbc = 10,
35 AnzPostBank = 11,
37 Asb = 12,
39 WestpacTrust = 13,
41 WestpacOtago = 14,
43 Tsb = 15,
45 WestpacSouthland = 16,
47 WestpacBop = 17,
49 WestpacCanterbury = 18,
51 WestpacWaikato = 19,
53 WestpacWellington = 20,
55 WestpacWestland = 21,
57 WestpacSouthCant = 22,
59 WestpacAuckland = 23,
61 AsbPartner = 24,
63 AnzPartner = 25,
65 Hsbc = 30,
67 Citibank = 31,
69 Kiwibank = 38,
71 BankOfChina = 88,
73}
74
75impl BankPrefix {
76 pub const fn as_str(&self) -> &'static str {
78 match self {
79 Self::Anz => "01",
80 Self::Bnz => "02",
81 Self::Westpac => "03",
82 Self::AnzWise => "04",
83 Self::ChinaConstruction => "05",
84 Self::AnzNational => "06",
85 Self::Nab => "08",
86 Self::Icbc => "10",
87 Self::AnzPostBank => "11",
88 Self::Asb => "12",
89 Self::WestpacTrust => "13",
90 Self::WestpacOtago => "14",
91 Self::Tsb => "15",
92 Self::WestpacSouthland => "16",
93 Self::WestpacBop => "17",
94 Self::WestpacCanterbury => "18",
95 Self::WestpacWaikato => "19",
96 Self::WestpacWellington => "20",
97 Self::WestpacWestland => "21",
98 Self::WestpacSouthCant => "22",
99 Self::WestpacAuckland => "23",
100 Self::AsbPartner => "24",
101 Self::AnzPartner => "25",
102 Self::Hsbc => "30",
103 Self::Citibank => "31",
104 Self::Kiwibank => "38",
105 Self::BankOfChina => "88",
106 }
107 }
108
109 pub const fn as_bytes(&self) -> &'static [u8] {
111 self.as_str().as_bytes()
112 }
113
114 pub const fn bank_name(&self) -> &'static str {
116 match self {
117 Self::Anz
118 | Self::AnzNational
119 | Self::AnzPostBank
120 | Self::AnzWise
121 | Self::AnzPartner => "ANZ",
122 Self::Bnz | Self::Nab => "Bank of New Zealand",
123 Self::Westpac
124 | Self::WestpacTrust
125 | Self::WestpacOtago
126 | Self::WestpacSouthland
127 | Self::WestpacBop
128 | Self::WestpacCanterbury
129 | Self::WestpacWaikato
130 | Self::WestpacWellington
131 | Self::WestpacWestland
132 | Self::WestpacSouthCant
133 | Self::WestpacAuckland => "Westpac",
134 Self::Asb | Self::AsbPartner => "ASB",
135 Self::Kiwibank => "Kiwibank",
136 Self::Tsb => "TSB",
137 Self::ChinaConstruction => "China Construction Bank",
138 Self::Icbc => "ICBC",
139 Self::Hsbc => "HSBC",
140 Self::Citibank => "Citibank",
141 Self::BankOfChina => "Bank of China",
142 }
143 }
144}
145
146impl FromStr for BankPrefix {
147 type Err = ();
148 fn from_str(s: &str) -> Result<Self, Self::Err> {
149 let numeric_part = s.trim_start_matches('0');
150 if numeric_part.is_empty() {
151 return Err(());
152 }
153 let val = numeric_part.parse::<u8>().map_err(|_| ())?;
154 Self::try_from(val)
155 }
156}
157
158impl TryFrom<u8> for BankPrefix {
159 type Error = ();
160 fn try_from(value: u8) -> Result<Self, Self::Error> {
161 match value {
162 1 => Ok(Self::Anz),
163 2 => Ok(Self::Bnz),
164 3 => Ok(Self::Westpac),
165 4 => Ok(Self::AnzWise),
166 5 => Ok(Self::ChinaConstruction),
167 6 => Ok(Self::AnzNational),
168 8 => Ok(Self::Nab),
169 10 => Ok(Self::Icbc),
170 11 => Ok(Self::AnzPostBank),
171 12 => Ok(Self::Asb),
172 13 => Ok(Self::WestpacTrust),
173 14 => Ok(Self::WestpacOtago),
174 15 => Ok(Self::Tsb),
175 16 => Ok(Self::WestpacSouthland),
176 17 => Ok(Self::WestpacBop),
177 18 => Ok(Self::WestpacCanterbury),
178 19 => Ok(Self::WestpacWaikato),
179 20 => Ok(Self::WestpacWellington),
180 21 => Ok(Self::WestpacWestland),
181 22 => Ok(Self::WestpacSouthCant),
182 23 => Ok(Self::WestpacAuckland),
183 24 => Ok(Self::AsbPartner),
184 25 => Ok(Self::AnzPartner),
185 30 => Ok(Self::Hsbc),
186 31 => Ok(Self::Citibank),
187 38 => Ok(Self::Kiwibank),
188 88 => Ok(Self::BankOfChina),
189 _ => Err(()),
190 }
191 }
192}
193
194impl TryFrom<String> for BankPrefix {
195 type Error = ();
196 fn try_from(value: String) -> Result<Self, Self::Error> {
197 Self::from_str(&value)
198 }
199}
200
201impl TryFrom<&str> for BankPrefix {
202 type Error = ();
203 fn try_from(value: &str) -> Result<Self, Self::Error> {
204 Self::from_str(value)
205 }
206}
207
208impl std::fmt::Display for BankPrefix {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 write!(f, "{}", self.as_str())
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226#[serde(transparent)]
227pub struct BankAccountNumber(String);
228
229impl BankAccountNumber {
230 pub fn new<T: Into<String>>(value: T) -> Result<Self, InvalidBankAccountError> {
232 let s = value.into();
233 let validate_parts = |parts: &[&str]| -> Result<(), ()> {
234 if parts.len() != 4 {
235 return Err(());
236 }
237 let Some(bank_code) = parts.get(0) else {
238 return Err(());
239 };
240 let Some(branch) = parts.get(1) else {
241 return Err(());
242 };
243 let Some(account) = parts.get(2) else {
244 return Err(());
245 };
246 let Some(suffix) = parts.get(3) else {
247 return Err(());
248 };
249
250 if BankPrefix::from_str(bank_code).is_err() {
251 return Err(());
252 }
253 if branch.len() != 4 || account.len() != 7 || suffix.len() != 3 {
254 return Err(());
255 }
256 if !parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) {
257 return Err(());
258 }
259 Ok(())
260 };
261
262 if s.contains('-') {
263 let parts: Vec<&str> = s.split('-').collect();
264 if validate_parts(&parts).is_err() {
265 return Err(InvalidBankAccountError(s));
266 }
267 Ok(Self(s))
268 } else {
269 if s.len() != 16 || !s.chars().all(|c| c.is_ascii_digit()) {
270 return Err(InvalidBankAccountError(s));
271 }
272 let bank_code = s
274 .get(0..2)
275 .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
276 let branch = s
277 .get(2..6)
278 .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
279 let account = s
280 .get(6..13)
281 .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
282 let suffix = s
283 .get(13..16)
284 .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
285
286 let parts = vec![bank_code, branch, account, suffix];
287 if validate_parts(&parts).is_err() {
288 return Err(InvalidBankAccountError(s));
289 }
290 let formatted = format!("{}-{}-{}-{}", bank_code, branch, account, suffix);
291 Ok(Self(formatted))
292 }
293 }
294
295 pub fn prefix(&self) -> BankPrefix {
297 BankPrefix::from_str(self.bank_code()).expect("Invalid prefix in stored account number")
298 }
299
300 pub fn bank_code(&self) -> &str {
302 self.0
303 .get(0..2)
304 .expect("bank code is always present in validated account number")
305 }
306
307 pub fn branch_code(&self) -> &str {
309 self.0
310 .get(3..7)
311 .expect("branch code is always present in validated account number")
312 }
313
314 pub fn account_number(&self) -> &str {
316 self.0
317 .get(8..15)
318 .expect("account number is always present in validated account number")
319 }
320
321 pub fn suffix(&self) -> &str {
323 self.0
324 .get(16..19)
325 .expect("suffix is always present in validated account number")
326 }
327
328 pub fn as_str(&self) -> &str {
330 &self.0
331 }
332}
333
334impl FromStr for BankAccountNumber {
335 type Err = InvalidBankAccountError;
336
337 fn from_str(s: &str) -> Result<Self, Self::Err> {
338 Self::new(s)
339 }
340}
341
342impl TryFrom<String> for BankAccountNumber {
343 type Error = InvalidBankAccountError;
344
345 fn try_from(value: String) -> Result<Self, Self::Error> {
346 Self::new(value)
347 }
348}
349
350impl TryFrom<&str> for BankAccountNumber {
351 type Error = InvalidBankAccountError;
352
353 fn try_from(value: &str) -> Result<Self, Self::Error> {
354 Self::new(value)
355 }
356}
357
358impl std::fmt::Display for BankAccountNumber {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 write!(f, "{}", self.0)
361 }
362}
363
364impl AsRef<str> for BankAccountNumber {
365 fn as_ref(&self) -> &str {
366 &self.0
367 }
368}
369
370impl std::ops::Deref for BankAccountNumber {
371 type Target = str;
372 fn deref(&self) -> &Self::Target {
373 &self.0
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn test_component_extraction() {
383 let raw = "01-2345-6789012-000";
386 let account = BankAccountNumber::new(raw).expect("Should be valid");
387
388 assert_eq!(account.bank_code(), "01");
389 assert_eq!(account.branch_code(), "2345");
390 assert_eq!(account.account_number(), "6789012");
391 assert_eq!(account.suffix(), "000");
392 }
393
394 #[test]
395 fn test_component_extraction_from_unformatted() {
396 let raw = "3890000000000123"; let account = BankAccountNumber::new(raw).expect("Should be valid");
399
400 assert_eq!(account.as_str(), "38-9000-0000000-123");
402
403 assert_eq!(account.prefix(), BankPrefix::Kiwibank);
404 assert_eq!(account.bank_code(), "38");
405 assert_eq!(account.branch_code(), "9000");
406 assert_eq!(account.account_number(), "0000000");
407 assert_eq!(account.suffix(), "123");
408 }
409
410 #[test]
411 #[allow(clippy::unwrap_used, reason = "Tests are allowed to unwrap")]
412 fn test_extraction_integrity() {
413 let account = BankAccountNumber::new("12-3456-7890123-001").unwrap();
415
416 let reconstructed = format!(
417 "{}-{}-{}-{}",
418 account.bank_code(),
419 account.branch_code(),
420 account.account_number(),
421 account.suffix()
422 );
423
424 assert_eq!(account.as_str(), reconstructed);
425 }
426}