akahu_client/
bank_account_number.rs

1//! New Zealand bank account number parsing and validation.
2
3use serde::{Deserialize, Serialize};
4use std::convert::TryFrom;
5use std::str::FromStr;
6
7/// Error when a bank account number is invalid
8#[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/// Represents the specific Bank/Financial Institution based on the 2-digit prefix.
13///
14/// Each bank in New Zealand uses a specific 2-digit prefix in their account numbers.
15/// This enum identifies the bank from the prefix.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[repr(u8)]
18pub enum BankPrefix {
19    /// ANZ (prefix: 01)
20    Anz = 1,
21    /// Bank of New Zealand (prefix: 02)
22    Bnz = 2,
23    /// Westpac (prefix: 03)
24    Westpac = 3,
25    /// ANZ Wise (prefix: 04)
26    AnzWise = 4,
27    /// China Construction Bank (prefix: 05)
28    ChinaConstruction = 5,
29    /// ANZ National (prefix: 06)
30    AnzNational = 6,
31    /// NAB (prefix: 08)
32    Nab = 8,
33    /// ICBC (prefix: 10)
34    Icbc = 10,
35    /// ANZ PostBank (prefix: 11)
36    AnzPostBank = 11,
37    /// ASB (prefix: 12)
38    Asb = 12,
39    /// Westpac Trust (prefix: 13)
40    WestpacTrust = 13,
41    /// Westpac Otago (prefix: 14)
42    WestpacOtago = 14,
43    /// TSB (prefix: 15)
44    Tsb = 15,
45    /// Westpac Southland (prefix: 16)
46    WestpacSouthland = 16,
47    /// Westpac Bay of Plenty (prefix: 17)
48    WestpacBop = 17,
49    /// Westpac Canterbury (prefix: 18)
50    WestpacCanterbury = 18,
51    /// Westpac Waikato (prefix: 19)
52    WestpacWaikato = 19,
53    /// Westpac Wellington (prefix: 20)
54    WestpacWellington = 20,
55    /// Westpac Westland (prefix: 21)
56    WestpacWestland = 21,
57    /// Westpac South Canterbury (prefix: 22)
58    WestpacSouthCant = 22,
59    /// Westpac Auckland (prefix: 23)
60    WestpacAuckland = 23,
61    /// ASB Partner (prefix: 24)
62    AsbPartner = 24,
63    /// ANZ Partner (prefix: 25)
64    AnzPartner = 25,
65    /// HSBC (prefix: 30)
66    Hsbc = 30,
67    /// Citibank (prefix: 31)
68    Citibank = 31,
69    /// Kiwibank (prefix: 38)
70    Kiwibank = 38,
71    /// Bank of China (prefix: 88)
72    BankOfChina = 88,
73}
74
75impl BankPrefix {
76    /// Get the 2-digit bank prefix as a string (e.g., "01" for ANZ).
77    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    /// Get the 2-digit bank prefix as bytes.
110    pub const fn as_bytes(&self) -> &'static [u8] {
111        self.as_str().as_bytes()
112    }
113
114    /// Get the common name of the bank (e.g., "ANZ", "Kiwibank").
115    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/// A validated New Zealand bank account number.
215///
216/// This type ensures the account number follows the standard NZ format: XX-XXXX-XXXXXXX-XXX
217/// where:
218/// - XX: 2-digit bank code
219/// - XXXX: 4-digit branch code
220/// - XXXXXXX: 7-digit account number
221/// - XXX: 3-digit suffix
222///
223/// The account number is always stored in formatted form with hyphens, even if provided
224/// without them during construction.
225#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226#[serde(transparent)]
227pub struct BankAccountNumber(String);
228
229impl BankAccountNumber {
230    /// Create a new bank account number with format validation.
231    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            // Safe: we've validated the string is exactly 16 ASCII digits
273            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    /// Returns the Bank Prefix enum.
296    pub fn prefix(&self) -> BankPrefix {
297        BankPrefix::from_str(self.bank_code()).expect("Invalid prefix in stored account number")
298    }
299
300    /// Returns the 2-digit bank code string (e.g., "01").
301    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    /// Returns the 4-digit branch code string (e.g., "0123").
308    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    /// Returns the 7-digit account base number string (e.g., "0012345").
315    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    /// Returns the 3-digit suffix string (e.g., "000").
322    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    /// Returns the full string representation.
329    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        // Format: XX-XXXX-XXXXXXX-XXX
384        //         01-2345-6789012-000
385        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        // Unformatted input should result in correctly formatted output and extraction
397        let raw = "3890000000000123"; // Kiwibank
398        let account = BankAccountNumber::new(raw).expect("Should be valid");
399
400        // Internal format should be 38-9000-0000000-123
401        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        // Ensure that reconstructing the string from components matches the original
414        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}