payrix 0.3.0

Rust client for the Payrix payment processing API
//! Payrix CLI - Command line tool for interacting with the Payrix API.
//!
//! # Usage
//!
//! ```bash
//! # Look up an entity by ID (auto-detects type and environment from ID prefix)
//! payrix lookup p1_txn_65f214f1e8e012556d14237
//!
//! # Explicitly specify environment (overrides ID prefix detection)
//! payrix --env test lookup p1_txn_65f214f1e8e012556d14237
//! payrix --env production lookup t1_txn_65f214f1e8e012556d14237
//! payrix -e prod lookup p1_txn_65f214f1e8e012556d14237  # short form with alias
//!
//! # The CLI auto-detects (when --env not specified):
//! # - Environment: p1_ = production, t1_ = test
//! # - Entity type: txn = transaction, ent = entity, hld = hold, etc.
//! ```
//!
//! # Environment Variables
//!
//! - `PAYRIX_API_KEY` - API key for production environment
//! - `TEST_PAYRIX_API_KEY` - API key for test/sandbox environment

use std::process;

use clap::{Parser, Subcommand, ValueEnum};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

use payrix::{EntityType, Environment, PayrixClient};

// =============================================================================
// Environment Selection
// =============================================================================

/// CLI environment selector for explicitly choosing test or production.
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CliEnvironment {
    /// Test/sandbox environment (test-api.payrix.com)
    Test,
    /// Production environment (api.payrix.com)
    #[value(alias = "prod")]
    Production,
}

impl From<CliEnvironment> for Environment {
    fn from(cli_env: CliEnvironment) -> Self {
        match cli_env {
            CliEnvironment::Test => Environment::Test,
            CliEnvironment::Production => Environment::Production,
        }
    }
}

// =============================================================================
// CLI Definition
// =============================================================================

#[derive(Parser)]
#[command(name = "payrix")]
#[command(about = "Payrix API command line tool", long_about = None)]
#[command(version)]
struct Cli {
    /// Enable verbose output
    #[arg(short, long, global = true)]
    verbose: bool,

    /// Target environment (test or production).
    /// If not specified, auto-detected from ID prefix (t1_/p1_).
    #[arg(short, long, global = true, value_enum)]
    env: Option<CliEnvironment>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Look up an entity by its Payrix ID
    ///
    /// The entity type and environment are automatically detected from the ID.
    /// For example: p1_txn_65f214f1e8e012556d14237
    ///   - p1_ means production environment
    ///   - txn means transaction entity
    Lookup {
        /// The Payrix entity ID to look up
        id: String,
    },
}

// =============================================================================
// ID Parsing
// =============================================================================

/// Parsed components of a Payrix ID.
#[derive(Debug)]
struct ParsedId {
    /// Environment (test or production)
    environment: Environment,
    /// The entity type
    entity_type: EntityType,
    /// The full original ID
    full_id: String,
}

/// Parse a Payrix ID into its components.
///
/// ID format: `{env}_{type}_{rest}`
/// - env: `t1` (test) or `p1` (production)
/// - type: 3-letter entity code (txn, ent, hld, mer, etc.)
/// - rest: unique identifier
fn parse_id(id: &str) -> Result<ParsedId, String> {
    // Trim whitespace from input
    let id = id.trim();

    // Split by underscore
    let parts: Vec<&str> = id.split('_').collect();
    if parts.len() < 3 {
        return Err(format!(
            "Invalid ID format: expected at least 3 parts separated by underscores, got {}",
            parts.len()
        ));
    }

    // Parse environment
    let environment = match parts[0] {
        "t1" => Environment::Test,
        "p1" => Environment::Production,
        other => {
            return Err(format!(
                "Unknown environment prefix '{}'. Expected 't1' (test) or 'p1' (production)",
                other
            ))
        }
    };

    // Parse entity type from 3-letter code
    let entity_type = entity_type_from_code(parts[1])?;

    Ok(ParsedId {
        environment,
        entity_type,
        full_id: id.to_string(),
    })
}

/// Map a 3-letter entity code to EntityType.
fn entity_type_from_code(code: &str) -> Result<EntityType, String> {
    match code {
        // Core entities
        "acc" => Ok(EntityType::Accounts),
        "cst" => Ok(EntityType::Customers),
        "ent" => Ok(EntityType::Entities),
        "fnd" => Ok(EntityType::Funds),
        "mbr" => Ok(EntityType::Members),
        "mer" => Ok(EntityType::Merchants),
        "org" => Ok(EntityType::Orgs),
        "pyt" => Ok(EntityType::Payouts),
        "pln" => Ok(EntityType::Plans),
        "sub" => Ok(EntityType::Subscriptions),
        "stk" => Ok(EntityType::SubscriptionTokens),
        "log" => Ok(EntityType::Logins),
        "tlg" => Ok(EntityType::TeamLogins),
        "tkn" => Ok(EntityType::Tokens),
        "txn" => Ok(EntityType::Txns),

        // Reserves and fees
        "ers" => Ok(EntityType::EntityReserves),
        "frl" => Ok(EntityType::FeeRules),
        "fee" => Ok(EntityType::Fees),
        "oen" => Ok(EntityType::OrgEntities),
        "ren" => Ok(EntityType::ReserveEntries),
        "rsv" => Ok(EntityType::Reserves),
        "vnd" => Ok(EntityType::Vendors),

        // Verifications and adjustments
        "avr" => Ok(EntityType::AccountVerifications),
        "adj" => Ok(EntityType::Adjustments),
        "bch" => Ok(EntityType::Batches),

        // Chargebacks
        "cbk" => Ok(EntityType::Chargebacks),
        "cbm" => Ok(EntityType::ChargebackMessages),
        "cbd" => Ok(EntityType::ChargebackDocuments),
        "cmr" => Ok(EntityType::ChargebackMessageResults),
        "cbs" => Ok(EntityType::ChargebackStatuses),

        // Other
        "cnt" => Ok(EntityType::Contacts),
        "dsb" => Ok(EntityType::Disbursements),
        "dse" => Ok(EntityType::DisbursementEntries),
        "etr" => Ok(EntityType::Entries),
        "pen" => Ok(EntityType::PendingEntries),
        "rfd" => Ok(EntityType::Refunds),

        // Alerts
        "alt" => Ok(EntityType::Alerts),
        "ala" => Ok(EntityType::AlertActions),
        "atr" => Ok(EntityType::AlertTriggers),

        // Notes
        "nte" => Ok(EntityType::Notes),
        "ntd" => Ok(EntityType::NoteDocuments),

        // Holds
        "hld" => Ok(EntityType::Holds),

        // Unknown code
        other => Err(format!(
            "Unknown entity type code '{}'. \n\
            Known codes: acc, cst, ent, fnd, mbr, mer, org, pyt, pln, sub, stk, log, tlg, tkn, txn, \
            ers, frl, fee, oen, ren, rsv, vnd, avr, adj, bch, cbk, cbm, cbd, cmr, cbs, \
            cnt, dsb, dse, etr, pen, rfd, alt, ala, atr, nte, ntd, hld",
            other
        )),
    }
}

/// Get a human-readable name for an entity type.
fn entity_type_name(entity_type: &EntityType) -> &'static str {
    match entity_type {
        EntityType::Accounts => "Bank Account",
        EntityType::Customers => "Customer",
        EntityType::Entities => "Entity",
        EntityType::Funds => "Fund",
        EntityType::Members => "Member",
        EntityType::Merchants => "Merchant",
        EntityType::Orgs => "Organization",
        EntityType::Payouts => "Payout",
        EntityType::Plans => "Plan",
        EntityType::Subscriptions => "Subscription",
        EntityType::SubscriptionTokens => "Subscription Token",
        EntityType::TeamLogins => "Team Login",
        EntityType::Tokens => "Token",
        EntityType::Txns => "Transaction",
        EntityType::EntityReserves => "Entity Reserve",
        EntityType::FeeRules => "Fee Rule",
        EntityType::Fees => "Fee",
        EntityType::OrgEntities => "Org Entity",
        EntityType::ReserveEntries => "Reserve Entry",
        EntityType::Reserves => "Reserve",
        EntityType::Vendors => "Vendor",
        EntityType::AccountVerifications => "Account Verification",
        EntityType::Adjustments => "Adjustment",
        EntityType::Batches => "Batch",
        EntityType::Chargebacks => "Chargeback",
        EntityType::ChargebackMessages => "Chargeback Message",
        EntityType::ChargebackDocuments => "Chargeback Document",
        EntityType::ChargebackMessageResults => "Chargeback Message Result",
        EntityType::ChargebackStatuses => "Chargeback Status",
        EntityType::Contacts => "Contact",
        EntityType::Disbursements => "Disbursement",
        EntityType::DisbursementEntries => "Disbursement Entry",
        EntityType::Entries => "Entry",
        EntityType::PendingEntries => "Pending Entry",
        EntityType::Refunds => "Refund",
        EntityType::Alerts => "Alert",
        EntityType::AlertActions => "Alert Action",
        EntityType::AlertTriggers => "Alert Trigger",
        EntityType::Logins => "Login",
        EntityType::Notes => "Note",
        EntityType::NoteDocuments => "Note Document",
        EntityType::Holds => "Hold",
    }
}

// =============================================================================
// Commands
// =============================================================================

/// Look up an entity by ID.
async fn lookup(
    id: &str,
    verbose: bool,
    explicit_env: Option<CliEnvironment>,
) -> Result<(), Box<dyn std::error::Error>> {
    // Parse the ID
    let parsed = parse_id(id)?;

    // Resolve environment: explicit flag overrides ID prefix detection
    let environment = match explicit_env {
        Some(cli_env) => {
            let env = Environment::from(cli_env);
            if verbose && env != parsed.environment {
                eprintln!(
                    "Note: --env {:?} overrides ID prefix environment ({:?})",
                    cli_env, parsed.environment
                );
            }
            env
        }
        None => parsed.environment,
    };

    if verbose {
        eprintln!(
            "Looking up {} in {} environment...",
            entity_type_name(&parsed.entity_type),
            if environment == Environment::Test {
                "test"
            } else {
                "production"
            }
        );
    }

    // Get API key based on environment
    let api_key = match environment {
        Environment::Test => std::env::var("TEST_PAYRIX_API_KEY").map_err(|_| {
            "TEST_PAYRIX_API_KEY environment variable not set.\n\
            Set it with: export TEST_PAYRIX_API_KEY=your_api_key"
        })?,
        Environment::Production => std::env::var("PAYRIX_API_KEY").map_err(|_| {
            "PAYRIX_API_KEY environment variable not set.\n\
            Set it with: export PAYRIX_API_KEY=your_api_key"
        })?,
    };

    // Create client
    let client = PayrixClient::new(&api_key, environment)?;

    // Fetch the entity as raw JSON (we don't need to deserialize into a specific type)
    let result: Option<serde_json::Value> = client
        .get_one(parsed.entity_type, &parsed.full_id)
        .await?;

    match result {
        Some(entity) => {
            // Pretty print the JSON
            let pretty = serde_json::to_string_pretty(&entity)?;
            println!("{}", pretty);
            Ok(())
        }
        None => {
            eprintln!(
                "Error: {} with ID '{}' not found",
                entity_type_name(&parsed.entity_type),
                parsed.full_id
            );
            process::exit(1);
        }
    }
}

// =============================================================================
// Main
// =============================================================================

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    // Initialize tracing if verbose
    if cli.verbose {
        tracing_subscriber::registry()
            .with(
                tracing_subscriber::EnvFilter::try_from_default_env()
                    .unwrap_or_else(|_| "payrix=debug".into()),
            )
            .with(tracing_subscriber::fmt::layer())
            .init();
    }

    let result = match cli.command {
        Commands::Lookup { id } => lookup(&id, cli.verbose, cli.env).await,
    };

    if let Err(e) = result {
        eprintln!("Error: {}", e);
        process::exit(1);
    }
}