- 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
888 lines
32 KiB
Rust
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,
|
|
}
|