feat: profile photo upload, PDF resume generation, AI auto-apply, schema fixes

- Profile photo upload: POST /api/profile/photo for all roles, stores via B2 storage
- PDF resume: auto-generated from job seeker portfolio on every profile save (printpdf)
- Company applications: enriched with applicant name, avatar, headline, skills, education
- AI auto-apply cron: rewrote run_auto_apply with correct schema (job_seeker_profiles,
  cover_note, ai_auto_apply_settings, ai_auto_apply_logs, credit deduction)
- Schema fix: job_seeker_profiles table name (was incorrectly 'job_seekers' in two places)
- Migration: add resume_url column to job_seeker_profiles
- Migrations: PayU rename, tracecoin hardening, lead reserve linkage, invoice/wallet crates
- PayU integration: ai_credits, packages, admin payment handlers
- Wallet and invoice crates added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-07-02 13:15:43 +02:00
parent c9d0e283ac
commit eb009ac3d0
43 changed files with 4894 additions and 328 deletions

120
Cargo.lock generated
View file

@ -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"

View file

@ -56,3 +56,4 @@ async-trait = "0.1"
bytes = "1"
tower-http = "0.6"
regex = "1"
printpdf = "0.7"

View file

@ -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<String> = row.as_ref()
.and_then(|r| r.try_get("avatar_url").ok());
let resume_url: Option<String> = row.as_ref()
.and_then(|r| r.try_get("resume_url").ok());
let custom_data: Option<serde_json::Value> = 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(

View file

@ -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<String, Box<dyn std::error::Error + Send + Sync>> {
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<String>,
experience_years: i32,
summary: Option<String>,
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<String>,
}
const AUTO_APPLY_CREDIT_COST: i32 = 5;
pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = AutoApplyConfig::from_env();
@ -161,103 +156,144 @@ pub async fn run_auto_apply(pool: &PgPool) -> Result<(), Box<dyn std::error::Err
let client = Client::new();
let cutoff_time = Utc::now() - Duration::hours(24);
let seekers: Vec<JobSeekerWithAi> = sqlx::query_as(
// Fetch job seekers who have auto-apply enabled and sufficient AI credits
let seekers: Vec<EligibleSeeker> = 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<NewJob> = 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<String> = 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<MatchingJob> = 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<dyn std::error::Err
{
Ok(cl) => 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(())
}

View file

@ -122,12 +122,13 @@ async fn list_requirements(
async fn create_requirement(
State(state): State<AppState>,
_auth: AuthUser,
auth: AuthUser,
Json(payload): Json<CreateRequirementRequest>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
_auth: AuthUser,
auth: AuthUser,
Json(payload): Json<DbUpdateRequirementPayload>,
) -> 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<AppState>,
Path(lead_id): Path<Uuid>,
_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<AppState>,
Path(lead_id): Path<Uuid>,
_auth: AuthUser,
auth: AuthUser,
Json(_payload): Json<RejectRequestPayload>,
) -> 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()
}
}
}

View file

@ -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 }

View file

@ -19,7 +19,7 @@ use contracts::auth_middleware::AuthUser;
pub fn router() -> Router<AppState> {
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<String, Box<dyn std::error::Error + Send + Sync>> {
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<String>, Option<String>)>(
"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<AppState>,
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<String> {
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<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
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), &regular);
y -= 5.0;
}
// Separator (text-based)
layer.use_text("___________________________________________________", 9.0, left, Mm(y), &regular);
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), &regular);
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),
&regular,
);
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(

View file

@ -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"

View file

@ -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<Arc<AppState>>,
Query(q): Query<PaginationQuery>,
) -> 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<LeadRequestResponse> = 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<Arc<AppState>>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
Json(payload): Json<SendLeadRequestPayload>,
) -> 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<Arc<AppState>>,
Json(payload): Json<SendLeadRequestAiPayload>,
) -> 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<i32>, Option<i32>)>(
"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<Arc<AppState>>,
Path(id): Path<Uuid>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> 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<Arc<AppState>>,
Path(id): Path<Uuid>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> 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<Arc<AppState>>,
Query(q): Query<PaginationQuery>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> 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<LeadRequestResponse> = 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<Arc<AppState>>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> 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<LeadRequestResponse> = 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<Arc<AppState>>,
Path(lead_id): Path<Uuid>,
Query(q): Query<PaginationQuery>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> 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<LeadRequestResponse> = requests.into_iter().map(lead_request_to_response).collect();

View file

@ -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);

851
apps/payments/src/admin.rs Normal file
View file

@ -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<String>,
is_active: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
struct CreateTaxRuleRequest {
name: String,
tax_type: String,
tax_rate: f64,
applies_to: Option<String>,
is_active: Option<bool>,
}
async fn list_tax_rules(
_auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&_auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
let rows: Vec<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
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<AppState>,
Json(payload): Json<CreateTaxRuleRequest>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<String>,
entry_type: String,
amount: i32,
balance_after: Option<i32>,
reason: Option<String>,
reference_id: Option<Uuid>,
actor_user_id: Option<Uuid>,
metadata: Option<serde_json::Value>,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
struct LedgerQuery {
user_id: Option<Uuid>,
#[serde(default)]
page: Option<i64>,
#[serde(default)]
limit: Option<i64>,
}
async fn list_ledger(
_auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<LedgerQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<String>,
user_name: Option<String>,
package_id: Option<Uuid>,
package_name: Option<String>,
payu_txnid: Option<String>,
payu_mihpayid: Option<String>,
amount_inr: i32,
tracecoins_credited: i32,
status: String,
verified_at: Option<chrono::DateTime<chrono::Utc>>,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
struct OrdersQuery {
status: Option<String>,
page: Option<i64>,
limit: Option<i64>,
}
async fn list_orders(
_auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<OrdersQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<serde_json::Value> = 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<Uuid>,
userId: Option<Uuid>,
}
async fn get_credit_balance(
_auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<BalanceQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Query(q): Query<LedgerQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<String>,
reason: String,
#[serde(default)]
reference_id: Option<Uuid>,
}
async fn adjust_credits(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<AdjustCreditRequest>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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(&notif_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<chrono::DateTime<chrono::Utc>>,
to: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize)]
struct ReconcileRow {
user_id: Uuid,
user_email: Option<String>,
expected_balance: i64,
actual_balance: i32,
discrepancy: i64,
}
async fn reconcile_credits(
_auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<ReconcileQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<String>, 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<ReconcileRow> = 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<String>,
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<String>,
customer_gstin: Option<String>,
pdf_object_key: Option<String>,
issued_at: chrono::DateTime<chrono::Utc>,
paid_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Deserialize)]
struct ListInvoicesQuery {
status: Option<String>,
page: Option<i64>,
limit: Option<i64>,
}
async fn list_invoices(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<ListInvoicesQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<String> = 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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<VoidInvoicePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState> {
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))
}

View file

@ -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
"#,

View file

@ -82,7 +82,7 @@ struct PaymentRow {
tracecoins_credited: Option<i32>,
amount_inr: i32,
status: String,
razorpay_payment_id: Option<String>,
payu_mihpayid: Option<String>,
}
#[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<Json<PaymentStatusResponse>, (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,

View file

@ -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<AppState>,
Json(payload): Json<CreatePackageRequest>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdatePackageRequest>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
) -> 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)

View file

@ -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<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<UserRoleProfile> 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<ProfessionState> {
Router::new()
.route("/", get(list_ugc_content_creators))
.route("/{id}", get(get_ugc_content_creator))
}
async fn list_ugc_content_creators(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AdminUgcContentCreatorList> = creators.into_iter().map(|p| p.into()).collect();
Ok(Json(list))
}
async fn get_ugc_content_creator(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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())),
}
}

View file

@ -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"] }

View file

@ -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<CreateAbTestRequest>,
) -> 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<SwitchModelRequest>,
) -> 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")

View file

@ -49,8 +49,6 @@ pub struct RegisterPayload {
pub intent: Option<String>,
#[serde(alias = "role_key", alias = "roleKey")]
pub profession: Option<String>,
#[serde(default)]
pub test_mode: Option<bool>,
}
#[derive(Deserialize)]
@ -67,6 +65,7 @@ pub struct CheckEmailPayload {
#[derive(Deserialize)]
pub struct VerifyEmailPayload {
pub otp: String,
pub email: Option<String>,
}
#[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<Vec<String>> = 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<RegisterPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
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<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
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<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
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::<u32>() % 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<AppState>,
Json(payload): Json<V1VerifyOtpPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp })).await
verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp, email: None })).await
}

View file

@ -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<AppState> {
@ -64,8 +65,10 @@ const MODULES: &[&str] = &[
const ACTIONS: &[&str] = &["View", "Create", "Update", "Delete"];
async fn list_permissions(
auth: AuthUser,
_: axum::extract::State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let permissions: Vec<PermissionEntry> = MODULES
.iter()
.flat_map(|module| {

View file

@ -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<AppState> {
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<AppState> {
@ -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<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
use bytes::Bytes;
let mut file_bytes: Option<Bytes> = 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<String> = 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,
})))
}

View file

@ -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<AppState>,
Query(params): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Json(payload): Json<CreateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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)

View file

@ -11,9 +11,10 @@ use mail::Mailer;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub mail: Arc<Mailer>,
pub redis: cache::RedisPool,
pub pool: PgPool,
pub mail: Arc<Mailer>,
pub redis: cache::RedisPool,
pub storage: Arc<storage::StorageClient>,
}
#[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()

View file

@ -90,8 +90,14 @@ pub fn shared_routes(profession_key: &'static str) -> Router<ProfessionState> {
),
)
.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<ProfessionState>,
auth: AuthUser,
Path(id): Path<Uuid>,
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<ProfessionState>,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
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,

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
$$;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS tracecoin_holds;
DROP TABLE IF EXISTS tracecoin_buckets;

View file

@ -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);

View file

@ -0,0 +1 @@
ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS resume_url;

View file

@ -0,0 +1 @@
ALTER TABLE job_seeker_profiles ADD COLUMN IF NOT EXISTS resume_url VARCHAR(500);

View file

@ -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)

13
crates/invoice/Cargo.toml Normal file
View file

@ -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

180
crates/invoice/src/html.rs Normal file
View file

@ -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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Invoice "#);
out.push_str(&escape_html(&invoice.invoice_number));
out.push_str(r#"</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #0F172A; margin: 0; padding: 32px; background: #fff; }
.invoice { max-width: 800px; margin: 0 auto; }
h1 { font-size: 28px; margin: 0; }
.muted { color: #64748B; }
.row { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.card { border: 1px solid #E2E8F0; border-radius: 8px; padding: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
th { text-align: left; background: #F1F5F9; padding: 8px 12px; }
td { padding: 8px 12px; border-top: 1px solid #E2E8F0; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.totals { margin-top: 16px; }
.totals .row { padding: 4px 0; }
.totals .grand { font-size: 18px; font-weight: 700; border-top: 2px solid #0F172A; padding-top: 8px; margin-top: 8px; }
.badge { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge.PAID { background: #DCFCE7; color: #166534; }
.badge.ISSUED { background: #DBEAFE; color: #1E40AF; }
.badge.VOID { background: #FEE2E2; color: #991B1B; }
.badge.REFUNDED { background: #F1F5F9; color: #475569; }
.badge.DRAFT { background: #FEF3C7; color: #92400E; }
@media print {
body { padding: 0; }
.card { border: none; }
}
</style>
</head>
<body>
<div class="invoice">
<div class="row" style="margin-bottom: 24px;">
<div>
<h1>Tax Invoice</h1>
<p class="muted" style="margin: 4px 0 0; font-family: monospace;">"#);
out.push_str(&escape_html(&invoice.invoice_number));
out.push_str("</p>\n </div>\n <div style=\"text-align: right;\">\n <span class=\"badge ");
out.push_str(&escape_html(&invoice.status));
out.push_str("\">");
out.push_str(&escape_html(&invoice.status));
out.push_str(r#"</span>
<p class="muted" style="margin: 8px 0 0;">Issued "#);
out.push_str(&invoice.issued_at.format("%d %b %Y").to_string());
out.push_str("</p>\n </div>\n </div>\n\n <div class=\"grid-2\" style=\"margin-bottom: 16px;\">\n <div class=\"card\">\n <p class=\"muted\" style=\"margin: 0 0 8px; font-size: 12px; text-transform: uppercase;\">From</p>\n <p style=\"margin: 0; font-weight: 600;\">");
out.push_str(&escape_html(&invoice.seller_name));
out.push_str("</p>\n <p style=\"margin: 4px 0; white-space: pre-line;\">");
out.push_str(&escape_html(&invoice.seller_address));
out.push_str("</p>\n <p style=\"margin: 4px 0; font-size: 13px;\">");
if let Some(gstin) = &invoice.seller_gstin {
out.push_str(&format!("GSTIN: {}<br>", escape_html(gstin)));
}
if let Some(pan) = &invoice.seller_pan {
out.push_str(&format!("PAN: {}", escape_html(pan)));
}
out.push_str("</p>\n </div>\n <div class=\"card\">\n <p class=\"muted\" style=\"margin: 0 0 8px; font-size: 12px; text-transform: uppercase;\">Bill To</p>\n <p style=\"margin: 0; font-weight: 600;\">");
out.push_str(&escape_html(invoice.customer_name.as_deref().unwrap_or("N/A")));
out.push_str("</p>\n <p style=\"margin: 4px 0; white-space: pre-line;\">");
out.push_str(&escape_html(
invoice.customer_billing_address.as_deref().unwrap_or("N/A"),
));
out.push_str("</p>\n <p style=\"margin: 4px 0; font-size: 13px;\">");
if let Some(gstin) = &invoice.customer_gstin {
out.push_str(&format!("GSTIN: {}<br>", 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("</p>\n </div>\n </div>\n\n <table>\n <thead>\n <tr>\n <th style=\"width: 4%;\">#</th>\n <th>Description</th>\n <th>HSN/SAC</th>\n <th class=\"num\">Qty</th>\n <th class=\"num\">Rate</th>\n <th class=\"num\">Amount</th>\n </tr>\n </thead>\n <tbody>\n");
for line in lines {
out.push_str(&format!(
" <tr>\n <td>{}</td>\n <td>{}</td>\n <td>{}</td>\n <td class=\"num\">{}</td>\n <td class=\"num\">{}</td>\n <td class=\"num\">{}</td>\n </tr>\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(" </tbody>\n </table>\n\n <div class=\"totals\">\n <div class=\"row\"><span>Subtotal</span><span class=\"num\">");
out.push_str(&format_inr(totals.subtotal));
out.push_str("</span></div>\n");
if totals.discount > 0 {
out.push_str(&format!(
" <div class=\"row\"><span>Discount</span><span class=\"num\">-{}</span></div>\n",
format_inr(totals.discount),
));
}
out.push_str(&format!(
" <div class=\"row\"><span>Taxable value</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.taxable_value),
));
if totals.cgst > 0 {
out.push_str(&format!(
" <div class=\"row\"><span>CGST</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.cgst),
));
out.push_str(&format!(
" <div class=\"row\"><span>SGST</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.sgst),
));
}
if totals.igst > 0 {
out.push_str(&format!(
" <div class=\"row\"><span>IGST</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.igst),
));
}
out.push_str(&format!(
" <div class=\"row grand\"><span>Total ({})</span><span class=\"num\">{}</span></div>\n",
invoice.currency,
format_inr(totals.total),
));
out.push_str(" </div>\n\n <p class=\"muted\" style=\"margin-top: 32px; font-size: 12px;\">\n This is a computer-generated invoice. No signature is required.\n </p>\n</div>\n</body>\n</html>\n");
out
}
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
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> {
Utc::now()
}

298
crates/invoice/src/lib.rs Normal file
View file

@ -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<String>,
pub quantity: f64,
pub unit_price_paise: i64,
pub tax_rate_percent: f64,
pub metadata: Option<serde_json::Value>,
}
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<String>,
pub seller_pan: Option<String>,
pub seller_state_code: Option<String>,
pub place_of_supply_state: Option<String>,
pub customer_name: Option<String>,
pub customer_email: Option<String>,
pub customer_phone: Option<String>,
pub customer_billing_address: Option<String>,
pub customer_gstin: Option<String>,
pub customer_state_code: Option<String>,
pub discount_label: Option<String>,
pub notes: Option<String>,
pub pdf_object_key: Option<String>,
pub issued_at: DateTime<Utc>,
pub paid_at: Option<DateTime<Utc>>,
pub voided_at: Option<DateTime<Utc>>,
pub voided_by_user_id: Option<Uuid>,
pub void_reason: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BillingDetails {
pub legal_name: String,
pub email: Option<String>,
pub phone: Option<String>,
pub gstin: Option<String>,
pub pan: Option<String>,
pub billing_address: String,
pub state_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SellerDetails {
pub name: String,
pub address: String,
pub gstin: Option<String>,
pub pan: Option<String>,
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<T> = Result<T, InvoiceError>;

View file

@ -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<LineItem>,
pub discount_amount: i64,
pub discount_label: Option<String>,
pub notes: Option<String>,
pub customer: BillingDetails,
pub seller: SellerDetails,
pub inter_state: bool,
pub pdf_object_key: Option<String>,
}
// ──────────────────────────────────────────────────────────────────────────
// 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<String>,
pub seller_pan: Option<String>,
pub seller_state_code: Option<String>,
pub place_of_supply_state: Option<String>,
pub customer_name: Option<String>,
pub customer_email: Option<String>,
pub customer_phone: Option<String>,
pub customer_billing_address: Option<String>,
pub customer_gstin: Option<String>,
pub customer_state_code: Option<String>,
pub discount_label: Option<String>,
pub notes: Option<String>,
pub pdf_object_key: Option<String>,
pub issued_at: DateTime<Utc>,
pub paid_at: Option<DateTime<Utc>>,
pub voided_at: Option<DateTime<Utc>>,
pub voided_by_user_id: Option<Uuid>,
pub void_reason: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<InvoiceRow> 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<String>,
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<serde_json::Value>,
}
impl From<LineItemRow> 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<Invoice> {
// 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<Option<Invoice>> {
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<Option<Invoice>> {
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<Vec<Invoice>> {
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<Vec<Invoice>> {
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<Vec<LineItem>> {
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<Option<BillingDetails>> {
let row: Option<(String, Option<String>, Option<String>, Option<String>, Option<String>, String, Option<String>)> =
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>) {}

13
crates/wallet/Cargo.toml Normal file
View file

@ -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

500
crates/wallet/src/hold.rs Normal file
View file

@ -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<Uuid>,
pub status: String,
pub expires_at: Option<DateTime<Utc>>,
pub settled_at: Option<DateTime<Utc>>,
pub settled_ledger_id: Option<Uuid>,
pub released_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[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<T> = Result<T, HoldError>;
// ──────────────────────────────────────────────────────────────────────────
// 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<Uuid>,
expires_at: Option<DateTime<Utc>>,
) -> 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<Uuid>, String, Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<Uuid>, Option<DateTime<Utc>>, DateTime<Utc>)> = 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<u64> {
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<Vec<Hold>> {
let rows: Vec<(Uuid, Uuid, Uuid, Uuid, i32, String, Option<Uuid>, String, Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<Uuid>, Option<DateTime<Utc>>, DateTime<Utc>)> = 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<Vec<Hold>> {
let limit = limit.clamp(1, 200);
let rows: Vec<(Uuid, Uuid, Uuid, Uuid, i32, String, Option<Uuid>, String, Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<Uuid>, Option<DateTime<Utc>>, DateTime<Utc>)> = 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>) {}

844
crates/wallet/src/lib.rs Normal file
View file

@ -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<Utc>,
}
/// 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<i32>,
pub reason: Option<String>,
pub reference_id: Option<Uuid>,
pub actor_user_id: Option<Uuid>,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
}
#[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<sqlx::Error> 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<Wallet, WalletError> {
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<Uuid>,
actor_user_id: Option<Uuid>,
metadata: Option<serde_json::Value>,
) -> Result<LedgerEntry, WalletError> {
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<Uuid>,
actor_user_id: Option<Uuid>,
metadata: Option<serde_json::Value>,
) -> 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<serde_json::Value>,
) -> 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<serde_json::Value>,
) -> 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<serde_json::Value>,
) -> 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<serde_json::Value>,
) -> 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<Vec<LedgerEntry>, 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(())
}