payrix 0.3.0

Rust client for the Payrix payment processing API
// Transaction Management Workflow
//
// This workflow handles transaction operations:
// - Refunds (credit card and bank account)
// - Voids/cancellations (batch status checking)
// - Transaction lookups
//
// Generic API - callers handle any application-specific logic

use crate::{EntityType, PayrixClient, Result};

/// Contact name information for bank account refunds
#[derive(Debug, Clone)]
pub struct ContactName {
    /// Contact first name
    pub first: String,
    /// Contact middle name
    pub middle: String,
    /// Contact last name
    pub last: String,
}

/// Refund a credit card transaction
///
/// # Arguments
/// * `client` - Payrix API client
/// * `transaction_id` - Payrix transaction ID to refund
/// * `amount_cents` - Amount to refund in cents
/// * `description` - Refund description
/// * `client_ip` - Client IP address
///
/// # Returns
/// The refund transaction
pub async fn refund_credit_card(
    client: &PayrixClient,
    transaction_id: &str,
    amount_cents: i64,
    description: &str,
    client_ip: &str,
) -> Result<serde_json::Value> {
    let refund_data = serde_json::json!({
        "fortxn": transaction_id,
        "clientIp": client_ip,
        "type": 3, // CreditCardRefund
        "amount": amount_cents,
        "description": description,
    });

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

    tracing::info!(
        "Refunded credit card transaction {} - ${:.2}",
        transaction_id,
        amount_cents as f64 / 100.0
    );

    Ok(refund)
}

/// Refund a bank account transaction
///
/// # Arguments
/// * `client` - Payrix API client
/// * `transaction_id` - Payrix transaction ID to refund
/// * `amount_cents` - Amount to refund in cents
/// * `description` - Refund description
/// * `contact` - Contact name information (required for bank refunds)
/// * `client_ip` - Client IP address
///
/// # Returns
/// The refund transaction
pub async fn refund_bank_account(
    client: &PayrixClient,
    transaction_id: &str,
    amount_cents: i64,
    description: &str,
    contact: &ContactName,
    client_ip: &str,
) -> Result<serde_json::Value> {
    let refund_data = serde_json::json!({
        "fortxn": transaction_id,
        "clientIp": client_ip,
        "type": 4, // ECheckRefund
        "amount": amount_cents,
        "first": contact.first,
        "middle": contact.middle,
        "last": contact.last,
        "description": description,
    });

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

    tracing::info!(
        "Refunded bank account transaction {} - ${:.2}",
        transaction_id,
        amount_cents as f64 / 100.0
    );

    Ok(refund)
}

/// Void a transaction by removing it from its batch
///
/// This only works if the batch is still open (not settled).
///
/// # Arguments
/// * `client` - Payrix API client
/// * `transaction_id` - Payrix transaction ID to void
///
/// # Returns
/// Ok if void was successful, error if transaction cannot be voided
pub async fn void_transaction(client: &PayrixClient, transaction_id: &str) -> Result<()> {
    let transaction = client
        .get_one::<serde_json::Value>(EntityType::Txns, transaction_id)
        .await?
        .ok_or_else(|| crate::Error::NotFound(format!("Transaction {} not found", transaction_id)))?;

    // Check if transaction has a batch
    let batch_id = transaction
        .get("batch")
        .and_then(|b| b.as_str())
        .ok_or_else(|| {
            crate::Error::BadRequest("Transaction has no batch - cannot void".to_string())
        })?;

    // Get the batch to check status
    let batch = client
        .get_one::<serde_json::Value>(EntityType::Batches, batch_id)
        .await?
        .ok_or_else(|| crate::Error::NotFound(format!("Batch {} not found", batch_id)))?;

    let batch_status = batch
        .get("status")
        .and_then(|s| s.as_str())
        .unwrap_or("closed");

    if batch_status != "open" {
        return Err(crate::Error::BadRequest(format!(
            "Cannot void transaction {} - batch is {} (must be open)",
            transaction_id, batch_status
        )));
    }

    // Void by removing from batch
    let update = serde_json::json!({ "batch": null });
    client
        .update::<_, serde_json::Value>(EntityType::Txns, transaction_id, &update)
        .await?;

    tracing::info!(
        "Voided transaction {} (removed from open batch)",
        transaction_id
    );

    Ok(())
}

/// Check if a transaction can be voided
///
/// # Arguments
/// * `client` - Payrix API client
/// * `transaction_id` - Payrix transaction ID
///
/// # Returns
/// True if the transaction can be voided (batch is open)
pub async fn can_void_transaction(client: &PayrixClient, transaction_id: &str) -> Result<bool> {
    let transaction = client
        .get_one::<serde_json::Value>(EntityType::Txns, transaction_id)
        .await?
        .ok_or_else(|| crate::Error::NotFound(format!("Transaction {} not found", transaction_id)))?;

    // Check if transaction has a batch
    let batch_id = match transaction.get("batch").and_then(|b| b.as_str()) {
        Some(id) => id,
        None => return Ok(false),
    };

    // Get the batch to check status
    let batch = client
        .get_one::<serde_json::Value>(EntityType::Batches, batch_id)
        .await?
        .ok_or_else(|| crate::Error::NotFound(format!("Batch {} not found", batch_id)))?;

    let batch_status = batch
        .get("status")
        .and_then(|s| s.as_str())
        .unwrap_or("closed");

    Ok(batch_status == "open")
}

/// Cancel a transaction - void if possible, otherwise refund
///
/// Smart cancellation logic:
/// - If batch is still "open" (not settled): void by removing from batch
/// - If batch is closed/settled: refund instead (can't void settled transactions)
///
/// # Arguments
/// * `client` - Payrix API client
/// * `transaction_id` - Payrix transaction ID to cancel
/// * `description` - Cancellation description (used if refund is needed)
/// * `client_ip` - Client IP address
/// * `contact` - Contact name information (only needed for bank account refunds)
///
/// # Returns
/// The voided or refunded transaction details
pub async fn cancel_transaction(
    client: &PayrixClient,
    transaction_id: &str,
    description: &str,
    client_ip: &str,
    contact: Option<&ContactName>,
) -> Result<serde_json::Value> {
    let transaction = client
        .get_one::<serde_json::Value>(EntityType::Txns, transaction_id)
        .await?
        .ok_or_else(|| crate::Error::NotFound(format!("Transaction {} not found", transaction_id)))?;

    // Check if transaction has a batch and if it's open
    if let Some(batch_id) = transaction.get("batch").and_then(|b| b.as_str()) {
        let batch = client
            .get_one::<serde_json::Value>(EntityType::Batches, batch_id)
            .await?
            .ok_or_else(|| crate::Error::NotFound(format!("Batch {} not found", batch_id)))?;

        let batch_status = batch
            .get("status")
            .and_then(|s| s.as_str())
            .unwrap_or("open");

        if batch_status == "open" {
            // Batch is still open, we can void
            let update = serde_json::json!({ "batch": null });
            client
                .update::<_, serde_json::Value>(EntityType::Txns, transaction_id, &update)
                .await?;

            tracing::info!(
                "Voided transaction {} (removed from open batch)",
                transaction_id
            );

            return Ok(transaction);
        }
    }

    // Batch is closed or no batch, must refund
    let amount_cents = transaction
        .get("total")
        .and_then(|t| t.as_i64())
        .unwrap_or(0);

    let txn_type = transaction
        .get("type")
        .and_then(|t| t.as_i64())
        .unwrap_or(0);

    tracing::info!(
        "Cannot void settled transaction {}, issuing refund instead",
        transaction_id
    );

    // Determine if credit card or bank account based on transaction type
    // Type 1 = CreditCardSale, Type 2 = ECheckSale
    if txn_type == 1 {
        refund_credit_card(client, transaction_id, amount_cents, description, client_ip).await
    } else {
        let contact = contact.ok_or_else(|| {
            crate::Error::BadRequest(
                "Contact name required for bank account refund".to_string(),
            )
        })?;
        refund_bank_account(
            client,
            transaction_id,
            amount_cents,
            description,
            contact,
            client_ip,
        )
        .await
    }
}

/// 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_contact_name_creation() {
        let contact = ContactName {
            first: "John".to_string(),
            middle: "Q".to_string(),
            last: "Doe".to_string(),
        };

        assert_eq!(contact.first, "John");
        assert_eq!(contact.last, "Doe");
    }
}