dwctl 8.40.0

The Doubleword Control Layer - A self-hostable observability and analytics platform for LLM applications
//! Payment provider abstraction layer
//!
//! This module defines the `PaymentProvider` trait which abstracts payment processing
//! functionality across different payment providers (Stripe, PayPal, etc.).

use async_trait::async_trait;
use axum::http::StatusCode;
use rust_decimal::Decimal;
use sqlx::PgPool;

use crate::{UserId, config::PaymentConfig};

pub mod dummy;
pub mod stripe;

/// Create a payment provider from configuration
///
/// This is the single point where we convert config into provider instances.
/// Adding a new provider requires adding a match arm here.
pub fn create_provider(config: PaymentConfig) -> Box<dyn PaymentProvider> {
    match config {
        PaymentConfig::Stripe(stripe_config) => Box::new(stripe::StripeProvider::from(stripe_config)),
        PaymentConfig::Dummy(dummy_config) => Box::new(dummy::DummyProvider::from(dummy_config)),
        // Future providers:
        // PaymentConfig::PayPal(paypal_config) => {
        //     Box::new(paypal::PayPalProvider::from(paypal_config))
        // }
    }
}

/// Result type for payment provider operations
pub type Result<T> = std::result::Result<T, PaymentError>;

/// Errors that can occur during payment processing
#[derive(Debug, thiserror::Error)]
pub enum PaymentError {
    #[error("Payment provider API error: {0}")]
    ProviderApi(String),

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("Payment not completed yet")]
    PaymentNotCompleted,

    #[error("Invalid payment data: {0}")]
    InvalidData(String),

    #[error("Payment already processed")]
    AlreadyProcessed,

    #[error("User does not have a payment provider customer ID")]
    NoCustomerId,
}

impl From<PaymentError> for StatusCode {
    fn from(err: PaymentError) -> Self {
        match err {
            PaymentError::PaymentNotCompleted => StatusCode::PAYMENT_REQUIRED,
            PaymentError::InvalidData(_) | PaymentError::NoCustomerId => StatusCode::BAD_REQUEST,
            PaymentError::AlreadyProcessed => StatusCode::OK,
            PaymentError::ProviderApi(_) | PaymentError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

impl From<crate::db::errors::DbError> for PaymentError {
    fn from(err: crate::db::errors::DbError) -> Self {
        match err {
            // Handle the specific case of duplicate source_id as AlreadyProcessed for idempotency
            crate::db::errors::DbError::UniqueViolation { constraint, .. }
                if constraint.as_deref() == Some("credits_transactions_source_id_unique") =>
            {
                PaymentError::AlreadyProcessed
            }
            // Convert all other DbError cases through anyhow to sqlx::Error
            _ => {
                // DbError has an Other variant that contains anyhow::Error
                // We can wrap it as a generic database error
                PaymentError::InvalidData(format!("Database error: {}", err))
            }
        }
    }
}

/// Result of processing an auto top-up setup session
#[derive(Debug, Clone)]
pub struct AutoTopupSetupResult {
    /// Payment provider customer ID (may be newly created)
    pub customer_id: Option<String>,
    /// The user ID that initiated this session (from `client_reference_id`)
    pub user_id: Option<String>,
}

/// Represents a completed payment session
#[derive(Debug, Clone)]
pub struct PaymentSession {
    /// Local User ID for the creditee
    pub creditee_id: UserId,
    /// Amount paid (in dollars)
    pub amount: Decimal,
    /// Whether the payment has been completed
    pub is_paid: bool,
    /// Local User ID for the creditor (person who paid)
    pub creditor_id: UserId,
    /// Optional: Payment provider ID for the creditor
    pub payment_provider_id: Option<String>,
}

/// Represents a webhook event from a payment provider
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebhookEvent {
    /// Type of event (e.g., "checkout.session.completed")
    pub event_type: String,
    /// Session ID associated with this event, if applicable
    pub session_id: Option<String>,
}

/// The entity paying for a checkout session (individual user or org).
pub struct CheckoutPayer {
    pub id: UserId,
    pub email: String,
    pub payment_provider_id: Option<String>,
}

/// Abstract payment provider interface
///
/// Implementors provide payment processing capabilities for different providers
/// (Stripe, PayPal, Square, etc.)
#[async_trait]
pub trait PaymentProvider: Send + Sync {
    /// Create a new checkout session
    ///
    /// Returns a URL that the user should be redirected to for payment.
    ///
    /// # Arguments
    /// * `payer` - The billing target paying for the checkout (individual or org)
    /// * `creditee_id` - Optional user ID to credit (for admin granting credits to another user).
    ///   If None, the payer is also the creditee.
    /// * `cancel_url` - URL to redirect to if payment is cancelled
    /// * `success_url` - URL to redirect to if payment succeeds
    async fn create_checkout_session(
        &self,
        payer: &CheckoutPayer,
        creditee_id: Option<&str>,
        cancel_url: &str,
        success_url: &str,
    ) -> Result<String>;

    /// Retrieve and validate a payment session
    ///
    /// Fetches the payment session from the provider and returns validated details.
    async fn get_payment_session(&self, session_id: &str) -> Result<PaymentSession>;

    /// Process a completed payment session
    ///
    /// This is idempotent - calling multiple times with the same session_id
    /// should not create duplicate transactions.
    async fn process_payment_session(&self, db_pool: &PgPool, session_id: &str) -> Result<()>;

    /// Validate and extract webhook event from raw request data
    ///
    /// Returns None if this provider doesn't support webhooks.
    /// Returns Err if validation fails (invalid signature, malformed data, etc.)
    async fn validate_webhook(&self, headers: &axum::http::HeaderMap, body: &str) -> Result<Option<WebhookEvent>>;

    /// Process a validated webhook event
    ///
    /// This is called after validate_webhook succeeds.
    /// Should be idempotent - processing the same event multiple times should be safe.
    async fn process_webhook_event(&self, db_pool: &PgPool, event: &WebhookEvent) -> Result<()>;

    /// Create a billing portal session for customer self-service
    ///
    /// Returns a URL that the user should be redirected to for managing their billing.
    ///
    /// # Arguments
    /// * `customer_id` - The payment provider customer ID
    /// * `return_url` - The complete URL to redirect to after the customer is done (e.g., "https://example.com/cost-management")
    async fn create_billing_portal_session(&self, customer_id: &str, return_url: &str) -> Result<String>;

    /// Create a checkout session for auto top-up setup
    ///
    /// Creates a setup-mode checkout session to collect and save a payment method
    /// for future off-session charges. Returns the checkout URL.
    ///
    /// # Arguments
    /// * `payer` - The billing target (individual or org)
    /// * `cancel_url` - URL to redirect to if the user cancels
    /// * `success_url` - URL to redirect to on success
    async fn create_auto_topup_checkout_session(&self, payer: &CheckoutPayer, cancel_url: &str, success_url: &str) -> Result<String>;

    /// Validate and process an auto top-up checkout session
    ///
    /// Verifies that the checkout session completed successfully with the payment
    /// provider (e.g., payment method was saved). Called after the user completes
    /// the auto top-up checkout flow.
    ///
    /// Unlike `process_payment_session`, this does not create a credit transaction.
    /// It only validates the session so the caller can safely enable auto top-up.
    async fn process_auto_topup_session(&self, db_pool: &PgPool, session_id: &str) -> Result<AutoTopupSetupResult>;

    /// Charge a saved payment method off-session for auto top-up.
    ///
    /// Creates a payment intent using the saved payment method and customer ID.
    /// Returns the payment intent ID on success (logged for reconciliation; the caller
    /// uses a deterministic `source_id` for the credit transaction, not this ID).
    ///
    /// # Arguments
    /// * `amount_cents` - Amount to charge in cents
    /// * `customer_id` - Payment provider customer ID
    /// * `payment_method_id` - Saved payment method ID
    /// * `idempotency_key` - Idempotency key to prevent duplicate charges (e.g. `auto_topup_{user_id}_{minute}`)
    async fn charge_auto_topup(
        &self,
        amount_cents: i64,
        customer_id: &str,
        payment_method_id: &str,
        idempotency_key: &str,
    ) -> Result<String>;

    /// Get the customer's default payment method from the payment provider.
    ///
    /// Returns `Some(payment_method_id)` if the customer has a default payment method,
    /// or `None` if no default is set.
    async fn get_default_payment_method(&self, customer_id: &str) -> Result<Option<String>>;

    /// Check whether the customer has an address on file with the payment provider.
    ///
    /// Required for tax calculation — auto top-up must not be enabled without one.
    async fn customer_has_address(&self, customer_id: &str) -> Result<bool>;

    /// Create a new customer with the payment provider.
    ///
    /// Returns the provider's customer ID for the newly created customer.
    async fn create_customer(&self, email: &str, name: Option<&str>) -> Result<String>;
}