nxtgauge-backend-rust/crates/db/src/models/ai_credits.rs
Ashwin Kumar Sivakumar b99651f330 fix: resolve warnings (partial)
- Fix unused variable entry_type -> _entry_type
- Remove unused imports BufMut and cache_jobs from companies handlers
- Fix doc comment on lazy_static
- Add #[allow(dead_code)] to reset_daily_actions and reset_monthly_credits
- Fix unused imports in job_seekers handlers
2026-07-06 02:55:42 +05:30

888 lines
32 KiB
Rust

//! 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<String>,
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<DateTime<Utc>>,
pub billing_cycle: String,
pub auto_renew: bool,
pub current_period_start: DateTime<Utc>,
pub current_period_end: DateTime<Utc>,
pub status: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<i32>,
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<i32>,
pub max_output_tokens: Option<i32>,
pub min_plan_code: Option<String>,
pub timeout_ms: i32,
pub fallback_model: Option<String>,
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<AiWallet, sqlx::Error> {
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<Option<AiWallet>, 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<Option<AiPlan>, 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<Uuid>,
) -> Result<bool, sqlx::Error> {
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<AiWallet, AiCreditsError> {
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<Option<AiFeatureCost>, 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<AiReservationHold, AiCreditsError> {
// 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<i32>,
) -> Result<bool, AiCreditsError> {
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<bool, AiCreditsError> {
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<Uuid>,
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<Vec<RefundRow>, 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<String>,
pub admin_actor_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
pub original_entry_type: String,
pub original_credits: i32,
}