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 common name of the bank (e.g., "ANZ", "Kiwibank").
110    pub const fn bank_name(&self) -> &'static str {
111        match self {
112            Self::Anz
113            | Self::AnzNational
114            | Self::AnzPostBank
115            | Self::AnzWise
116            | Self::AnzPartner => "ANZ",
117            Self::Bnz | Self::Nab => "Bank of New Zealand",
118            Self::Westpac
119            | Self::WestpacTrust
120            | Self::WestpacOtago
121            | Self::WestpacSouthland
122            | Self::WestpacBop
123            | Self::WestpacCanterbury
124            | Self::WestpacWaikato
125            | Self::WestpacWellington
126            | Self::WestpacWestland
127            | Self::WestpacSouthCant
128            | Self::WestpacAuckland => "Westpac",
129            Self::Asb | Self::AsbPartner => "ASB",
130            Self::Kiwibank => "Kiwibank",
131            Self::Tsb => "TSB",
132            Self::ChinaConstruction => "China Construction Bank",
133            Self::Icbc => "ICBC",
134            Self::Hsbc => "HSBC",
135            Self::Citibank => "Citibank",
136            Self::BankOfChina => "Bank of China",
137        }
138    }
139}
140
141impl FromStr for BankPrefix {
142    type Err = ();
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        let numeric_part = s.trim_start_matches('0');
145        if numeric_part.is_empty() {
146            return Err(());
147        }
148        let val = numeric_part.parse::<u8>().map_err(|_| ())?;
149        Self::try_from(val)
150    }
151}
152
153impl TryFrom<u8> for BankPrefix {
154    type Error = ();
155    fn try_from(value: u8) -> Result<Self, Self::Error> {
156        match value {
157            1 => Ok(Self::Anz),
158            2 => Ok(Self::Bnz),
159            3 => Ok(Self::Westpac),
160            4 => Ok(Self::AnzWise),
161            5 => Ok(Self::ChinaConstruction),
162            6 => Ok(Self::AnzNational),
163            8 => Ok(Self::Nab),
164            10 => Ok(Self::Icbc),
165            11 => Ok(Self::AnzPostBank),
166            12 => Ok(Self::Asb),
167            13 => Ok(Self::WestpacTrust),
168            14 => Ok(Self::WestpacOtago),
169            15 => Ok(Self::Tsb),
170            16 => Ok(Self::WestpacSouthland),
171            17 => Ok(Self::WestpacBop),
172            18 => Ok(Self::WestpacCanterbury),
173            19 => Ok(Self::WestpacWaikato),
174            20 => Ok(Self::WestpacWellington),
175            21 => Ok(Self::WestpacWestland),
176            22 => Ok(Self::WestpacSouthCant),
177            23 => Ok(Self::WestpacAuckland),
178            24 => Ok(Self::AsbPartner),
179            25 => Ok(Self::AnzPartner),
180            30 => Ok(Self::Hsbc),
181            31 => Ok(Self::Citibank),
182            38 => Ok(Self::Kiwibank),
183            88 => Ok(Self::BankOfChina),
184            _ => Err(()),
185        }
186    }
187}
188
189/// A validated New Zealand bank account number.
190///
191/// This type ensures the account number follows the standard NZ format: XX-XXXX-XXXXXXX-XXX
192/// where:
193/// - XX: 2-digit bank code
194/// - XXXX: 4-digit branch code
195/// - XXXXXXX: 7-digit account number
196/// - XXX: 3-digit suffix
197///
198/// The account number is always stored in formatted form with hyphens, even if provided
199/// without them during construction.
200#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[serde(transparent)]
202pub struct BankAccountNumber(String);
203
204impl BankAccountNumber {
205    /// Create a new bank account number with format validation.
206    pub fn new<T: Into<String>>(value: T) -> Result<Self, InvalidBankAccountError> {
207        let s = value.into();
208        let validate_parts = |parts: &[&str]| -> Result<(), ()> {
209            if parts.len() != 4 {
210                return Err(());
211            }
212            let Some(bank_code) = parts.get(0) else {
213                return Err(());
214            };
215            let Some(branch) = parts.get(1) else {
216                return Err(());
217            };
218            let Some(account) = parts.get(2) else {
219                return Err(());
220            };
221            let Some(suffix) = parts.get(3) else {
222                return Err(());
223            };
224
225            if BankPrefix::from_str(bank_code).is_err() {
226                return Err(());
227            }
228            if branch.len() != 4 || account.len() != 7 || suffix.len() != 3 {
229                return Err(());
230            }
231            if !parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) {
232                return Err(());
233            }
234            Ok(())
235        };
236
237        if s.contains('-') {
238            let parts: Vec<&str> = s.split('-').collect();
239            if validate_parts(&parts).is_err() {
240                return Err(InvalidBankAccountError(s));
241            }
242            Ok(Self(s))
243        } else {
244            if s.len() != 16 || !s.chars().all(|c| c.is_ascii_digit()) {
245                return Err(InvalidBankAccountError(s));
246            }
247            // Safe: we've validated the string is exactly 16 ASCII digits
248            let bank_code = s
249                .get(0..2)
250                .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
251            let branch = s
252                .get(2..6)
253                .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
254            let account = s
255                .get(6..13)
256                .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
257            let suffix = s
258                .get(13..16)
259                .ok_or_else(|| InvalidBankAccountError(s.clone()))?;
260
261            let parts = vec![bank_code, branch, account, suffix];
262            if validate_parts(&parts).is_err() {
263                return Err(InvalidBankAccountError(s));
264            }
265            let formatted = format!("{}-{}-{}-{}", bank_code, branch, account, suffix);
266            Ok(Self(formatted))
267        }
268    }
269
270    /// Returns the Bank Prefix enum.
271    pub fn prefix(&self) -> BankPrefix {
272        BankPrefix::from_str(self.bank_code()).expect("Invalid prefix in stored account number")
273    }
274
275    /// Returns the 2-digit bank code string (e.g., "01").
276    pub fn bank_code(&self) -> &str {
277        self.0
278            .get(0..2)
279            .expect("bank code is always present in validated account number")
280    }
281
282    /// Returns the 4-digit branch code string (e.g., "0123").
283    pub fn branch_code(&self) -> &str {
284        self.0
285            .get(3..7)
286            .expect("branch code is always present in validated account number")
287    }
288
289    /// Returns the 7-digit account base number string (e.g., "0012345").
290    pub fn account_number(&self) -> &str {
291        self.0
292            .get(8..15)
293            .expect("account number is always present in validated account number")
294    }
295
296    /// Returns the 3-digit suffix string (e.g., "000").
297    pub fn suffix(&self) -> &str {
298        self.0
299            .get(16..19)
300            .expect("suffix is always present in validated account number")
301    }
302
303    /// Returns the full string representation.
304    pub fn as_str(&self) -> &str {
305        &self.0
306    }
307}
308
309impl FromStr for BankAccountNumber {
310    type Err = InvalidBankAccountError;
311
312    fn from_str(s: &str) -> Result<Self, Self::Err> {
313        Self::new(s)
314    }
315}
316
317impl TryFrom<String> for BankAccountNumber {
318    type Error = InvalidBankAccountError;
319
320    fn try_from(value: String) -> Result<Self, Self::Error> {
321        Self::new(value)
322    }
323}
324
325impl TryFrom<&str> for BankAccountNumber {
326    type Error = InvalidBankAccountError;
327
328    fn try_from(value: &str) -> Result<Self, Self::Error> {
329        Self::new(value)
330    }
331}
332
333impl std::fmt::Display for BankAccountNumber {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        write!(f, "{}", self.0)
336    }
337}
338
339impl AsRef<str> for BankAccountNumber {
340    fn as_ref(&self) -> &str {
341        &self.0
342    }
343}
344
345impl std::ops::Deref for BankAccountNumber {
346    type Target = str;
347    fn deref(&self) -> &Self::Target {
348        &self.0
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_component_extraction() {
358        // Format: XX-XXXX-XXXXXXX-XXX
359        //         01-2345-6789012-000
360        let raw = "01-2345-6789012-000";
361        let account = BankAccountNumber::new(raw).expect("Should be valid");
362
363        assert_eq!(account.bank_code(), "01");
364        assert_eq!(account.branch_code(), "2345");
365        assert_eq!(account.account_number(), "6789012");
366        assert_eq!(account.suffix(), "000");
367    }
368
369    #[test]
370    fn test_component_extraction_from_unformatted() {
371        // Unformatted input should result in correctly formatted output and extraction
372        let raw = "3890000000000123"; // Kiwibank
373        let account = BankAccountNumber::new(raw).expect("Should be valid");
374
375        // Internal format should be 38-9000-0000000-123
376        assert_eq!(account.as_str(), "38-9000-0000000-123");
377
378        assert_eq!(account.prefix(), BankPrefix::Kiwibank);
379        assert_eq!(account.bank_code(), "38");
380        assert_eq!(account.branch_code(), "9000");
381        assert_eq!(account.account_number(), "0000000");
382        assert_eq!(account.suffix(), "123");
383    }
384
385    #[test]
386    #[allow(clippy::unwrap_used, reason = "Tests are allowed to unwrap")]
387    fn test_extraction_integrity() {
388        // Ensure that reconstructing the string from components matches the original
389        let account = BankAccountNumber::new("12-3456-7890123-001").unwrap();
390
391        let reconstructed = format!(
392            "{}-{}-{}-{}",
393            account.bank_code(),
394            account.branch_code(),
395            account.account_number(),
396            account.suffix()
397        );
398
399        assert_eq!(account.as_str(), reconstructed);
400    }
401}