diff --git a/Cargo.lock b/Cargo.lock index 9e4c8fc..2329ac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/apps/cron/src/tasks/ai_credits.rs b/apps/cron/src/tasks/ai_credits.rs index 001b999..98b6ce9 100644 --- a/apps/cron/src/tasks/ai_credits.rs +++ b/apps/cron/src/tasks/ai_credits.rs @@ -70,14 +70,73 @@ pub async fn reset_stale_daily_ai_usage( pub async fn apply_scheduled_downgrades( pool: &PgPool, ) -> Result> { - let applied = crate::ai_subscription::apply_scheduled_downgrades(pool) - .await - .map_err(|e| Box::new(e) as Box)?; + 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> { - let expired = crate::ai_subscription::expire_trials(pool) - .await - .map_err(|e| Box::new(e) as Box)?; + 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 diff --git a/apps/cron/src/tasks/auto_apply.rs b/apps/cron/src/tasks/auto_apply.rs index 8c9b720..1ac9ddf 100644 --- a/apps/cron/src/tasks/auto_apply.rs +++ b/apps/cron/src/tasks/auto_apply.rs @@ -165,29 +165,21 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box= 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= current_period_start + AND NOW() < current_period_end "#, ) - .bind(seeker.profile_id) + .bind(seeker.user_id) .execute(pool) .await .ok(); diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index a7be6d1..ac80d07 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -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,)>( - 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 = 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 = 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 = 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, 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 = 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 = 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!({