//! Ask Ash AI credits wallet + ledger. //! //! Phase 1 of docs/ASK_ASH_BILLING_ARCHITECTURE.md (nxtgauge-ai-assistant //! repo). Mirrors the reserve/capture/release + row-locked-transaction //! pattern already proven by `tracecoin_wallet.rs` for TraceCoins -- see //! that file for the pattern this one is deliberately consistent with. //! This is a completely separate currency/schema from TraceCoins; nothing //! here should ever read or write `tracecoin_wallets`/`tracecoin_ledger`. use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool, Postgres, Transaction}; use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, FromRow, Clone)] pub struct AiWallet { pub id: Uuid, pub user_id: Uuid, pub plan_id: Uuid, pub role_code: Option, pub monthly_credits_total: i32, pub monthly_credits_used: i32, pub purchased_credits_total: i32, pub purchased_credits_used: i32, pub bonus_credits_total: i32, pub bonus_credits_used: i32, pub reserved_credits: i32, pub locked_credits: i32, pub lifetime_purchased_credits: i32, pub lifetime_used_credits: i32, pub daily_actions_used: i32, pub daily_credits_used: i32, pub daily_usage_date: NaiveDate, pub purchased_credits_expire_at: Option>, pub billing_cycle: String, pub auto_renew: bool, pub current_period_start: DateTime, pub current_period_end: DateTime, pub status: String, pub created_at: DateTime, pub updated_at: DateTime, } impl AiWallet { /// Credits actually spendable right now. Reserved/locked credits are /// excluded even though they're still "owned" -- see Section 4.1 of /// the architecture doc for the full pool breakdown. pub fn available_credits(&self) -> i32 { let owned = (self.monthly_credits_total - self.monthly_credits_used) + (self.purchased_credits_total - self.purchased_credits_used) + (self.bonus_credits_total - self.bonus_credits_used); (owned - self.reserved_credits - self.locked_credits).max(0) } } #[derive(Debug, Serialize, Deserialize, FromRow, Clone)] pub struct AiPlan { pub id: Uuid, pub code: String, pub name: String, pub monthly_credits: i32, pub daily_action_limit: i32, pub daily_credit_limit: Option, pub allowed_models: serde_json::Value, pub allowed_features: serde_json::Value, pub is_active: bool, } #[derive(Debug, Serialize, Deserialize, FromRow, Clone)] pub struct AiFeatureCost { pub id: Uuid, pub feature_code: String, pub display_name: String, pub default_model: String, pub credit_cost: i32, pub max_input_tokens: Option, pub max_output_tokens: Option, pub min_plan_code: Option, pub timeout_ms: i32, pub fallback_model: Option, pub is_active: bool, } #[derive(Debug, Serialize, Deserialize, FromRow, Clone)] pub struct AiReservationHold { pub id: Uuid, pub wallet_id: Uuid, pub credits_held: i32, pub feature_code: String, pub status: String, } #[derive(Debug, thiserror::Error)] pub enum AiCreditsError { #[error("Database error: {0}")] Db(#[from] sqlx::Error), #[error("Unknown AI feature '{0}'")] UnknownFeature(String), #[error("Insufficient AI credits")] InsufficientCredits, #[error("Daily AI action limit reached")] DailyActionLimitReached, #[error("Daily AI credit limit reached")] DailyCreditLimitReached, #[error("Reservation not found or already resolved")] ReservationNotFound, #[error("Invalid amount: {0}")] InvalidAmount(String), } pub struct AiCreditsRepository; impl AiCreditsRepository { /// Get-or-create the wallet for a user, defaulting to the `free` plan. /// Uses the same `INSERT ... ON CONFLICT DO NOTHING` + re-fetch shape /// as `TracecoinWalletRepository::ensure_wallet` so a race between two /// concurrent first-time callers can't surface a raw duplicate-key /// error to either caller. pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result { if let Some(wallet) = Self::get_wallet(pool, user_id).await? { return Ok(wallet); } let free_plan: (Uuid, i32) = sqlx::query_as( "SELECT id, monthly_credits FROM ai_plans WHERE code = 'free' AND is_active = TRUE", ) .fetch_one(pool) .await?; sqlx::query( r#" INSERT INTO user_ai_subscriptions (user_id, plan_id, monthly_credits_total, current_period_start, current_period_end) VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '30 days') ON CONFLICT (user_id) DO NOTHING "#, ) .bind(user_id) .bind(free_plan.0) .bind(free_plan.1) .execute(pool) .await?; Self::get_wallet(pool, user_id) .await? .ok_or_else(|| sqlx::Error::RowNotFound) } pub async fn get_wallet(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE user_id = $1") .bind(user_id) .fetch_optional(pool) .await } pub async fn get_plan(pool: &PgPool, plan_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, AiPlan>("SELECT id, code, name, monthly_credits, daily_action_limit, daily_credit_limit, allowed_models, allowed_features, is_active FROM ai_plans WHERE id = $1") .bind(plan_id) .fetch_optional(pool) .await } /// Credit purchased credits to a user's wallet (e.g. after a verified /// payment). Row-locked and idempotency-keyed like every other wallet /// mutation in this file -- a retried/replayed verify callback with /// the same `idempotency_key` is a no-op, not a double-credit. /// Returns `false` (no-op) if the idempotency key was already used. pub async fn add_purchased_credits( pool: &PgPool, user_id: Uuid, credits: i32, idempotency_key: &str, reference_type: &str, reference_id: Option, ) -> Result { let mut tx = pool.begin().await?; if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM ai_credit_ledger WHERE idempotency_key = $1") .bind(idempotency_key) .fetch_one(&mut *tx) .await? > 0 { tx.rollback().await?; return Ok(false); } let wallet = sqlx::query_as::<_, AiWallet>( "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE", ) .bind(user_id) .fetch_one(&mut *tx) .await?; sqlx::query( r#" UPDATE user_ai_subscriptions SET purchased_credits_total = purchased_credits_total + $1, lifetime_purchased_credits = lifetime_purchased_credits + $1, updated_at = NOW() WHERE id = $2 "#, ) .bind(credits) .bind(wallet.id) .execute(&mut *tx) .await?; let balance_after = wallet.available_credits() + credits; sqlx::query( r#" INSERT INTO ai_credit_ledger (wallet_id, entry_type, credits, balance_after, idempotency_key, reference_type, reference_id, actor_type) VALUES ($1, 'purchase', $2, $3, $4, $5, $6, 'system') "#, ) .bind(wallet.id) .bind(credits) .bind(balance_after) .bind(idempotency_key) .bind(reference_type) .bind(reference_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(true) } /// Admin-issued credit adjustment (e.g. goodwill/compensation credit, /// or a manual correction) -- mirrors the shape of the existing /// TraceCoins admin adjust endpoint (`POST /api/admin/credits/adjust`, /// {user_id, amount, type: ADD|DEDUCT, reason, reference_id?}) so the /// admin UI can reuse the same form pattern for both currencies. /// `reason` is mandatory -- every admin-issued credit change must be /// explained, both for audit and to guard against casual misuse. /// ADD grants bonus credits (not "purchased" -- no money changed /// hands); DEDUCT consumes bonus first, then monthly, then purchased, /// same order as `try_capture_reservation`. pub async fn admin_adjust_credits( pool: &PgPool, user_id: Uuid, amount: i32, is_add: bool, reason: &str, actor_id: Uuid, idempotency_key: Option<&str>, ) -> Result { if amount <= 0 { return Err(AiCreditsError::InvalidAmount("amount must be positive".to_string())); } let mut tx = pool.begin().await?; if let Some(key) = idempotency_key { if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM ai_credit_ledger WHERE idempotency_key = $1") .bind(key) .fetch_one(&mut *tx) .await? > 0 { let wallet = sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE user_id = $1") .bind(user_id) .fetch_one(&mut *tx) .await?; tx.rollback().await?; return Ok(wallet); } } let wallet = sqlx::query_as::<_, AiWallet>( "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE", ) .bind(user_id) .fetch_one(&mut *tx) .await?; let (entry_type, balance_after) = if is_add { sqlx::query( "UPDATE user_ai_subscriptions SET bonus_credits_total = bonus_credits_total + $1, updated_at = NOW() WHERE id = $2", ) .bind(amount) .bind(wallet.id) .execute(&mut *tx) .await?; ("admin_adjustment_credit", wallet.available_credits() + amount) } else { if wallet.available_credits() < amount { tx.rollback().await?; return Err(AiCreditsError::InsufficientCredits); } let bonus_available = wallet.bonus_credits_total - wallet.bonus_credits_used; let from_bonus = amount.min(bonus_available.max(0)); let remaining = amount - from_bonus; let monthly_available = wallet.monthly_credits_total - wallet.monthly_credits_used; let from_monthly = remaining.min(monthly_available.max(0)); let from_purchased = remaining - from_monthly; sqlx::query( r#" UPDATE user_ai_subscriptions SET bonus_credits_used = bonus_credits_used + $1, monthly_credits_used = monthly_credits_used + $2, purchased_credits_used = purchased_credits_used + $3, lifetime_used_credits = lifetime_used_credits + $4, updated_at = NOW() WHERE id = $5 "#, ) .bind(from_bonus) .bind(from_monthly) .bind(from_purchased) .bind(amount) .bind(wallet.id) .execute(&mut *tx) .await?; ("admin_adjustment_debit", wallet.available_credits() - amount) }; sqlx::query( r#" INSERT INTO ai_credit_ledger (wallet_id, entry_type, credits, balance_after, idempotency_key, reference_type, actor_type, actor_id, metadata) VALUES ($1, $2, $3, $4, $5, 'admin_action', 'admin', $6, jsonb_build_object('reason', $7::text)) "#, ) .bind(wallet.id) .bind(entry_type) .bind(if is_add { amount } else { -amount }) .bind(balance_after) .bind(idempotency_key) .bind(actor_id) .bind(reason) .execute(&mut *tx) .await?; tx.commit().await?; sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE id = $1") .bind(wallet.id) .fetch_one(pool) .await .map_err(AiCreditsError::Db) } pub async fn get_feature_cost( pool: &PgPool, feature_code: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, AiFeatureCost>( r#" SELECT id, feature_code, display_name, default_model, credit_cost, max_input_tokens, max_output_tokens, min_plan_code, timeout_ms, fallback_model, is_active FROM ai_feature_costs WHERE feature_code = $1 AND is_active = TRUE "#, ) .bind(feature_code) .fetch_optional(pool) .await } /// Roll `daily_actions_used`/`daily_credits_used` over to zero if the /// wallet's stored usage date isn't today. Called inline, inside the /// same locked transaction as a reserve, rather than depending on a /// separate cron job -- there is no reset job in this codebase yet /// (Section 20 Phase 2 of the architecture doc), and an inline reset /// is strictly safer than shipping enforcement that depends on a job /// that doesn't exist. async fn reset_daily_counters_if_needed( tx: &mut Transaction<'_, Postgres>, wallet_id: Uuid, stored_date: NaiveDate, ) -> Result<(), sqlx::Error> { let today = Utc::now().date_naive(); if stored_date < today { sqlx::query( r#" UPDATE user_ai_subscriptions SET daily_actions_used = 0, daily_credits_used = 0, daily_usage_date = $2 WHERE id = $1 "#, ) .bind(wallet_id) .bind(today) .execute(&mut **tx) .await?; } Ok(()) } /// Atomically reserve `credits` against a user's wallet for a feature /// call. Mirrors `TracecoinWalletRepository::try_reserve_tracecoins`: /// `pool.begin()` -> `SELECT ... FOR UPDATE` -> check -> mutate -> /// ledger insert -> commit (or rollback on any failed check). Callers /// must follow up with `try_capture_reservation` on success or /// `try_release_reservation` on failure -- never leave a hold /// unresolved (a background reaper for abandoned holds is a Phase 1 /// follow-up, not yet implemented; `expires_at` is written now so that /// reaper has something to key off of once it exists). #[allow(clippy::too_many_arguments)] pub async fn try_reserve_credits( pool: &PgPool, user_id: Uuid, feature_code: &str, credits: i32, request_id: Option<&str>, idempotency_key: Option<&str>, ) -> Result { // Ensure the wallet exists before opening the locked transaction -- // ensure_wallet does its own get-or-create round trip and would // deadlock with itself if run inside the same FOR UPDATE tx. Self::ensure_wallet(pool, user_id).await?; let mut tx = pool.begin().await?; // Idempotency: a retried request with the same key returns the // existing hold instead of reserving twice. if let Some(key) = idempotency_key { if let Some(existing) = sqlx::query_as::<_, AiReservationHold>( "SELECT id, wallet_id, credits_held, feature_code, status FROM ai_reservation_holds WHERE idempotency_key = $1", ) .bind(key) .fetch_optional(&mut *tx) .await? { tx.rollback().await?; return Ok(existing); } } let wallet = sqlx::query_as::<_, AiWallet>( "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE", ) .bind(user_id) .fetch_one(&mut *tx) .await?; Self::reset_daily_counters_if_needed(&mut tx, wallet.id, wallet.daily_usage_date).await?; // Re-read after the possible reset so the checks below see fresh // counters (cheap: still inside the same row lock). let wallet = sqlx::query_as::<_, AiWallet>( "SELECT * FROM user_ai_subscriptions WHERE id = $1 FOR UPDATE", ) .bind(wallet.id) .fetch_one(&mut *tx) .await?; let plan = sqlx::query_as::<_, AiPlan>( "SELECT id, code, name, monthly_credits, daily_action_limit, daily_credit_limit, allowed_models, allowed_features, is_active FROM ai_plans WHERE id = $1", ) .bind(wallet.plan_id) .fetch_one(&mut *tx) .await?; if wallet.daily_actions_used >= plan.daily_action_limit { tx.rollback().await?; return Err(AiCreditsError::DailyActionLimitReached); } if let Some(daily_credit_limit) = plan.daily_credit_limit { if wallet.daily_credits_used + credits > daily_credit_limit { tx.rollback().await?; return Err(AiCreditsError::DailyCreditLimitReached); } } if wallet.available_credits() < credits { tx.rollback().await?; return Err(AiCreditsError::InsufficientCredits); } sqlx::query( r#" UPDATE user_ai_subscriptions SET reserved_credits = reserved_credits + $1, daily_actions_used = daily_actions_used + 1, daily_credits_used = daily_credits_used + $1, updated_at = NOW() WHERE id = $2 "#, ) .bind(credits) .bind(wallet.id) .execute(&mut *tx) .await?; let hold_id: Uuid = sqlx::query_scalar( r#" INSERT INTO ai_reservation_holds (wallet_id, credits_held, feature_code, request_id, idempotency_key, status) VALUES ($1, $2, $3, $4, $5, 'held') RETURNING id "#, ) .bind(wallet.id) .bind(credits) .bind(feature_code) .bind(request_id) .bind(idempotency_key) .fetch_one(&mut *tx) .await?; let balance_after = wallet.available_credits() - credits; sqlx::query( r#" INSERT INTO ai_credit_ledger (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type) VALUES ($1, 'reservation_hold', $2, $3, 'reservation', $4, 'user') "#, ) .bind(wallet.id) .bind(-credits) .bind(balance_after) .bind(hold_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(AiReservationHold { id: hold_id, wallet_id: wallet.id, credits_held: credits, feature_code: feature_code.to_string(), status: "held".to_string(), }) } /// Convert a hold into an actual charge. `actual_credits` lets a /// caller capture less than was held (e.g. a streaming response that /// used fewer tokens than the reservation's ceiling); the difference /// is simply never moved into a `_used` column, which makes it /// available again with no separate "release remainder" step needed. /// Consumption order: bonus credits first, then monthly, then /// purchased -- see Section 4.1 of the architecture doc. pub async fn try_capture_reservation( pool: &PgPool, hold_id: Uuid, actual_credits: Option, ) -> Result { let mut tx = pool.begin().await?; let hold = sqlx::query_as::<_, AiReservationHold>( "SELECT id, wallet_id, credits_held, feature_code, status FROM ai_reservation_holds WHERE id = $1 AND status = 'held' FOR UPDATE", ) .bind(hold_id) .fetch_optional(&mut *tx) .await?; let Some(hold) = hold else { tx.rollback().await?; return Ok(false); }; let captured = actual_credits.unwrap_or(hold.credits_held).min(hold.credits_held).max(0); let wallet = sqlx::query_as::<_, AiWallet>( "SELECT * FROM user_ai_subscriptions WHERE id = $1 FOR UPDATE", ) .bind(hold.wallet_id) .fetch_one(&mut *tx) .await?; let bonus_available = wallet.bonus_credits_total - wallet.bonus_credits_used; let from_bonus = captured.min(bonus_available.max(0)); let remaining = captured - from_bonus; let monthly_available = wallet.monthly_credits_total - wallet.monthly_credits_used; let from_monthly = remaining.min(monthly_available.max(0)); let from_purchased = remaining - from_monthly; sqlx::query( r#" UPDATE user_ai_subscriptions SET reserved_credits = reserved_credits - $1, bonus_credits_used = bonus_credits_used + $2, monthly_credits_used = monthly_credits_used + $3, purchased_credits_used = purchased_credits_used + $4, lifetime_used_credits = lifetime_used_credits + $5, updated_at = NOW() WHERE id = $6 "#, ) .bind(hold.credits_held) .bind(from_bonus) .bind(from_monthly) .bind(from_purchased) .bind(captured) .bind(wallet.id) .execute(&mut *tx) .await?; let balance_after = wallet.available_credits() + (hold.credits_held - captured); sqlx::query( r#" INSERT INTO ai_credit_ledger (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type) VALUES ($1, 'reservation_capture', $2, $3, 'reservation', $4, 'system') "#, ) .bind(wallet.id) .bind(-captured) .bind(balance_after) .bind(hold.id) .execute(&mut *tx) .await?; sqlx::query("UPDATE ai_reservation_holds SET status = 'captured', resolved_at = NOW() WHERE id = $1") .bind(hold.id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(true) } /// Release a hold without charging -- the LLM call failed, timed out, /// or a downstream validation error occurred after the reserve. pub async fn try_release_reservation(pool: &PgPool, hold_id: Uuid) -> Result { let mut tx = pool.begin().await?; let hold = sqlx::query_as::<_, AiReservationHold>( "SELECT id, wallet_id, credits_held, feature_code, status FROM ai_reservation_holds WHERE id = $1 AND status = 'held' FOR UPDATE", ) .bind(hold_id) .fetch_optional(&mut *tx) .await?; let Some(hold) = hold else { tx.rollback().await?; return Ok(false); }; let wallet = sqlx::query_as::<_, AiWallet>( "SELECT * FROM user_ai_subscriptions WHERE id = $1 FOR UPDATE", ) .bind(hold.wallet_id) .fetch_one(&mut *tx) .await?; // Roll back both the reservation itself AND the daily-action/ // daily-credit counters bumped when it was created (try_reserve_credits) // -- otherwise a request that fails after reserving (e.g. the LLM // call errors) still permanently burns the user's daily quota even // though they were never charged. sqlx::query( r#" UPDATE user_ai_subscriptions SET reserved_credits = reserved_credits - $1, daily_actions_used = GREATEST(daily_actions_used - 1, 0), daily_credits_used = GREATEST(daily_credits_used - $1, 0), updated_at = NOW() WHERE id = $2 "#, ) .bind(hold.credits_held) .bind(wallet.id) .execute(&mut *tx) .await?; let balance_after = wallet.available_credits() + hold.credits_held; sqlx::query( r#" INSERT INTO ai_credit_ledger (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type) VALUES ($1, 'reservation_release', $2, $3, 'reservation', $4, 'system') "#, ) .bind(wallet.id) .bind(hold.credits_held) .bind(balance_after) .bind(hold.id) .execute(&mut *tx) .await?; sqlx::query("UPDATE ai_reservation_holds SET status = 'released', resolved_at = NOW() WHERE id = $1") .bind(hold.id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(true) } /// Process a refund for a completed AI credit charge. /// /// This creates a new ledger entry with entry_type='refund' and credits back /// the specified amount to the user's wallet. The refund is tied to the /// original debit ledger entry for audit purposes. /// /// # Arguments /// * `pool` - Database connection pool /// * `user_id` - The user receiving the refund /// * `ledger_debit_entry_id` - The original debit ledger entry being refunded /// * `credits_to_refund` - Amount of credits to refund /// * `reason` - One of: provider_failure, timeout, validation_failure, manual, user_dispute /// * `initiated_by` - Who initiated: system, admin, user_dispute /// * `actor_id` - The admin/user who approved the refund (if applicable) /// * `idempotency_key` - Optional idempotency key /// * `notes` - Optional notes about the refund /// /// # Returns /// * `Ok((refund_id, AiWallet))` - The refund record ID and updated wallet /// * `Err(AiCreditsError)` - If the refund cannot be processed #[allow(clippy::too_many_arguments)] pub async fn refund_credits( pool: &PgPool, user_id: Uuid, ledger_debit_entry_id: Uuid, credits_to_refund: i32, reason: &str, initiated_by: &str, actor_id: Option, idempotency_key: Option<&str>, notes: Option<&str>, ) -> Result<(Uuid, AiWallet), AiCreditsError> { if credits_to_refund <= 0 { return Err(AiCreditsError::InvalidAmount("credits_to_refund must be positive".to_string())); } let mut tx = pool.begin().await?; // Idempotency check if let Some(key) = idempotency_key { if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM ai_refunds WHERE idempotency_key = $1") .bind(key) .fetch_one(&mut *tx) .await? > 0 { let refund_id: Uuid = sqlx::query_scalar("SELECT id FROM ai_refunds WHERE idempotency_key = $1") .bind(key) .fetch_one(&mut *tx) .await?; let wallet = sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE user_id = $1") .bind(user_id) .fetch_one(&mut *tx) .await?; tx.rollback().await?; return Ok((refund_id, wallet)); } } // Verify the original debit entry exists and is a charge let debit_entry: Option<(Uuid, i32, String)> = sqlx::query_as( "SELECT id, credits, entry_type FROM ai_credit_ledger WHERE id = $1" ) .bind(ledger_debit_entry_id) .fetch_optional(&mut *tx) .await?; let Some((_, debit_credits, _entry_type)) = debit_entry else { tx.rollback().await?; return Err(AiCreditsError::ReservationNotFound); }; // Ensure it's actually a debit (negative credits) if debit_credits >= 0 { tx.rollback().await?; return Err(AiCreditsError::InvalidAmount("ledger entry is not a debit".to_string())); } // Get wallet and lock it let wallet = sqlx::query_as::<_, AiWallet>( "SELECT * FROM user_ai_subscriptions WHERE user_id = $1 FOR UPDATE", ) .bind(user_id) .fetch_one(&mut *tx) .await?; // Refund as bonus credits (goodwill/compensation) sqlx::query( "UPDATE user_ai_subscriptions SET bonus_credits_total = bonus_credits_total + $1, updated_at = NOW() WHERE id = $2", ) .bind(credits_to_refund) .bind(wallet.id) .execute(&mut *tx) .await?; // Create refund record let refund_id: Uuid = sqlx::query_scalar( r#" INSERT INTO ai_refunds (user_id, ledger_debit_entry_id, credits_refunded, reason, initiated_by, status, notes, admin_actor_id, idempotency_key, resolved_at) VALUES ($1, $2, $3, $4, $5, 'completed', $6, $7, $8, NOW()) RETURNING id "#, ) .bind(user_id) .bind(ledger_debit_entry_id) .bind(credits_to_refund) .bind(reason) .bind(initiated_by) .bind(notes) .bind(actor_id) .bind(idempotency_key) .fetch_one(&mut *tx) .await?; // Create ledger entry for the refund let balance_after = wallet.available_credits() + credits_to_refund; sqlx::query( r#" INSERT INTO ai_credit_ledger (wallet_id, entry_type, credits, balance_after, reference_type, reference_id, actor_type, actor_id, metadata) VALUES ($1, 'refund', $2, $3, 'refund', $4, 'system', $5, jsonb_build_object('reason', $6, 'refund_id', $7)) "#, ) .bind(wallet.id) .bind(credits_to_refund) // Positive for credit .bind(balance_after) .bind(ledger_debit_entry_id) .bind(actor_id) .bind(reason) .bind(refund_id) .execute(&mut *tx) .await?; tx.commit().await?; // Return updated wallet let updated_wallet = sqlx::query_as::<_, AiWallet>("SELECT * FROM user_ai_subscriptions WHERE id = $1") .bind(wallet.id) .fetch_one(pool) .await .map_err(AiCreditsError::Db)?; Ok((refund_id, updated_wallet)) } /// List refunds for a user (paginated) pub async fn list_refunds( pool: &PgPool, user_id: Uuid, page: i64, limit: i64, ) -> Result, sqlx::Error> { let offset = (page - 1) * limit; sqlx::query_as::<_, RefundRow>( r#" SELECT r.id, r.user_id, r.ledger_debit_entry_id, r.credits_refunded, r.reason, r.initiated_by, r.status, r.notes, r.admin_actor_id, r.created_at, r.resolved_at, l.entry_type as original_entry_type, l.credits as original_credits FROM ai_refunds r JOIN ai_credit_ledger l ON r.ledger_debit_entry_id = l.id WHERE r.user_id = $1 ORDER BY r.created_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(user_id) .bind(limit) .bind(offset) .fetch_all(pool) .await } } #[derive(Debug, Serialize, Deserialize, FromRow, Clone)] pub struct RefundRow { pub id: Uuid, pub user_id: Uuid, pub ledger_debit_entry_id: Uuid, pub credits_refunded: i32, pub reason: String, pub initiated_by: String, pub status: String, pub notes: Option, pub admin_actor_id: Option, pub created_at: DateTime, pub resolved_at: Option>, pub original_entry_type: String, pub original_credits: i32, }