chore: remove legacy AI quota system

Delete legacy code that used old company_ai_usage/job_seeker_ai_usage tables:
- Remove has_active_ai_pack() - old AI_PACK pricing package check
- Remove check_and_increment_usage() - legacy daily quota tracking
- Remove BASE_AI_LIMIT, get_ai_limit_for_package constants/functions
- Remove legacy queries from ai_auto_apply() and ai_usage_status()
- Update auto_apply.rs to use user_ai_subscriptions.daily_actions_used
  instead of job_seeker_ai_usage table
- Inline apply_scheduled_downgrades() and expire_trials() in cron tasks
  to remove dependency on users crate internal modules

The new system uses user_ai_subscriptions with:
- daily_actions_used / daily_credits_used counters
- monthly_credits_total / monthly_credits_used
- purchased_credits_total / purchased_credits_used

All AI billing now flows through the wallet/ledger system with
LiteLLM integration (Tasks 1-10).
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-06 02:14:23 +05:30
parent c795242040
commit 4f2683d085
4 changed files with 170 additions and 255 deletions

21
Cargo.lock generated
View file

@ -2680,6 +2680,15 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "owned_ttf_parser"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4"
dependencies = [
"ttf-parser",
]
[[package]]
name = "p256"
version = "0.13.2"
@ -2907,6 +2916,18 @@ dependencies = [
"elliptic-curve",
]
[[package]]
name = "printpdf"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d"
dependencies = [
"js-sys",
"lopdf",
"owned_ttf_parser",
"time",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"

View file

@ -70,14 +70,73 @@ pub async fn reset_stale_daily_ai_usage(
pub async fn apply_scheduled_downgrades(
pool: &PgPool,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let applied = crate::ai_subscription::apply_scheduled_downgrades(pool)
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
let now = chrono::Utc::now();
if applied > 0 {
tracing::info!("Applied {} scheduled plan downgrades.", applied);
// Find all subscriptions with scheduled downgrades that should be applied
let to_downgrade: Vec<(uuid::Uuid, uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
r#"
SELECT w.id as wallet_id, w.user_id, w.downgrade_scheduled_to as new_plan_id
FROM user_ai_subscriptions w
WHERE w.downgrade_scheduled_to IS NOT NULL
AND w.current_period_end <= $1
"#
)
.bind(now)
.fetch_all(pool)
.await?;
let mut applied_count = 0u64;
for (wallet_id, user_id, new_plan_id) in to_downgrade {
let mut tx = pool.begin().await?;
// Get new plan credits
let new_monthly_credits: i32 = sqlx::query_scalar(
"SELECT monthly_credits FROM ai_plans WHERE id = $1"
)
.bind(new_plan_id)
.fetch_one(&mut *tx)
.await?;
// Update subscription
sqlx::query(
r#"
UPDATE user_ai_subscriptions
SET plan_id = downgrade_scheduled_to,
monthly_credits_total = $1,
monthly_credits_used = 0,
downgrade_scheduled_to = NULL,
current_period_start = NOW(),
current_period_end = NOW() + INTERVAL '30 days',
updated_at = NOW()
WHERE id = $2
"#
)
.bind(new_monthly_credits)
.bind(wallet_id)
.execute(&mut *tx)
.await?;
// Update history record
sqlx::query(
r#"
UPDATE ai_subscription_history
SET status = 'completed', effective_at = NOW()
WHERE user_id = $1 AND status = 'scheduled' AND change_type = 'downgrade'
"#
)
.bind(user_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
applied_count += 1;
}
Ok(applied as u64)
if applied_count > 0 {
tracing::info!("Applied {} scheduled plan downgrades.", applied_count);
}
Ok(applied_count)
}
/// Expire trials that have reached their end date
@ -85,14 +144,74 @@ pub async fn apply_scheduled_downgrades(
pub async fn expire_trials(
pool: &PgPool,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let expired = crate::ai_subscription::expire_trials(pool)
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
let now = chrono::Utc::now();
if expired > 0 {
tracing::info!("Expired {} trials.", expired);
// Get free plan
let free_plan: Option<(uuid::Uuid, i32)> = sqlx::query_as(
"SELECT id, monthly_credits FROM ai_plans WHERE code = 'free' AND is_active = TRUE"
)
.fetch_optional(pool)
.await?;
let Some((free_plan_id, free_monthly)) = free_plan else {
return Ok(0);
};
// Find expired trials
let expired: Vec<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
r#"
SELECT user_id, plan_id
FROM user_ai_subscriptions
WHERE is_trial = TRUE AND trial_ends_at <= $1
"#
)
.bind(now)
.fetch_all(pool)
.await?;
let mut expired_count = 0u64;
for (user_id, old_plan_id) in expired {
sqlx::query(
r#"
UPDATE user_ai_subscriptions
SET plan_id = $1,
monthly_credits_total = $2,
is_trial = FALSE,
trial_days = NULL,
trial_ends_at = NULL,
current_period_end = NOW() + INTERVAL '30 days',
updated_at = NOW()
WHERE user_id = $3
"#
)
.bind(free_plan_id)
.bind(free_monthly)
.bind(user_id)
.execute(pool)
.await?;
// Record trial expiration
sqlx::query(
r#"
INSERT INTO ai_subscription_history
(user_id, from_plan_id, to_plan_id, change_type, effective_at, status)
VALUES ($1, $2, $3, 'trial_expired', NOW(), 'completed')
"#
)
.bind(user_id)
.bind(old_plan_id)
.bind(free_plan_id)
.execute(pool)
.await?;
expired_count += 1;
}
Ok(expired as u64)
if expired_count > 0 {
tracing::info!("Expired {} trials.", expired_count);
}
Ok(expired_count)
}
/// Expire purchased credits that have passed their expiration date

View file

@ -165,29 +165,21 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box<dyn std::error::Err
COALESCE(CONCAT(u.first_name, ' ', u.last_name), 'Candidate') AS full_name,
COALESCE(js.experience_years, 0) AS experience_years,
COALESCE(js.custom_data, '{}'::jsonb) AS custom_data,
COALESCE(aas.max_applications_per_day, 3) AS daily_limit,
COALESCE(uas.daily_action_limit, 3) AS daily_limit,
COALESCE(uas.daily_actions_used, 0) AS used_today,
COALESCE((
SELECT generations_used
FROM job_seeker_ai_usage jau
WHERE jau.job_seeker_id = js.id AND jau.usage_date = CURRENT_DATE
), 0) AS used_today,
COALESCE((
SELECT (monthly_credits_total - monthly_credits_used
+ purchased_credits_total - purchased_credits_used)
FROM user_ai_subscriptions uas
WHERE uas.user_id = u.id
AND uas.status = 'active'
AND NOW() >= uas.current_period_start
AND NOW() < uas.current_period_end
ORDER BY uas.updated_at DESC
LIMIT 1
monthly_credits_total - monthly_credits_used
+ purchased_credits_total - purchased_credits_used
), 0) AS available_credits
FROM users u
INNER JOIN job_seeker_profiles js ON js.user_id = u.id
INNER JOIN ai_auto_apply_settings aas ON aas.user_id = u.id
INNER JOIN user_ai_subscriptions uas ON uas.user_id = u.id
WHERE u.status = 'ACTIVE'
AND aas.is_enabled = true
AND js.has_ai_pack = true
AND uas.status = 'active'
AND NOW() >= uas.current_period_start
AND NOW() < uas.current_period_end
"#,
)
.fetch_all(pool)
@ -388,18 +380,19 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box<dyn std::error::Err
.await
.ok();
// Track daily usage counter
// Increment daily_actions_used on subscription
sqlx::query(
r#"
INSERT INTO job_seeker_ai_usage (job_seeker_id, usage_date, generations_used)
VALUES ($1, CURRENT_DATE, 1)
ON CONFLICT (job_seeker_id, usage_date)
DO UPDATE SET
generations_used = job_seeker_ai_usage.generations_used + 1,
UPDATE user_ai_subscriptions
SET daily_actions_used = daily_actions_used + 1,
updated_at = NOW()
WHERE user_id = $1
AND status = 'active'
AND NOW() >= current_period_start
AND NOW() < current_period_end
"#,
)
.bind(seeker.profile_id)
.bind(seeker.user_id)
.execute(pool)
.await
.ok();

View file

@ -898,108 +898,7 @@ struct TicketRow {
// ── AI Pack & Rate Limit Helpers ────────────────────────────────────────────────
const BASE_AI_LIMIT: i32 = 5;
fn get_ai_limit_for_package(features: &serde_json::Value) -> i32 {
features
.get("ai_generations_per_day")
.and_then(|v| v.as_i64())
.map(|v| v as i32)
.unwrap_or(BASE_AI_LIMIT)
}
async fn has_active_ai_pack(
pool: &sqlx::PgPool,
user_role_profile_id: Uuid,
role_key: &str,
) -> (bool, i32) {
let now = chrono::Utc::now();
let result = sqlx::query_as::<_, (Option<serde_json::Value>,)>(
r#"
SELECT pp.features
FROM pricing_packages pp
JOIN payments p ON p.package_id = pp.id
WHERE pp.package_type = 'AI_PACK'
AND pp.is_active = true
AND p.user_role_profile_id = $1
AND $2 = ANY(pp.applicable_roles)
AND p.tracecoins_credited > 0
AND (pp.valid_from IS NULL OR pp.valid_from <= $3)
AND (pp.valid_until IS NULL OR pp.valid_until >= $3)
ORDER BY p.created_at DESC
LIMIT 1
"#,
)
.bind(user_role_profile_id)
.bind(role_key)
.bind(now)
.fetch_optional(pool)
.await;
match result {
Ok(Some((Some(features),))) => {
let limit = get_ai_limit_for_package(&features);
(true, limit)
}
_ => (false, BASE_AI_LIMIT),
}
}
async fn check_and_increment_usage(
pool: &sqlx::PgPool,
redis: &mut cache::RedisPool,
profile_id: Uuid,
is_company: bool,
daily_limit: i32,
) -> Result<(i32, i32), String> {
let user_id_str = profile_id.to_string();
// Fast path: check Redis first for rate limiting
let redis_allowed = ai_cache::check_ai_rate_limit(redis, &user_id_str, daily_limit as i64)
.await
.map_err(|e| e.to_string())?;
if !redis_allowed {
return Err("Daily AI generation limit reached".to_string());
}
// DB is source of truth - check and increment
let today = chrono::Utc::now().date_naive();
let table = if is_company { "company_ai_usage" } else { "job_seeker_ai_usage" };
let id_col = if is_company { "company_id" } else { "job_seeker_id" };
let current: Option<i32> = sqlx::query_scalar(&format!(
"SELECT generations_used FROM {} WHERE {} = $1 AND usage_date = $2",
table, id_col
))
.bind(profile_id)
.bind(today)
.fetch_optional(pool)
.await
.map_err(|e| e.to_string())?;
let used = current.unwrap_or(0);
if used >= daily_limit {
return Err("Daily AI generation limit reached".to_string());
}
sqlx::query(&format!(
r#"
INSERT INTO {} ({} , usage_date, generations_used)
VALUES ($1, $2, 1)
ON CONFLICT ({}, usage_date)
DO UPDATE SET generations_used = {}.generations_used + 1, updated_at = NOW()
"#,
table, id_col, id_col, table
))
.bind(profile_id)
.bind(today)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok((used + 1, daily_limit))
}
// Legacy AI pack and quota system removed - now using user_ai_subscriptions with daily_actions_used
// ── Job Field Generation (Companies) ──────────────────────────────────────────
@ -1476,40 +1375,6 @@ async fn ai_auto_apply(
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Complete your profile (name and skills required) before auto-applying" }))).into_response();
}
let (_has_pack, daily_limit) = {
let profile_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'"
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
match profile_id {
Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await,
None => (false, BASE_AI_LIMIT),
}
};
let remaining = daily_limit - {
let today = chrono::Utc::now().date_naive();
let used: Option<i32> = sqlx::query_scalar(
"SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2"
)
.bind(seeker_id)
.bind(today)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
used.unwrap_or(0)
};
if remaining < body.job_ids.len() as i32 {
return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response();
}
let _ollama_base = get_llm_base_url();
let _model = get_llm_model();
let skills_str = skills.join(", ");
@ -1789,66 +1654,7 @@ async fn ai_usage_status(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
let (is_company, profile_id) = {
if let Some(cid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM company_profiles WHERE user_id = $1")
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
{
(true, cid)
} else if let Some(sid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM job_seeker_profiles WHERE user_id = $1")
.bind(auth.user_id)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
{
(false, sid)
} else {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "No profile found" }))).into_response();
}
};
let today = chrono::Utc::now().date_naive();
let _used: Option<i32> = if is_company {
sqlx::query_scalar("SELECT generations_used FROM company_ai_usage WHERE company_id = $1 AND usage_date = $2")
.bind(profile_id)
.bind(today)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
} else {
sqlx::query_scalar("SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2")
.bind(profile_id)
.bind(today)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
};
let role_key = if is_company { "COMPANY" } else { "JOB_SEEKER" };
let (_has_pack, _daily_limit) = {
let urp_id: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2"
)
.bind(auth.user_id)
.bind(role_key)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
match urp_id {
Some(pid) => has_active_ai_pack(&state.pool, pid, role_key).await,
None => (false, BASE_AI_LIMIT),
}
};
// New plan-aware usage status.
// Plan-aware usage status using new subscription system
let status = match plans::ensure_free_subscription(
&state.pool,
auth.user_id,
@ -4575,30 +4381,6 @@ mod tests {
assert_eq!(json["has_ai_pack"], false);
}
#[test]
fn test_base_ai_limit_constant() {
assert_eq!(BASE_AI_LIMIT, 5);
}
#[test]
fn test_get_ai_limit_from_features_with_value() {
let features = serde_json::json!({"ai_generations_per_day": 20});
let limit = get_ai_limit_for_package(&features);
assert_eq!(limit, 20);
}
#[test]
fn test_get_ai_limit_from_features_defaults_to_base() {
let features = serde_json::json!({});
assert_eq!(get_ai_limit_for_package(&features), BASE_AI_LIMIT);
let features_null = serde_json::json!({"ai_generations_per_day": null});
assert_eq!(get_ai_limit_for_package(&features_null), BASE_AI_LIMIT);
let features_wrong_type = serde_json::json!({"ai_generations_per_day": "unlimited"});
assert_eq!(get_ai_limit_for_package(&features_wrong_type), BASE_AI_LIMIT);
}
#[test]
fn test_invalid_field_error() {
let json = serde_json::json!({