payrix 0.3.0

Rust client for the Payrix payment processing API
// Tokenization Workflow
//
// This workflow handles tokenizing credit cards and bank accounts.
//
// Key features:
// - Tokenize credit card with automatic card type detection
// - Tokenize bank account
// - Get tokens for a customer
//
// Generic API - callers control their own metadata in description field

use crate::{EntityType, PayrixClient, Result, SearchBuilder};
use serde::Serialize;

// Re-export Address from customer_management
pub use super::customer_management::Address;

/// Credit card data for tokenization
#[derive(Debug, Clone)]
pub struct CardData {
    /// Credit card number
    pub number: String,
    /// Expiration month in MM format
    pub exp_month: String,
    /// Expiration year in YYYY or YY format
    pub exp_year: String,
    /// Card verification value (CVV)
    pub cvv: String,
    /// Billing address
    pub address: Address,
    /// Description or name for this payment method
    pub name: String,
}

/// Bank account data for tokenization
#[derive(Debug, Clone)]
pub struct BankData {
    /// Bank account number
    pub account_number: String,
    /// Bank routing number
    pub routing_number: String,
    /// Account type (checking or savings)
    pub account_type: BankAccountType,
    /// Description or name for this payment method
    pub name: String,
}

/// Bank account type
#[derive(Debug, Clone, Copy)]
pub enum BankAccountType {
    /// Checking account type
    Checking,
    /// Savings account type
    Savings,
}

/// Payment method information
#[derive(Debug, Serialize)]
pub struct PaymentMethod {
    /// The Payrix payment method ID
    pub id: String,
    /// The payment token for transactions
    pub payment_token: String,
    /// The payment method type (card or bank)
    pub payment_type: PaymentType,
    /// Last 4 digits of the payment number
    pub number: String,
    /// Bank routing number (for bank accounts)
    pub routing: Option<String>,
    /// Card expiration month (for credit cards)
    pub exp_month: Option<String>,
    /// Card expiration year (for credit cards)
    pub exp_year: Option<String>,
    /// Card type (Visa, Mastercard, etc.)
    pub card_type: Option<String>,
    /// Bank account type (checking or savings)
    pub account_type: Option<String>,
}

/// Payment method type
#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PaymentType {
    /// Credit or debit card
    Card,
    /// Bank account (ACH/eCheck)
    Bank,
}

/// Tokenize a credit card
///
/// Creates a payment token for a credit card linked to a customer.
/// Card type is automatically detected from the card number.
///
/// # Arguments
/// * `client` - Payrix API client
/// * `customer_id` - Payrix customer ID
/// * `login_id` - Payrix login ID
/// * `card_data` - Credit card information
/// * `description` - Token description (caller manages their own metadata)
///
/// # Returns
/// The created payment token
pub async fn tokenize_credit_card(
    client: &PayrixClient,
    customer_id: &str,
    login_id: &str,
    card_data: &CardData,
    description: &str,
) -> Result<serde_json::Value> {
    // Detect card type from card number
    let card_type = detect_card_type(&card_data.number)?;

    // Format expiration as MMYY
    let exp_month_padded = format!("{:02}", card_data.exp_month.parse::<u32>().unwrap_or(0));
    let exp_year_short = if card_data.exp_year.len() == 4 {
        &card_data.exp_year[2..]
    } else {
        &card_data.exp_year
    };
    let expiration = format!("{}{}", exp_month_padded, exp_year_short);

    let token_data = serde_json::json!({
        "customer": customer_id,
        "payment": {
            "method": card_type,
            "number": card_data.number,
            "expiration": expiration,
            "cvv": card_data.cvv,
        },
        "name": card_data.name,
        "login": login_id,
        "description": description,
        "inactive": 0,
        "frozen": 0,
    });

    let token = client
        .create::<_, serde_json::Value>(EntityType::Tokens, &token_data)
        .await?;

    tracing::info!(
        "Tokenized credit card {}",
        token
            .get("id")
            .and_then(|i| i.as_str())
            .unwrap_or("unknown")
    );

    Ok(token)
}

/// Tokenize a bank account
///
/// Creates a payment token for a bank account linked to a customer.
///
/// # Arguments
/// * `client` - Payrix API client
/// * `customer_id` - Payrix customer ID
/// * `login_id` - Payrix login ID
/// * `bank_data` - Bank account information
/// * `description` - Token description (caller manages their own metadata)
///
/// # Returns
/// The created payment token
pub async fn tokenize_bank_account(
    client: &PayrixClient,
    customer_id: &str,
    login_id: &str,
    bank_data: &BankData,
    description: &str,
) -> Result<serde_json::Value> {
    // Map account type to Payrix method code (100 = checking, 101 = savings)
    let method = match bank_data.account_type {
        BankAccountType::Checking => 100,
        BankAccountType::Savings => 101,
    };

    let token_data = serde_json::json!({
        "customer": customer_id,
        "payment": {
            "method": method,
            "number": bank_data.account_number,
            "routing": bank_data.routing_number,
        },
        "name": bank_data.name,
        "login": login_id,
        "description": description,
        "inactive": 0,
        "frozen": 0,
    });

    let token = client
        .create::<_, serde_json::Value>(EntityType::Tokens, &token_data)
        .await?;

    tracing::info!(
        "Tokenized bank account {}",
        token
            .get("id")
            .and_then(|i| i.as_str())
            .unwrap_or("unknown")
    );

    Ok(token)
}

/// Get all tokens for a customer
///
/// # Arguments
/// * `client` - Payrix API client
/// * `customer_id` - Payrix customer ID
///
/// # Returns
/// Vector of payment tokens
pub async fn get_tokens_for_customer(
    client: &PayrixClient,
    customer_id: &str,
) -> Result<Vec<serde_json::Value>> {
    let search = SearchBuilder::new()
        .field("customer", customer_id)
        .expand("payment")
        .build();

    let tokens = client
        .search::<serde_json::Value>(EntityType::Tokens, &search)
        .await?;

    Ok(tokens)
}

/// Get a token by ID
///
/// # Arguments
/// * `client` - Payrix API client
/// * `token_id` - Payrix token ID
///
/// # Returns
/// The token if found
pub async fn get_token(
    client: &PayrixClient,
    token_id: &str,
) -> Result<Option<serde_json::Value>> {
    client
        .get_one::<serde_json::Value>(EntityType::Tokens, token_id)
        .await
}

/// Deactivate a token
///
/// # Arguments
/// * `client` - Payrix API client
/// * `token_id` - Payrix token ID
///
/// # Returns
/// The updated token
pub async fn deactivate_token(
    client: &PayrixClient,
    token_id: &str,
) -> Result<serde_json::Value> {
    let update = serde_json::json!({ "inactive": 1 });
    let token = client
        .update::<_, serde_json::Value>(EntityType::Tokens, token_id, &update)
        .await?;

    tracing::info!("Deactivated token {}", token_id);

    Ok(token)
}

/// Detect card type from card number
///
/// Returns Payrix card type code:
/// - 1 = Visa
/// - 2 = Mastercard
/// - 3 = American Express
/// - 4 = Discover
/// - 6 = JCB
fn detect_card_type(card_number: &str) -> Result<i32> {
    let first_digit = card_number.chars().next().unwrap_or('0');
    let first_two: i32 = card_number[..2.min(card_number.len())]
        .parse()
        .unwrap_or(0);

    match first_digit {
        '4' => Ok(1), // Visa
        '5' => Ok(2), // Mastercard
        '3' => {
            if first_two == 34 || first_two == 37 {
                Ok(3) // American Express
            } else {
                Ok(6) // JCB
            }
        }
        '6' => Ok(4), // Discover
        _ => Err(crate::Error::BadRequest(format!(
            "Unsupported card type for card starting with {}",
            first_digit
        ))),
    }
}

/// Get card type name from Payrix method code
pub fn get_card_type_name(method: i64) -> String {
    match method {
        1 => "Visa".to_string(),
        2 => "Mastercard".to_string(),
        3 => "American Express".to_string(),
        4 => "Discover".to_string(),
        5 => "Diners Club".to_string(),
        6 => "JCB".to_string(),
        _ => "Unknown".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_detect_visa() {
        assert_eq!(detect_card_type("4111111111111111").unwrap(), 1);
    }

    #[test]
    fn test_detect_mastercard() {
        assert_eq!(detect_card_type("5555555555554444").unwrap(), 2);
    }

    #[test]
    fn test_detect_amex() {
        assert_eq!(detect_card_type("378282246310005").unwrap(), 3);
        assert_eq!(detect_card_type("341234567890123").unwrap(), 3);
    }

    #[test]
    fn test_detect_discover() {
        assert_eq!(detect_card_type("6011111111111117").unwrap(), 4);
    }

    #[test]
    fn test_detect_jcb() {
        // JCB starts with 35
        assert_eq!(detect_card_type("3530111333300000").unwrap(), 6);
    }

    #[test]
    fn test_card_type_names() {
        assert_eq!(get_card_type_name(1), "Visa");
        assert_eq!(get_card_type_name(2), "Mastercard");
        assert_eq!(get_card_type_name(3), "American Express");
        assert_eq!(get_card_type_name(4), "Discover");
        assert_eq!(get_card_type_name(6), "JCB");
    }

    #[test]
    fn test_bank_account_types() {
        let checking = BankAccountType::Checking;
        let savings = BankAccountType::Savings;

        // Just verify the enum works
        match checking {
            BankAccountType::Checking => assert!(true),
            BankAccountType::Savings => panic!("Wrong type"),
        }
        match savings {
            BankAccountType::Savings => assert!(true),
            BankAccountType::Checking => panic!("Wrong type"),
        }
    }
}