bitwarden_exporters/cxf/
card.rs

1//! Credit card credential conversion
2//!
3//! Handles conversion between internal [Card] and credential exchange [CreditCardCredential].
4
5use bitwarden_vault::CardBrand;
6use chrono::Month;
7use credential_exchange_format::{Credential, CreditCardCredential, EditableFieldYearMonth};
8use num_traits::FromPrimitive;
9
10use crate::Card;
11
12impl From<Card> for Vec<Credential> {
13    fn from(value: Card) -> Self {
14        let expiry_date = match (value.exp_year, value.exp_month) {
15            (Some(year), Some(month)) => {
16                let year_parsed = year.parse().ok();
17                let numeric_month: Option<u32> = month.parse().ok();
18                let month_parsed = numeric_month.and_then(Month::from_u32);
19                match (year_parsed, month_parsed) {
20                    (Some(year), Some(month)) => {
21                        Some(EditableFieldYearMonth { year, month }.into())
22                    }
23                    _ => None,
24                }
25            }
26            _ => None,
27        };
28
29        vec![Credential::CreditCard(Box::new(CreditCardCredential {
30            number: value.number.map(|v| v.into()),
31            full_name: value.cardholder_name.map(|v| v.into()),
32            card_type: value.brand.map(|v| v.into()),
33            verification_number: value.code.map(|v| v.into()),
34            pin: None,
35            expiry_date,
36            valid_from: None,
37        }))]
38    }
39}
40
41impl From<&CreditCardCredential> for Card {
42    fn from(value: &CreditCardCredential) -> Self {
43        Card {
44            cardholder_name: value.full_name.clone().map(|v| v.into()),
45            exp_month: value
46                .expiry_date
47                .as_ref()
48                .map(|v| v.value.month.number_from_month().to_string()),
49            exp_year: value.expiry_date.as_ref().map(|v| v.value.year.to_string()),
50            code: value.verification_number.clone().map(|v| v.into()),
51            brand: value
52                .card_type
53                .as_ref()
54                .and_then(|brand| sanitize_brand(&brand.value.0)),
55            number: value.number.clone().map(|v| v.into()),
56        }
57    }
58}
59
60/// Sanitize credit card brand
61///
62/// Performs a fuzzy match on the string to find a matching brand. By converting to lowercase and
63/// removing all whitespace.
64///
65/// - For recognized brands, the brand is normalized before being converted to a string.
66/// - For unrecognized brands, `None` is returned.
67fn sanitize_brand(value: &str) -> Option<String> {
68    match value.to_lowercase().replace(" ", "").as_str() {
69        "visa" => Some(CardBrand::Visa),
70        "mastercard" => Some(CardBrand::Mastercard),
71        "amex" | "americanexpress" => Some(CardBrand::Amex),
72        "discover" => Some(CardBrand::Discover),
73        "dinersclub" => Some(CardBrand::DinersClub),
74        "jcb" => Some(CardBrand::Jcb),
75        "maestro" => Some(CardBrand::Maestro),
76        "unionpay" => Some(CardBrand::UnionPay),
77        "rupay" => Some(CardBrand::RuPay),
78        _ => None,
79    }
80    .and_then(|brand| serde_json::to_value(&brand).ok())
81    .and_then(|v| v.as_str().map(|s| s.to_string()))
82}
83
84#[cfg(test)]
85mod tests {
86    use chrono::Month;
87    use credential_exchange_format::EditableFieldYearMonth;
88
89    use super::*;
90
91    #[test]
92    fn test_sanitize_brand() {
93        assert_eq!(sanitize_brand("Visa"), Some("Visa".to_string()));
94        assert_eq!(sanitize_brand("  visa  "), Some("Visa".to_string()));
95        assert_eq!(sanitize_brand("MasterCard"), Some("Mastercard".to_string()));
96        assert_eq!(sanitize_brand("amex"), Some("Amex".to_string()));
97        assert_eq!(sanitize_brand("American Express"), Some("Amex".to_string()));
98        assert_eq!(
99            sanitize_brand("DinersClub"),
100            Some("Diners Club".to_string())
101        );
102        assert_eq!(sanitize_brand("j c b"), Some("JCB".to_string()));
103        assert_eq!(sanitize_brand("Some unknown"), None);
104    }
105
106    #[test]
107    fn test_card_to_credentials() {
108        let card = Card {
109            cardholder_name: Some("John Doe".to_string()),
110            exp_month: Some("12".to_string()),
111            exp_year: Some("2025".to_string()),
112            code: Some("123".to_string()),
113            brand: Some("Visa".to_string()),
114            number: Some("4111111111111111".to_string()),
115        };
116
117        let credentials: Vec<Credential> = card.into();
118        assert_eq!(credentials.len(), 1);
119
120        if let Credential::CreditCard(credit_card) = &credentials[0] {
121            assert_eq!(credit_card.full_name.as_ref().unwrap().value.0, "John Doe");
122            assert_eq!(
123                credit_card.expiry_date.as_ref().unwrap().value,
124                EditableFieldYearMonth {
125                    year: 2025,
126                    month: Month::December
127                }
128            );
129            assert_eq!(
130                credit_card.verification_number.as_ref().unwrap().value.0,
131                "123".to_string()
132            );
133            assert_eq!(
134                credit_card.card_type.as_ref().unwrap().value.0,
135                "Visa".to_string()
136            );
137            assert_eq!(
138                credit_card.number.as_ref().unwrap().value.0,
139                "4111111111111111"
140            );
141        } else {
142            panic!("Expected CreditCardCredential");
143        }
144    }
145
146    #[test]
147    fn test_credit_card_credential_to_card() {
148        let credit_card = CreditCardCredential {
149            number: Some("4111111111111111".to_string().into()),
150            full_name: Some("John Doe".to_string().into()),
151            card_type: Some("Visa".to_string().into()),
152            verification_number: Some("123".to_string().into()),
153            pin: None,
154            expiry_date: Some(
155                EditableFieldYearMonth {
156                    year: 2025,
157                    month: Month::December,
158                }
159                .into(),
160            ),
161            valid_from: None,
162        };
163
164        let card: Card = (&credit_card).into();
165        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
166        assert_eq!(card.exp_month, Some("12".to_string()));
167        assert_eq!(card.exp_year, Some("2025".to_string()));
168        assert_eq!(card.code, Some("123".to_string()));
169        assert_eq!(card.brand, Some("Visa".to_string()));
170        assert_eq!(card.number, Some("4111111111111111".to_string()));
171    }
172}