// retrigger-build-marker-3
#![allow(dead_code)]

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use contracts::auth_middleware::AuthUser;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha512};
use sqlx::{postgres::PgPool, FromRow};
use std::net::SocketAddr;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;

pub mod ai_credits;
pub mod packages;
pub mod payu;

#[derive(Clone)]
pub struct AppState {
    pool: PgPool,
    payu: payu::PayuConfig,
}

#[derive(Debug, Deserialize)]
struct CreateOrderRequest {
    // Client-supplied amount/currency are accepted for backward
    // compatibility but NOT trusted for pricing -- the real charge is
    // always derived server-side from pricing_packages.price_inr (see
    // create_order). Trusting a client-supplied amount for a real payment
    // gateway would let a client pay any amount it likes for a package.
    #[allow(dead_code)]
    amount: Option<u64>,
    #[allow(dead_code)]
    currency: Option<String>,
    #[allow(dead_code)]
    receipt: Option<String>,
    package_id: Option<String>,
}

#[derive(Debug, Serialize)]
struct CreateOrderResponse {
    key: String,
    txnid: String,
    amount: String,
    productinfo: String,
    firstname: String,
    email: String,
    phone: String,
    surl: String,
    furl: String,
    hash: String,
    payu_base_url: String,
    udf1: String,
    udf2: String,
    // Kept for callers still reading the pre-PayU response shape.
    order_id: String,
    currency: String,
    status: String,
}

#[derive(Debug, Deserialize)]
struct VerifyPaymentRequest {
    txnid: String,
    mihpayid: String,
    status: String,
    hash: String,
    amount: String,
    productinfo: String,
    firstname: String,
    email: String,
    #[serde(default)]
    phone: Option<String>,
    #[serde(default)]
    udf1: Option<String>,
    #[serde(default)]
    udf2: Option<String>,
}

#[derive(Debug, Serialize)]
struct VerifyPaymentResponse {
    verified: bool,
    payment_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    reference_number: Option<String>,
    status: String,
    message: String,
}

#[derive(Debug, Serialize)]
struct PaymentStatusResponse {
    payment_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    reference_number: Option<String>,
    status: String,
    amount: u64,
    currency: String,
}

#[derive(Debug, FromRow)]
struct PricingPackageRow {
    name: String,
    tracecoins_amount: i32,
    price_inr: i32,
}

#[derive(Debug, FromRow)]
struct UserContactRow {
    email: String,
    full_name: Option<String>,
    phone: Option<String>,
}

#[derive(Debug, FromRow)]
struct PaymentRow {
    id: Uuid,
    reference_number: String,
    user_id: Uuid,
    #[allow(dead_code)]
    package_id: Option<Uuid>,
    tracecoins_credited: Option<i32>,
    amount_inr: i32,
    status: String,
    payu_mihpayid: Option<String>,
}

#[derive(Debug, Deserialize)]
struct StoredGatewayConfig {
    mode: Option<String>,
    base_url: Option<String>,
    merchant_id: Option<String>,
    api_key: Option<String>,
    secret_key: Option<String>,
}

#[derive(Debug, FromRow)]
struct PaymentGatewayConfigRow {
    #[allow(dead_code)]
    display_name: Option<String>,
    config_json: Option<serde_json::Value>,
    is_active: bool,
}

#[derive(Debug, Clone)]
pub(crate) struct PayuConfig {
    pub(crate) key: String,
    pub(crate) salt: String,
    pub(crate) base_url: String,
}

fn error_response(status: StatusCode, message: impl Into<String>) -> (StatusCode, String) {
    (status, message.into())
}

/// Nxtgauge's own seller identity for GST invoicing — configurable via env
/// since GSTIN/PAN/registered address are business details, not code.
fn seller_details() -> invoice::SellerDetails {
    invoice::SellerDetails {
        name: std::env::var("INVOICE_SELLER_NAME").unwrap_or_else(|_| "Nxtgauge".to_string()),
        address: std::env::var("INVOICE_SELLER_ADDRESS")
            .unwrap_or_else(|_| "Registered address not configured".to_string()),
        gstin: std::env::var("INVOICE_SELLER_GSTIN").ok(),
        pan: std::env::var("INVOICE_SELLER_PAN").ok(),
        state_code: std::env::var("INVOICE_SELLER_STATE_CODE").unwrap_or_else(|_| "KA".to_string()),
    }
}

/// Generates a GST invoice for a successful Tracecoin/package purchase.
/// Never blocks the payment response — invoice generation failures are
/// logged, not surfaced to the buyer, since the payment itself already
/// succeeded and the wallet was already credited by the time this runs.
async fn generate_purchase_invoice(
    pool: &PgPool,
    payment: &PaymentRow,
    payload: &VerifyPaymentRequest,
) {
    let package_name: Option<String> = sqlx::query_scalar("SELECT name FROM pricing_packages WHERE id = $1")
        .bind(payment.package_id)
        .fetch_optional(pool)
        .await
        .ok()
        .flatten();

    let description = package_name.unwrap_or_else(|| "Tracecoin package purchase".to_string());

    let line = invoice::LineItem {
        line_number: 1,
        description,
        hsn_sac_code: None,
        quantity: 1.0,
        // payments.amount_inr is already paise (copied straight from
        // pricing_packages.price_inr, itself paise despite the name — see
        // payu::paise_to_rupee_string dividing by 100), not rupees.
        unit_price_paise: payment.amount_inr as i64,
        tax_rate_percent: 18.0,
        metadata: payment.package_id.map(|id| serde_json::json!({ "package_id": id })),
    };

    let customer = invoice::BillingDetails {
        legal_name: payload.firstname.clone(),
        email: Some(payload.email.clone()),
        phone: payload.phone.clone(),
        gstin: None,
        pan: None,
        billing_address: "Billing address not provided".to_string(),
        state_code: None,
    };

    let new_invoice = invoice::service::NewInvoice {
        payment_id: payment.id,
        user_id: payment.user_id,
        currency: "INR".to_string(),
        invoice_type: "TRACECOIN_PURCHASE".to_string(),
        lines: vec![line],
        discount_amount: 0,
        discount_label: None,
        notes: None,
        customer,
        seller: seller_details(),
        inter_state: false,
        pdf_object_key: None,
    };

    match invoice::service::InvoiceService::create(pool, new_invoice).await {
        Ok(inv) => tracing::info!(
            "Generated invoice {} for payment {}",
            inv.invoice_number,
            payment.id
        ),
        Err(e) => tracing::error!("Failed to generate invoice for payment {}: {:?}", payment.id, e),
    }
}

pub(crate) fn build_txnid() -> String {
    format!("tc{}", Uuid::new_v4().simple())
        .chars()
        .take(40)
        .collect()
}

/// Resolves the PayU merchant key/salt from the admin-configured gateway settings,
/// falling back to the deployment's PAYU_MERCHANT_KEY / PAYU_SALT env vars.
pub(crate) async fn resolve_payu_config(state: &AppState) -> Result<PayuConfig, (StatusCode, String)> {
    let row = sqlx::query_as::<_, PaymentGatewayConfigRow>(
        r#"
        SELECT display_name, config_json, is_active
        FROM payment_gateway_configs
        WHERE gateway_key = 'PAYU'
        ORDER BY is_active DESC, created_at DESC
        LIMIT 1
        "#,
    )
    .fetch_optional(&state.pool)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    let mut key = state.payu.merchant_key.clone();
    let mut salt = state.payu.merchant_salt.clone();
    let mut base_url = state.payu.base_url.clone();
    let mut mode = "sandbox".to_string();
    let mut enabled = true;

    if let Some(row) = row {
        enabled = row.is_active;
        if let Some(config_json) = row.config_json {
            if let Ok(stored) = serde_json::from_value::<StoredGatewayConfig>(config_json) {
                // merchant_id / api_key hold the PayU merchant key; secret_key holds the PayU salt.
                if let Some(value) = stored
                    .api_key
                    .filter(|v| !v.trim().is_empty())
                    .or_else(|| stored.merchant_id.filter(|v| !v.trim().is_empty()))
                {
                    key = value;
                }
                if let Some(value) = stored.secret_key.filter(|v| !v.trim().is_empty()) {
                    salt = value;
                }
                if let Some(value) = stored.mode.filter(|v| !v.trim().is_empty()) {
                    mode = value;
                }
                if let Some(value) = stored.base_url.filter(|v| !v.trim().is_empty()) {
                    base_url = value;
                }
            }
        }
    }

    if mode.eq_ignore_ascii_case("live") && base_url == "https://test.payu.in" {
        base_url = "https://secure.payu.in".to_string();
    }

    if !enabled {
        return Err(error_response(
            StatusCode::SERVICE_UNAVAILABLE,
            "PayU gateway is disabled",
        ));
    }

    if key.trim().is_empty() || salt.trim().is_empty() {
        return Err(error_response(
            StatusCode::INTERNAL_SERVER_ERROR,
            "PayU credentials are not configured",
        ));
    }

    Ok(PayuConfig { key, salt, base_url })
}

fn sha512_hex(raw: &str) -> String {
    let digest = Sha512::digest(raw.as_bytes());
    hex::encode(digest)
}

/// PayU's documented request hash: sha512(key|txnid|amount|productinfo|firstname|email|udf1..udf5|||||SALT)
#[allow(clippy::too_many_arguments)]
pub(crate) fn payu_request_hash(
    key: &str,
    txnid: &str,
    amount: &str,
    productinfo: &str,
    firstname: &str,
    email: &str,
    udf1: &str,
    udf2: &str,
    udf3: &str,
    udf4: &str,
    udf5: &str,
    salt: &str,
) -> String {
    let raw = format!(
        "{key}|{txnid}|{amount}|{productinfo}|{firstname}|{email}|{udf1}|{udf2}|{udf3}|{udf4}|{udf5}||||||{salt}"
    );
    sha512_hex(&raw)
}

/// PayU's documented response hash: sha512(SALT|status|||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key)
#[allow(clippy::too_many_arguments)]
pub(crate) fn payu_response_hash(
    salt: &str,
    status: &str,
    udf1: &str,
    udf2: &str,
    udf3: &str,
    udf4: &str,
    udf5: &str,
    email: &str,
    firstname: &str,
    productinfo: &str,
    amount: &str,
    txnid: &str,
    key: &str,
) -> String {
    let raw = format!(
        "{salt}|{status}||||||{udf5}|{udf4}|{udf3}|{udf2}|{udf1}|{email}|{firstname}|{productinfo}|{amount}|{txnid}|{key}"
    );
    sha512_hex(&raw)
}

async fn create_order(
    auth: AuthUser,
    State(state): State<AppState>,
    Json(payload): Json<CreateOrderRequest>,
) -> Result<Json<CreateOrderResponse>, (StatusCode, String)> {
    let package_id_str = payload.package_id.as_ref().ok_or((StatusCode::BAD_REQUEST, "package_id is required".to_string()))?;
    let package_id = Uuid::parse_str(package_id_str).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?;

    let package = sqlx::query_as::<_, PricingPackageRow>(
        "SELECT tracecoins_amount, price_inr, name FROM pricing_packages WHERE id = $1 AND is_active = true",
    )
    .bind(package_id)
    .fetch_optional(&state.pool)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    let package = package.ok_or_else(|| {
        error_response(StatusCode::BAD_REQUEST, "Invalid or inactive package")
    })?;

    let tracecoins_credited = package.tracecoins_amount;

    tracing::info!("Creating PayU order for package {} (₹{})", package_id, payu::paise_to_rupee_string(package.price_inr));

    let contact = sqlx::query_as::<_, UserContactRow>(
        "SELECT email, full_name, phone FROM users WHERE id = $1",
    )
    .bind(auth.user_id)
    .fetch_optional(&state.pool)
    .await
    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
    .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;

    let firstname = contact
        .full_name
        .as_deref()
        .and_then(|n| n.split_whitespace().next())
        .unwrap_or("Customer")
        .to_string();
    let phone = contact.phone.unwrap_or_default();

    let txnid = payu::generate_txnid();
    // Price is derived from pricing_packages.price_inr (server-side truth),
    // never from the client-supplied `payload.amount` -- see CreateOrderRequest.
    let amount_str = payu::paise_to_rupee_string(package.price_inr);
    let productinfo = package.name.clone();
    let udf1 = package_id_str.clone();
    let udf2 = String::new();

    let hash = payu::request_hash(
        &state.payu,
        &txnid,
        &amount_str,
        &productinfo,
        &firstname,
        &contact.email,
        &udf1,
        &udf2,
    );

    sqlx::query(
        r#"
        INSERT INTO payments (user_id, package_id, payu_txnid, amount_inr, tracecoins_credited, status)
        VALUES ($1, $2, $3, $4, $5, 'PENDING')
        "#,
    )
    .bind(auth.user_id)
    .bind(package_id)
    .bind(&txnid)
    .bind(package.price_inr)
    .bind(tracecoins_credited)
    .execute(&state.pool)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    Ok(Json(CreateOrderResponse {
        key: state.payu.merchant_key.clone(),
        txnid: txnid.clone(),
        amount: amount_str,
        productinfo,
        firstname,
        email: contact.email,
        phone,
        surl: state.payu.surl.clone(),
        furl: state.payu.furl.clone(),
        hash,
        payu_base_url: state.payu.base_url.clone(),
        udf1,
        udf2,
        order_id: txnid,
        currency: "INR".to_string(),
        status: "created".to_string(),
    }))
}

async fn verify_payment(
    auth: AuthUser,
    State(state): State<AppState>,
    Json(payload): Json<VerifyPaymentRequest>,
) -> Result<Json<VerifyPaymentResponse>, (StatusCode, String)> {
    tracing::info!("Verifying PayU payment: txnid={}", payload.txnid);

    if !payu::verify_response_hash(
        &state.payu,
        &payload.status,
        &payload.txnid,
        &payload.amount,
        &payload.productinfo,
        &payload.firstname,
        &payload.email,
        payload.udf1.as_deref().unwrap_or(""),
        payload.udf2.as_deref().unwrap_or(""),
        &payload.hash,
    ) {
        return Err((StatusCode::BAD_REQUEST, "Payment hash verification failed".to_string()));
    }

    if !payload.status.eq_ignore_ascii_case("success") {
        return Err((StatusCode::BAD_REQUEST, "Payment was not successful".to_string()));
    }

    // The whole claim-and-credit sequence runs in one transaction with the
    // payments row locked via SELECT ... FOR UPDATE. Without this, two
    // concurrent calls with the same (replayed) valid PayU success payload
    // could both observe status = 'PENDING' before either commits its
    // UPDATE, and both would credit the wallet — a double-credit exploit a
    // user fully controls, since this endpoint is called directly by the
    // client after PayU redirects back, not by a server-to-server webhook.
    let mut tx = state
        .pool
        .begin()
        .await
        .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    let payment = sqlx::query_as::<_, PaymentRow>(
        r#"
        SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
        FROM payments
        WHERE payu_txnid = $1 AND status = 'PENDING'
        FOR UPDATE
        "#,
    )
    .bind(&payload.txnid)
    .fetch_optional(&mut *tx)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    let payment = match payment {
        Some(payment) => payment,
        None => {
            let _ = tx.rollback().await;
            return Err(error_response(
                StatusCode::NOT_FOUND,
                "Payment not found or already processed",
            ));
        }
    };

    if payment.user_id != auth.user_id {
        let _ = tx.rollback().await;
        return Err(error_response(
            StatusCode::FORBIDDEN,
            "Payment does not belong to user",
        ));
    }

    if !payload.status.eq_ignore_ascii_case("success") {
        sqlx::query("UPDATE payments SET status = 'FAILED', payu_mihpayid = $1 WHERE id = $2")
            .bind(&payload.mihpayid)
            .bind(payment.id)
            .execute(&mut *tx)
            .await
            .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

        tx.commit()
            .await
            .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

        return Ok(Json(VerifyPaymentResponse {
            verified: false,
            payment_id: payload.mihpayid,
            reference_number: Some(payment.reference_number.clone()),
            status: "failed".to_string(),
            message: "PayU reported a non-success status.".to_string(),
        }));
    }

    let tracecoins = payment.tracecoins_credited.unwrap_or(0);

    sqlx::query(
        r#"
        UPDATE payments SET
            status = 'SUCCESS',
            payu_mihpayid = $1,
            verified_at = NOW()
        WHERE id = $2
        "#,
    )
    .bind(&payload.mihpayid)
    .bind(payment.id)
    .execute(&mut *tx)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    let wallet_id: Uuid = sqlx::query_scalar(
        r#"
        INSERT INTO tracecoin_wallets (user_id, balance, reserved)
        VALUES ($1, $2, 0)
        ON CONFLICT (user_id) DO UPDATE SET
            balance = tracecoin_wallets.balance + excluded.balance,
            updated_at = NOW()
        RETURNING id
        "#,
    )
    .bind(payment.user_id)
    .bind(tracecoins as i64)
    .fetch_one(&mut *tx)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    sqlx::query(
        r#"
        INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
        VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
        "#,
    )
    .bind(wallet_id)
    .bind(tracecoins as i64)
    .bind(payment.id)
    .execute(&mut *tx)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    tx.commit()
        .await
        .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    generate_purchase_invoice(&state.pool, &payment, &payload).await;

    let _ = sqlx::query(
        r#"
        INSERT INTO notifications (user_id, title, body, type, reference_id)
        VALUES ($1, $2, $3, $4, $5)
        "#,
    )
    .bind(payment.user_id)
    .bind("Tracecoins Purchased Successfully")
    .bind(format!(
        "Your {} Tracecoin package has been credited to your wallet.",
        tracecoins
    ))
    .bind("PAYMENT")
    .bind(payment.id)
    .execute(&state.pool)
    .await;

    Ok(Json(VerifyPaymentResponse {
        verified: true,
        payment_id: payload.mihpayid,
        reference_number: Some(payment.reference_number.clone()),
        status: "success".to_string(),
        message: "Payment verified successfully".to_string(),
    }))
}

async fn get_payment_status(
    auth: AuthUser,
    State(state): State<AppState>,
    Path(payment_id): Path<String>,
) -> Result<Json<PaymentStatusResponse>, (StatusCode, String)> {
    let payment = sqlx::query_as::<_, PaymentRow>(
        r#"
        SELECT id, reference_number, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid
        FROM payments
        WHERE payu_mihpayid = $1 OR payu_txnid = $1
        ORDER BY created_at DESC
        LIMIT 1
        "#,
    )
    .bind(&payment_id)
    .fetch_optional(&state.pool)
    .await
    .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;

    let payment = match payment {
        Some(payment) => payment,
        None => {
            return Ok(Json(PaymentStatusResponse {
                payment_id,
                reference_number: None,
                status: "not_found".to_string(),
                amount: 0,
                currency: "INR".to_string(),
            }))
        }
    };

    if payment.user_id != auth.user_id {
        return Err(error_response(
            StatusCode::FORBIDDEN,
            "Payment does not belong to user",
        ));
    }

    Ok(Json(PaymentStatusResponse {
        payment_id: payment
            .payu_mihpayid
            .clone()
            .unwrap_or(payment_id),
        reference_number: Some(payment.reference_number.clone()),
        status: payment.status.to_lowercase(),
        amount: payment.amount_inr as u64,
        currency: "INR".to_string(),
    }))
}

#[derive(Debug, serde::Deserialize)]
struct ListInvoicesQuery {
    page: Option<i64>,
    limit: Option<i64>,
}

async fn list_my_invoices(
    auth: AuthUser,
    State(state): State<AppState>,
    axum::extract::Query(q): axum::extract::Query<ListInvoicesQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
    let page = q.page.unwrap_or(1);
    let limit = q.limit.unwrap_or(20);
    let invoices = invoice::service::InvoiceService::list_for_user(&state.pool, auth.user_id, page, limit)
        .await
        .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;

    Ok(Json(serde_json::json!({
        "data": invoices,
        "pagination": { "page": page, "limit": limit }
    })))
}

async fn get_my_invoice(
    auth: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
    let inv = invoice::service::InvoiceService::get(&state.pool, id)
        .await
        .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?
        .ok_or_else(|| error_response(StatusCode::NOT_FOUND, "Invoice not found"))?;

    if inv.user_id != auth.user_id {
        return Err(error_response(StatusCode::FORBIDDEN, "Invoice does not belong to user"));
    }

    let lines = invoice::service::InvoiceService::list_line_items(&state.pool, id)
        .await
        .map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;

    Ok(Json(serde_json::json!({ "invoice": inv, "line_items": lines })))
}

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::new(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
        ))
        .with(tracing_subscriber::fmt::layer())
        .init();

    let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = PgPool::connect(&db_url)
        .await
        .expect("Failed to connect to database");

    let state = AppState {
        pool,
        payu: payu::PayuConfig::from_env(),
    };

    let app = Router::new()
        .route("/api/payments/create-order", post(create_order))
        .route("/api/payments/verify", post(verify_payment))
        .route("/api/payments/{id}/status", get(get_payment_status))
        .route("/api/payments/invoices", get(list_my_invoices))
        .route("/api/payments/invoices/{id}", get(get_my_invoice))
        .nest("/api/packages", packages::router())
        .nest("/api/ai-credits", ai_credits::router())
        .nest("/api/admin/ai-credits/packages", ai_credits::admin_router())
        .with_state(state);

    let port: u16 = std::env::var("PORT")
        .unwrap_or_else(|_| "9116".to_string())
        .parse()
        .expect("PORT must be a valid u16");
    let addr = SocketAddr::from(([0, 0, 0, 0], port));

    tracing::info!("Payments service listening on {}", addr);

    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
