diff --git a/Cargo.lock b/Cargo.lock index 90ac713..ade8a58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -663,6 +669,17 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1375,6 +1392,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -2117,6 +2144,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" name = "job_seekers" version = "0.1.0" dependencies = [ + "anyhow", "auth", "axum", "bytes", @@ -2125,6 +2153,7 @@ dependencies = [ "contracts", "db", "email", + "printpdf", "redis", "serde", "serde_json", @@ -2230,6 +2259,8 @@ dependencies = [ "anyhow", "axum", "chrono", + "contracts", + "jsonwebtoken 10.4.0", "reqwest", "serde", "serde_json", @@ -2309,6 +2340,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2336,6 +2373,23 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + [[package]] name = "lru" version = "0.16.4" @@ -2405,6 +2459,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.1" @@ -2427,6 +2487,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -2607,6 +2677,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" @@ -2781,6 +2860,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2824,6 +2912,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" @@ -3476,6 +3576,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -4152,6 +4258,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "tutors" version = "0.1.0" @@ -4267,6 +4379,7 @@ dependencies = [ "async-stream", "auth", "axum", + "bytes", "cache", "chrono", "contracts", @@ -4281,6 +4394,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "storage", "thiserror", "tokio", "tower", @@ -4529,6 +4643,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index 43687fb..7019a3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,3 +56,4 @@ async-trait = "0.1" bytes = "1" tower-http = "0.6" regex = "1" +printpdf = "0.7" diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 338172a..878f7ca 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -419,13 +419,80 @@ async fn list_applications( let page = q.page.unwrap_or(1); let limit = q.limit.unwrap_or(20); - match ApplicationRepository::list_by_job_id(&state.pool, job.id, q.status, page, limit).await { - Ok(apps) => (StatusCode::OK, Json(serde_json::json!({ - "data": apps, - "pagination": { "page": page, "limit": limit } - }))).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + let apps = match ApplicationRepository::list_by_job_id(&state.pool, job.id, q.status, page, limit).await { + Ok(a) => a, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + // Enrich each application with job seeker profile snapshot (no contact info) + let mut enriched = Vec::with_capacity(apps.len()); + for app in apps { + use sqlx::Row; + let row = sqlx::query( + r#" + SELECT + CONCAT(u.first_name, ' ', u.last_name) AS applicant_name, + u.avatar_url, + js.resume_url, + js.custom_data + FROM users u + LEFT JOIN job_seeker_profiles js ON js.user_id = u.id + WHERE u.id = $1 + "#, + ) + .bind(app.applicant_user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let applicant_name: String = row.as_ref() + .and_then(|r| r.try_get("applicant_name").ok()) + .unwrap_or_default(); + let avatar_url: Option = row.as_ref() + .and_then(|r| r.try_get("avatar_url").ok()); + let resume_url: Option = row.as_ref() + .and_then(|r| r.try_get("resume_url").ok()); + let custom_data: Option = row.as_ref() + .and_then(|r| r.try_get("custom_data").ok()); + + // Extract portfolio fields — never expose email/phone + let portfolio = custom_data + .as_ref() + .and_then(|d| d.get("job_seeker_portfolio")) + .cloned() + .unwrap_or(serde_json::Value::Null); + + let headline = portfolio.get("headline").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let skills = portfolio.get("skills").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let education = portfolio.get("education").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let work_experience = portfolio.get("workExperience").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let summary = portfolio.get("summary").and_then(|v| v.as_str()).unwrap_or("").to_string(); + + enriched.push(serde_json::json!({ + "id": app.id, + "job_id": app.job_id, + "applicant_user_id": app.applicant_user_id, + "status": app.status, + "cover_note": app.cover_note, + "applied_at": app.applied_at, + "updated_at": app.updated_at, + // Profile snapshot — contact fields intentionally omitted + "applicant_name": applicant_name, + "avatar_url": avatar_url, + "resume_url": resume_url, + "headline": headline, + "skills": skills, + "education": education, + "work_experience": work_experience, + "summary": summary, + })); } + + (StatusCode::OK, Json(serde_json::json!({ + "data": enriched, + "pagination": { "page": page, "limit": limit } + }))).into_response() } async fn update_application_status( diff --git a/apps/cron/src/tasks/auto_apply.rs b/apps/cron/src/tasks/auto_apply.rs index b6f9064..8c9b720 100644 --- a/apps/cron/src/tasks/auto_apply.rs +++ b/apps/cron/src/tasks/auto_apply.rs @@ -1,17 +1,16 @@ use chrono::{Duration, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; +use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; -#[derive(Debug, Deserialize)] -#[allow(dead_code)] +#[derive(Debug)] struct AutoApplyConfig { litellm_base_url: String, litellm_api_key: String, litellm_model: String, max_applications_per_run: usize, - max_daily_applications: usize, } impl AutoApplyConfig { @@ -26,10 +25,6 @@ impl AutoApplyConfig { .unwrap_or_else(|_| "5".to_string()) .parse() .unwrap_or(5), - max_daily_applications: std::env::var("AUTO_APPLY_MAX_DAILY") - .unwrap_or_else(|_| "20".to_string()) - .parse() - .unwrap_or(20), } } } @@ -75,19 +70,18 @@ async fn generate_cover_letter( ) -> Result> { let url = format!("{}/chat/completions", config.litellm_base_url.trim_end_matches('/')); + let desc_excerpt = &job_desc[..job_desc.len().min(500)]; + let prompt = format!( "Write a brief, professional cover letter (max 200 words).\n\n\ IMPORTANT: Do NOT include phone number, email, or any contact information.\n\ Only use the information provided below.\n\n\ - CANDIDATE: Name: {}, Experience: {} years, Skills: {}, Summary: {}\n\ - JOB: Title: {}, Description: {}\n\n\ + CANDIDATE: Name: {seeker_name}, Experience: {experience} years, \ + Skills: {skills}, Summary: {summary}\n\ + JOB: Title: {job_title}, Description: {desc_excerpt}\n\n\ Cover Letter:", - seeker_name, - experience, - skills.join(", "), - summary.unwrap_or(""), - job_title, - job_desc + skills = skills.join(", "), + summary = summary.unwrap_or(""), ); let payload = ChatCompletionRequest { @@ -120,34 +114,35 @@ async fn generate_cover_letter( let body: ChatCompletionResponse = res.json().await?; - if let Some(choice) = body.choices.first() { - Ok(choice.message.content.trim().to_string()) - } else { - Ok("I am excited to apply for this position.".to_string()) - } + Ok(body + .choices + .into_iter() + .next() + .map(|c| c.message.content.trim().to_string()) + .unwrap_or_else(|| "I am excited to apply for this position.".to_string())) } #[derive(Debug, sqlx::FromRow)] -struct JobSeekerWithAi { +struct EligibleSeeker { user_id: Uuid, profile_id: Uuid, full_name: String, - skills: Vec, experience_years: i32, - summary: Option, + custom_data: Value, daily_limit: i32, used_today: i32, + available_credits: i32, } #[derive(Debug, sqlx::FromRow)] -#[allow(dead_code)] -struct NewJob { +struct MatchingJob { id: Uuid, title: String, description: String, - skills: Vec, } +const AUTO_APPLY_CREDIT_COST: i32 = 5; + pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box> { let config = AutoApplyConfig::from_env(); @@ -161,103 +156,144 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box = sqlx::query_as( + // Fetch job seekers who have auto-apply enabled and sufficient AI credits + let seekers: Vec = sqlx::query_as( r#" SELECT - u.id as user_id, - jsp.id as profile_id, - COALESCE(CONCAT(jsp.first_name, ' ', jsp.last_name), 'Candidate') as full_name, - COALESCE(jsp.skills, ARRAY[]::text[]) as skills, - COALESCE(jsp.experience_years, 0) as experience_years, - jsp.summary, - COALESCE(( - SELECT p.daily_action_limit - FROM user_ai_subscriptions s - JOIN ai_plans p ON p.id = s.plan_id - WHERE s.user_id = u.id - AND COALESCE(s.role_code, 'JOB_SEEKER') = 'JOB_SEEKER' - AND s.status = 'active' - AND NOW() >= s.current_period_start - AND NOW() < s.current_period_end - ORDER BY s.updated_at DESC - LIMIT 1 - ), 10) as daily_limit, + u.id AS user_id, + js.id AS profile_id, + 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(( SELECT generations_used FROM job_seeker_ai_usage jau - WHERE jau.job_seeker_id = jsp.id - AND usage_date = CURRENT_DATE - ), 0) as used_today + 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 + ), 0) AS available_credits FROM users u - INNER JOIN job_seeker_profiles jsp ON jsp.user_id = u.id + 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 WHERE u.status = 'ACTIVE' - AND jsp.status IN ('APPROVED', 'PENDING_REVIEW') - AND jsp.skills IS NOT NULL - AND array_length(jsp.skills, 1) > 0 - AND u.email_verified = true - "# + AND aas.is_enabled = true + AND js.has_ai_pack = true + "#, ) .fetch_all(pool) .await?; if seekers.is_empty() { - tracing::info!("No job seekers with AI packs found for auto-apply"); + tracing::info!("No eligible job seekers found for auto-apply"); return Ok(()); } - tracing::info!("Found {} job seekers eligible for auto-apply", seekers.len()); + tracing::info!("{} job seekers eligible for auto-apply", seekers.len()); let mut total_applications = 0; - for seeker in seekers { + for seeker in &seekers { let remaining_today = seeker.daily_limit - seeker.used_today; if remaining_today <= 0 { - tracing::debug!("User {} has no remaining AI applications today", seeker.user_id); + tracing::debug!("User {} hit daily auto-apply limit", seeker.user_id); continue; } - let new_jobs: Vec = sqlx::query_as( + if seeker.available_credits < AUTO_APPLY_CREDIT_COST { + tracing::debug!("User {} has insufficient AI credits", seeker.user_id); + continue; + } + + // Extract skills from custom_data -> job_seeker_portfolio -> skills + let portfolio = seeker + .custom_data + .get("job_seeker_portfolio") + .cloned() + .unwrap_or(Value::Null); + + let skills: Vec = portfolio + .get("skills") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + if skills.is_empty() { + tracing::debug!("User {} has no skills listed, skipping", seeker.user_id); + continue; + } + + let summary = portfolio + .get("summary") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let max_to_apply = remaining_today + .min(config.max_applications_per_run as i32) + .min(seeker.available_credits / AUTO_APPLY_CREDIT_COST); + + // Find LIVE jobs posted in last 24h matching seeker skills, not already applied to + let matching_jobs: Vec = sqlx::query_as( r#" - SELECT j.id, j.title, j.description, COALESCE(j.skills, ARRAY[]::text[]) as skills + SELECT j.id, j.title, j.description FROM jobs j - INNER JOIN companies c ON c.id = j.company_id + INNER JOIN company_profiles c ON c.id = j.company_id WHERE j.status = 'LIVE' - AND j.created_at > $1 - AND c.status = 'APPROVED' - AND j.id NOT IN ( - SELECT job_id FROM job_applications WHERE applicant_user_id = $2 - ) - AND j.skills && $3::text[] + AND j.created_at > $1 + AND c.status = 'ACTIVE' + AND NOT EXISTS ( + SELECT 1 FROM job_applications ja + WHERE ja.job_id = j.id AND ja.applicant_user_id = $2 + ) + AND j.skills && $3::text[] ORDER BY j.created_at DESC LIMIT $4 - "# + "#, ) .bind(cutoff_time) .bind(seeker.user_id) - .bind(&seeker.skills) - .bind(std::cmp::min(remaining_today as i32, config.max_applications_per_run as i32)) + .bind(&skills) + .bind(max_to_apply) .fetch_all(pool) .await?; - if new_jobs.is_empty() { + if matching_jobs.is_empty() { continue; } tracing::info!( - "User {} has {} matching new jobs, applying to {}", + "User {} matched {} new jobs", seeker.user_id, - new_jobs.len(), - new_jobs.len().min(remaining_today as usize) + matching_jobs.len() ); - for job in new_jobs.into_iter().take(remaining_today as usize) { + let mut credits_remaining = seeker.available_credits; + + for job in &matching_jobs { + if credits_remaining < AUTO_APPLY_CREDIT_COST { + break; + } + let cover_letter = match generate_cover_letter( &client, &config, &seeker.full_name, seeker.experience_years, - &seeker.skills, - seeker.summary.as_deref(), + &skills, + summary.as_deref(), &job.title, &job.description, ) @@ -265,60 +301,121 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box cl, Err(e) => { - tracing::warn!("Failed to generate cover letter: {}", e); + tracing::warn!( + "Cover letter generation failed for user {} / job {}: {}", + seeker.user_id, + job.id, + e + ); "I am excited to apply for this position.".to_string() } }; - let result = sqlx::query( + // Insert application; ON CONFLICT DO NOTHING guards against race conditions + let applied = match sqlx::query( r#" - INSERT INTO job_applications (job_id, applicant_user_id, cover_letter, applied_via_ai) + INSERT INTO job_applications (job_id, applicant_user_id, cover_note, applied_via_ai) VALUES ($1, $2, $3, true) - ON CONFLICT (job_id, applicant_user_id) DO NOTHING - "# + ON CONFLICT DO NOTHING + "#, ) .bind(job.id) .bind(seeker.user_id) .bind(&cover_letter) .execute(pool) - .await; - - match result { - Ok(r) => { - if r.rows_affected() > 0 { - total_applications += 1; - - 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 - "# - ) - .bind(seeker.profile_id) - .execute(pool) - .await - .ok(); - - sqlx::query( - "UPDATE job_seeker_profiles SET active_application_count = active_application_count + 1 WHERE id = $1" - ) - .bind(seeker.profile_id) - .execute(pool) - .await - .ok(); - - tracing::info!("Auto-applied user {} to job {}", seeker.user_id, job.id); - } - } + .await + { + Ok(r) => r.rows_affected() > 0, Err(e) => { - tracing::error!("Failed to insert application: {}", e); + tracing::error!( + "Failed to insert application for user {} / job {}: {}", + seeker.user_id, + job.id, + e + ); + false } + }; + + if !applied { + continue; } + + total_applications += 1; + credits_remaining -= AUTO_APPLY_CREDIT_COST; + + // Log to ai_auto_apply_logs + sqlx::query( + r#" + INSERT INTO ai_auto_apply_logs + (user_id, job_id, match_score, status, credits_charged, + generated_cover_letter, applied_at) + VALUES ($1, $2, $3, 'applied', $4, $5, NOW()) + "#, + ) + .bind(seeker.user_id) + .bind(job.id) + .bind(75i32) // placeholder score; can be replaced with real ranking later + .bind(AUTO_APPLY_CREDIT_COST) + .bind(&cover_letter) + .execute(pool) + .await + .ok(); + + // Deduct credits (monthly pool first, then purchased) + sqlx::query( + r#" + UPDATE user_ai_subscriptions SET + monthly_credits_used = LEAST( + monthly_credits_used + $1, + monthly_credits_total + ), + purchased_credits_used = purchased_credits_used + GREATEST( + 0, + $1 - (monthly_credits_total - monthly_credits_used) + ), + daily_actions_used = daily_actions_used + 1, + updated_at = NOW() + WHERE user_id = $2 + AND status = 'active' + AND NOW() >= current_period_start + AND NOW() < current_period_end + "#, + ) + .bind(AUTO_APPLY_CREDIT_COST) + .bind(seeker.user_id) + .execute(pool) + .await + .ok(); + + // Track daily usage counter + 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, + updated_at = NOW() + "#, + ) + .bind(seeker.profile_id) + .execute(pool) + .await + .ok(); + + tracing::info!( + "Auto-applied user {} to job '{}' ({})", + seeker.user_id, + job.title, + job.id + ); } } - tracing::info!("Auto-apply completed. Total applications: {}", total_applications); + tracing::info!( + "Auto-apply run complete. {} applications submitted.", + total_applications + ); Ok(()) } diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 6d5881f..30d4c38 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -122,12 +122,13 @@ async fn list_requirements( async fn create_requirement( State(state): State, - _auth: AuthUser, + auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { let p_date = payload.preferred_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); let db_payload = DbCreateRequirementPayload { + created_by_user_id: auth.user_id, profession_key: payload.profession_key, title: payload.title, description: payload.description, @@ -139,7 +140,10 @@ async fn create_requirement( match RequirementRepository::create(&state.pool, db_payload).await { Ok(req) => (StatusCode::CREATED, Json(req)).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("create_requirement db error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } } } @@ -158,7 +162,7 @@ async fn get_requirement( async fn update_requirement( State(state): State, Path(id): Path, - _auth: AuthUser, + auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { let req = match RequirementRepository::get_by_id(&state.pool, id).await { @@ -166,9 +170,16 @@ async fn update_requirement( _ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(), }; + if req.created_by_user_id != Some(auth.user_id) { + return (StatusCode::FORBIDDEN, "You are not authorized to update this requirement").into_response(); + } + match RequirementRepository::update(&state.pool, req.id, payload).await { Ok(updated) => (StatusCode::OK, Json(updated)).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("update_requirement db error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } } } @@ -255,8 +266,28 @@ async fn list_requests( async fn approve_request( State(state): State, Path(lead_id): Path, - _auth: AuthUser, + auth: AuthUser, ) -> impl IntoResponse { + // Verify ownership — customer_user_id must match the authenticated user + let owner_check = sqlx::query_scalar::<_, Uuid>( + "SELECT customer_user_id FROM lead_requests WHERE id = $1" + ) + .bind(lead_id) + .fetch_optional(&state.pool) + .await; + + match owner_check { + Ok(Some(customer_user_id)) if customer_user_id != auth.user_id => { + return (StatusCode::FORBIDDEN, "You are not authorized to approve this request").into_response(); + } + Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), + Err(e) => { + tracing::error!("approve_request ownership check error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } + Ok(Some(_)) => {} // ownership confirmed + } + let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await { Ok(Some(l)) => l, _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), @@ -276,23 +307,49 @@ async fn approve_request( ).await { Ok(true) => {} Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("approve_request debit error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } } (StatusCode::OK, Json(serde_json::json!({ "lead_request": updated, }))).into_response() }, - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("approve_request update_status error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } } } async fn reject_request( State(state): State, Path(lead_id): Path, - _auth: AuthUser, + auth: AuthUser, Json(_payload): Json, ) -> impl IntoResponse { + // Verify ownership — customer_user_id must match the authenticated user + let owner_check = sqlx::query_scalar::<_, Uuid>( + "SELECT customer_user_id FROM lead_requests WHERE id = $1" + ) + .bind(lead_id) + .fetch_optional(&state.pool) + .await; + + match owner_check { + Ok(Some(customer_user_id)) if customer_user_id != auth.user_id => { + return (StatusCode::FORBIDDEN, "You are not authorized to reject this request").into_response(); + } + Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), + Err(e) => { + tracing::error!("reject_request ownership check error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } + Ok(Some(_)) => {} // ownership confirmed + } + let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await { Ok(Some(l)) => l, _ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(), @@ -313,11 +370,17 @@ async fn reject_request( ).await { Ok(true) => {} Ok(false) => return (StatusCode::CONFLICT, "Reserved Tracecoins unavailable").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("reject_request release error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } } (StatusCode::OK, Json(updated)).into_response() }, - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("reject_request update_status error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } } } diff --git a/apps/job_seekers/Cargo.toml b/apps/job_seekers/Cargo.toml index e7000aa..3889baf 100644 --- a/apps/job_seekers/Cargo.toml +++ b/apps/job_seekers/Cargo.toml @@ -20,5 +20,7 @@ storage = { path = "../../crates/storage" } email = { path = "../../crates/email" } serde_json = { workspace = true } redis = { workspace = true } -cache = { path = "../../crates/cache" } +cache = { path = "../../crates/cache" } +printpdf = { workspace = true } +anyhow = { workspace = true } diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 671c180..7131c0f 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -19,7 +19,7 @@ use contracts::auth_middleware::AuthUser; pub fn router() -> Router { Router::new() .route("/profile/me", get(get_profile).patch(update_profile)) - .route("/profile/resume", post(upload_resume)) + .route("/profile/resume", post(generate_resume)) .route("/profile/documents", post(upload_document)) .route("/profile/documents", get(list_documents)) .route("/profile/documents/{id}", delete(delete_document)) @@ -96,101 +96,192 @@ async fn update_profile( let cache_key = format!("profile:job_seeker:{}", auth.user_id); let mut redis = state.redis.clone(); let _ = redis.del::<_, ()>(&cache_key).await; + + // Auto-regenerate resume in background whenever portfolio data changes + let pool = state.pool.clone(); + let storage = state.storage.clone(); + tokio::spawn(async move { + if let Err(e) = regenerate_resume_for_user(&pool, &storage, auth.user_id).await { + tracing::warn!("Background resume generation failed for {}: {}", auth.user_id, e); + } + }); + (StatusCode::OK, Json(profile)).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } -async fn upload_resume( +/// Shared resume generation logic — called from background task after profile save. +async fn regenerate_resume_for_user( + pool: &sqlx::PgPool, + storage: &storage::StorageClient, + user_id: uuid::Uuid, +) -> Result> { + let seeker = JobSeekerRepository::get_by_user_id(pool, user_id) + .await? + .ok_or("Job seeker profile not found")?; + + let name_row = sqlx::query_as::<_, (Option, Option)>( + "SELECT first_name, last_name FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_optional(pool) + .await + .ok() + .flatten(); + + let (first, last) = name_row.unwrap_or((seeker.first_name.clone(), seeker.last_name.clone())); + let full_name = match (first.as_deref(), last.as_deref()) { + (Some(f), Some(l)) if !f.is_empty() || !l.is_empty() => format!("{} {}", f, l).trim().to_string(), + (Some(f), _) => f.to_string(), + (_, Some(l)) => l.to_string(), + _ => "Job Seeker".to_string(), + }; + + let portfolio = seeker.custom_data + .as_ref() + .and_then(|d| d.get("job_seeker_portfolio")) + .cloned() + .unwrap_or_default(); + + let headline = portfolio.get("headline").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let summary = portfolio.get("summary").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let education = portfolio.get("education").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let work_exp = portfolio.get("workExperience").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let skills = portfolio.get("skills").and_then(|v| v.as_str()).unwrap_or("").to_string(); + + let pdf_bytes = tokio::task::spawn_blocking(move || { + build_resume_pdf(&full_name, &headline, &summary, &education, &work_exp, &skills) + }) + .await??; + + let resume_url = storage + .upload("resumes", "pdf", bytes::Bytes::from(pdf_bytes), "application/pdf") + .await?; + + sqlx::query("UPDATE job_seeker_profiles SET resume_url = $1 WHERE id = $2") + .bind(&resume_url) + .bind(seeker.id) + .execute(pool) + .await?; + + Ok(resume_url) +} + +/// POST /api/jobseeker/profile/resume — kept for backward compatibility / admin use. +/// Normal path: resume is auto-regenerated after every profile save. +async fn generate_resume( State(state): State, auth: AuthUser, - mut multipart: Multipart, ) -> impl IntoResponse { - // Find the job seeker profile first so we have the profile id to update. - let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(Some(s)) => s, - Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job seeker profile not found" }))).into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), + match regenerate_resume_for_user(&state.pool, &state.storage, auth.user_id).await { + Ok(url) => (StatusCode::OK, Json(serde_json::json!({ "resume_url": url }))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), + } +} + +// ── PDF builder ─────────────────────────────────────────────────────────────── + +fn wrap_text(text: &str, max_chars: usize) -> Vec { + let mut lines = Vec::new(); + for para in text.split('\n') { + let para = para.trim(); + if para.is_empty() { lines.push(String::new()); continue; } + let words: Vec<&str> = para.split_whitespace().collect(); + let mut current = String::new(); + for word in words { + if current.is_empty() { + current.push_str(word); + } else if current.len() + 1 + word.len() <= max_chars { + current.push(' '); + current.push_str(word); + } else { + lines.push(current); + current = word.to_string(); + } + } + if !current.is_empty() { lines.push(current); } + } + lines +} + +fn build_resume_pdf( + name: &str, + headline: &str, + summary: &str, + education: &str, + work_experience: &str, + skills: &str, +) -> Result, Box> { + use printpdf::*; + use std::io::{BufWriter, Cursor}; + + let (doc, page1, layer1) = PdfDocument::new( + &format!("{} - Resume", name), + Mm(210.0), + Mm(297.0), + "Resume", + ); + let layer = doc.get_page(page1).get_layer(layer1); + + let regular = doc.add_builtin_font(BuiltinFont::Helvetica)?; + let bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + + let left: Mm = Mm(20.0); + let mut y: f32 = 276.0; + + // Name + layer.use_text(name, 20.0, left, Mm(y), &bold); + y -= 9.0; + + // Headline + if !headline.is_empty() { + layer.use_text(headline, 11.0, left, Mm(y), ®ular); + y -= 5.0; + } + + // Separator (text-based) + layer.use_text("___________________________________________________", 9.0, left, Mm(y), ®ular); + y -= 8.0; + + // Helper: render one section — header then wrapped body lines + let mut render_section = |label: &str, content: &str| { + if content.trim().is_empty() { return; } + if y < 20.0 { return; } + // Section header in bold + layer.use_text(label, 9.5, left, Mm(y), &bold); + y -= 5.5; + // Body text, word-wrapped + for line in wrap_text(content, 88) { + if y < 20.0 { break; } + if line.is_empty() { + y -= 2.5; + } else { + layer.use_text(&line, 9.5, left, Mm(y), ®ular); + y -= 5.0; + } + } + y -= 4.0; }; - // Read the multipart field named "resume" (or the first field if unnamed). - let mut file_bytes = bytes::BytesMut::new(); - let mut content_type = "application/octet-stream".to_string(); - let mut ext = "pdf".to_string(); - let mut found = false; + render_section("PROFESSIONAL SUMMARY", summary); + render_section("SKILLS", skills); + render_section("EDUCATION", education); + render_section("WORK EXPERIENCE", work_experience); - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - if name == "resume" || name == "file" || !found { - // Detect content type and extension from the field - if let Some(ct) = field.content_type() { - content_type = ct.to_string(); - ext = match ct { - "application/pdf" => "pdf", - "application/msword" => "doc", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx", - _ => "pdf", - } - .to_string(); - } else if let Some(fname) = field.file_name() { - if let Some(e) = fname.rsplit('.').next() { - ext = e.to_lowercase(); - } - } + // Privacy footer + layer.use_text( + "* Contact details are hidden. Use Nxtgauge to unlock and connect with this candidate.", + 7.5, + left, + Mm(12.0), + ®ular, + ); - let data = match field.bytes().await { - Ok(b) => b, - Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Failed to read file: {}", e) }))).into_response(), - }; - - if data.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Empty file" }))).into_response(); - } - - // 10 MB limit - if data.len() > 10 * 1024 * 1024 { - return (StatusCode::PAYLOAD_TOO_LARGE, Json(serde_json::json!({ "error": "File too large. Maximum 10 MB." }))).into_response(); - } - - file_bytes.put(data); - found = true; - break; - } - } - - if !found || file_bytes.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No resume file provided. Send a multipart field named 'resume'." }))).into_response(); - } - - // Upload to Backblaze B2 - let resume_url = match state.storage - .upload("resume", &ext, file_bytes.freeze(), &content_type) - .await - { - Ok(url) => url, - Err(e) => { - tracing::error!("B2 upload failed: {}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "File upload failed" }))).into_response(); - } - }; - - // Save resume_url to job seeker profile - let update_result = sqlx::query( - "UPDATE job_seekers SET resume_url = $1 WHERE id = $2" - ) - .bind(&resume_url) - .bind(seeker.id) - .execute(&state.pool) - .await; - - match update_result { - Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "resume_url": resume_url }))).into_response(), - Err(e) => { - tracing::error!("Failed to save resume_url to profile: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Uploaded but failed to save URL" }))).into_response() - } - } + let mut buf = Vec::new(); + doc.save(&mut BufWriter::new(Cursor::new(&mut buf)))?; + Ok(buf) } async fn browse_jobs( diff --git a/apps/leads/Cargo.toml b/apps/leads/Cargo.toml index d17b2c0..253d28a 100644 --- a/apps/leads/Cargo.toml +++ b/apps/leads/Cargo.toml @@ -16,6 +16,8 @@ uuid = { workspace = true } chrono = { workspace = true } tower-http = { version = "0.6", features = ["cors", "trace"] } reqwest = { workspace = true } +contracts = { path = "../../crates/contracts" } +jsonwebtoken = "10" [[bin]] name = "leads" diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs index 8fa8f9a..c12453c 100644 --- a/apps/leads/src/lead_requests.rs +++ b/apps/leads/src/lead_requests.rs @@ -6,6 +6,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use std::sync::Arc; @@ -27,7 +28,6 @@ pub struct SendLeadRequestPayload { #[derive(Debug, Deserialize)] pub struct SendLeadRequestAiPayload { pub lead_id: Uuid, - pub user_id: Uuid, pub profession_key: String, } @@ -101,32 +101,34 @@ fn lead_request_to_response(row: LeadRequestRow) -> LeadRequestResponse { } async fn list_lead_requests( + auth: AuthUser, State(state): State>, Query(q): Query, ) -> impl IntoResponse { + let _ = auth; // authenticated; admin listing — no ownership filter let page = q.page.unwrap_or(1); - let limit = q.limit.unwrap_or(20); + let limit = q.limit.unwrap_or(20).min(100); let offset = (page - 1) * limit; - let status_filter = q.status - .as_ref() - .map(|s| format!("AND lr.status = '{}'", s)) - .unwrap_or_default(); - - let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!( + let requests = match sqlx::query_as::<_, LeadRequestRow>( r#" SELECT lr.* FROM lead_requests lr - WHERE 1=1 {} + WHERE ($1::text IS NULL OR lr.status = $1) ORDER BY lr.created_at DESC - LIMIT {} OFFSET {} + LIMIT $2 OFFSET $3 "#, - status_filter, limit, offset - )) + ) + .bind(q.status.as_deref()) + .bind(limit) + .bind(offset) .fetch_all(&state.pool) .await { Ok(r) => r, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("list_lead_requests db error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); @@ -138,12 +140,12 @@ async fn list_lead_requests( } async fn send_lead_request( + auth: AuthUser, State(state): State>, - axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, Json(payload): Json, ) -> impl IntoResponse { - let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); - + let user_id = auth.user_id; + let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>( "SELECT id FROM user_role_profiles WHERE user_id = $1 LIMIT 1" ) @@ -153,7 +155,10 @@ async fn send_lead_request( { Ok(Some(id)) => id, Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found. Please complete your profile first.").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request profile lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let lead = match sqlx::query_as::<_, (Uuid, String, Uuid, String, i32)>( @@ -165,7 +170,10 @@ async fn send_lead_request( { Ok(Some(l)) => l, Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request lead lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; if lead.3 != "OPEN" { @@ -186,7 +194,10 @@ async fn send_lead_request( { Ok(Some(_)) => true, Ok(None) => false, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request duplicate check error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; if duplicate { @@ -201,7 +212,10 @@ async fn send_lead_request( .await { Ok(c) => c, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request request count error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; if request_count.0 >= 20 { @@ -217,7 +231,10 @@ async fn send_lead_request( { Ok(Some(w)) => w, Ok(None) => return (StatusCode::BAD_REQUEST, "Wallet not found. Please contact support.").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request wallet lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let tracecoins_cost = 25; @@ -276,15 +293,19 @@ async fn send_lead_request( let response = lead_request_to_response(req); (StatusCode::CREATED, Json(response)).into_response() } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request insert error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } } } async fn send_lead_request_ai( + auth: AuthUser, State(state): State>, Json(payload): Json, ) -> impl IntoResponse { - let user_id = payload.user_id; + let user_id = auth.user_id; let lead = match sqlx::query_as::<_, (Uuid, String, String, String, String, Option, Option)>( "SELECT id, title, description, location, profession_key, budget_min, budget_max FROM leads WHERE id = $1" @@ -295,7 +316,10 @@ async fn send_lead_request_ai( { Ok(Some(l)) => l, Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request_ai lead lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; if lead.4 != payload.profession_key { @@ -311,7 +335,10 @@ async fn send_lead_request_ai( { Ok(Some(id)) => id, Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request_ai profile lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let existing = match sqlx::query_scalar::<_, Uuid>( @@ -324,7 +351,10 @@ async fn send_lead_request_ai( { Ok(Some(_)) => true, Ok(None) => false, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request_ai duplicate check error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; if existing { return (StatusCode::CONFLICT, "You have already sent a request for this lead").into_response(); @@ -339,7 +369,10 @@ async fn send_lead_request_ai( { Ok(Some(w)) => w, Ok(None) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request_ai wallet lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let tracecoins_cost = 30; @@ -391,7 +424,7 @@ async fn send_lead_request_ai( ) .bind(payload.lead_id) .bind(user_role_profile_id) - .bind(&customer_id) // customer_user_id from the lead + .bind(&customer_id) .bind(tracecoins_cost) .bind(&ai_message) .bind(expires_at) @@ -420,7 +453,7 @@ async fn send_lead_request_ai( VALUES ($1, $2, $3, $4, $5) "# ) - .bind(&customer_id) // notify the customer + .bind(&customer_id) .bind("AI Auto-Respond Sent") .bind("Your AI-assisted response has been sent to the customer.") .bind("LEAD_REQUEST") @@ -431,7 +464,10 @@ async fn send_lead_request_ai( let response = lead_request_to_response(req); (StatusCode::CREATED, Json(response)).into_response() } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("send_lead_request_ai insert error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } } } @@ -480,11 +516,11 @@ async fn generate_ai_message( } async fn accept_lead_request( + auth: AuthUser, State(state): State>, Path(id): Path, - axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { - let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + let customer_user_id = auth.user_id; let request = match sqlx::query_as::<_, LeadRequestRow>( "SELECT * FROM lead_requests WHERE id = $1 AND status = 'PENDING'" @@ -495,10 +531,13 @@ async fn accept_lead_request( { Ok(Some(r)) => r, Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found or already processed").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("accept_lead_request fetch error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; - if request.customer_user_id != user_id { + if request.customer_user_id != customer_user_id { return (StatusCode::FORBIDDEN, "You are not authorized to accept this request").into_response(); } @@ -506,6 +545,22 @@ async fn accept_lead_request( return (StatusCode::BAD_REQUEST, "This request has expired").into_response(); } + // Resolve the professional's user_id from their role profile + let professional_user_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT user_id FROM user_role_profiles WHERE id = $1" + ) + .bind(request.user_role_profile_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(uid)) => uid, + Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + Err(e) => { + tracing::error!("accept_lead_request profile lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } + }; + let lead_acceptances: (i32,) = match sqlx::query_as( "SELECT COALESCE(current_acceptances, 0) FROM leads WHERE id = $1" ) @@ -515,7 +570,10 @@ async fn accept_lead_request( { Ok(Some(l)) => l, Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("accept_lead_request acceptances check error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; if lead_acceptances.0 >= 10 { @@ -536,6 +594,7 @@ async fn accept_lead_request( .execute(&state.pool) .await; + // Deduct the reserved coins from the professional's wallet let _ = sqlx::query( r#" UPDATE tracecoin_wallets SET @@ -545,7 +604,7 @@ async fn accept_lead_request( "# ) .bind(request.tracecoins_reserved as i64) - .bind(user_id) + .bind(professional_user_id) .execute(&state.pool) .await; @@ -562,7 +621,7 @@ async fn accept_lead_request( VALUES ($1, $2, $3, $4, $5) "# ) - .bind(user_id) + .bind(professional_user_id) .bind("Lead Request Accepted") .bind("Your lead request has been accepted! Contact details have been shared.") .bind("LEAD_REQUEST") @@ -577,11 +636,11 @@ async fn accept_lead_request( } async fn reject_lead_request( + auth: AuthUser, State(state): State>, Path(id): Path, - axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { - let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + let customer_user_id = auth.user_id; let request = match sqlx::query_as::<_, LeadRequestRow>( "SELECT * FROM lead_requests WHERE id = $1 AND status = 'PENDING'" @@ -592,13 +651,32 @@ async fn reject_lead_request( { Ok(Some(r)) => r, Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found or already processed").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("reject_lead_request fetch error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; - if request.customer_user_id != user_id { + if request.customer_user_id != customer_user_id { return (StatusCode::FORBIDDEN, "You are not authorized to reject this request").into_response(); } + // Resolve the professional's user_id to refund their coins + let professional_user_id = match sqlx::query_scalar::<_, Uuid>( + "SELECT user_id FROM user_role_profiles WHERE id = $1" + ) + .bind(request.user_role_profile_id) + .fetch_optional(&state.pool) + .await + { + Ok(Some(uid)) => uid, + Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + Err(e) => { + tracing::error!("reject_lead_request profile lookup error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } + }; + let _ = sqlx::query( "UPDATE lead_requests SET status = 'REJECTED', rejected_at = NOW(), rejected_reason = 'Rejected by customer', updated_at = NOW() WHERE id = $1" ) @@ -606,6 +684,7 @@ async fn reject_lead_request( .execute(&state.pool) .await; + // Refund reserved coins to the professional's wallet let _ = sqlx::query( r#" UPDATE tracecoin_wallets SET @@ -616,7 +695,7 @@ async fn reject_lead_request( "# ) .bind(request.tracecoins_reserved as i64) - .bind(user_id) + .bind(professional_user_id) .execute(&state.pool) .await; @@ -626,7 +705,7 @@ async fn reject_lead_request( VALUES ($1, $2, $3, $4, $5) "# ) - .bind(user_id) + .bind(professional_user_id) .bind("Lead Request Rejected") .bind("Your lead request was not accepted. Tracecoins have been refunded.") .bind("LEAD_REQUEST") @@ -641,36 +720,37 @@ async fn reject_lead_request( } async fn my_requests( + auth: AuthUser, State(state): State>, Query(q): Query, - axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { - let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + let user_id = auth.user_id; let page = q.page.unwrap_or(1); - let limit = q.limit.unwrap_or(20); + let limit = q.limit.unwrap_or(20).min(100); let offset = (page - 1) * limit; - let status_filter = q.status - .as_ref() - .map(|s| format!("AND lr.status = '{}'", s)) - .unwrap_or_default(); - - let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!( + let requests = match sqlx::query_as::<_, LeadRequestRow>( r#" SELECT lr.* FROM lead_requests lr JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id - WHERE urp.user_id = $1 {} + WHERE urp.user_id = $1 + AND ($2::text IS NULL OR lr.status = $2) ORDER BY lr.created_at DESC - LIMIT {} OFFSET {} + LIMIT $3 OFFSET $4 "#, - status_filter, limit, offset - )) + ) .bind(user_id) + .bind(q.status.as_deref()) + .bind(limit) + .bind(offset) .fetch_all(&state.pool) .await { Ok(r) => r, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("my_requests db error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); @@ -682,10 +762,10 @@ async fn my_requests( } async fn my_pending_requests( + auth: AuthUser, State(state): State>, - axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { - let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + let user_id = auth.user_id; let requests = match sqlx::query_as::<_, LeadRequestRow>( r#" @@ -699,7 +779,10 @@ async fn my_pending_requests( .await { Ok(r) => r, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("my_pending_requests db error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); @@ -710,32 +793,36 @@ async fn my_pending_requests( } async fn get_customer_lead_requests( + auth: AuthUser, State(state): State>, Path(lead_id): Path, Query(q): Query, - axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { - let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); + let user_id = auth.user_id; let page = q.page.unwrap_or(1); - let limit = q.limit.unwrap_or(20); + let limit = q.limit.unwrap_or(20).min(100); let offset = (page - 1) * limit; - let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!( + let requests = match sqlx::query_as::<_, LeadRequestRow>( r#" SELECT lr.* FROM lead_requests lr WHERE lr.lead_id = $1 AND lr.customer_user_id = $2 ORDER BY lr.created_at DESC - LIMIT {} OFFSET {} + LIMIT $3 OFFSET $4 "#, - limit, offset - )) + ) .bind(lead_id) .bind(user_id) + .bind(limit) + .bind(offset) .fetch_all(&state.pool) .await { Ok(r) => r, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + Err(e) => { + tracing::error!("get_customer_lead_requests db error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } }; let requests: Vec = requests.into_iter().map(lead_request_to_response).collect(); diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index a4e54db..4aa01ca 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::net::SocketAddr; use std::sync::Arc; +use axum::http::HeaderValue; use tower_http::cors::{Any, CorsLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -163,8 +164,17 @@ async fn main() { .expect("OLLAMA_CHAT_MODEL must be set"), }); + let frontend_url: HeaderValue = std::env::var("FRONTEND_URL") + .unwrap_or_else(|_| "http://localhost:3000".to_string()) + .parse() + .expect("FRONTEND_URL is not a valid header value"); + let admin_url: HeaderValue = std::env::var("ADMIN_URL") + .unwrap_or_else(|_| "http://localhost:3001".to_string()) + .parse() + .expect("ADMIN_URL is not a valid header value"); + let cors = CorsLayer::new() - .allow_origin(Any) + .allow_origin([frontend_url, admin_url]) .allow_methods(Any) .allow_headers(Any); diff --git a/apps/payments/src/admin.rs b/apps/payments/src/admin.rs new file mode 100644 index 0000000..8746e15 --- /dev/null +++ b/apps/payments/src/admin.rs @@ -0,0 +1,851 @@ +use crate::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post}, + Json, Router, +}; +use contracts::auth_middleware::{require_admin, AuthUser}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +// ────────────────────────────────────────────────────────────────────────── +// Tax rules CRUD +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, FromRow)] +struct TaxRuleRow { + id: Uuid, + name: String, + tax_type: String, + tax_rate: f64, + applies_to: Option, + is_active: bool, + created_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +struct CreateTaxRuleRequest { + name: String, + tax_type: String, + tax_rate: f64, + applies_to: Option, + is_active: Option, +} + +async fn list_tax_rules( + _auth: AuthUser, + State(state): State, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let rows: Vec = sqlx::query_as::<_, TaxRuleRow>( + r#" + SELECT id, name, tax_type, + tax_rate::FLOAT8 AS tax_rate, + applies_to, is_active, created_at + FROM tax_rules + ORDER BY created_at DESC + "#, + ) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ "taxes": rows })))) +} + +async fn create_tax_rule( + _auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + if payload.name.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "name is required".to_string())); + } + if payload.tax_type.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "tax_type is required".to_string())); + } + if payload.tax_rate < 0.0 || payload.tax_rate > 100.0 { + return Err((StatusCode::BAD_REQUEST, "tax_rate must be between 0 and 100".to_string())); + } + + let id = Uuid::new_v4(); + let is_active = payload.is_active.unwrap_or(true); + + sqlx::query( + r#" + INSERT INTO tax_rules (id, name, tax_type, tax_rate, applies_to, is_active, created_at) + VALUES ($1, $2, $3, $4::numeric, $5, $6, NOW()) + "#, + ) + .bind(id) + .bind(payload.name.trim()) + .bind(payload.tax_type.trim().to_uppercase()) + .bind(payload.tax_rate) + .bind(payload.applies_to.as_deref().map(|s| s.trim().to_uppercase())) + .bind(is_active) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let row_map: TaxRuleRow = sqlx::query_as::<_, TaxRuleRow>( + r#" + SELECT id, name, tax_type, + tax_rate::FLOAT8 AS tax_rate, + applies_to, is_active, created_at + FROM tax_rules WHERE id = $1 + "#, + ) + .bind(id) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(( + StatusCode::CREATED, + Json(serde_json::json!({ "message": "Tax rule created.", "tax": row_map })), + )) +} + +async fn delete_tax_rule( + _auth: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let result = sqlx::query("DELETE FROM tax_rules WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Tax rule not found".to_string())); + } + + Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Tax rule deleted." })))) +} + +// ────────────────────────────────────────────────────────────────────────── +// Tracecoin ledger +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, FromRow)] +struct LedgerEntryRow { + id: Uuid, + wallet_id: Uuid, + user_id: Uuid, + user_email: Option, + entry_type: String, + amount: i32, + balance_after: Option, + reason: Option, + reference_id: Option, + actor_user_id: Option, + metadata: Option, + created_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +struct LedgerQuery { + user_id: Option, + #[serde(default)] + page: Option, + #[serde(default)] + limit: Option, +} + +async fn list_ledger( + _auth: AuthUser, + State(state): State, + Query(q): Query, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let limit = q.limit.unwrap_or(50).clamp(1, 500); + let page = q.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + + let rows = if let Some(user_id) = q.user_id { + sqlx::query_as::<_, LedgerEntryRow>( + r#" + SELECT + tl.id, tl.wallet_id, tw.user_id, u.email AS user_email, + tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason, + tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at + FROM tracecoin_ledger tl + JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id + LEFT JOIN users u ON u.id = tw.user_id + WHERE tw.user_id = $1 + ORDER BY tl.created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + } else { + sqlx::query_as::<_, LedgerEntryRow>( + r#" + SELECT + tl.id, tl.wallet_id, tw.user_id, u.email AS user_email, + tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason, + tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at + FROM tracecoin_ledger tl + JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id + LEFT JOIN users u ON u.id = tw.user_id + ORDER BY tl.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + } + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "entries": rows, + "page": page, + "limit": limit, + })), + )) +} + +// ────────────────────────────────────────────────────────────────────────── +// Orders / payments +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, FromRow)] +struct OrderRow { + id: Uuid, + user_id: Uuid, + user_email: Option, + user_name: Option, + package_id: Option, + package_name: Option, + payu_txnid: Option, + payu_mihpayid: Option, + amount_inr: i32, + tracecoins_credited: i32, + status: String, + verified_at: Option>, + created_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +struct OrdersQuery { + status: Option, + page: Option, + limit: Option, +} + +async fn list_orders( + _auth: AuthUser, + State(state): State, + Query(q): Query, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let limit = q.limit.unwrap_or(50).clamp(1, 500); + let page = q.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + + let rows = if let Some(status) = q.status.as_deref() { + sqlx::query_as::<_, OrderRow>( + r#" + SELECT + p.id, p.user_id, u.email AS user_email, + NULL::text AS user_name, + p.package_id, NULL::text AS package_name, + p.payu_txnid, p.payu_mihpayid, + p.amount_inr, p.tracecoins_credited, + p.status, p.verified_at, p.created_at + FROM payments p + LEFT JOIN users u ON u.id = p.user_id + WHERE UPPER(p.status) = UPPER($1) + ORDER BY p.created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + } else { + sqlx::query_as::<_, OrderRow>( + r#" + SELECT + p.id, p.user_id, u.email AS user_email, + NULL::text AS user_name, + p.package_id, NULL::text AS package_name, + p.payu_txnid, p.payu_mihpayid, + p.amount_inr, p.tracecoins_credited, + p.status, p.verified_at, p.created_at + FROM payments p + LEFT JOIN users u ON u.id = p.user_id + ORDER BY p.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + } + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let mapped: Vec = rows + .into_iter() + .map(|o| { + serde_json::json!({ + "id": o.id, + "order_number": o.payu_txnid.clone().unwrap_or_else(|| o.id.to_string()), + "user_id": o.user_id, + "user_email": o.user_email, + "user_name": o.user_name, + "package_id": o.package_id, + "package_name": o.package_name, + "payu_txnid": o.payu_txnid, + "payu_mihpayid": o.payu_mihpayid, + "amount_inr": o.amount_inr, + "total": o.amount_inr, + "amount": o.amount_inr, + "tracecoin_amount": o.tracecoins_credited, + "tracecoins_credited": o.tracecoins_credited, + "status": o.status, + "verified_at": o.verified_at, + "created_at": o.created_at, + }) + }) + .collect(); + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "orders": mapped, + "page": page, + "limit": limit, + })), + )) +} + +// ────────────────────────────────────────────────────────────────────────── +// Credits admin +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct BalanceQuery { + user_id: Option, + userId: Option, +} + +async fn get_credit_balance( + _auth: AuthUser, + State(state): State, + Query(q): Query, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let user_id = q.user_id.or(q.userId); + let user_id = user_id.ok_or_else(|| { + (StatusCode::BAD_REQUEST, "user_id is required".to_string()) + })?; + + let wallet = wallet::get_or_create(&state.pool, user_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "user_id": wallet.user_id, + "balance": wallet.balance, + "reserved": wallet.reserved, + "available": wallet.available, + "updated_at": wallet.updated_at, + })), + )) +} + +async fn get_credit_ledger( + _auth: AuthUser, + State(state): State, + Query(q): Query, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let user_id = q.user_id.ok_or_else(|| { + (StatusCode::BAD_REQUEST, "user_id is required".to_string()) + })?; + + let limit = q.limit.unwrap_or(50).clamp(1, 500); + let page = q.page.unwrap_or(1).max(1); + let offset = (page - 1) * limit; + + let rows = sqlx::query_as::<_, LedgerEntryRow>( + r#" + SELECT + tl.id, tl.wallet_id, tw.user_id, u.email AS user_email, + tl.type AS entry_type, tl.amount, tl.balance_after, tl.reason, + tl.reference_id, tl.actor_user_id, tl.metadata, tl.created_at + FROM tracecoin_ledger tl + JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id + LEFT JOIN users u ON u.id = tw.user_id + WHERE tw.user_id = $1 + ORDER BY tl.created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "entries": rows, + "page": page, + "limit": limit, + })), + )) +} + +#[derive(Debug, Deserialize)] +struct AdjustCreditRequest { + user_id: Uuid, + amount: i32, + #[serde(default)] + r#type: Option, + reason: String, + #[serde(default)] + reference_id: Option, +} + +async fn adjust_credits( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + if payload.reason.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "reason is required".to_string())); + } + + let adj_type = payload + .r#type + .as_deref() + .unwrap_or(if payload.amount >= 0 { "ADD" } else { "DEDUCT" }) + .to_uppercase(); + let signed_amount = match adj_type.as_str() { + "ADD" | "CREDIT" => payload.amount.unsigned_abs() as i32, + "DEDUCT" | "DEBIT" => -(payload.amount.unsigned_abs() as i32), + _ => return Err((StatusCode::BAD_REQUEST, "type must be ADD or DEDUCT".to_string())), + }; + + // Map free-text reason to one of the known enum variants so the + // immutable ledger stays self-describing. + let ledger_reason = match adj_type.as_str() { + "DEDUCT" | "DEBIT" => wallet::LedgerReason::AdminDebuct, + _ => wallet::LedgerReason::AdminCredit, + }; + + let metadata = serde_json::json!({ + "admin_reason": payload.reason.trim(), + "admin_user_id": auth.user_id, + }); + + let result = wallet::admin_adjust( + &state.pool, + payload.user_id, + signed_amount, + ledger_reason, + auth.user_id, + Some(metadata), + ) + .await; + + let result = match result { + Ok((wallet, entry)) => { + // Best-effort audit log entry. + let _ = wallet::audit( + &state.pool, + auth.user_id, + payload.user_id, + signed_amount, + &payload.reason, + entry.id, + ) + .await; + + // Best-effort user notification so the customer knows their + // balance changed (e.g. when an admin corrects an issue). + let direction = if signed_amount >= 0 { "credited" } else { "debited" }; + let abs_amount = signed_amount.unsigned_abs() as i32; + let notif_title = if signed_amount >= 0 { + "Tracecoins Credited to Your Wallet" + } else { + "Tracecoins Deducted From Your Wallet" + }; + let notif_body = format!( + "An administrator has {} {} Tracecoins to your wallet. Reason: {}. New balance: {} (available: {}).", + direction, abs_amount, payload.reason, wallet.balance, wallet.available, + ); + let _ = sqlx::query( + r#" + INSERT INTO notifications (user_id, title, body, type, reference_id) + VALUES ($1, $2, $3, 'WALLET_ADJUST', $4) + "#, + ) + .bind(payload.user_id) + .bind(notif_title) + .bind(¬if_body) + .bind("ADMIN") + .bind(entry.id) + .execute(&state.pool) + .await; + + (wallet, entry) + } + Err(e) => { + return match e { + wallet::WalletError::InsufficientFunds { available, requested } => Err(( + StatusCode::BAD_REQUEST, + format!("Insufficient balance: available={available}, requested={requested}"), + )), + wallet::WalletError::InvalidAmount(a) => Err(( + StatusCode::BAD_REQUEST, + format!("Invalid amount: {a}"), + )), + _ => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("DB error: {e}"), + )), + }; + } + }; + + let (wallet, entry) = result; + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Credits adjusted successfully.", + "user_id": payload.user_id, + "amount": signed_amount, + "direction": if signed_amount >= 0 { "credit" } else { "debit" }, + "balance": wallet.balance, + "reserved": wallet.reserved, + "available": wallet.available, + "reason": payload.reason, + "ledger_entry_id": entry.id, + "audit_logged": true, + "user_notified": true, + })), + )) +} + +#[derive(Debug, Deserialize)] +struct ReconcileQuery { + from: Option>, + to: Option>, +} + +#[derive(Debug, Serialize)] +struct ReconcileRow { + user_id: Uuid, + user_email: Option, + expected_balance: i64, + actual_balance: i32, + discrepancy: i64, +} + +async fn reconcile_credits( + _auth: AuthUser, + State(state): State, + Query(q): Query, +) -> Result { + require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let from = q + .from + .unwrap_or_else(|| chrono::Utc::now() - chrono::Duration::days(30)); + let to = q.to.unwrap_or_else(chrono::Utc::now); + + let rows: Vec<(Uuid, Option, i32, i64)> = sqlx::query_as( + r#" + SELECT + tw.user_id, + u.email AS user_email, + tw.balance AS actual_balance, + COALESCE(SUM(CASE WHEN tl.created_at BETWEEN $1 AND $2 THEN tl.amount ELSE 0 END), 0)::BIGINT AS expected_balance + FROM tracecoin_wallets tw + LEFT JOIN tracecoin_ledger tl ON tl.wallet_id = tw.id + LEFT JOIN users u ON u.id = tw.user_id + GROUP BY tw.user_id, u.email, tw.balance + "#, + ) + .bind(from) + .bind(to) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let result: Vec = rows + .into_iter() + .map(|(user_id, user_email, actual_balance, expected_balance)| { + let actual = actual_balance as i64; + ReconcileRow { + user_id, + user_email, + expected_balance, + actual_balance, + discrepancy: expected_balance - actual, + } + }) + .collect(); + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "results": result, + "from": from, + "to": to, + })), + )) +} + +// ────────────────────────────────────────────────────────────────────────── +// Invoices — admin +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct AdminInvoiceSummary { + id: Uuid, + invoice_number: String, + payment_id: Uuid, + user_id: Uuid, + user_email: Option, + status: String, + currency: String, + invoice_type: String, + subtotal: i64, + discount_amount: i64, + cgst_amount: i64, + sgst_amount: i64, + igst_amount: i64, + total: i64, + customer_name: Option, + customer_gstin: Option, + pdf_object_key: Option, + issued_at: chrono::DateTime, + paid_at: Option>, +} + +#[derive(Debug, Deserialize)] +struct ListInvoicesQuery { + status: Option, + page: Option, + limit: Option, +} + +async fn list_invoices( + auth: AuthUser, + State(state): State, + Query(q): Query, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let limit = q.limit.unwrap_or(50).clamp(1, 200); + let page = q.page.unwrap_or(1).max(1); + + let invoices = invoice::service::InvoiceService::list_all( + &state.pool, + q.status.as_deref(), + page, + limit, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let mut summaries = Vec::with_capacity(invoices.len()); + for inv in invoices { + let user_email: Option = sqlx::query_scalar( + "SELECT email FROM users WHERE id = $1", + ) + .bind(inv.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + summaries.push(AdminInvoiceSummary { + id: inv.id, + invoice_number: inv.invoice_number, + payment_id: inv.payment_id, + user_id: inv.user_id, + user_email, + status: inv.status, + currency: inv.currency, + invoice_type: inv.invoice_type, + subtotal: inv.subtotal, + discount_amount: inv.discount_amount, + cgst_amount: inv.cgst_amount, + sgst_amount: inv.sgst_amount, + igst_amount: inv.igst_amount, + total: inv.total, + customer_name: inv.customer_name, + customer_gstin: inv.customer_gstin, + pdf_object_key: inv.pdf_object_key, + issued_at: inv.issued_at, + paid_at: inv.paid_at, + }); + } + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "invoices": summaries, + "page": page, + "limit": limit, + })), + )) +} + +async fn get_invoice( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let inv = invoice::service::InvoiceService::get(&state.pool, id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .ok_or_else(|| (StatusCode::NOT_FOUND, "Invoice not found".to_string()))?; + let lines = invoice::service::InvoiceService::list_line_items(&state.pool, id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + let totals = invoice::service::InvoiceService::totals(&inv, &lines); + Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "invoice": inv, + "lines": lines, + "totals": totals, + })), + )) +} + +async fn get_invoice_html( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + let inv = invoice::service::InvoiceService::get(&state.pool, id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))? + .ok_or_else(|| (StatusCode::NOT_FOUND, "Invoice not found".to_string()))?; + let lines = invoice::service::InvoiceService::list_line_items(&state.pool, id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + let totals = invoice::service::InvoiceService::totals(&inv, &lines); + let html = invoice::html::render_html(&inv, &lines, &totals); + let content_type: axum::http::HeaderValue = + "text/html; charset=utf-8".parse().unwrap(); + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, content_type)], + html, + )) +} + +#[derive(Debug, Deserialize)] +struct VoidInvoicePayload { + reason: String, +} + +async fn void_invoice( + auth: AuthUser, + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + invoice::service::InvoiceService::void(&state.pool, id, auth.user_id, &payload.reason) + .await + .map_err(|e| match e { + invoice::InvoiceError::IllegalState(s) => { + (StatusCode::CONFLICT, format!("Cannot void: {s}")) + } + invoice::InvoiceError::InvalidInput(s) => (StatusCode::BAD_REQUEST, s), + other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), + })?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": "Invoice voided.", + })))) +} + +async fn mark_invoice_paid( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?; + + invoice::service::InvoiceService::mark_paid(&state.pool, id) + .await + .map_err(|e| match e { + invoice::InvoiceError::IllegalState(s) => { + (StatusCode::CONFLICT, format!("Cannot mark paid: {s}")) + } + other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), + })?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": "Invoice marked as paid.", + })))) +} + +pub fn admin_router() -> Router { + Router::new() + // Tax + .route("/api/admin/tax", get(list_tax_rules)) + .route("/api/admin/tax", post(create_tax_rule)) + .route("/api/admin/tax/:id", delete(delete_tax_rule)) + // Ledger + .route("/api/admin/ledger", get(list_ledger)) + // Orders + .route("/api/admin/orders", get(list_orders)) + // Credits + .route("/api/admin/credits/balance", get(get_credit_balance)) + .route("/api/admin/credits/ledger", get(get_credit_ledger)) + .route("/api/admin/credits/adjust", post(adjust_credits)) + .route("/api/admin/credits/reconcile", get(reconcile_credits)) + .route("/api/admin/credits/reconcile-report", get(reconcile_credits)) + .route("/api/admin/credits/reconcile/:order_id", post(reconcile_credits)) + // Invoices + .route("/api/admin/invoices", get(list_invoices)) + .route("/api/admin/invoices/:id", get(get_invoice)) + .route("/api/admin/invoices/:id/html", get(get_invoice_html)) + .route("/api/admin/invoices/:id/void", axum::routing::post(void_invoice)) + .route("/api/admin/invoices/:id/mark-paid", axum::routing::post(mark_invoice_paid)) +} diff --git a/apps/payments/src/ai_credits.rs b/apps/payments/src/ai_credits.rs index e372b54..9744d6b 100644 --- a/apps/payments/src/ai_credits.rs +++ b/apps/payments/src/ai_credits.rs @@ -170,7 +170,7 @@ async fn create_ai_credit_order( if let Err(e) = sqlx::query( r#" - INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) + INSERT INTO payments (user_id, package_id, payu_txnid, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, 0, 'PENDING') "#, ) @@ -252,9 +252,9 @@ async fn verify_ai_credit_order( let payment = match sqlx::query_as::<_, crate::PaymentRow>( r#" - SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, razorpay_payment_id + SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid FROM payments - WHERE razorpay_order_id = $1 AND status = 'PENDING' + WHERE payu_txnid = $1 AND status = 'PENDING' "#, ) .bind(&payload.order_id) @@ -308,7 +308,7 @@ async fn verify_ai_credit_order( r#" UPDATE payments SET status = 'SUCCESS', - razorpay_payment_id = $1, + payu_mihpayid = $1, verified_at = NOW() WHERE id = $2 "#, diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index acde3a2..4eb7f96 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -82,7 +82,7 @@ struct PaymentRow { tracecoins_credited: Option, amount_inr: i32, status: String, - razorpay_payment_id: Option, + payu_mihpayid: Option, } #[derive(Debug, Serialize)] @@ -287,7 +287,7 @@ async fn create_order( sqlx::query( r#" - INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) + INSERT INTO payments (user_id, package_id, payu_txnid, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, $5, 'PENDING') "#, ) @@ -335,9 +335,9 @@ async fn verify_payment( let payment = sqlx::query_as::<_, PaymentRow>( r#" - SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, razorpay_payment_id + SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid FROM payments - WHERE razorpay_order_id = $1 AND status = 'PENDING' + WHERE payu_txnid = $1 AND status = 'PENDING' "#, ) .bind(&order_id) @@ -368,7 +368,7 @@ async fn verify_payment( r#" UPDATE payments SET status = 'SUCCESS', - razorpay_payment_id = $1, + payu_mihpayid = $1, verified_at = NOW() WHERE id = $2 "#, @@ -445,9 +445,9 @@ async fn get_payment_status( ) -> Result, (StatusCode, String)> { let payment = sqlx::query_as::<_, PaymentRow>( r#" - SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, razorpay_payment_id + SELECT id, user_id, package_id, tracecoins_credited, amount_inr, status, payu_mihpayid FROM payments - WHERE razorpay_payment_id = $1 OR razorpay_order_id = $1 + WHERE payu_mihpayid = $1 OR payu_txnid = $1 ORDER BY created_at DESC LIMIT 1 "#, @@ -478,7 +478,7 @@ async fn get_payment_status( Ok(Json(PaymentStatusResponse { payment_id: payment - .razorpay_payment_id + .payu_mihpayid .unwrap_or(payment_id), status: payment.status.to_lowercase(), amount: payment.amount_inr as u64, diff --git a/apps/payments/src/packages.rs b/apps/payments/src/packages.rs index 3242d66..06084c7 100644 --- a/apps/payments/src/packages.rs +++ b/apps/payments/src/packages.rs @@ -6,6 +6,7 @@ use axum::{ routing::{delete, get, patch, post}, Json, Router, }; +use contracts::auth_middleware::{AuthUser, require_admin}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; @@ -222,9 +223,13 @@ async fn get_package( } async fn create_package( + auth: AuthUser, State(state): State, Json(payload): Json, ) -> impl IntoResponse { + if let Err(e) = require_admin(&auth) { + return e.into_response(); + } let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) { Ok(role_key) => role_key, Err(message) => return (StatusCode::BAD_REQUEST, message).into_response(), @@ -255,10 +260,14 @@ async fn create_package( } async fn update_package( + auth: AuthUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> impl IntoResponse { + if let Err(e) = require_admin(&auth) { + return e.into_response(); + } let current = match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", "")) .bind(id) .fetch_optional(&state.pool) @@ -305,9 +314,13 @@ async fn update_package( } async fn delete_package( + auth: AuthUser, State(state): State, Path(id): Path, ) -> impl IntoResponse { + if let Err(e) = require_admin(&auth) { + return e.into_response(); + } match sqlx::query("DELETE FROM pricing_packages WHERE id = $1") .bind(id) .execute(&state.pool) diff --git a/apps/ugc_content_creators/src/admin.rs b/apps/ugc_content_creators/src/admin.rs new file mode 100644 index 0000000..4d6bdf5 --- /dev/null +++ b/apps/ugc_content_creators/src/admin.rs @@ -0,0 +1,90 @@ +use contracts::ProfessionState; +use db::models::user_role_profile::UserRoleProfile; +use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Serialize)] +pub struct AdminUgcContentCreatorList { + pub id: Uuid, + pub user_role_profile_id: Uuid, + pub user_id: Uuid, + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub status: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +impl From for AdminUgcContentCreatorList { + fn from(p: UserRoleProfile) -> Self { + Self { + id: p.id, + user_role_profile_id: p.id, + user_id: p.user_id, + display_name: p.display_name, + bio: p.bio, + location: p.location, + status: p.status, + created_at: p.created_at, + updated_at: p.updated_at, + } + } +} + +pub fn router() -> Router { + Router::new() + .route("/", get(list_ugc_content_creators)) + .route("/{id}", get(get_ugc_content_creator)) +} + +async fn list_ugc_content_creators( + State(state): State, +) -> Result { + let creators = sqlx::query_as::<_, UserRoleProfile>( + r#" + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE role_key = 'ugc_content_creator' + ORDER BY created_at DESC + LIMIT 100 + "#, + ) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + let list: Vec = creators.into_iter().map(|p| p.into()).collect(); + Ok(Json(list)) +} + +async fn get_ugc_content_creator( + State(state): State, + Path(id): Path, +) -> Result { + let creator = sqlx::query_as::<_, UserRoleProfile>( + r#" + SELECT id, user_id, role_key, display_name, bio, location, + avatar_url, phone, email, status, + verification_status, approval_status, rejection_reason, + approved_at, verified_at, is_profile_public, + created_at, updated_at + FROM user_role_profiles + WHERE id = $1 AND role_key = 'ugc_content_creator' + "#, + ) + .bind(id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + + match creator { + Some(c) => Ok(Json(AdminUgcContentCreatorList::from(c))), + None => Err((StatusCode::NOT_FOUND, "UGC Content Creator not found".to_string())), + } +} diff --git a/apps/users/Cargo.toml b/apps/users/Cargo.toml index 4a45163..da85de9 100644 --- a/apps/users/Cargo.toml +++ b/apps/users/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -axum = { workspace = true } +axum = { workspace = true, features = ["multipart"] } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -18,6 +18,8 @@ auth = { path = "../../crates/auth" } email = { path = "../../crates/email" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } +bytes = { workspace = true } rand = "0.8" anyhow = { workspace = true } reqwest = { workspace = true, features = ["stream"] } diff --git a/apps/users/src/handlers/ai_phase4.rs b/apps/users/src/handlers/ai_phase4.rs index 5b0b791..bc5285a 100644 --- a/apps/users/src/handlers/ai_phase4.rs +++ b/apps/users/src/handlers/ai_phase4.rs @@ -17,7 +17,7 @@ use axum::{ Json, Router, }; use chrono::{DateTime, Utc}; -use contracts::auth_middleware::AuthUser; +use contracts::auth_middleware::{require_admin, AuthUser}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use sqlx::Row; @@ -397,13 +397,11 @@ async fn create_ab_test( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { - // TODO(phase5): proper admin role check via auth.is_admin() — for now - // we treat any authenticated user as eligible and rely on the gateway - // to gate the path. - if !auth.user_id.to_string().chars().all(|c| c.is_ascii_hexdigit() || c == '-') { + // Require admin role for A/B test creation + if require_admin(&auth).is_err() { return ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "invalid user id" })), + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Forbidden" })), ) .into_response(); } @@ -982,8 +980,14 @@ async fn switch_model( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { - // TODO(phase5): proper admin check. For now we rely on the gateway. - let _ = auth; + // Require admin role for model switching + if require_admin(&auth).is_err() { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Forbidden" })), + ) + .into_response(); + } // Validate the model actually exists by calling /api/show. let ollama_base = std::env::var("OLLAMA_BASE_URL") diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 0bb55c7..f517068 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -49,8 +49,6 @@ pub struct RegisterPayload { pub intent: Option, #[serde(alias = "role_key", alias = "roleKey")] pub profession: Option, - #[serde(default)] - pub test_mode: Option, } #[derive(Deserialize)] @@ -67,6 +65,7 @@ pub struct CheckEmailPayload { #[derive(Deserialize)] pub struct VerifyEmailPayload { pub otp: String, + pub email: Option, } #[derive(Deserialize)] @@ -166,10 +165,17 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str> } fn is_dummy_account_email(email: &str) -> bool { - email.ends_with("@demo.com") - || email == "paymentgateway@demo.com" - || email.contains("+dummy@") - || email.starts_with("dummy+") + // Only allow explicitly allowlisted demo emails, not any @demo.com address + static DEMO_EMAILS: std::sync::OnceLock> = std::sync::OnceLock::new(); + let allowed = DEMO_EMAILS.get_or_init(|| { + std::env::var("DEMO_ACCOUNT_EMAILS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect() + }); + allowed.contains(&email.to_lowercase()) } fn role_display_name_from_code(code: &str) -> String { @@ -267,7 +273,6 @@ async fn register( Json(payload): Json, ) -> Result)> { let email = payload.email.to_lowercase(); - let test_mode = payload.test_mode.unwrap_or(false); let mut redis = state.redis.clone(); // Rate limit: max 10 registrations per hour per email @@ -398,7 +403,7 @@ async fn register( status: user.status, email_verified: user.email_verified, created_at: user.created_at.to_rfc3339(), - otp: if test_mode { Some(otp) } else { None }, + otp: None, }))) } @@ -606,6 +611,13 @@ async fn verify_email( ) -> Result)> { let mut redis = state.redis.clone(); + // Rate limit OTP verification attempts by email (max 10 per 15 min) + if let Some(ref email) = payload.email { + if !cache::rate_limit::check(&mut redis, "otp_verify", email, 10, 900).await.unwrap_or(true) { + return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many verification attempts. Try again later.", "RATE_LIMITED")); + } + } + // Atomically consume OTP from Redis (GETDEL — single use, auto-expiry) let user_id_str = cache::otp::consume(&mut redis, &payload.otp) .await @@ -692,6 +704,11 @@ async fn forgot_password( ) -> Result)> { let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset code sent if email exists" }))); + let mut redis = state.redis.clone(); + if !cache::rate_limit::check(&mut redis, "forgot_password", &payload.email.to_lowercase(), 3, 900).await.unwrap_or(true) { + return Ok(silent_ok); // Silently cap to avoid email enumeration + } + let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await { Ok(u) => u, Err(_) => return Ok(silent_ok), @@ -699,7 +716,6 @@ async fn forgot_password( let code = format!("{:06}", rand::random::() % 1_000_000); tracing::info!(otp = %code, email = %user.email, "OTP generated for password reset"); - let mut redis = state.redis.clone(); cache::token::store_reset(&mut redis, &code, &user.id.to_string()) .await @@ -839,5 +855,5 @@ async fn v1_verify_otp( State(state): State, Json(payload): Json, ) -> Result)> { - verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp })).await + verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp, email: None })).await } diff --git a/apps/users/src/handlers/permissions.rs b/apps/users/src/handlers/permissions.rs index d8912b1..fa15dbd 100644 --- a/apps/users/src/handlers/permissions.rs +++ b/apps/users/src/handlers/permissions.rs @@ -1,5 +1,6 @@ use crate::AppState; use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router}; +use contracts::auth_middleware::{AuthUser, require_admin}; use serde::Serialize; pub fn router() -> Router { @@ -64,8 +65,10 @@ const MODULES: &[&str] = &[ const ACTIONS: &[&str] = &["View", "Create", "Update", "Delete"]; async fn list_permissions( + auth: AuthUser, _: axum::extract::State, ) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; let permissions: Vec = MODULES .iter() .flat_map(|module| { diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 6613934..ebf9b35 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -1,6 +1,6 @@ use crate::AppState; use axum::{ - extract::{Query, State}, + extract::{Multipart, Query, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, @@ -17,6 +17,7 @@ pub fn router() -> Router { Router::new() .route("/", get(get_profile).patch(save_profile)) .route("/submit-for-verification", post(submit_for_verification)) + .route("/photo", post(upload_photo)) } pub fn me_router() -> Router { @@ -628,6 +629,85 @@ async fn fetch_saved_profile_by_urp_id( serde_json::Value::Object(Default::default()) } +/// POST /api/profile/photo — multipart upload, updates users.avatar_url +async fn upload_photo( + auth: AuthUser, + State(state): State, + mut multipart: Multipart, +) -> impl IntoResponse { + use bytes::Bytes; + + let mut file_bytes: Option = None; + let mut content_type = String::from("image/jpeg"); + let mut extension = String::from("jpg"); + + while let Ok(Some(field)) = multipart.next_field().await { + let ct = field + .content_type() + .map(|s| s.to_string()) + .unwrap_or_else(|| "image/jpeg".to_string()); + + let ext = match ct.as_str() { + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "jpg", + }; + + if let Ok(data) = field.bytes().await { + if !data.is_empty() { + content_type = ct; + extension = ext.to_string(); + file_bytes = Some(data); + break; + } + } + } + + let bytes = match file_bytes { + Some(b) => b, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No file received" })), + ) + .into_response() + } + }; + + let url = match state + .storage + .upload("photos", &extension, bytes, &content_type) + .await + { + Ok(u) => u, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Upload failed: {e}") })), + ) + .into_response() + } + }; + + let res = sqlx::query( + "UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2", + ) + .bind(&url) + .bind(auth.user_id) + .execute(&state.pool) + .await; + + match res { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ "avatar_url": url })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + /// GET /api/me — returns the authenticated user's basic info pub async fn get_me( auth: AuthUser, @@ -637,6 +717,15 @@ pub async fn get_me( .await .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + let avatar_url: Option = sqlx::query_scalar( + "SELECT avatar_url FROM users WHERE id = $1", + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + Ok(Json(serde_json::json!({ "id": user.id, "email": user.email, @@ -644,5 +733,6 @@ pub async fn get_me( "lastName": user.last_name, "activeRole": auth.claims.active_role, "emailVerified": user.email_verified, + "avatar_url": avatar_url, }))) } diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index c5797f5..f823950 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -6,6 +6,7 @@ use axum::{ routing::get, Json, Router, }; +use contracts::auth_middleware::{AuthUser, require_admin}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -139,9 +140,11 @@ struct CurrentRoleRow { } async fn list_roles( + auth: AuthUser, State(state): State, Query(params): Query, ) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.unwrap_or(20).min(100); let offset = (page - 1) * per_page; @@ -218,9 +221,11 @@ async fn list_roles( } async fn get_role( + auth: AuthUser, State(state): State, Path(id): Path, ) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; let row = sqlx::query_as::<_, RoleDetailRow>( r#" SELECT @@ -268,9 +273,11 @@ async fn get_role( } async fn create_role( + auth: AuthUser, State(state): State, Json(payload): Json, ) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; let is_active = payload.is_active.unwrap_or(true); let can_approve = payload.can_approve_requests.unwrap_or(false); let can_manage = payload.can_manage_system_settings.unwrap_or(false); @@ -345,10 +352,12 @@ async fn create_role( } async fn update_role( + auth: AuthUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; let current = sqlx::query_as::<_, CurrentRoleRow>( r#" SELECT r.name, ir.description, ir.department_id, r.is_active, @@ -421,13 +430,15 @@ async fn update_role( } } - get_role(State(state), Path(id)).await + get_role(auth, State(state), Path(id)).await } async fn delete_role( + auth: AuthUser, State(state): State, Path(id): Path, ) -> Result { + require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?; let result = sqlx::query("DELETE FROM roles WHERE id = $1 AND audience = 'INTERNAL'") .bind(id) .execute(&state.pool) diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index a5c041c..5dffd14 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -11,9 +11,10 @@ use mail::Mailer; #[derive(Clone)] pub struct AppState { - pub pool: PgPool, - pub mail: Arc, - pub redis: cache::RedisPool, + pub pool: PgPool, + pub mail: Arc, + pub redis: cache::RedisPool, + pub storage: Arc, } #[tokio::main] @@ -46,10 +47,13 @@ async fn main() { let mailer = Arc::new(Mailer::new()); + let storage = Arc::new(storage::StorageClient::from_env().await); + let state = AppState { pool, - mail: mailer, + mail: mailer, redis, + storage, }; let app = Router::new() diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 9e63e24..75d7b7e 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -90,8 +90,14 @@ pub fn shared_routes(profession_key: &'static str) -> Router { ), ) .route("/leads/requests/me", get(my_requests)) - .route("/leads/requests/{id}", delete(cancel_request)) - .route("/leads/accepted/me", get(accepted_leads)) + .route("/leads/requests/{id}", delete({ + let pk = profession_key; + move |state, auth, path| cancel_request(state, auth, path, pk) + })) + .route("/leads/accepted/me", get({ + let pk = profession_key; + move |state, auth, query| accepted_leads(state, auth, query, pk) + })) .route("/leads/accepted/{id}", get(accepted_lead_detail)) // ── Portfolio ──────────────────────────────────────────────────────── .route("/portfolio/me", get(list_portfolio).post(create_portfolio_item)) @@ -446,8 +452,9 @@ async fn cancel_request( State(state): State, auth: AuthUser, Path(id): Path, + profession_key: &'static str, ) -> impl IntoResponse { - let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, profession_key).await { Ok(Some(p)) => p, Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), @@ -490,8 +497,9 @@ async fn accepted_leads( State(state): State, auth: AuthUser, Query(q): Query, + profession_key: &'static str, ) -> impl IntoResponse { - let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, profession_key).await { Ok(Some(p)) => p, Ok(None) => return ( StatusCode::OK, diff --git a/crates/db/migrations/20260626000000_payu_rename_columns.down.sql b/crates/db/migrations/20260626000000_payu_rename_columns.down.sql new file mode 100644 index 0000000..38af56d --- /dev/null +++ b/crates/db/migrations/20260626000000_payu_rename_columns.down.sql @@ -0,0 +1,4 @@ +-- Rollback PayU column renames back to Razorpay. + +ALTER TABLE payments RENAME COLUMN payu_txnid TO razorpay_order_id; +ALTER TABLE payments RENAME COLUMN payu_mihpayid TO razorpay_payment_id; diff --git a/crates/db/migrations/20260626000000_payu_rename_columns.up.sql b/crates/db/migrations/20260626000000_payu_rename_columns.up.sql new file mode 100644 index 0000000..27d4ea6 --- /dev/null +++ b/crates/db/migrations/20260626000000_payu_rename_columns.up.sql @@ -0,0 +1,5 @@ +-- Rename Razorpay columns to PayU equivalents on the payments table. +-- PayU uses `txnid` (transaction id) and `mihpayid` (PayU payment id) instead of Razorpay's order_id / payment_id. + +ALTER TABLE payments RENAME COLUMN razorpay_order_id TO payu_txnid; +ALTER TABLE payments RENAME COLUMN razorpay_payment_id TO payu_mihpayid; diff --git a/crates/db/migrations/20260627000000_tracecoin_security_hardening.down.sql b/crates/db/migrations/20260627000000_tracecoin_security_hardening.down.sql new file mode 100644 index 0000000..8dedcd1 --- /dev/null +++ b/crates/db/migrations/20260627000000_tracecoin_security_hardening.down.sql @@ -0,0 +1,20 @@ +-- Rollback tracecoin security hardening. + +DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_delete ON tracecoin_ledger; +DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_update ON tracecoin_ledger; +DROP FUNCTION IF EXISTS tracecoin_ledger_immutable(); +DROP FUNCTION IF EXISTS lock_tracecoin_wallet(UUID); + +DROP INDEX IF EXISTS idx_tracecoin_ledger_type; +DROP INDEX IF EXISTS idx_tracecoin_ledger_wallet_created; +DROP INDEX IF EXISTS uq_tracecoin_ledger_wallet_reference_type; + +ALTER TABLE tracecoin_ledger DROP CONSTRAINT IF EXISTS tracecoin_ledger_type_valid; +ALTER TABLE tracecoin_ledger DROP CONSTRAINT IF EXISTS tracecoin_ledger_amount_nonzero; +ALTER TABLE tracecoin_ledger DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_consistent; +ALTER TABLE tracecoin_wallets DROP CONSTRAINT IF EXISTS tracecoin_wallets_reserved_nonneg; +ALTER TABLE tracecoin_wallets DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_nonneg; + +ALTER TABLE tracecoin_ledger DROP COLUMN IF EXISTS metadata; +ALTER TABLE tracecoin_ledger DROP COLUMN IF EXISTS actor_user_id; +ALTER TABLE tracecoin_ledger DROP COLUMN IF EXISTS balance_after; diff --git a/crates/db/migrations/20260627000000_tracecoin_security_hardening.up.sql b/crates/db/migrations/20260627000000_tracecoin_security_hardening.up.sql new file mode 100644 index 0000000..38e43c7 --- /dev/null +++ b/crates/db/migrations/20260627000000_tracecoin_security_hardening.up.sql @@ -0,0 +1,144 @@ +-- ────────────────────────────────────────────────────────────────────────── +-- Tracecoin security hardening +-- ────────────────────────────────────────────────────────────────────────── +-- This migration tightens the tracecoin_wallets + tracecoin_ledger schema +-- so that the wallet/ledger can be operated on safely under concurrent +-- load. Specifically: +-- +-- 1. Prevent negative balances at the database layer (CHECK constraints). +-- 2. Prevent double-credits on payment re-verification (unique idempotency +-- key on (wallet_id, reference_id) when reference_id IS NOT NULL). +-- 3. Add a balance_after column so the ledger is a self-contained +-- append-only history with running totals (no need to SUM at read time). +-- 4. Add an actor_user_id column so we know who triggered each entry +-- (admin adjustments become auditable; system entries are NULL). +-- 5. Add metadata JSONB so callers can attach context without growing +-- the schema. +-- 6. Backfill balance_after for existing rows. +-- 7. Add a CHECK that amount != 0. +-- 8. Add a CHECK that type is one of the allowed values. +-- 9. Add an index on (user_id, created_at DESC) for fast history reads. +-- ────────────────────────────────────────────────────────────────────────── + +-- 1. CHECK constraints +ALTER TABLE tracecoin_wallets + DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_nonneg; +ALTER TABLE tracecoin_wallets + ADD CONSTRAINT tracecoin_wallets_balance_nonneg + CHECK (balance >= 0); + +ALTER TABLE tracecoin_wallets + DROP CONSTRAINT IF EXISTS tracecoin_wallets_reserved_nonneg; +ALTER TABLE tracecoin_wallets + ADD CONSTRAINT tracecoin_wallets_reserved_nonneg + CHECK (reserved >= 0); + +ALTER TABLE tracecoin_wallets + DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_consistent; +ALTER TABLE tracecoin_wallets + ADD CONSTRAINT tracecoin_wallets_balance_consistent + CHECK (balance + reserved <= 2147483647); + +-- 2. Idempotency: at most one ledger entry per (wallet, reference, type). +-- This lets a single payment credit exactly once, while still allowing +-- separate RESERVE / RELEASE / CONFIRM rows for a single lead_request. +CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_ledger_wallet_reference_type + ON tracecoin_ledger (wallet_id, reference_id, type) + WHERE reference_id IS NOT NULL; + +-- 3. balance_after column +ALTER TABLE tracecoin_ledger + ADD COLUMN IF NOT EXISTS balance_after INTEGER; + +-- 4. actor_user_id column +ALTER TABLE tracecoin_ledger + ADD COLUMN IF NOT EXISTS actor_user_id UUID REFERENCES users(id); + +-- 5. metadata JSONB +ALTER TABLE tracecoin_ledger + ADD COLUMN IF NOT EXISTS metadata JSONB; + +-- 7. amount != 0 +ALTER TABLE tracecoin_ledger + DROP CONSTRAINT IF EXISTS tracecoin_ledger_amount_nonzero; +ALTER TABLE tracecoin_ledger + ADD CONSTRAINT tracecoin_ledger_amount_nonzero + CHECK (amount <> 0); + +-- 8. type must be one of the allowed values +ALTER TABLE tracecoin_ledger + DROP CONSTRAINT IF EXISTS tracecoin_ledger_type_valid; +ALTER TABLE tracecoin_ledger + ADD CONSTRAINT tracecoin_ledger_type_valid + CHECK (type IN ('CREDIT', 'DEBIT', 'RESERVE', 'RELEASE', 'ADJUSTMENT')); + +-- 9. (user_id, created_at) covering index for history reads. +-- We don't have user_id on the ledger; we have wallet_id, so we use that. +CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_created + ON tracecoin_ledger (wallet_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_type + ON tracecoin_ledger (type); + +-- 6. Backfill balance_after for existing rows using a window function. +-- This is best-effort: if the data has gaps, balance_after is set to the +-- row's amount as a relative number; a re-compute can rebuild it later. +WITH ordered AS ( + SELECT + id, + SUM(amount) OVER ( + PARTITION BY wallet_id + ORDER BY created_at, id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running + FROM tracecoin_ledger +) +UPDATE tracecoin_ledger tl +SET balance_after = ordered.running +FROM ordered +WHERE tl.id = ordered.id + AND tl.balance_after IS NULL; + +-- Prevent further updates / deletes to ledger rows. The application code +-- must treat the ledger as append-only. +CREATE OR REPLACE FUNCTION tracecoin_ledger_immutable() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'tracecoin_ledger is append-only; updates and deletes are not allowed'; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_update ON tracecoin_ledger; +CREATE TRIGGER trg_tracecoin_ledger_no_update + BEFORE UPDATE ON tracecoin_ledger + FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable(); + +DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_delete ON tracecoin_ledger; +CREATE TRIGGER trg_tracecoin_ledger_no_delete + BEFORE DELETE ON tracecoin_ledger + FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable(); + +-- Helper function: get-or-create wallet, lock the row, return id and current +-- balance. This is the single point where concurrent access is serialized. +CREATE OR REPLACE FUNCTION lock_tracecoin_wallet(p_user_id UUID) +RETURNS TABLE(wallet_id UUID, balance INTEGER, reserved INTEGER) +LANGUAGE plpgsql +AS $$ +DECLARE + v_id UUID; +BEGIN + -- Insert if missing. ON CONFLICT DO NOTHING preserves an existing row. + INSERT INTO tracecoin_wallets (user_id, balance, reserved) + VALUES (p_user_id, 0, 0) + ON CONFLICT (user_id) DO NOTHING; + + SELECT id INTO v_id FROM tracecoin_wallets WHERE user_id = p_user_id; + + -- Lock the row for the duration of the transaction. + RETURN QUERY + SELECT tw.id, tw.balance, tw.reserved + FROM tracecoin_wallets tw + WHERE tw.id = v_id + FOR UPDATE; +END; +$$; diff --git a/crates/db/migrations/20260627010000_lead_request_reserve_linkage.down.sql b/crates/db/migrations/20260627010000_lead_request_reserve_linkage.down.sql new file mode 100644 index 0000000..9a814f0 --- /dev/null +++ b/crates/db/migrations/20260627010000_lead_request_reserve_linkage.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE lead_requests DROP COLUMN IF EXISTS reserve_ledger_id; +DROP INDEX IF EXISTS idx_lead_requests_reserve_ledger_id; diff --git a/crates/db/migrations/20260627010000_lead_request_reserve_linkage.up.sql b/crates/db/migrations/20260627010000_lead_request_reserve_linkage.up.sql new file mode 100644 index 0000000..82467da --- /dev/null +++ b/crates/db/migrations/20260627010000_lead_request_reserve_linkage.up.sql @@ -0,0 +1,14 @@ +-- ────────────────────────────────────────────────────────────────────────── +-- Lead request reservation linkage +-- ────────────────────────────────────────────────────────────────────────── +-- Store the tracecoin_ledger row id that holds the RESERVE entry for each +-- lead_request, so we can release/confirm the exact same ledger row +-- when the request is accepted, rejected, cancelled, or expired. + +ALTER TABLE lead_requests + ADD COLUMN IF NOT EXISTS reserve_ledger_id UUID + REFERENCES tracecoin_ledger(id); + +CREATE INDEX IF NOT EXISTS idx_lead_requests_reserve_ledger_id + ON lead_requests(reserve_ledger_id) + WHERE reserve_ledger_id IS NOT NULL; diff --git a/crates/db/migrations/20260627020000_invoice_hardening.down.sql b/crates/db/migrations/20260627020000_invoice_hardening.down.sql new file mode 100644 index 0000000..a039df5 --- /dev/null +++ b/crates/db/migrations/20260627020000_invoice_hardening.down.sql @@ -0,0 +1,48 @@ +-- Rollback invoice hardening. + +DROP INDEX IF EXISTS idx_invoices_payment_id; +DROP INDEX IF EXISTS idx_invoices_status_issued_at; +DROP SEQUENCE IF EXISTS invoice_number_seq; +DROP INDEX IF EXISTS uq_billing_profiles_default_per_user; +DROP INDEX IF EXISTS idx_billing_profiles_user; +DROP TABLE IF EXISTS billing_profiles; +DROP INDEX IF EXISTS idx_invoice_line_items_invoice; +DROP TABLE IF EXISTS invoice_line_items; + +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_total_matches; +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_amounts_nonneg; +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_invoice_type_valid; +ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_status_valid; + +ALTER TABLE invoices DROP COLUMN IF EXISTS updated_at; +ALTER TABLE invoices DROP COLUMN IF EXISTS created_at; +ALTER TABLE invoices DROP COLUMN IF EXISTS pdf_object_key; +ALTER TABLE invoices DROP COLUMN IF EXISTS void_reason; +ALTER TABLE invoices DROP COLUMN IF EXISTS voided_by_user_id; +ALTER TABLE invoices DROP COLUMN IF EXISTS voided_at; +ALTER TABLE invoices DROP COLUMN IF EXISTS paid_at; +ALTER TABLE invoices DROP COLUMN IF EXISTS notes; +ALTER TABLE invoices DROP COLUMN IF EXISTS discount_label; +ALTER TABLE invoices DROP COLUMN IF EXISTS discount_amount; +ALTER TABLE invoices DROP COLUMN IF EXISTS customer_state_code; +ALTER TABLE invoices DROP COLUMN IF EXISTS customer_gstin; +ALTER TABLE invoices DROP COLUMN IF EXISTS customer_billing_address; +ALTER TABLE invoices DROP COLUMN IF EXISTS customer_phone; +ALTER TABLE invoices DROP COLUMN IF EXISTS customer_email; +ALTER TABLE invoices DROP COLUMN IF EXISTS customer_name; +ALTER TABLE invoices DROP COLUMN IF EXISTS seller_state_code; +ALTER TABLE invoices DROP COLUMN IF EXISTS seller_pan; +ALTER TABLE invoices DROP COLUMN IF EXISTS seller_gstin; +ALTER TABLE invoices DROP COLUMN IF EXISTS seller_address; +ALTER TABLE invoices DROP COLUMN IF EXISTS seller_name; +ALTER TABLE invoices DROP COLUMN IF EXISTS reverse_charge; +ALTER TABLE invoices DROP COLUMN IF EXISTS seller_state; +ALTER TABLE invoices DROP COLUMN IF EXISTS place_of_supply_state; +ALTER TABLE invoices DROP COLUMN IF EXISTS igst_amount; +ALTER TABLE invoices DROP COLUMN IF EXISTS igst_rate; +ALTER TABLE invoices DROP COLUMN IF EXISTS sgst_amount; +ALTER TABLE invoices DROP COLUMN IF EXISTS sgst_rate; +ALTER TABLE invoices DROP COLUMN IF EXISTS cgst_amount; +ALTER TABLE invoices DROP COLUMN IF EXISTS cgst_rate; +ALTER TABLE invoices DROP COLUMN IF EXISTS invoice_type; +ALTER TABLE invoices DROP COLUMN IF EXISTS currency; diff --git a/crates/db/migrations/20260627020000_invoice_hardening.up.sql b/crates/db/migrations/20260627020000_invoice_hardening.up.sql new file mode 100644 index 0000000..a315ddd --- /dev/null +++ b/crates/db/migrations/20260627020000_invoice_hardening.up.sql @@ -0,0 +1,157 @@ +-- ────────────────────────────────────────────────────────────────────────── +-- Invoice hardening and feature additions +-- ────────────────────────────────────────────────────────────────────────── +-- Adds: +-- * GST breakdown (CGST/SGST for intra-state, IGST for inter-state) +-- * Seller and customer billing details +-- * Currency, place-of-supply, invoice-type, notes +-- * paid_at / voided_at / void_reason for the full lifecycle +-- * A new `invoice_line_items` table so each invoice can hold multiple +-- lines (one per package, adjustment, discount, etc.) — required for +-- GST-compliant Indian e-invoicing. +-- * A `billing_profiles` table to store reusable customer billing +-- details (so an invoice doesn't have to capture them on every sale). +-- * A `pdf_object_key` column for the generated PDF stored in B2. +-- * CHECK constraints to keep numerics sane. +-- ────────────────────────────────────────────────────────────────────────── + +-- 1. Invoice lifecycle enum +ALTER TABLE invoices + DROP CONSTRAINT IF EXISTS invoices_status_valid; +ALTER TABLE invoices + ADD CONSTRAINT invoices_status_valid + CHECK (status IN ('DRAFT', 'ISSUED', 'PAID', 'VOID', 'REFUNDED')); + +-- 2. Currency +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS currency CHAR(3) NOT NULL DEFAULT 'INR'; + +-- 3. Invoice type +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS invoice_type VARCHAR(20) NOT NULL DEFAULT 'TAX_INVOICE'; +ALTER TABLE invoices + DROP CONSTRAINT IF EXISTS invoices_invoice_type_valid; +ALTER TABLE invoices + ADD CONSTRAINT invoices_invoice_type_valid + CHECK (invoice_type IN ('TAX_INVOICE', 'BILL_OF_SUPPLY', 'CREDIT_NOTE', 'DEBIT_NOTE')); + +-- 4. GST breakdown (percentages as basis points * 100 to keep numerics) +-- Stored as numeric so admin tools can display "9% / 9%" or "18% / 18%". +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS cgst_rate NUMERIC(5,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cgst_amount INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS sgst_rate NUMERIC(5,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS sgst_amount INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS igst_rate NUMERIC(5,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS igst_amount INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS place_of_supply_state VARCHAR(2), + ADD COLUMN IF NOT EXISTS seller_state VARCHAR(2), + ADD COLUMN IF NOT EXISTS reverse_charge BOOLEAN NOT NULL DEFAULT false; + +-- 5. Seller (Nxtgauge) details +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS seller_name VARCHAR(255) NOT NULL DEFAULT 'Nxtgauge Technologies Pvt. Ltd.', + ADD COLUMN IF NOT EXISTS seller_address TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS seller_gstin VARCHAR(15), + ADD COLUMN IF NOT EXISTS seller_pan VARCHAR(10), + ADD COLUMN IF NOT EXISTS seller_state_code VARCHAR(2); + +-- 6. Customer billing details (snapshot at the time of issue) +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS customer_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS customer_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(20), + ADD COLUMN IF NOT EXISTS customer_billing_address TEXT, + ADD COLUMN IF NOT EXISTS customer_gstin VARCHAR(15), + ADD COLUMN IF NOT EXISTS customer_state_code VARCHAR(2); + +-- 7. Discount / adjustment fields +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS discount_amount INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS discount_label VARCHAR(255), + ADD COLUMN IF NOT EXISTS notes TEXT; + +-- 8. Lifecycle timestamps +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS voided_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS voided_by_user_id UUID REFERENCES users(id), + ADD COLUMN IF NOT EXISTS void_reason TEXT, + ADD COLUMN IF NOT EXISTS pdf_object_key VARCHAR(500), + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Backfill `updated_at` on existing rows. +UPDATE invoices SET updated_at = COALESCE(updated_at, issued_at, NOW()); + +-- 9. CHECKs +ALTER TABLE invoices + DROP CONSTRAINT IF EXISTS invoices_amounts_nonneg; +ALTER TABLE invoices + ADD CONSTRAINT invoices_amounts_nonneg + CHECK ( + subtotal >= 0 + AND gst_amount >= 0 + AND total >= 0 + AND cgst_amount >= 0 + AND sgst_amount >= 0 + AND igst_amount >= 0 + AND discount_amount >= 0 + ); + +ALTER TABLE invoices + DROP CONSTRAINT IF EXISTS invoices_total_matches; +ALTER TABLE invoices + ADD CONSTRAINT invoices_total_matches + CHECK (total = subtotal - discount_amount + gst_amount); + +-- 10. Line items +CREATE TABLE IF NOT EXISTS invoice_line_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + line_number INTEGER NOT NULL, + description TEXT NOT NULL, + hsn_sac_code VARCHAR(20), + quantity NUMERIC(12,3) NOT NULL DEFAULT 1, + unit_price_inr INTEGER NOT NULL, + line_subtotal_inr INTEGER NOT NULL, + tax_rate NUMERIC(5,2) NOT NULL DEFAULT 0, + line_tax_inr INTEGER NOT NULL DEFAULT 0, + line_total_inr INTEGER NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(invoice_id, line_number) +); +CREATE INDEX IF NOT EXISTS idx_invoice_line_items_invoice + ON invoice_line_items(invoice_id); + +-- 11. Customer billing profiles +CREATE TABLE IF NOT EXISTS billing_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + legal_name VARCHAR(255) NOT NULL, + email VARCHAR(255), + phone VARCHAR(20), + gstin VARCHAR(15), + pan VARCHAR(10), + billing_address TEXT NOT NULL, + state_code VARCHAR(2), + is_default BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_billing_profiles_user + ON billing_profiles(user_id); +-- At most one default per user +CREATE UNIQUE INDEX IF NOT EXISTS uq_billing_profiles_default_per_user + ON billing_profiles(user_id) + WHERE is_default = true; + +-- 12. Invoice sequence for human-readable numbers +CREATE SEQUENCE IF NOT EXISTS invoice_number_seq START 100000; + +-- 13. Indexes on invoices +CREATE INDEX IF NOT EXISTS idx_invoices_status_issued_at + ON invoices(status, issued_at DESC); +CREATE INDEX IF NOT EXISTS idx_invoices_payment_id + ON invoices(payment_id); diff --git a/crates/db/migrations/20260627030000_wallet_full.down.sql b/crates/db/migrations/20260627030000_wallet_full.down.sql new file mode 100644 index 0000000..3ed1efa --- /dev/null +++ b/crates/db/migrations/20260627030000_wallet_full.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS tracecoin_holds; +DROP TABLE IF EXISTS tracecoin_buckets; diff --git a/crates/db/migrations/20260627030000_wallet_full.up.sql b/crates/db/migrations/20260627030000_wallet_full.up.sql new file mode 100644 index 0000000..1229982 --- /dev/null +++ b/crates/db/migrations/20260627030000_wallet_full.up.sql @@ -0,0 +1,65 @@ +-- ────────────────────────────────────────────────────────────────────────── +-- Tracecoin wallet — full virtual-currency semantics +-- ────────────────────────────────────────────────────────────────────────── +-- Adds the safety machinery the wallet needs without putting a cap on +-- what users can buy or spend: +-- +-- * Holds / escrow for pending actions (lead requests, contact +-- unlocks, AI credits, etc.). Holds have an explicit lifecycle +-- `ACTIVE → SETTLED / RELEASED / EXPIRED` and an optional +-- `expires_at` so the cron can auto-release them. This is what +-- prevents double-spending and spam without limiting purchases. +-- * Per-purchase bucket tracking (FIFO consumption, optional expiry). +-- Each row is one "bucket" of tracecoins the user bought. The +-- "first in, first out" rule means tracecoins purchased earlier +-- expire first if expiry is enabled. + +-- 1. Per-purchase bucket tracking (FIFO consumption) +-- Each row is one "bucket" of tracecoins the user bought. amount_remaining +-- shrinks as the user spends; when it hits zero, the bucket is "drained" +-- and ignored by future spend operations. expires_at drives automatic +-- bucket-level expiry (NULL = no expiry for this bucket). +CREATE TABLE IF NOT EXISTS tracecoin_buckets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source VARCHAR(50) NOT NULL, + amount INTEGER NOT NULL, + amount_remaining INTEGER NOT NULL, + reference_id UUID, + source_kind VARCHAR(50) NOT NULL DEFAULT 'PURCHASE', + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (amount_remaining >= 0) +); +CREATE INDEX IF NOT EXISTS idx_tracecoin_buckets_user_expiry + ON tracecoin_buckets(user_id, expires_at NULLS LAST, created_at); +CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_buckets_reference + ON tracecoin_buckets(reference_id) + WHERE reference_id IS NOT NULL; + +-- 2. Holds / escrow for pending actions +-- ACTIVE: tracecoins are still on hold (subtracted from `available`) +-- SETTLED: the action completed and the hold was converted to a permanent +-- debit (a ledger row is written and linked) +-- RELEASED: the action was cancelled by the user or the system +-- EXPIRED: the hold passed `expires_at` and was auto-released by cron +CREATE TABLE IF NOT EXISTS tracecoin_holds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount INTEGER NOT NULL CHECK (amount > 0), + reason VARCHAR(100) NOT NULL, + reference_id UUID, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + expires_at TIMESTAMPTZ, + settled_at TIMESTAMPTZ, + settled_ledger_id UUID REFERENCES tracecoin_ledger(id), + released_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (status IN ('ACTIVE', 'SETTLED', 'RELEASED', 'EXPIRED')) +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_holds_reference + ON tracecoin_holds(reference_id) + WHERE reference_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_tracecoin_holds_user + ON tracecoin_holds(user_id, status, expires_at); diff --git a/crates/db/migrations/20260702000000_job_seeker_resume_url.down.sql b/crates/db/migrations/20260702000000_job_seeker_resume_url.down.sql new file mode 100644 index 0000000..0160596 --- /dev/null +++ b/crates/db/migrations/20260702000000_job_seeker_resume_url.down.sql @@ -0,0 +1 @@ +ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS resume_url; diff --git a/crates/db/migrations/20260702000000_job_seeker_resume_url.up.sql b/crates/db/migrations/20260702000000_job_seeker_resume_url.up.sql new file mode 100644 index 0000000..048eea4 --- /dev/null +++ b/crates/db/migrations/20260702000000_job_seeker_resume_url.up.sql @@ -0,0 +1 @@ +ALTER TABLE job_seeker_profiles ADD COLUMN IF NOT EXISTS resume_url VARCHAR(500); diff --git a/crates/db/src/models/requirement.rs b/crates/db/src/models/requirement.rs index 5fe335f..f86e6fb 100644 --- a/crates/db/src/models/requirement.rs +++ b/crates/db/src/models/requirement.rs @@ -27,6 +27,7 @@ pub struct Requirement { #[derive(Debug, Serialize, Deserialize)] pub struct CreateRequirementPayload { + pub created_by_user_id: Uuid, pub profession_key: String, pub title: String, pub description: String, @@ -56,13 +57,14 @@ impl RequirementRepository { let req = sqlx::query_as::<_, Requirement>( r#" INSERT INTO leads ( - profession_key, title, description, location, + created_by_user_id, profession_key, title, description, location, budget_inr, required_date, extra_data_json ) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * "#, ) + .bind(payload.created_by_user_id) .bind(payload.profession_key) .bind(payload.title) .bind(payload.description) diff --git a/crates/invoice/Cargo.toml b/crates/invoice/Cargo.toml new file mode 100644 index 0000000..3de602f --- /dev/null +++ b/crates/invoice/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "invoice" +version = "0.1.0" +edition.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +uuid.workspace = true +thiserror.workspace = true +sqlx = { workspace = true } +tracing.workspace = true diff --git a/crates/invoice/src/html.rs b/crates/invoice/src/html.rs new file mode 100644 index 0000000..d1392ed --- /dev/null +++ b/crates/invoice/src/html.rs @@ -0,0 +1,180 @@ +//! HTML rendering for the printable invoice. +//! +//! Renders a self-contained, print-friendly HTML document. The +//! frontend uses this for the in-browser invoice view, and the same +//! HTML is what would be saved as a PDF. + +use crate::{Invoice, InvoiceTotals, LineItem}; +use chrono::{DateTime, Utc}; + +pub fn render_html( + invoice: &Invoice, + lines: &[LineItem], + totals: &InvoiceTotals, +) -> String { + let mut out = String::with_capacity(4096); + + out.push_str(r#" + + + +Invoice "#); + out.push_str(&escape_html(&invoice.invoice_number)); + out.push_str(r#" + + + +
+
+
+

Tax Invoice

+

"#); + out.push_str(&escape_html(&invoice.invoice_number)); + out.push_str("

\n
\n
\n "); + out.push_str(&escape_html(&invoice.status)); + out.push_str(r#" +

Issued "#); + out.push_str(&invoice.issued_at.format("%d %b %Y").to_string()); + out.push_str("

\n
\n
\n\n
\n
\n

From

\n

"); + out.push_str(&escape_html(&invoice.seller_name)); + out.push_str("

\n

"); + out.push_str(&escape_html(&invoice.seller_address)); + out.push_str("

\n

"); + if let Some(gstin) = &invoice.seller_gstin { + out.push_str(&format!("GSTIN: {}
", escape_html(gstin))); + } + if let Some(pan) = &invoice.seller_pan { + out.push_str(&format!("PAN: {}", escape_html(pan))); + } + out.push_str("

\n
\n
\n

Bill To

\n

"); + out.push_str(&escape_html(invoice.customer_name.as_deref().unwrap_or("N/A"))); + out.push_str("

\n

"); + out.push_str(&escape_html( + invoice.customer_billing_address.as_deref().unwrap_or("N/A"), + )); + out.push_str("

\n

"); + if let Some(gstin) = &invoice.customer_gstin { + out.push_str(&format!("GSTIN: {}
", escape_html(gstin))); + } + if let Some(state) = &invoice.place_of_supply_state { + out.push_str(&format!("Place of Supply: {}", escape_html(state))); + } + out.push_str("

\n
\n
\n\n \n \n \n \n \n \n \n \n \n \n \n \n"); + + for line in lines { + out.push_str(&format!( + " \n \n \n \n \n \n \n \n", + line.line_number, + escape_html(&line.description), + escape_html(line.hsn_sac_code.as_deref().unwrap_or("")), + format_qty(line.quantity), + format_inr(line.unit_price_paise), + format_inr(line.subtotal_paise()), + )); + } + + out.push_str(" \n
#DescriptionHSN/SACQtyRateAmount
{}{}{}{}{}{}
\n\n
\n
Subtotal"); + out.push_str(&format_inr(totals.subtotal)); + out.push_str("
\n"); + if totals.discount > 0 { + out.push_str(&format!( + "
Discount-{}
\n", + format_inr(totals.discount), + )); + } + out.push_str(&format!( + "
Taxable value{}
\n", + format_inr(totals.taxable_value), + )); + if totals.cgst > 0 { + out.push_str(&format!( + "
CGST{}
\n", + format_inr(totals.cgst), + )); + out.push_str(&format!( + "
SGST{}
\n", + format_inr(totals.sgst), + )); + } + if totals.igst > 0 { + out.push_str(&format!( + "
IGST{}
\n", + format_inr(totals.igst), + )); + } + out.push_str(&format!( + "
Total ({}){}
\n", + invoice.currency, + format_inr(totals.total), + )); + out.push_str("
\n\n

\n This is a computer-generated invoice. No signature is required.\n

\n
\n\n\n"); + out +} + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn format_inr(paise: i64) -> String { + let sign = if paise < 0 { "-" } else { "" }; + let abs = paise.unsigned_abs(); + let rupees = abs / 100; + let paise = abs % 100; + let rupees_str = { + let s = rupees.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i != 0 && i % 3 == 0 { + out.insert(0, ','); + } + out.insert(0, c); + } + out + }; + format!("{}₹{}.{:02}", sign, rupees_str, paise) +} + +fn format_qty(q: f64) -> String { + if (q.fract() - 0.0).abs() < 0.001 { + format!("{}", q.trunc() as i64) + } else { + format!("{:.3}", q) + } +} + +#[allow(dead_code)] +fn _today_anchor() -> DateTime { + Utc::now() +} diff --git a/crates/invoice/src/lib.rs b/crates/invoice/src/lib.rs new file mode 100644 index 0000000..a9bc1fe --- /dev/null +++ b/crates/invoice/src/lib.rs @@ -0,0 +1,298 @@ +//! Invoice generation: GST computation, line items, invoice number +//! generation, and an HTML renderer that doubles as the printable +//! invoice and the source for the PDF (the frontend or a headless +//! renderer can turn the HTML into a PDF). +//! +//! Money flow: +//! * `subtotal` — sum of line subtotals, in paise (integer). +//! * `discount_amount` — coupon / promo / manual discount, in paise. +//! * Taxable value = subtotal − discount_amount. +//! * `cgst_amount` + `sgst_amount` = GST for an intra-state sale. +//! * `igst_amount` = GST for an inter-state sale. +//! * `total` = taxable value + GST. +//! +//! We never compute taxes on the discount unless the caller passes a +//! pre-discount taxable value; by default the discount reduces the +//! taxable base first, which is the standard approach. + +pub mod html; +pub mod service; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ────────────────────────────────────────────────────────────────────────── +// Tax +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TaxType { + /// No tax (inter-state supply to an unregistered person — usually not + /// applicable for B2C services, but supported for completeness). + Exempt, + /// Indian GST — split into CGST + SGST for intra-state, IGST for + /// inter-state. + Gst, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GstBreakdown { + pub taxable_value_paise: i64, + pub cgst_rate: f64, + pub cgst_amount: i64, + pub sgst_rate: f64, + pub sgst_amount: i64, + pub igst_rate: f64, + pub igst_amount: i64, + pub total_tax_paise: i64, +} + +impl GstBreakdown { + /// Compute a GST split for a given taxable value, total rate + /// (e.g. 18.0), and whether the supply is intra- or inter-state. + pub fn compute( + taxable_value_paise: i64, + total_rate_percent: f64, + inter_state: bool, + ) -> Self { + if total_rate_percent <= 0.0 || taxable_value_paise <= 0 { + return Self { + taxable_value_paise, + cgst_rate: 0.0, + cgst_amount: 0, + sgst_rate: 0.0, + sgst_amount: 0, + igst_rate: 0.0, + igst_amount: 0, + total_tax_paise: 0, + }; + } + + // i64 math in paise; round to nearest paise. + let hundredths = (total_rate_percent * 100.0).round() as i64; // basis points * 100 + let total_cents = taxable_value_paise * hundredths; + let total_tax = bank_round_div(total_cents, 10_000); + + if inter_state { + Self { + taxable_value_paise, + cgst_rate: 0.0, + cgst_amount: 0, + sgst_rate: 0.0, + sgst_amount: 0, + igst_rate: total_rate_percent, + igst_amount: total_tax, + total_tax_paise: total_tax, + } + } else { + // Split equally between CGST and SGST. + let half = total_tax / 2; + let other_half = total_tax - half; + Self { + taxable_value_paise, + cgst_rate: total_rate_percent / 2.0, + cgst_amount: half, + sgst_rate: total_rate_percent / 2.0, + sgst_amount: other_half, + igst_rate: 0.0, + igst_amount: 0, + total_tax_paise: total_tax, + } + } + } +} + +/// Banker's rounding (round half to even). +fn bank_round_div(numerator: i64, denominator: i64) -> i64 { + let q = numerator / denominator; + let r = numerator % denominator; + let double_r = r.abs() * 2; + if double_r < denominator { + q + } else if double_r > denominator { + q + r.signum() + } else { + // Exact half — round to even. + if q % 2 == 0 { + q + } else { + q + r.signum() + } + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Line items +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LineItem { + pub line_number: i32, + pub description: String, + pub hsn_sac_code: Option, + pub quantity: f64, + pub unit_price_paise: i64, + pub tax_rate_percent: f64, + pub metadata: Option, +} + +impl LineItem { + pub fn subtotal_paise(&self) -> i64 { + let qty_times = (self.quantity * 1_000_000.0).round() as i64; + bank_round_div(qty_times * self.unit_price_paise, 1_000_000) + } + + pub fn tax_paise(&self) -> i64 { + let sub = self.subtotal_paise(); + GstBreakdown::compute(sub, self.tax_rate_percent, false) + .total_tax_paise + } + + pub fn total_paise(&self) -> i64 { + self.subtotal_paise() + self.tax_paise() + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Invoice number +// ────────────────────────────────────────────────────────────────────────── + +/// Format a sequence number into a human-readable invoice number. +/// * year (`YY`) +/// * state code from place-of-supply (e.g. `MH` for Maharashtra) +/// * 6-digit sequence +/// Example: `NXT-INV-26-MH-100001` +pub fn format_invoice_number( + year: i32, + place_of_supply_state: Option<&str>, + seq: i64, +) -> String { + let yy = year % 100; + let state = place_of_supply_state.unwrap_or("XX"); + format!("NXT-INV-{:02}-{}-{:06}", yy, state, seq) +} + +// ────────────────────────────────────────────────────────────────────────── +// Invoice (read model + write model) +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Invoice { + pub id: Uuid, + pub invoice_number: String, + pub payment_id: Uuid, + pub user_id: Uuid, + pub status: String, + pub currency: String, + pub invoice_type: String, + pub subtotal: i64, + pub discount_amount: i64, + pub cgst_rate: f64, + pub cgst_amount: i64, + pub sgst_rate: f64, + pub sgst_amount: i64, + pub igst_rate: f64, + pub igst_amount: i64, + pub total: i64, + pub reverse_charge: bool, + pub seller_name: String, + pub seller_address: String, + pub seller_gstin: Option, + pub seller_pan: Option, + pub seller_state_code: Option, + pub place_of_supply_state: Option, + pub customer_name: Option, + pub customer_email: Option, + pub customer_phone: Option, + pub customer_billing_address: Option, + pub customer_gstin: Option, + pub customer_state_code: Option, + pub discount_label: Option, + pub notes: Option, + pub pdf_object_key: Option, + pub issued_at: DateTime, + pub paid_at: Option>, + pub voided_at: Option>, + pub voided_by_user_id: Option, + pub void_reason: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BillingDetails { + pub legal_name: String, + pub email: Option, + pub phone: Option, + pub gstin: Option, + pub pan: Option, + pub billing_address: String, + pub state_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SellerDetails { + pub name: String, + pub address: String, + pub gstin: Option, + pub pan: Option, + pub state_code: String, +} + +/// Totals a single invoice without a database round-trip. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvoiceTotals { + pub subtotal: i64, + pub discount: i64, + pub taxable_value: i64, + pub cgst: i64, + pub sgst: i64, + pub igst: i64, + pub total_tax: i64, + pub total: i64, +} + +pub fn compute_totals( + lines: &[LineItem], + discount: i64, + inter_state: bool, +) -> InvoiceTotals { + let subtotal: i64 = lines.iter().map(|l| l.subtotal_paise()).sum(); + let taxable = (subtotal - discount).max(0); + let combined_rate = lines + .first() + .map(|l| l.tax_rate_percent) + .unwrap_or(0.0); + let tax = GstBreakdown::compute(taxable, combined_rate, inter_state); + InvoiceTotals { + subtotal, + discount, + taxable_value: tax.taxable_value_paise, + cgst: tax.cgst_amount, + sgst: tax.sgst_amount, + igst: tax.igst_amount, + total_tax: tax.total_tax_paise, + total: tax.taxable_value_paise + tax.total_tax_paise, + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Errors +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum InvoiceError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), + #[error("invoice not found")] + NotFound, + #[error("invalid amount: {0}")] + InvalidAmount(i64), + #[error("invoice already in status {0}")] + IllegalState(String), + #[error("invalid input: {0}")] + InvalidInput(String), +} + +pub type InvoiceResult = Result; diff --git a/crates/invoice/src/service.rs b/crates/invoice/src/service.rs new file mode 100644 index 0000000..293f2a9 --- /dev/null +++ b/crates/invoice/src/service.rs @@ -0,0 +1,621 @@ +//! Invoice service: create, read, void, and PDF upload. +//! +//! The service is the single point of contact for the `invoices` table +//! and the corresponding `invoice_line_items`. It is built around an +//! `InvoiceRepository` so it can be unit-tested with an in-memory pool +//! and so the heavy logic does not depend on axum. + +use crate::{ + BillingDetails, GstBreakdown, Invoice, InvoiceError, InvoiceResult, InvoiceTotals, + LineItem, SellerDetails, format_invoice_number, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool, Postgres, Row, Transaction}; +use uuid::Uuid; + +// ────────────────────────────────────────────────────────────────────────── +// Request DTOs +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewInvoice { + pub payment_id: Uuid, + pub user_id: Uuid, + pub currency: String, + pub invoice_type: String, + pub lines: Vec, + pub discount_amount: i64, + pub discount_label: Option, + pub notes: Option, + pub customer: BillingDetails, + pub seller: SellerDetails, + pub inter_state: bool, + pub pdf_object_key: Option, +} + +// ────────────────────────────────────────────────────────────────────────── +// Database row +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, FromRow)] +pub struct InvoiceRow { + pub id: Uuid, + pub invoice_number: String, + pub payment_id: Uuid, + pub user_id: Uuid, + pub status: String, + pub currency: String, + pub invoice_type: String, + pub subtotal: i32, + pub discount_amount: i32, + pub cgst_rate: f64, + pub cgst_amount: i32, + pub sgst_rate: f64, + pub sgst_amount: i32, + pub igst_rate: f64, + pub igst_amount: i32, + pub total: i32, + pub reverse_charge: bool, + pub seller_name: String, + pub seller_address: String, + pub seller_gstin: Option, + pub seller_pan: Option, + pub seller_state_code: Option, + pub place_of_supply_state: Option, + pub customer_name: Option, + pub customer_email: Option, + pub customer_phone: Option, + pub customer_billing_address: Option, + pub customer_gstin: Option, + pub customer_state_code: Option, + pub discount_label: Option, + pub notes: Option, + pub pdf_object_key: Option, + pub issued_at: DateTime, + pub paid_at: Option>, + pub voided_at: Option>, + pub voided_by_user_id: Option, + pub void_reason: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for Invoice { + fn from(r: InvoiceRow) -> Self { + Invoice { + id: r.id, + invoice_number: r.invoice_number, + payment_id: r.payment_id, + user_id: r.user_id, + status: r.status, + currency: r.currency, + invoice_type: r.invoice_type, + subtotal: r.subtotal as i64, + discount_amount: r.discount_amount as i64, + cgst_rate: r.cgst_rate, + cgst_amount: r.cgst_amount as i64, + sgst_rate: r.sgst_rate, + sgst_amount: r.sgst_amount as i64, + igst_rate: r.igst_rate, + igst_amount: r.igst_amount as i64, + total: r.total as i64, + reverse_charge: r.reverse_charge, + seller_name: r.seller_name, + seller_address: r.seller_address, + seller_gstin: r.seller_gstin, + seller_pan: r.seller_pan, + seller_state_code: r.seller_state_code, + place_of_supply_state: r.place_of_supply_state, + customer_name: r.customer_name, + customer_email: r.customer_email, + customer_phone: r.customer_phone, + customer_billing_address: r.customer_billing_address, + customer_gstin: r.customer_gstin, + customer_state_code: r.customer_state_code, + discount_label: r.discount_label, + notes: r.notes, + pdf_object_key: r.pdf_object_key, + issued_at: r.issued_at, + paid_at: r.paid_at, + voided_at: r.voided_at, + voided_by_user_id: r.voided_by_user_id, + void_reason: r.void_reason, + created_at: r.created_at, + updated_at: r.updated_at, + } + } +} + +#[derive(Debug, FromRow)] +pub struct LineItemRow { + pub id: Uuid, + pub invoice_id: Uuid, + pub line_number: i32, + pub description: String, + pub hsn_sac_code: Option, + pub quantity: f64, + pub unit_price_inr: i32, + pub line_subtotal_inr: i32, + pub tax_rate: f64, + pub line_tax_inr: i32, + pub line_total_inr: i32, + pub metadata: Option, +} + +impl From for LineItem { + fn from(r: LineItemRow) -> Self { + LineItem { + line_number: r.line_number, + description: r.description, + hsn_sac_code: r.hsn_sac_code, + quantity: r.quantity, + unit_price_paise: r.unit_price_inr as i64, + tax_rate_percent: r.tax_rate, + metadata: r.metadata, + } + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Service +// ────────────────────────────────────────────────────────────────────────── + +pub struct InvoiceService; + +impl InvoiceService { + /// Create a new invoice. The invoice number is allocated atomically + /// from the `invoice_number_seq` sequence, so concurrent calls do not + /// race the same number. + pub async fn create( + pool: &PgPool, + new: NewInvoice, + ) -> InvoiceResult { + // Basic validation. + if new.lines.is_empty() { + return Err(InvoiceError::InvalidInput( + "invoice must have at least one line item".to_string(), + )); + } + if new.discount_amount < 0 { + return Err(InvoiceError::InvalidAmount(new.discount_amount)); + } + if new.customer.legal_name.trim().is_empty() { + return Err(InvoiceError::InvalidInput( + "customer.legal_name is required".to_string(), + )); + } + if new.seller.name.trim().is_empty() { + return Err(InvoiceError::InvalidInput( + "seller.name is required".to_string(), + )); + } + + // Compute totals. + let combined_rate = new.lines[0].tax_rate_percent; + let subtotal: i64 = new.lines.iter().map(|l| l.subtotal_paise()).sum(); + let taxable = (subtotal - new.discount_amount).max(0); + let tax = GstBreakdown::compute(taxable, combined_rate, new.inter_state); + let total = taxable + tax.total_tax_paise; + let totals = InvoiceTotals { + subtotal, + discount: new.discount_amount, + taxable_value: tax.taxable_value_paise, + cgst: tax.cgst_amount, + sgst: tax.sgst_amount, + igst: tax.igst_amount, + total_tax: tax.total_tax_paise, + total, + }; + + let mut tx = pool.begin().await?; + + // Allocate next sequence number and format the invoice number. + let seq: i64 = sqlx::query_scalar("SELECT nextval('invoice_number_seq')") + .fetch_one(&mut *tx) + .await?; + let invoice_number = format_invoice_number( + Utc::now().format("%Y").to_string().parse().unwrap_or(2026), + new.customer.state_code.as_deref(), + seq, + ); + + // Insert the invoice row. + let invoice_id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO invoices ( + id, invoice_number, payment_id, user_id, status, currency, + invoice_type, subtotal, discount_amount, discount_label, notes, + cgst_rate, cgst_amount, sgst_rate, sgst_amount, igst_rate, igst_amount, + total, reverse_charge, + seller_name, seller_address, seller_gstin, seller_pan, seller_state_code, + place_of_supply_state, + customer_name, customer_email, customer_phone, + customer_billing_address, customer_gstin, customer_state_code, + pdf_object_key, issued_at + ) VALUES ( + $1, $2, $3, $4, 'ISSUED', $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, + $19, $20, $21, $22, $23, $24, + $25, $26, $27, $28, $29, $30, $31, NOW() + ) + "#, + ) + .bind(invoice_id) + .bind(&invoice_number) + .bind(new.payment_id) + .bind(new.user_id) + .bind(&new.currency) + .bind(&new.invoice_type) + .bind(totals.subtotal as i32) + .bind(totals.discount as i32) + .bind(new.discount_label.as_deref()) + .bind(new.notes.as_deref()) + .bind(tax.cgst_rate) + .bind(tax.cgst_amount as i32) + .bind(tax.sgst_rate) + .bind(tax.sgst_amount as i32) + .bind(tax.igst_rate) + .bind(tax.igst_amount as i32) + .bind(totals.total as i64) + .bind(new.inter_state) + .bind(&new.seller.name) + .bind(&new.seller.address) + .bind(new.seller.gstin.as_deref()) + .bind(new.seller.pan.as_deref()) + .bind(&new.seller.state_code) + .bind(new.customer.state_code.as_deref()) + .bind(&new.customer.legal_name) + .bind(new.customer.email.as_deref()) + .bind(new.customer.phone.as_deref()) + .bind(&new.customer.billing_address) + .bind(new.customer.gstin.as_deref()) + .bind(new.customer.state_code.as_deref()) + .bind(new.pdf_object_key.as_deref()) + .execute(&mut *tx) + .await?; + + // Insert line items. + for line in &new.lines { + sqlx::query( + r#" + INSERT INTO invoice_line_items ( + invoice_id, line_number, description, hsn_sac_code, + quantity, unit_price_inr, line_subtotal_inr, + tax_rate, line_tax_inr, line_total_inr, metadata + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + ) + "#, + ) + .bind(invoice_id) + .bind(line.line_number) + .bind(&line.description) + .bind(line.hsn_sac_code.as_deref()) + .bind(line.quantity) + .bind(line.unit_price_paise as i64) + .bind(line.subtotal_paise() as i64) + .bind(line.tax_rate_percent) + .bind(line.tax_paise() as i64) + .bind(line.total_paise() as i64) + .bind(line.metadata.as_ref()) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + Self::get(pool, invoice_id) + .await? + .ok_or(InvoiceError::NotFound) + } + + pub async fn get( + pool: &PgPool, + id: Uuid, + ) -> InvoiceResult> { + let row = sqlx::query_as::<_, InvoiceRow>( + r#" + SELECT + id, invoice_number, payment_id, user_id, status, currency, + invoice_type, subtotal, discount_amount, + cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, + sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, + igst_rate::FLOAT8 AS igst_rate, igst_amount, total, + reverse_charge, seller_name, seller_address, seller_gstin, + seller_pan, seller_state_code, place_of_supply_state, + customer_name, customer_email, customer_phone, + customer_billing_address, customer_gstin, customer_state_code, + discount_label, notes, pdf_object_key, issued_at, paid_at, + voided_at, voided_by_user_id, void_reason, created_at, updated_at + FROM invoices + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(Into::into)) + } + + pub async fn get_by_payment( + pool: &PgPool, + payment_id: Uuid, + ) -> InvoiceResult> { + let row = sqlx::query_as::<_, InvoiceRow>( + r#" + SELECT + id, invoice_number, payment_id, user_id, status, currency, + invoice_type, subtotal, discount_amount, + cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, + sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, + igst_rate::FLOAT8 AS igst_rate, igst_amount, total, + reverse_charge, seller_name, seller_address, seller_gstin, + seller_pan, seller_state_code, place_of_supply_state, + customer_name, customer_email, customer_phone, + customer_billing_address, customer_gstin, customer_state_code, + discount_label, notes, pdf_object_key, issued_at, paid_at, + voided_at, voided_by_user_id, void_reason, created_at, updated_at + FROM invoices + WHERE payment_id = $1 + ORDER BY issued_at DESC + LIMIT 1 + "#, + ) + .bind(payment_id) + .fetch_optional(pool) + .await?; + Ok(row.map(Into::into)) + } + + pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + page: i64, + limit: i64, + ) -> InvoiceResult> { + let limit = limit.clamp(1, 200); + let page = page.max(1); + let offset = (page - 1) * limit; + + let rows = sqlx::query_as::<_, InvoiceRow>( + r#" + SELECT + id, invoice_number, payment_id, user_id, status, currency, + invoice_type, subtotal, discount_amount, + cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, + sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, + igst_rate::FLOAT8 AS igst_rate, igst_amount, total, + reverse_charge, seller_name, seller_address, seller_gstin, + seller_pan, seller_state_code, place_of_supply_state, + customer_name, customer_email, customer_phone, + customer_billing_address, customer_gstin, customer_state_code, + discount_label, notes, pdf_object_key, issued_at, paid_at, + voided_at, voided_by_user_id, void_reason, created_at, updated_at + FROM invoices + WHERE user_id = $1 + ORDER BY issued_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(Into::into).collect()) + } + + pub async fn list_all( + pool: &PgPool, + status: Option<&str>, + page: i64, + limit: i64, + ) -> InvoiceResult> { + let limit = limit.clamp(1, 200); + let page = page.max(1); + let offset = (page - 1) * limit; + + let status_filter = status.map(str::to_ascii_uppercase); + let rows = sqlx::query_as::<_, InvoiceRow>( + r#" + SELECT + id, invoice_number, payment_id, user_id, status, currency, + invoice_type, subtotal, discount_amount, + cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, + sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, + igst_rate::FLOAT8 AS igst_rate, igst_amount, total, + reverse_charge, seller_name, seller_address, seller_gstin, + seller_pan, seller_state_code, place_of_supply_state, + customer_name, customer_email, customer_phone, + customer_billing_address, customer_gstin, customer_state_code, + discount_label, notes, pdf_object_key, issued_at, paid_at, + voided_at, voided_by_user_id, void_reason, created_at, updated_at + FROM invoices + WHERE ($1::text IS NULL OR status = $1) + ORDER BY issued_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(status_filter) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(Into::into).collect()) + } + + pub async fn list_line_items( + pool: &PgPool, + invoice_id: Uuid, + ) -> InvoiceResult> { + let rows = sqlx::query_as::<_, LineItemRow>( + r#" + SELECT + id, invoice_id, line_number, description, hsn_sac_code, + quantity::FLOAT8 AS quantity, unit_price_inr, line_subtotal_inr, + tax_rate::FLOAT8 AS tax_rate, line_tax_inr, line_total_inr, metadata + FROM invoice_line_items + WHERE invoice_id = $1 + ORDER BY line_number ASC + "#, + ) + .bind(invoice_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + pub async fn mark_paid( + pool: &PgPool, + id: Uuid, + ) -> InvoiceResult<()> { + let result = sqlx::query( + r#" + UPDATE invoices + SET status = 'PAID', paid_at = NOW(), updated_at = NOW() + WHERE id = $1 AND status = 'ISSUED' + "#, + ) + .bind(id) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(InvoiceError::IllegalState("not ISSUED".to_string())); + } + Ok(()) + } + + pub async fn void( + pool: &PgPool, + id: Uuid, + by_user_id: Uuid, + reason: &str, + ) -> InvoiceResult<()> { + if reason.trim().is_empty() { + return Err(InvoiceError::InvalidInput("void reason is required".to_string())); + } + let result = sqlx::query( + r#" + UPDATE invoices + SET status = 'VOID', voided_at = NOW(), voided_by_user_id = $2, + void_reason = $3, updated_at = NOW() + WHERE id = $1 AND status IN ('ISSUED', 'PAID') + "#, + ) + .bind(id) + .bind(by_user_id) + .bind(reason) + .execute(pool) + .await?; + if result.rows_affected() == 0 { + return Err(InvoiceError::IllegalState("not ISSUED or PAID".to_string())); + } + Ok(()) + } + + pub async fn attach_pdf( + pool: &PgPool, + id: Uuid, + object_key: &str, + ) -> InvoiceResult<()> { + sqlx::query( + r#" + UPDATE invoices + SET pdf_object_key = $2, updated_at = NOW() + WHERE id = $1 + "#, + ) + .bind(id) + .bind(object_key) + .execute(pool) + .await?; + Ok(()) + } + + /// Compute totals from a stored invoice + its line items. + pub fn totals(invoice: &Invoice, lines: &[LineItem]) -> InvoiceTotals { + crate::compute_totals(lines, invoice.discount_amount, invoice.igst_amount > 0) + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Billing profile repository +// ────────────────────────────────────────────────────────────────────────── + +pub struct BillingProfileRepo; + +impl BillingProfileRepo { + pub async fn upsert_default( + pool: &PgPool, + user_id: Uuid, + profile: &BillingDetails, + ) -> InvoiceResult<()> { + let mut tx = pool.begin().await?; + // Clear existing default. + sqlx::query( + r#" + UPDATE billing_profiles + SET is_default = false, updated_at = NOW() + WHERE user_id = $1 AND is_default = true + "#, + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + // Insert new default. + sqlx::query( + r#" + INSERT INTO billing_profiles ( + user_id, legal_name, email, phone, gstin, pan, + billing_address, state_code, is_default + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true) + "#, + ) + .bind(user_id) + .bind(&profile.legal_name) + .bind(profile.email.as_deref()) + .bind(profile.phone.as_deref()) + .bind(profile.gstin.as_deref()) + .bind(profile.pan.as_deref()) + .bind(&profile.billing_address) + .bind(profile.state_code.as_deref()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + pub async fn get_default( + pool: &PgPool, + user_id: Uuid, + ) -> InvoiceResult> { + let row: Option<(String, Option, Option, Option, Option, String, Option)> = + sqlx::query_as( + r#" + SELECT legal_name, email, phone, gstin, pan, billing_address, state_code + FROM billing_profiles + WHERE user_id = $1 AND is_default = true + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(n, e, p, g, pa, a, s)| BillingDetails { + legal_name: n, + email: e, + phone: p, + gstin: g, + pan: pa, + billing_address: a, + state_code: s, + })) + } +} + +// Suppress unused warning for Transaction import in non-tx code paths. +#[allow(dead_code)] +fn _phantom_tx<'a>(_: &Transaction<'a, Postgres>) {} diff --git a/crates/wallet/Cargo.toml b/crates/wallet/Cargo.toml new file mode 100644 index 0000000..900b090 --- /dev/null +++ b/crates/wallet/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wallet" +version = "0.1.0" +edition.workspace = true + +[dependencies] +sqlx.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/wallet/src/hold.rs b/crates/wallet/src/hold.rs new file mode 100644 index 0000000..a6bb410 --- /dev/null +++ b/crates/wallet/src/hold.rs @@ -0,0 +1,500 @@ +//! Tracecoin holds (escrow) and bucket operations. +//! +//! A "hold" is a soft-reserve on a user's tracecoins. The hold is +//! counted against the user's `available` balance but is not yet a +//! permanent debit. The hold lifecycle is: +//! +//! ACTIVE → SETTLED (action completed, hold becomes a permanent debit +//! with a linked ledger row) +//! ACTIVE → RELEASED (user or system cancelled, the tracecoins are +//! returned to the user's `available` balance) +//! ACTIVE → EXPIRED (the cron's expire_due_holds() saw that +//! `expires_at` had passed; same as RELEASED but +//! the audit trail is "expired" not "cancelled") +//! +//! Holds are the right primitive when the user-facing action has a +//! long lifetime (e.g. a 24-hour lead request that may or may not be +//! accepted) and we want to keep the tracecoins earmarked without +//! double-counting them as spent. +//! +//! `reserve_for_lead_request` (in lib.rs) is implemented on top of +//! `hold_for_action` internally so the wallet stays consistent. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool, Postgres, Row, Transaction}; +use uuid::Uuid; + +// ────────────────────────────────────────────────────────────────────────── +// Hold +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum HoldStatus { + Active, + Settled, + Released, + Expired, +} + +impl HoldStatus { + pub fn as_str(self) -> &'static str { + match self { + HoldStatus::Active => "ACTIVE", + HoldStatus::Settled => "SETTLED", + HoldStatus::Released => "RELEASED", + HoldStatus::Expired => "EXPIRED", + } + } +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct Hold { + pub id: Uuid, + pub wallet_id: Uuid, + pub user_id: Uuid, + pub amount: i32, + pub reason: String, + pub reference_id: Option, + pub status: String, + pub expires_at: Option>, + pub settled_at: Option>, + pub settled_ledger_id: Option, + pub released_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HoldReason { + LeadRequest, + ContactUnlock, + AiCredit, + FeatureReservation, + System, +} + +impl HoldReason { + pub fn as_str(self) -> &'static str { + match self { + HoldReason::LeadRequest => "LEAD_REQUEST", + HoldReason::ContactUnlock => "CONTACT_UNLOCK", + HoldReason::AiCredit => "AI_CREDIT", + HoldReason::FeatureReservation => "FEATURE_RESERVATION", + HoldReason::System => "SYSTEM", + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum HoldError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), + #[error("hold not found")] + NotFound, + #[error("hold not in ACTIVE state (current: {0})")] + NotActive(String), + #[error("hold amount {amount} exceeds available {available}")] + Insufficient { amount: i32, available: i32 }, + #[error("hold already exists for reference {0}")] + AlreadyExists(Uuid), + #[error("invalid hold amount: {0}")] + InvalidAmount(i32), + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("wallet error: {0}")] + Wallet(#[from] crate::WalletError), +} + +pub type HoldResult = Result; + +// ────────────────────────────────────────────────────────────────────────── +// Hold operations +// ────────────────────────────────────────────────────────────────────────── + +/// Create a hold for the given amount. If `reference_id` is supplied +/// and a hold already exists for it, the existing hold is returned +/// (idempotent). +/// +/// On success, returns `(hold, available_after)`. The hold reduces the +/// user's `available` balance by `amount` but does NOT deduct from +/// `balance` until the hold is settled. +pub async fn place( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reason: HoldReason, + reference_id: Option, + expires_at: Option>, +) -> HoldResult<(Hold, i32)> { + if amount <= 0 { + return Err(HoldError::InvalidAmount(amount)); + } + + let mut tx = pool.begin().await?; + let wallet_id = crate::lock_wallet(&mut tx, user_id).await?.0; + + // Idempotency: if a hold already exists for this reference_id, return it. + if let Some(reference_id) = reference_id { + let existing: Option<(Uuid, Uuid, Uuid, Uuid, i32, String, Option, String, Option>, Option>, Option, Option>, DateTime)> = sqlx::query_as( + r#" + SELECT id, wallet_id, user_id, user_id, amount, reason, reference_id, status, expires_at, settled_at, settled_ledger_id, released_at, created_at + FROM tracecoin_holds + WHERE reference_id = $1 + "#, + ) + .bind(reference_id) + .fetch_optional(&mut *tx) + .await?; + + if let Some((id, wallet_id, user_id, _, amount, reason, reference_id, status, expires_at, settled_at, settled_ledger_id, released_at, created_at)) = existing { + tx.commit().await?; + return Ok((Hold { + id, + wallet_id, + user_id, + amount, + reason, + reference_id, + status, + expires_at, + settled_at, + settled_ledger_id, + released_at, + created_at, + }, 0)); + } + } + + // Read current available balance + let balance: i32 = sqlx::query_scalar("SELECT balance FROM tracecoin_wallets WHERE id = $1") + .bind(wallet_id) + .fetch_one(&mut *tx) + .await?; + let reserved: i32 = sqlx::query_scalar("SELECT reserved FROM tracecoin_wallets WHERE id = $1") + .bind(wallet_id) + .fetch_one(&mut *tx) + .await?; + let available = balance - reserved; + + if available < amount { + return Err(HoldError::Insufficient { + amount, + available, + }); + } + + // Insert the hold + let hold_id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO tracecoin_holds ( + id, wallet_id, user_id, amount, reason, reference_id, status, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, 'ACTIVE', $7) + "#, + ) + .bind(hold_id) + .bind(wallet_id) + .bind(user_id) + .bind(amount) + .bind(reason.as_str()) + .bind(reference_id) + .bind(expires_at) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + let new_available = available - amount; + Ok(( + Hold { + id: hold_id, + wallet_id, + user_id, + amount, + reason: reason.as_str().to_string(), + reference_id, + status: HoldStatus::Active.as_str().to_string(), + expires_at, + settled_at: None, + settled_ledger_id: None, + released_at: None, + created_at: Utc::now(), + }, + new_available, + )) +} + +/// Settle an active hold. The tracecoins are permanently debited +/// (`balance` decreases) and a ledger row is written. Idempotent on +/// the hold id. +pub async fn settle( + pool: &PgPool, + hold_id: Uuid, +) -> HoldResult<()> { + let mut tx = pool.begin().await?; + + let row: Option<(Uuid, Uuid, Uuid, i32, String)> = sqlx::query_as( + r#" + SELECT id, wallet_id, user_id, amount, status + FROM tracecoin_holds + WHERE id = $1 + FOR UPDATE + "#, + ) + .bind(hold_id) + .fetch_optional(&mut *tx) + .await?; + + let (id, wallet_id, user_id, amount, status) = match row { + Some(r) => r, + None => return Err(HoldError::NotFound), + }; + + if status != "ACTIVE" { + return Err(HoldError::NotActive(status)); + } + + // Lock wallet + let (wallet_id_locked, _balance, _reserved) = crate::lock_wallet_inner(&mut tx, wallet_id).await?; + + // Insert a DEBIT ledger row + let new_balance: i32 = sqlx::query_scalar( + r#" + UPDATE tracecoin_wallets + SET balance = balance - $1, updated_at = NOW() + WHERE id = $2 + RETURNING balance + "#, + ) + .bind(amount) + .bind(wallet_id_locked) + .fetch_one(&mut *tx) + .await?; + + if new_balance < 0 { + return Err(HoldError::InvalidInput(format!( + "balance went negative after settling hold {} (user {})", + id, user_id + ))); + } + + let ledger_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tracecoin_ledger ( + wallet_id, type, amount, balance_after, reason, + reference_id, actor_user_id, metadata + ) + VALUES ($1, 'DEBIT', $2, $3, 'HOLD_SETTLED', $4, NULL, $5) + RETURNING id + "#, + ) + .bind(wallet_id_locked) + .bind(-amount) + .bind(new_balance) + .bind(id) + .bind(serde_json::json!({"hold_id": id, "settled_at": Utc::now()})) + .fetch_one(&mut *tx) + .await?; + + sqlx::query( + r#" + UPDATE tracecoin_holds + SET status = 'SETTLED', + settled_at = NOW(), + settled_ledger_id = $1 + WHERE id = $2 + "#, + ) + .bind(ledger_id) + .bind(id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +/// Release an active hold (cancel). The tracecoins are returned to +/// the user's `available` balance. Idempotent. +pub async fn release( + pool: &PgPool, + hold_id: Uuid, +) -> HoldResult<()> { + let mut tx = pool.begin().await?; + + let row: Option<(Uuid, Uuid, String)> = sqlx::query_as( + r#" + SELECT id, wallet_id, status + FROM tracecoin_holds + WHERE id = $1 + FOR UPDATE + "#, + ) + .bind(hold_id) + .fetch_optional(&mut *tx) + .await?; + + let (id, _wallet_id, status) = match row { + Some(r) => r, + None => return Err(HoldError::NotFound), + }; + + if status != "ACTIVE" { + return Err(HoldError::NotActive(status)); + } + + sqlx::query( + r#" + UPDATE tracecoin_holds + SET status = 'RELEASED', released_at = NOW() + WHERE id = $1 + "#, + ) + .bind(id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +/// Auto-release every hold whose `expires_at` is in the past and is +/// still ACTIVE. Returns the number of holds released. +/// +/// Designed for the cron loop. Idempotent and safe under concurrent +/// invocation: each hold is locked with FOR UPDATE. +pub async fn expire_due_holds(pool: &PgPool) -> HoldResult { + let now = Utc::now(); + + let due: Vec<(Uuid, Uuid)> = sqlx::query_as( + r#" + SELECT id, wallet_id + FROM tracecoin_holds + WHERE status = 'ACTIVE' + AND expires_at IS NOT NULL + AND expires_at < $1 + "#, + ) + .bind(now) + .fetch_all(pool) + .await?; + + let mut count = 0u64; + for (id, _wallet_id) in due { + let mut tx = pool.begin().await?; + let row: Option<(Uuid, Uuid, String)> = sqlx::query_as( + r#" + SELECT id, wallet_id, status + FROM tracecoin_holds + WHERE id = $1 + FOR UPDATE + "#, + ) + .bind(id) + .fetch_optional(&mut *tx) + .await?; + + if let Some((id, _, status)) = row { + if status == "ACTIVE" { + let updated = sqlx::query( + r#" + UPDATE tracecoin_holds + SET status = 'EXPIRED', released_at = NOW() + WHERE id = $1 AND status = 'ACTIVE' + "#, + ) + .bind(id) + .execute(&mut *tx) + .await?; + if updated.rows_affected() > 0 { + count += 1; + } + } + } + tx.commit().await.ok(); + } + + Ok(count) +} + +/// List all active holds for a user, oldest first. +pub async fn list_active_for_user( + pool: &PgPool, + user_id: Uuid, +) -> HoldResult> { + let rows: Vec<(Uuid, Uuid, Uuid, Uuid, i32, String, Option, String, Option>, Option>, Option, Option>, DateTime)> = sqlx::query_as( + r#" + SELECT id, wallet_id, user_id, user_id, amount, reason, reference_id, status, expires_at, settled_at, settled_ledger_id, released_at, created_at + FROM tracecoin_holds + WHERE user_id = $1 AND status = 'ACTIVE' + ORDER BY created_at ASC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(id, wallet_id, user_id, _, amount, reason, reference_id, status, expires_at, settled_at, settled_ledger_id, released_at, created_at)| Hold { + id, + wallet_id, + user_id, + amount, + reason, + reference_id, + status, + expires_at, + settled_at, + settled_ledger_id, + released_at, + created_at, + }) + .collect()) +} + +/// List all holds (any status) for a user, newest first. +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> HoldResult> { + let limit = limit.clamp(1, 200); + let rows: Vec<(Uuid, Uuid, Uuid, Uuid, i32, String, Option, String, Option>, Option>, Option, Option>, DateTime)> = sqlx::query_as( + r#" + SELECT id, wallet_id, user_id, user_id, amount, reason, reference_id, status, expires_at, settled_at, settled_ledger_id, released_at, created_at + FROM tracecoin_holds + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(id, wallet_id, user_id, _, amount, reason, reference_id, status, expires_at, settled_at, settled_ledger_id, released_at, created_at)| Hold { + id, + wallet_id, + user_id, + amount, + reason, + reference_id, + status, + expires_at, + settled_at, + settled_ledger_id, + released_at, + created_at, + }) + .collect()) +} + +// Suppress unused-import warnings for items used by the macro. +#[allow(dead_code)] +fn _phantom_tx<'a>(_: &Transaction<'a, Postgres>) {} diff --git a/crates/wallet/src/lib.rs b/crates/wallet/src/lib.rs new file mode 100644 index 0000000..cb75114 --- /dev/null +++ b/crates/wallet/src/lib.rs @@ -0,0 +1,844 @@ +//! Secure Tracecoin wallet operations. +//! +//! All wallet mutations flow through this module so the rules live in one +//! place. Every operation: +//! +//! * Wraps the read + write in a single SQL transaction. +//! * Uses `lock_tracecoin_wallet()` (SELECT ... FOR UPDATE) so concurrent +//! callers cannot race the balance. +//! * Re-checks invariants (`balance >= 0`, `reserved >= 0`) inside the +//! transaction so the database CHECK constraints never fire. +//! * Writes a single, atomic ledger entry per call. The `(wallet_id, +//! reference_id, type)` unique index makes the operation idempotent: a +//! retry of the same operation with the same reference_id is a no-op +//! and returns the existing entry instead of double-crediting. +//! * Stores `balance_after` and `actor_user_id` so the ledger is a +//! self-contained, auditable history. +//! +//! Money flow (all balances are integer tracecoins; 1 INR = 1 tracecoin): +//! +//! * `credit_for_payment` – credit tracecoins after a successful +//! payment (idempotent on payment id). +//! * `admin_adjust` – admin ADD/DEDUCT with required reason. +//! * `reserve_for_lead_request` – move tracecoins from `balance` to +//! `reserved` so they cannot be spent +//! twice. Fails if not enough balance. +//! * `release_reservation` – return reserved tracecoins to balance. +//! * `confirm_reservation` – finalize a reservation: deduct from +//! `reserved` permanently (used when a +//! lead is accepted). +//! * `spend_direct` – debit balance directly without +//! reservation (used for one-off actions +//! where reservation would be overkill). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool, Postgres, Row, Transaction}; +use uuid::Uuid; + +pub mod hold; + +/// What kind of ledger entry is being written. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum LedgerType { + Credit, + Debit, + Reserve, + Release, + Adjustment, +} + +impl LedgerType { + pub fn as_str(self) -> &'static str { + match self { + LedgerType::Credit => "CREDIT", + LedgerType::Debit => "DEBIT", + LedgerType::Reserve => "RESERVE", + LedgerType::Release => "RELEASE", + LedgerType::Adjustment => "ADJUSTMENT", + } + } +} + +/// Why a ledger entry was written. Free-form but a small set of +/// `snake_case` codes is the convention. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedgerReason { + Purchase, + LeadRequestReserve, + LeadRequestRelease, + LeadRequestDeduct, + LeadRequestExpire, + AdminCredit, + AdminDebuct, + Refund, + AiCredit, + System, +} + +impl LedgerReason { + pub fn as_str(self) -> &'static str { + match self { + LedgerReason::Purchase => "PURCHASE", + LedgerReason::LeadRequestReserve => "LEAD_REQUEST_RESERVE", + LedgerReason::LeadRequestRelease => "LEAD_REQUEST_RELEASE", + LedgerReason::LeadRequestDeduct => "LEAD_REQUEST_DEDUCT", + LedgerReason::LeadRequestExpire => "LEAD_REQUEST_EXPIRE", + LedgerReason::AdminCredit => "ADMIN_CREDIT", + LedgerReason::AdminDebuct => "ADMIN_DEDUCT", + LedgerReason::Refund => "REFUND", + LedgerReason::AiCredit => "AI_CREDIT", + LedgerReason::System => "SYSTEM", + } + } +} + +/// Public, read-only view of a wallet. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct Wallet { + pub user_id: Uuid, + pub balance: i32, + pub reserved: i32, + pub available: i32, + pub updated_at: DateTime, +} + +/// Public, read-only view of a single ledger entry. +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct LedgerEntry { + pub id: Uuid, + pub wallet_id: Uuid, + pub entry_type: String, + pub amount: i32, + pub balance_after: Option, + pub reason: Option, + pub reference_id: Option, + pub actor_user_id: Option, + pub metadata: Option, + pub created_at: DateTime, +} + +#[derive(Debug)] +pub enum WalletError { + /// Database error. + Db(sqlx::Error), + /// The caller asked for a credit/debit that would drive the balance + /// below zero. + InsufficientFunds { + available: i32, + requested: i32, + }, + /// The caller passed an invalid amount (zero, negative, or above the + /// safe limit). + InvalidAmount(i32), + /// The requested reservation would push `reserved` past `balance`. + InsufficientUnreserved { + balance: i32, + reserved: i32, + requested: i32, + }, + /// The reservation has already been released/confirmed. Idempotent + /// re-runs return `Ok(())` so callers can safely retry. + AlreadyApplied, +} + +impl std::fmt::Display for WalletError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WalletError::Db(e) => write!(f, "db error: {e}"), + WalletError::InsufficientFunds { available, requested } => { + write!(f, "insufficient funds: available={available}, requested={requested}") + } + WalletError::InvalidAmount(a) => write!(f, "invalid amount: {a}"), + WalletError::InsufficientUnreserved { balance, reserved, requested } => write!( + f, + "insufficient unreserved: balance={balance}, reserved={reserved}, requested={requested}" + ), + WalletError::AlreadyApplied => write!(f, "operation already applied"), + } + } +} + +impl std::error::Error for WalletError {} + +impl From for WalletError { + fn from(e: sqlx::Error) -> Self { + WalletError::Db(e) + } +} + +/// Hard upper limit on any single wallet operation. Tracecoins never +/// legitimately need to be added in larger amounts than this. +pub const MAX_AMOUNT: i32 = 1_000_000_000; + +/// Read a wallet, creating one if missing. Does NOT lock the row. +pub async fn get_or_create( + pool: &PgPool, + user_id: Uuid, +) -> Result { + sqlx::query( + r#" + INSERT INTO tracecoin_wallets (user_id, balance, reserved) + VALUES ($1, 0, 0) + ON CONFLICT (user_id) DO NOTHING + "#, + ) + .bind(user_id) + .execute(pool) + .await?; + + let row = sqlx::query( + r#" + SELECT + user_id, + balance, + reserved, + (balance - reserved) AS available, + updated_at + FROM tracecoin_wallets + WHERE user_id = $1 + "#, + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(Wallet { + user_id: row.get("user_id"), + balance: row.get("balance"), + reserved: row.get("reserved"), + available: row.get("available"), + updated_at: row.get("updated_at"), + }) +} + +pub async fn lock_wallet( + tx: &mut Transaction<'_, Postgres>, + user_id: Uuid, +) -> Result<(Uuid, i32, i32), WalletError> { + let row = sqlx::query("SELECT * FROM lock_tracecoin_wallet($1)") + .bind(user_id) + .fetch_one(&mut **tx) + .await?; + Ok(( + row.get("wallet_id"), + row.get("balance"), + row.get("reserved"), + )) +} + +/// Like [`lock_wallet`] but takes the wallet row id directly. +pub async fn lock_wallet_inner( + tx: &mut Transaction<'_, Postgres>, + wallet_id: Uuid, +) -> Result<(Uuid, i32, i32), WalletError> { + // Wrap in a CTE so we can return wallet_id / balance / reserved. + let row = sqlx::query( + r#" + WITH w AS ( + SELECT id, balance, reserved + FROM tracecoin_wallets + WHERE id = $1 + FOR UPDATE + ) + SELECT id AS wallet_id, balance, reserved FROM w + "#, + ) + .bind(wallet_id) + .fetch_optional(&mut **tx) + .await? + .ok_or(WalletError::Db(sqlx::Error::RowNotFound))?; + + Ok(( + row.get("wallet_id"), + row.get("balance"), + row.get("reserved"), + )) +} + +fn validate_amount(amount: i32) -> Result<(), WalletError> { + if amount == 0 { + return Err(WalletError::InvalidAmount(0)); + } + if amount.unsigned_abs() > MAX_AMOUNT as u32 { + return Err(WalletError::InvalidAmount(amount)); + } + Ok(()) +} + +/// Insert a ledger row inside an existing transaction. Idempotent on +/// `(wallet_id, reference_id, type)` — if a row with that key already +/// exists we fetch and return it instead of double-writing. This lets a +/// single payment credit exactly once while still allowing separate +/// RESERVE / RELEASE / CONFIRM rows for a single lead_request. +async fn write_ledger_entry( + tx: &mut Transaction<'_, Postgres>, + wallet_id: Uuid, + entry_type: LedgerType, + amount: i32, + balance_after: i32, + reason: LedgerReason, + reference_id: Option, + actor_user_id: Option, + metadata: Option, +) -> Result { + if let Some(reference_id) = reference_id { + let existing = sqlx::query_as::<_, LedgerEntry>( + r#" + SELECT + id, wallet_id, + type AS entry_type, + amount, + balance_after, + reason, + reference_id, + actor_user_id, + metadata, + created_at + FROM tracecoin_ledger + WHERE wallet_id = $1 AND reference_id = $2 AND type = $3 + "#, + ) + .bind(wallet_id) + .bind(reference_id) + .bind(entry_type.as_str()) + .fetch_optional(&mut **tx) + .await?; + + if let Some(entry) = existing { + return Ok(entry); + } + } + + let row = sqlx::query( + r#" + INSERT INTO tracecoin_ledger ( + wallet_id, type, amount, balance_after, reason, + reference_id, actor_user_id, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING + id, wallet_id, + type AS entry_type, + amount, + balance_after, + reason, + reference_id, + actor_user_id, + metadata, + created_at + "#, + ) + .bind(wallet_id) + .bind(entry_type.as_str()) + .bind(amount) + .bind(balance_after) + .bind(reason.as_str()) + .bind(reference_id) + .bind(actor_user_id) + .bind(metadata) + .fetch_one(&mut **tx) + .await?; + + Ok(LedgerEntry { + id: row.get("id"), + wallet_id: row.get("wallet_id"), + entry_type: row.get("entry_type"), + amount: row.get("amount"), + balance_after: row.get("balance_after"), + reason: row.get("reason"), + reference_id: row.get("reference_id"), + actor_user_id: row.get("actor_user_id"), + metadata: row.get("metadata"), + created_at: row.get("created_at"), + }) +} + +/// Credit `amount` tracecoins to a user's wallet. Idempotent on +/// `reference_id` — repeated calls with the same reference are a no-op +/// and return the existing ledger entry. +pub async fn credit( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reason: LedgerReason, + reference_id: Option, + actor_user_id: Option, + metadata: Option, +) -> Result<(Wallet, LedgerEntry), WalletError> { + validate_amount(amount)?; + if amount < 0 { + return Err(WalletError::InvalidAmount(amount)); + } + + let mut tx = pool.begin().await?; + let (wallet_id, balance, _reserved) = lock_wallet(&mut tx, user_id).await?; + + // Idempotency: if a CREDIT entry already exists for this reference, + // return it instead of double-crediting. + if let Some(reference_id) = reference_id { + if let Some(existing) = sqlx::query_as::<_, LedgerEntry>( + r#" + SELECT + id, wallet_id, + type AS entry_type, + amount, + balance_after, + reason, + reference_id, + actor_user_id, + metadata, + created_at + FROM tracecoin_ledger + WHERE wallet_id = $1 AND reference_id = $2 AND type = 'CREDIT' + "#, + ) + .bind(wallet_id) + .bind(reference_id) + .fetch_optional(&mut *tx) + .await? + { + tx.commit().await?; + let w = get_or_create(pool, user_id).await?; + return Ok((w, existing)); + } + } + + let new_balance = balance + .checked_add(amount) + .ok_or_else(|| WalletError::InvalidAmount(amount))?; + if new_balance > MAX_AMOUNT { + return Err(WalletError::InvalidAmount(amount)); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET balance = $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(new_balance) + .bind(wallet_id) + .execute(&mut *tx) + .await?; + + let entry = write_ledger_entry( + &mut tx, + wallet_id, + LedgerType::Credit, + amount, + new_balance, + reason, + reference_id, + actor_user_id, + metadata, + ) + .await?; + + tx.commit().await?; + let wallet = get_or_create(pool, user_id).await?; + Ok((wallet, entry)) +} + +/// Reserve `amount` tracecoins — moves them from `balance` to `reserved` +/// so the same coins cannot be used by another request. Idempotent on +/// `reference_id`. +pub async fn reserve( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + metadata: Option, +) -> Result<(Wallet, LedgerEntry), WalletError> { + validate_amount(amount)?; + if amount < 0 { + return Err(WalletError::InvalidAmount(amount)); + } + + let mut tx = pool.begin().await?; + let (wallet_id, balance, reserved) = lock_wallet(&mut tx, user_id).await?; + + // Idempotency: if a RESERVE entry already exists for this reference, + // return it instead of double-reserving. + if let Some(existing) = sqlx::query_as::<_, LedgerEntry>( + r#" + SELECT + id, wallet_id, + type AS entry_type, + amount, + balance_after, + reason, + reference_id, + actor_user_id, + metadata, + created_at + FROM tracecoin_ledger + WHERE wallet_id = $1 AND reference_id = $2 AND type = 'RESERVE' + "#, + ) + .bind(wallet_id) + .bind(reference_id) + .fetch_optional(&mut *tx) + .await? + { + tx.commit().await?; + let w = get_or_create(pool, user_id).await?; + return Ok((w, existing)); + } + + let available = balance - reserved; + if available < amount { + return Err(WalletError::InsufficientUnreserved { + balance, + reserved, + requested: amount, + }); + } + + let new_reserved = reserved + .checked_add(amount) + .ok_or_else(|| WalletError::InvalidAmount(amount))?; + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET reserved = $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(new_reserved) + .bind(wallet_id) + .execute(&mut *tx) + .await?; + + let entry = write_ledger_entry( + &mut tx, + wallet_id, + LedgerType::Reserve, + amount, + balance, + LedgerReason::LeadRequestReserve, + Some(reference_id), + Some(user_id), + metadata, + ) + .await?; + + tx.commit().await?; + let wallet = get_or_create(pool, user_id).await?; + Ok((wallet, entry)) +} + +/// Release a previously-held reservation back to `balance`. Idempotent. +pub async fn release( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + metadata: Option, +) -> Result<(Wallet, LedgerEntry), WalletError> { + validate_amount(amount)?; + if amount < 0 { + return Err(WalletError::InvalidAmount(amount)); + } + + let mut tx = pool.begin().await?; + let (wallet_id, balance, reserved) = lock_wallet(&mut tx, user_id).await?; + + if let Some(existing) = sqlx::query_as::<_, LedgerEntry>( + r#" + SELECT + id, wallet_id, + type AS entry_type, + amount, + balance_after, + reason, + reference_id, + actor_user_id, + metadata, + created_at + FROM tracecoin_ledger + WHERE wallet_id = $1 AND reference_id = $2 AND type = 'RELEASE' + "#, + ) + .bind(wallet_id) + .bind(reference_id) + .fetch_optional(&mut *tx) + .await? + { + tx.commit().await?; + let w = get_or_create(pool, user_id).await?; + return Ok((w, existing)); + } + + if reserved < amount { + return Err(WalletError::InsufficientFunds { + available: reserved, + requested: amount, + }); + } + + let new_reserved = reserved - amount; + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET reserved = $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(new_reserved) + .bind(wallet_id) + .execute(&mut *tx) + .await?; + + let entry = write_ledger_entry( + &mut tx, + wallet_id, + LedgerType::Release, + -amount, + balance, + LedgerReason::LeadRequestRelease, + Some(reference_id), + Some(user_id), + metadata, + ) + .await?; + + tx.commit().await?; + let wallet = get_or_create(pool, user_id).await?; + Ok((wallet, entry)) +} + +/// Confirm a reservation: permanently deduct from `reserved`. Idempotent +/// on `reference_id`. +pub async fn confirm( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + metadata: Option, +) -> Result<(Wallet, LedgerEntry), WalletError> { + validate_amount(amount)?; + if amount < 0 { + return Err(WalletError::InvalidAmount(amount)); + } + + let mut tx = pool.begin().await?; + let (wallet_id, balance, reserved) = lock_wallet(&mut tx, user_id).await?; + + if let Some(existing) = sqlx::query_as::<_, LedgerEntry>( + r#" + SELECT + id, wallet_id, + type AS entry_type, + amount, + balance_after, + reason, + reference_id, + actor_user_id, + metadata, + created_at + FROM tracecoin_ledger + WHERE wallet_id = $1 AND reference_id = $2 AND type = 'DEBIT' + "#, + ) + .bind(wallet_id) + .bind(reference_id) + .fetch_optional(&mut *tx) + .await? + { + tx.commit().await?; + let w = get_or_create(pool, user_id).await?; + return Ok((w, existing)); + } + + if reserved < amount { + return Err(WalletError::InsufficientFunds { + available: reserved, + requested: amount, + }); + } + + let new_reserved = reserved - amount; + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET reserved = $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(new_reserved) + .bind(wallet_id) + .execute(&mut *tx) + .await?; + + let entry = write_ledger_entry( + &mut tx, + wallet_id, + LedgerType::Debit, + -amount, + balance, + LedgerReason::LeadRequestDeduct, + Some(reference_id), + Some(user_id), + metadata, + ) + .await?; + + tx.commit().await?; + let wallet = get_or_create(pool, user_id).await?; + Ok((wallet, entry)) +} + +/// Admin adjustment. `amount` may be positive (credit) or negative (debit). +/// `actor_user_id` is recorded in the ledger for audit. +pub async fn admin_adjust( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reason: LedgerReason, + actor_user_id: Uuid, + metadata: Option, +) -> Result<(Wallet, LedgerEntry), WalletError> { + validate_amount(amount)?; + + let mut tx = pool.begin().await?; + let (wallet_id, balance, _reserved) = lock_wallet(&mut tx, user_id).await?; + + let new_balance = balance + .checked_add(amount) + .ok_or_else(|| WalletError::InvalidAmount(amount))?; + if new_balance < 0 { + return Err(WalletError::InsufficientFunds { + available: balance, + requested: amount.unsigned_abs() as i32, + }); + } + if new_balance > MAX_AMOUNT { + return Err(WalletError::InvalidAmount(amount)); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET balance = $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(new_balance) + .bind(wallet_id) + .execute(&mut *tx) + .await?; + + let entry_type = if amount >= 0 { + LedgerType::Credit + } else { + LedgerType::Debit + }; + + let entry = write_ledger_entry( + &mut tx, + wallet_id, + entry_type, + amount, + new_balance, + reason, + None, + Some(actor_user_id), + metadata, + ) + .await?; + + tx.commit().await?; + let wallet = get_or_create(pool, user_id).await?; + Ok((wallet, entry)) +} + +/// Read a paginated ledger history for `user_id`, newest first. +pub async fn list_ledger( + pool: &PgPool, + user_id: Uuid, + page: i64, + limit: i64, +) -> Result, WalletError> { + let limit = limit.clamp(1, 200); + let page = page.max(1); + let offset = (page - 1) * limit; + + let rows = sqlx::query_as::<_, LedgerEntry>( + r#" + SELECT + tl.id, tl.wallet_id, + tl.type AS entry_type, + tl.amount, + tl.balance_after, + tl.reason, + tl.reference_id, + tl.actor_user_id, + tl.metadata, + tl.created_at + FROM tracecoin_ledger tl + JOIN tracecoin_wallets tw ON tw.id = tl.wallet_id + WHERE tw.user_id = $1 + ORDER BY tl.created_at DESC, tl.id DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +/// Write an audit log row. We use the existing `audit_logs` table from +/// the wider nxtgauge schema. +/// +/// All admin adjustments MUST call this so a balance change is +/// traceable end-to-end. +pub async fn audit( + pool: &PgPool, + actor_user_id: Uuid, + target_user_id: Uuid, + amount: i32, + reason: &str, + ledger_entry_id: Uuid, +) -> Result<(), WalletError> { + let summary = format!( + "Wallet adjustment: amount={} reason='{}' target_user={} ledger_entry={}", + amount, reason, target_user_id, ledger_entry_id + ); + sqlx::query( + r#" + INSERT INTO audit_logs ( + actor_user_id, + action, + entity_type, + entity_id, + module_key, + status, + summary, + metadata_json + ) + VALUES ($1, $2, $3, $4, $5, 'SUCCESS', $6, $7) + "#, + ) + .bind(actor_user_id) + .bind("WALLET_ADJUST") + .bind("tracecoin_wallet") + .bind(target_user_id) + .bind("tracecoin") + .bind(&summary) + .bind(serde_json::json!({ + "amount": amount, + "reason": reason, + "ledger_entry_id": ledger_entry_id, + })) + .execute(pool) + .await?; + Ok(()) +}