nxtgauge-backend-rust/crates/invoice/src/service.rs
Tracewebstudio Dev eb009ac3d0 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>
2026-07-02 13:31:06 +02:00

621 lines
22 KiB
Rust

//! 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>) {}