use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Bic(String);
impl Bic {
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[inline]
#[must_use]
pub fn institution_code(&self) -> &str {
&self.0[..4]
}
#[inline]
#[must_use]
pub fn country_code(&self) -> &str {
&self.0[4..6]
}
#[inline]
#[must_use]
pub fn location_code(&self) -> &str {
&self.0[6..8]
}
#[inline]
#[must_use]
pub fn branch_code(&self) -> Option<&str> {
if self.0.len() == 11 {
Some(&self.0[8..])
} else {
None
}
}
#[inline]
#[must_use]
pub fn is_test(&self) -> bool {
self.location_suffix() == Some(b'0')
}
#[inline]
#[must_use]
pub fn is_passive(&self) -> bool {
self.location_suffix() == Some(b'1')
}
#[inline]
fn location_suffix(&self) -> Option<u8> {
self.0.as_bytes().get(7).copied()
}
#[inline]
#[must_use]
pub fn is_primary_office(&self) -> bool {
matches!(self.branch_code(), None | Some("XXX"))
}
}
impl std::fmt::Display for Bic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Bic {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for Bic {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for Bic {
fn borrow(&self) -> &str {
&self.0
}
}
impl From<Bic> for String {
fn from(bic: Bic) -> Self {
bic.0
}
}
impl FromStr for Bic {
type Err = BicError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
validate_bic(s)
}
}
impl TryFrom<&str> for Bic {
type Error = BicError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
validate_bic(s)
}
}
impl TryFrom<String> for Bic {
type Error = BicError;
fn try_from(s: String) -> Result<Self, Self::Error> {
validate_bic(&s)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for Bic {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Bic {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
validate_bic(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum BicError {
#[error("BIC length {len} is invalid — must be 8 or 11 characters")]
InvalidLength {
len: usize,
},
#[error("BIC contains invalid character {ch:?} at position {pos}")]
InvalidCharacter {
ch: char,
pos: usize,
},
#[error("BIC country code {code:?} is not an ISO 3166-1 alpha-2 country")]
UnknownCountryCode {
code: String,
},
#[error("\"NOTPROVIDED\" is an EPC placeholder, not a valid BIC — use Option<Bic> with None")]
Placeholder,
}
static BIC_COUNTRY_CODES: [&[u8; 2]; 250] = [
b"AD", b"AE", b"AF", b"AG", b"AI", b"AL", b"AM", b"AO", b"AQ", b"AR", b"AS", b"AT", b"AU",
b"AW", b"AX", b"AZ", b"BA", b"BB", b"BD", b"BE", b"BF", b"BG", b"BH", b"BI", b"BJ", b"BL",
b"BM", b"BN", b"BO", b"BQ", b"BR", b"BS", b"BT", b"BV", b"BW", b"BY", b"BZ", b"CA", b"CC",
b"CD", b"CF", b"CG", b"CH", b"CI", b"CK", b"CL", b"CM", b"CN", b"CO", b"CR", b"CU", b"CV",
b"CW", b"CX", b"CY", b"CZ", b"DE", b"DJ", b"DK", b"DM", b"DO", b"DZ", b"EC", b"EE", b"EG",
b"EH", b"ER", b"ES", b"ET", b"FI", b"FJ", b"FK", b"FM", b"FO", b"FR", b"GA", b"GB", b"GD",
b"GE", b"GF", b"GG", b"GH", b"GI", b"GL", b"GM", b"GN", b"GP", b"GQ", b"GR", b"GS", b"GT",
b"GU", b"GW", b"GY", b"HK", b"HM", b"HN", b"HR", b"HT", b"HU", b"ID", b"IE", b"IL", b"IM",
b"IN", b"IO", b"IQ", b"IR", b"IS", b"IT", b"JE", b"JM", b"JO", b"JP", b"KE", b"KG", b"KH",
b"KI", b"KM", b"KN", b"KP", b"KR", b"KW", b"KY", b"KZ", b"LA", b"LB", b"LC", b"LI", b"LK",
b"LR", b"LS", b"LT", b"LU", b"LV", b"LY", b"MA", b"MC", b"MD", b"ME", b"MF", b"MG", b"MH",
b"MK", b"ML", b"MM", b"MN", b"MO", b"MP", b"MQ", b"MR", b"MS", b"MT", b"MU", b"MV", b"MW",
b"MX", b"MY", b"MZ", b"NA", b"NC", b"NE", b"NF", b"NG", b"NI", b"NL", b"NO", b"NP", b"NR",
b"NU", b"NZ", b"OM", b"PA", b"PE", b"PF", b"PG", b"PH", b"PK", b"PL", b"PM", b"PN", b"PR",
b"PS", b"PT", b"PW", b"PY", b"QA", b"RE", b"RO", b"RS", b"RU", b"RW", b"SA", b"SB", b"SC",
b"SD", b"SE", b"SG", b"SH", b"SI", b"SJ", b"SK", b"SL", b"SM", b"SN", b"SO", b"SR", b"SS",
b"ST", b"SV", b"SX", b"SY", b"SZ", b"TC", b"TD", b"TF", b"TG", b"TH", b"TJ", b"TK", b"TL",
b"TM", b"TN", b"TO", b"TR", b"TT", b"TV", b"TW", b"TZ", b"UA", b"UG", b"UM", b"US", b"UY",
b"UZ", b"VA", b"VC", b"VE", b"VG", b"VI", b"VN", b"VU", b"WF", b"WS", b"XK", b"YE", b"YT",
b"ZA", b"ZM", b"ZW",
];
#[must_use]
pub fn is_bic_country_code(code: &str) -> bool {
let bytes = code.as_bytes();
let [a, b] = bytes else { return false };
let key = [a.to_ascii_uppercase(), b.to_ascii_uppercase()];
BIC_COUNTRY_CODES.binary_search(&&key).is_ok()
}
#[must_use = "ignoring a validated BIC loses the result"]
pub fn validate_bic(raw: &str) -> Result<Bic, BicError> {
let upper: String = raw.to_ascii_uppercase();
if upper == "NOTPROVIDED" {
return Err(BicError::Placeholder);
}
let len = upper.chars().count();
if len != 8 && len != 11 {
return Err(BicError::InvalidLength { len });
}
for (pos, ch) in upper.chars().enumerate() {
let ok = match pos {
0..=5 => ch.is_ascii_uppercase(),
6 => ch.is_ascii_uppercase() || ('2'..='9').contains(&ch),
7 => (ch.is_ascii_uppercase() && ch != 'O') || ch.is_ascii_digit(),
_ => ch.is_ascii_alphanumeric(),
};
if !ok {
return Err(BicError::InvalidCharacter { ch, pos });
}
}
let country = &upper[4..6];
if !is_bic_country_code(country) {
return Err(BicError::UnknownCountryCode {
code: country.to_owned(),
});
}
Ok(Bic(upper))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cobadeff_8_char() {
let bic = validate_bic("COBADEFF").unwrap();
assert_eq!(bic.institution_code(), "COBA");
assert_eq!(bic.country_code(), "DE");
assert_eq!(bic.location_code(), "FF");
assert!(bic.branch_code().is_none());
}
#[test]
fn cobadeffxxx_11_char() {
let bic = validate_bic("COBADEFFXXX").unwrap();
assert_eq!(bic.branch_code(), Some("XXX"));
}
#[test]
fn lowercase_normalised() {
assert!(validate_bic("cobadeff").is_ok());
}
#[test]
fn too_short() {
assert!(matches!(
validate_bic("COBA").unwrap_err(),
BicError::InvalidLength { len: 4 }
));
}
#[test]
fn too_long() {
assert!(matches!(
validate_bic("COBADEFFXXXX").unwrap_err(),
BicError::InvalidLength { len: 12 }
));
}
#[test]
fn not_provided_is_placeholder_error() {
assert!(matches!(
validate_bic("NOTPROVIDED").unwrap_err(),
BicError::Placeholder
));
}
#[test]
fn iso_only_prefixes_are_rejected_for_sepa() {
assert!(validate_bic("WG11US335AB").is_err());
assert!(validate_bic("C0BADEFF").is_err());
}
#[test]
fn location_code_character_restrictions() {
assert!(matches!(
validate_bic("DEUTDE0B").unwrap_err(),
BicError::InvalidCharacter { ch: '0', pos: 6 }
));
assert!(matches!(
validate_bic("DEUTDE1B").unwrap_err(),
BicError::InvalidCharacter { ch: '1', pos: 6 }
));
assert!(matches!(
validate_bic("DEUTDEFO").unwrap_err(),
BicError::InvalidCharacter { ch: 'O', pos: 7 }
));
assert!(validate_bic("DEUTDEF0").is_ok());
assert!(validate_bic("DEUTDEF2").is_ok());
}
#[test]
fn the_country_code_must_be_a_real_country() {
assert!(matches!(
validate_bic("COBAZZFF").unwrap_err(),
BicError::UnknownCountryCode { .. }
));
assert!(matches!(
validate_bic("COBAQQFFXXX").unwrap_err(),
BicError::UnknownCountryCode { .. }
));
assert!(validate_bic("NCBKXKPRXXX").is_ok());
}
#[test]
fn the_country_table_agrees_with_the_iban_registry() {
for a in b'A'..=b'Z' {
for b in b'A'..=b'Z' {
let cc = String::from_utf8(vec![a, b]).unwrap();
if crate::iban::iban_bban_format(&cc).is_some() {
assert!(
is_bic_country_code(&cc),
"{cc} is an IBAN country but not a BIC country"
);
}
}
}
}
#[test]
fn country_lookup_rejects_malformed_input_without_panicking() {
for bad in ["", "D", "DEU", "Ü!", "12"] {
assert!(!is_bic_country_code(bad), "{bad:?} must not be a country");
}
assert!(is_bic_country_code("de"));
}
#[test]
fn the_country_table_is_sorted_for_binary_search() {
assert!(
BIC_COUNTRY_CODES.windows(2).all(|w| w[0] < w[1]),
"the table must stay sorted or the binary search silently misses"
);
}
#[test]
fn test_bic_is_marked_by_second_location_character() {
assert!(validate_bic("DEUTDEF0").unwrap().is_test());
assert!(!validate_bic("COBADEFF").unwrap().is_test());
}
#[test]
fn passive_participant_flag() {
assert!(validate_bic("DEUTDEF1").unwrap().is_passive());
assert!(!validate_bic("COBADEFF").unwrap().is_passive());
}
#[test]
fn primary_office_detection() {
assert!(validate_bic("COBADEFF").unwrap().is_primary_office());
assert!(validate_bic("COBADEFFXXX").unwrap().is_primary_office());
assert!(!validate_bic("COBADEFF123").unwrap().is_primary_office());
}
#[test]
fn country_code_must_be_letters() {
assert!(matches!(
validate_bic("COBA1EFF").unwrap_err(),
BicError::InvalidCharacter { ch: '1', pos: 4 }
));
}
#[test]
fn real_world_sepa_bics_are_accepted() {
for b in [
"COBADEFFXXX", "DEUTDEFF", "PBNKDEFF", "GENODEF1S04", "ABNANL2A", "BNPAFRPP", "UNCRITMM", "CRESCHZZ80A", ] {
assert!(validate_bic(b).is_ok(), "{b} must validate");
}
}
#[test]
fn sspkdehhxxx() {
assert!(validate_bic("SSPKDEHHXXX").is_ok());
}
#[test]
fn from_str() {
let bic: Bic = "COBADEFF".parse().unwrap();
assert_eq!(bic.as_str(), "COBADEFF");
}
#[test]
fn try_from_str() {
assert!(Bic::try_from("COBADEFF").is_ok());
assert!(Bic::try_from("BAD").is_err());
}
#[test]
fn into_string() {
let bic = validate_bic("COBADEFF").unwrap();
let s: String = bic.into();
assert_eq!(s, "COBADEFF");
}
#[test]
fn ord() {
let a = validate_bic("COBADEFF").unwrap();
let b = validate_bic("DEUTDEDB").unwrap();
assert!(a < b);
}
#[test]
fn deref_to_str() {
let bic = validate_bic("COBADEFF").unwrap();
assert_eq!(bic.len(), 8); }
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let bic = validate_bic("COBADEFFXXX").unwrap();
let json = serde_json::to_string(&bic).unwrap();
assert_eq!(json, r#""COBADEFFXXX""#);
let back: Bic = serde_json::from_str(&json).unwrap();
assert_eq!(back, bic);
}
#[cfg(feature = "serde")]
#[test]
fn serde_invalid_rejected() {
let result: Result<Bic, _> = serde_json::from_str(r#""NOTPROVIDED""#);
assert!(result.is_err());
}
}