- 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>
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
//! 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>;
|