payrix 0.3.0

Rust client for the Payrix payment processing API
// Payment Processing Workflow
//
// This workflow handles high-level payment processing operations.
//
// Key features:
// - Credit card transactions (sale)
// - Bank account transactions (eCheck)
// - Generic API - callers control their own metadata in description field

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

/// Transaction origin - where the transaction was initiated
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[repr(i32)]
pub enum TransactionOrigin {
    /// Card present at terminal
    CardPresent = 1,
    /// Card not present (phone/mail order)
    CardNotPresent = 2,
    /// Ecommerce/online transaction
    #[default]
    Ecommerce = 3,
}

impl TransactionOrigin {
    /// Returns the numeric representation of the transaction origin
    pub fn as_i32(&self) -> i32 {
        *self as i32
    }
}

/// Card on File type - how the payment credentials are stored
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[repr(i32)]
pub enum CardOnFileType {
    /// Single use token (not stored)
    #[default]
    SingleUse = 1,
    /// Initial storage for recurring payments
    InitialStorage = 2,
    /// Subsequent use of stored credentials
    SubsequentUse = 3,
    /// Merchant-initiated transaction
    MerchantInitiated = 4,
}

impl CardOnFileType {
    /// Returns the numeric representation of the card-on-file type
    pub fn as_i32(&self) -> i32 {
        *self as i32
    }
}

/// Configuration for creating a transaction
#[derive(Debug, Clone)]
pub struct TransactionConfig {
    /// Transaction origin (default: Ecommerce)
    pub origin: TransactionOrigin,
    /// Card on file type (default: SingleUse)
    pub cof_type: CardOnFileType,
    /// Whether to allow partial payments (default: false)
    pub allow_partial: bool,
}

impl Default for TransactionConfig {
    fn default() -> Self {
        Self {
            origin: TransactionOrigin::Ecommerce,
            cof_type: CardOnFileType::SingleUse,
            allow_partial: false,
        }
    }
}

impl TransactionConfig {
    /// Create a standard ecommerce transaction config
    pub fn ecommerce() -> Self {
        Self::default()
    }

    /// Create a config for card-present transactions
    pub fn card_present() -> Self {
        Self {
            origin: TransactionOrigin::CardPresent,
            ..Self::default()
        }
    }

    /// Create a config for recurring/subscription payments
    pub fn recurring() -> Self {
        Self {
            cof_type: CardOnFileType::SubsequentUse,
            ..Self::default()
        }
    }

    /// Create a config for initial storage of credentials
    pub fn initial_storage() -> Self {
        Self {
            cof_type: CardOnFileType::InitialStorage,
            ..Self::default()
        }
    }

    /// Create a config allowing partial payments
    pub fn with_partial(mut self) -> Self {
        self.allow_partial = true;
        self
    }
}

/// Create a credit card transaction
///
/// # Arguments
/// * `client` - Payrix API client
/// * `merchant_id` - Payrix merchant ID
/// * `token` - Payment token (from `token.token`, not `token.id`)
/// * `amount_cents` - Amount in cents
/// * `description` - Transaction description (caller manages their own metadata)
/// * `client_ip` - Client IP address for fraud detection
/// * `config` - Transaction configuration (origin, card-on-file type, partial payment)
///
/// # Example
/// ```rust,ignore
/// use payrix::workflows::payment_processing::{create_credit_card_transaction, TransactionConfig};
///
/// // Standard ecommerce transaction
/// let txn = create_credit_card_transaction(
///     &client,
///     "t1_mer_123",
///     "tok_abc",
///     10000, // $100.00
///     "Order #12345",
///     "192.168.1.1",
///     TransactionConfig::ecommerce(),
/// ).await?;
///
/// // Recurring subscription payment
/// let txn = create_credit_card_transaction(
///     &client,
///     "t1_mer_123",
///     "tok_abc",
///     10000,
///     "Monthly subscription",
///     "192.168.1.1",
///     TransactionConfig::recurring(),
/// ).await?;
/// ```
///
/// # Returns
/// The created Payrix transaction
pub async fn create_credit_card_transaction(
    client: &PayrixClient,
    merchant_id: &str,
    token: &str,
    amount_cents: i64,
    description: &str,
    client_ip: &str,
    config: TransactionConfig,
) -> Result<serde_json::Value> {
    let txn_data = serde_json::json!({
        "merchant": merchant_id,
        "token": token,
        "clientIp": client_ip,
        "type": 1, // CreditCardSale
        "origin": config.origin.as_i32(),
        "cofType": config.cof_type.as_i32(),
        "allowPartial": if config.allow_partial { 1 } else { 0 },
        "total": amount_cents,
        "description": description,
    });

    let transaction = client
        .create::<_, serde_json::Value>(EntityType::Txns, &txn_data)
        .await?;

    tracing::info!(
        "Created credit card transaction {} for ${:.2}",
        transaction
            .get("id")
            .and_then(|i| i.as_str())
            .unwrap_or("unknown"),
        amount_cents as f64 / 100.0
    );

    Ok(transaction)
}

/// Create a bank account transaction (eCheck/ACH)
///
/// # Arguments
/// * `client` - Payrix API client
/// * `merchant_id` - Payrix merchant ID
/// * `token` - Payment token (from `token.token`, not `token.id`)
/// * `amount_cents` - Amount in cents
/// * `description` - Transaction description (caller manages their own metadata)
/// * `client_ip` - Client IP address for fraud detection
/// * `config` - Transaction configuration (origin, card-on-file type, partial payment)
///
/// # Returns
/// The created Payrix transaction
pub async fn create_bank_transaction(
    client: &PayrixClient,
    merchant_id: &str,
    token: &str,
    amount_cents: i64,
    description: &str,
    client_ip: &str,
    config: TransactionConfig,
) -> Result<serde_json::Value> {
    let txn_data = serde_json::json!({
        "merchant": merchant_id,
        "token": token,
        "clientIp": client_ip,
        "type": 2, // ECheckSale
        "origin": config.origin.as_i32(),
        "cofType": config.cof_type.as_i32(),
        "allowPartial": if config.allow_partial { 1 } else { 0 },
        "total": amount_cents,
        "description": description,
    });

    let transaction = client
        .create::<_, serde_json::Value>(EntityType::Txns, &txn_data)
        .await?;

    tracing::info!(
        "Created bank account transaction {} for ${:.2}",
        transaction
            .get("id")
            .and_then(|i| i.as_str())
            .unwrap_or("unknown"),
        amount_cents as f64 / 100.0
    );

    Ok(transaction)
}

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

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

    #[test]
    fn test_amount_conversion() {
        // Verify cents conversion
        let dollars = 10.99_f64;
        let cents = (dollars * 100.0).ceil() as i64;
        assert_eq!(cents, 1099);
    }

    #[test]
    fn test_amount_conversion_rounding() {
        // Verify rounding behavior
        let dollars = 10.999_f64;
        let cents = (dollars * 100.0).ceil() as i64;
        assert_eq!(cents, 1100); // Rounds up
    }

    #[test]
    fn test_transaction_origin_values() {
        assert_eq!(TransactionOrigin::CardPresent.as_i32(), 1);
        assert_eq!(TransactionOrigin::CardNotPresent.as_i32(), 2);
        assert_eq!(TransactionOrigin::Ecommerce.as_i32(), 3);
    }

    #[test]
    fn test_card_on_file_type_values() {
        assert_eq!(CardOnFileType::SingleUse.as_i32(), 1);
        assert_eq!(CardOnFileType::InitialStorage.as_i32(), 2);
        assert_eq!(CardOnFileType::SubsequentUse.as_i32(), 3);
        assert_eq!(CardOnFileType::MerchantInitiated.as_i32(), 4);
    }

    #[test]
    fn test_transaction_config_default() {
        let config = TransactionConfig::default();
        assert_eq!(config.origin.as_i32(), 3); // Ecommerce
        assert_eq!(config.cof_type.as_i32(), 1); // SingleUse
        assert!(!config.allow_partial);
    }

    #[test]
    fn test_transaction_config_ecommerce() {
        let config = TransactionConfig::ecommerce();
        assert_eq!(config.origin.as_i32(), 3);
    }

    #[test]
    fn test_transaction_config_card_present() {
        let config = TransactionConfig::card_present();
        assert_eq!(config.origin.as_i32(), 1);
    }

    #[test]
    fn test_transaction_config_recurring() {
        let config = TransactionConfig::recurring();
        assert_eq!(config.cof_type.as_i32(), 3); // SubsequentUse
    }

    #[test]
    fn test_transaction_config_with_partial() {
        let config = TransactionConfig::ecommerce().with_partial();
        assert!(config.allow_partial);
    }
}