nxtgauge-backend-rust/crates/invoice/src/lib.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

298 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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>;