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:
parent
c9d0e283ac
commit
eb009ac3d0
43 changed files with 4894 additions and 328 deletions
120
Cargo.lock
generated
120
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -56,3 +56,4 @@ async-trait = "0.1"
|
|||
bytes = "1"
|
||||
tower-http = "0.6"
|
||||
regex = "1"
|
||||
printpdf = "0.7"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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), ®ular);
|
||||
y -= 5.0;
|
||||
}
|
||||
|
||||
// Separator (text-based)
|
||||
layer.use_text("___________________________________________________", 9.0, left, Mm(y), ®ular);
|
||||
y -= 8.0;
|
||||
|
||||
// Helper: render one section — header then wrapped body lines
|
||||
let mut render_section = |label: &str, content: &str| {
|
||||
if content.trim().is_empty() { return; }
|
||||
if y < 20.0 { return; }
|
||||
// Section header in bold
|
||||
layer.use_text(label, 9.5, left, Mm(y), &bold);
|
||||
y -= 5.5;
|
||||
// Body text, word-wrapped
|
||||
for line in wrap_text(content, 88) {
|
||||
if y < 20.0 { break; }
|
||||
if line.is_empty() {
|
||||
y -= 2.5;
|
||||
} else {
|
||||
layer.use_text(&line, 9.5, left, Mm(y), ®ular);
|
||||
y -= 5.0;
|
||||
}
|
||||
}
|
||||
y -= 4.0;
|
||||
};
|
||||
|
||||
// Read the multipart field named "resume" (or the first field if unnamed).
|
||||
let mut file_bytes = bytes::BytesMut::new();
|
||||
let mut content_type = "application/octet-stream".to_string();
|
||||
let mut ext = "pdf".to_string();
|
||||
let mut found = false;
|
||||
render_section("PROFESSIONAL SUMMARY", summary);
|
||||
render_section("SKILLS", skills);
|
||||
render_section("EDUCATION", education);
|
||||
render_section("WORK EXPERIENCE", work_experience);
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name == "resume" || name == "file" || !found {
|
||||
// Detect content type and extension from the field
|
||||
if let Some(ct) = field.content_type() {
|
||||
content_type = ct.to_string();
|
||||
ext = match ct {
|
||||
"application/pdf" => "pdf",
|
||||
"application/msword" => "doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
|
||||
_ => "pdf",
|
||||
}
|
||||
.to_string();
|
||||
} else if let Some(fname) = field.file_name() {
|
||||
if let Some(e) = fname.rsplit('.').next() {
|
||||
ext = e.to_lowercase();
|
||||
}
|
||||
}
|
||||
// Privacy footer
|
||||
layer.use_text(
|
||||
"* Contact details are hidden. Use Nxtgauge to unlock and connect with this candidate.",
|
||||
7.5,
|
||||
left,
|
||||
Mm(12.0),
|
||||
®ular,
|
||||
);
|
||||
|
||||
let data = match field.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Failed to read file: {}", e) }))).into_response(),
|
||||
};
|
||||
|
||||
if data.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Empty file" }))).into_response();
|
||||
}
|
||||
|
||||
// 10 MB limit
|
||||
if data.len() > 10 * 1024 * 1024 {
|
||||
return (StatusCode::PAYLOAD_TOO_LARGE, Json(serde_json::json!({ "error": "File too large. Maximum 10 MB." }))).into_response();
|
||||
}
|
||||
|
||||
file_bytes.put(data);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found || file_bytes.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No resume file provided. Send a multipart field named 'resume'." }))).into_response();
|
||||
}
|
||||
|
||||
// Upload to Backblaze B2
|
||||
let resume_url = match state.storage
|
||||
.upload("resume", &ext, file_bytes.freeze(), &content_type)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
tracing::error!("B2 upload failed: {}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "File upload failed" }))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Save resume_url to job seeker profile
|
||||
let update_result = sqlx::query(
|
||||
"UPDATE job_seekers SET resume_url = $1 WHERE id = $2"
|
||||
)
|
||||
.bind(&resume_url)
|
||||
.bind(seeker.id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
match update_result {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "resume_url": resume_url }))).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to save resume_url to profile: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Uploaded but failed to save URL" }))).into_response()
|
||||
}
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
doc.save(&mut BufWriter::new(Cursor::new(&mut buf)))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
async fn browse_jobs(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
851
apps/payments/src/admin.rs
Normal 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(¬if_body)
|
||||
.bind("ADMIN")
|
||||
.bind(entry.id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
(wallet, entry)
|
||||
}
|
||||
Err(e) => {
|
||||
return match e {
|
||||
wallet::WalletError::InsufficientFunds { available, requested } => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Insufficient balance: available={available}, requested={requested}"),
|
||||
)),
|
||||
wallet::WalletError::InvalidAmount(a) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid amount: {a}"),
|
||||
)),
|
||||
_ => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("DB error: {e}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let (wallet, entry) = result;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"message": "Credits adjusted successfully.",
|
||||
"user_id": payload.user_id,
|
||||
"amount": signed_amount,
|
||||
"direction": if signed_amount >= 0 { "credit" } else { "debit" },
|
||||
"balance": wallet.balance,
|
||||
"reserved": wallet.reserved,
|
||||
"available": wallet.available,
|
||||
"reason": payload.reason,
|
||||
"ledger_entry_id": entry.id,
|
||||
"audit_logged": true,
|
||||
"user_notified": true,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReconcileQuery {
|
||||
from: Option<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))
|
||||
}
|
||||
|
|
@ -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
|
||||
"#,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
90
apps/ugc_content_creators/src/admin.rs
Normal file
90
apps/ugc_content_creators/src/admin.rs
Normal 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())),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
$$;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
157
crates/db/migrations/20260627020000_invoice_hardening.up.sql
Normal file
157
crates/db/migrations/20260627020000_invoice_hardening.up.sql
Normal 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);
|
||||
2
crates/db/migrations/20260627030000_wallet_full.down.sql
Normal file
2
crates/db/migrations/20260627030000_wallet_full.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS tracecoin_holds;
|
||||
DROP TABLE IF EXISTS tracecoin_buckets;
|
||||
65
crates/db/migrations/20260627030000_wallet_full.up.sql
Normal file
65
crates/db/migrations/20260627030000_wallet_full.up.sql
Normal 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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS resume_url;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE job_seeker_profiles ADD COLUMN IF NOT EXISTS resume_url VARCHAR(500);
|
||||
|
|
@ -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
13
crates/invoice/Cargo.toml
Normal 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
180
crates/invoice/src/html.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn format_inr(paise: i64) -> String {
|
||||
let sign = if paise < 0 { "-" } else { "" };
|
||||
let abs = paise.unsigned_abs();
|
||||
let rupees = abs / 100;
|
||||
let paise = abs % 100;
|
||||
let rupees_str = {
|
||||
let s = rupees.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in s.chars().rev().enumerate() {
|
||||
if i != 0 && i % 3 == 0 {
|
||||
out.insert(0, ',');
|
||||
}
|
||||
out.insert(0, c);
|
||||
}
|
||||
out
|
||||
};
|
||||
format!("{}₹{}.{:02}", sign, rupees_str, paise)
|
||||
}
|
||||
|
||||
fn format_qty(q: f64) -> String {
|
||||
if (q.fract() - 0.0).abs() < 0.001 {
|
||||
format!("{}", q.trunc() as i64)
|
||||
} else {
|
||||
format!("{:.3}", q)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _today_anchor() -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
298
crates/invoice/src/lib.rs
Normal file
298
crates/invoice/src/lib.rs
Normal 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>;
|
||||
621
crates/invoice/src/service.rs
Normal file
621
crates/invoice/src/service.rs
Normal 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
13
crates/wallet/Cargo.toml
Normal 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
500
crates/wallet/src/hold.rs
Normal 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
844
crates/wallet/src/lib.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue