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:
parent
c795242040
commit
4f2683d085
4 changed files with 170 additions and 255 deletions
21
Cargo.lock
generated
21
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue