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

180 lines
7.7 KiB
Rust

//! HTML rendering for the printable invoice.
//!
//! Renders a self-contained, print-friendly HTML document. The
//! frontend uses this for the in-browser invoice view, and the same
//! HTML is what would be saved as a PDF.
use crate::{Invoice, InvoiceTotals, LineItem};
use chrono::{DateTime, Utc};
pub fn render_html(
invoice: &Invoice,
lines: &[LineItem],
totals: &InvoiceTotals,
) -> String {
let mut out = String::with_capacity(4096);
out.push_str(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Invoice "#);
out.push_str(&escape_html(&invoice.invoice_number));
out.push_str(r#"</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #0F172A; margin: 0; padding: 32px; background: #fff; }
.invoice { max-width: 800px; margin: 0 auto; }
h1 { font-size: 28px; margin: 0; }
.muted { color: #64748B; }
.row { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.card { border: 1px solid #E2E8F0; border-radius: 8px; padding: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
th { text-align: left; background: #F1F5F9; padding: 8px 12px; }
td { padding: 8px 12px; border-top: 1px solid #E2E8F0; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.totals { margin-top: 16px; }
.totals .row { padding: 4px 0; }
.totals .grand { font-size: 18px; font-weight: 700; border-top: 2px solid #0F172A; padding-top: 8px; margin-top: 8px; }
.badge { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge.PAID { background: #DCFCE7; color: #166534; }
.badge.ISSUED { background: #DBEAFE; color: #1E40AF; }
.badge.VOID { background: #FEE2E2; color: #991B1B; }
.badge.REFUNDED { background: #F1F5F9; color: #475569; }
.badge.DRAFT { background: #FEF3C7; color: #92400E; }
@media print {
body { padding: 0; }
.card { border: none; }
}
</style>
</head>
<body>
<div class="invoice">
<div class="row" style="margin-bottom: 24px;">
<div>
<h1>Tax Invoice</h1>
<p class="muted" style="margin: 4px 0 0; font-family: monospace;">"#);
out.push_str(&escape_html(&invoice.invoice_number));
out.push_str("</p>\n </div>\n <div style=\"text-align: right;\">\n <span class=\"badge ");
out.push_str(&escape_html(&invoice.status));
out.push_str("\">");
out.push_str(&escape_html(&invoice.status));
out.push_str(r#"</span>
<p class="muted" style="margin: 8px 0 0;">Issued "#);
out.push_str(&invoice.issued_at.format("%d %b %Y").to_string());
out.push_str("</p>\n </div>\n </div>\n\n <div class=\"grid-2\" style=\"margin-bottom: 16px;\">\n <div class=\"card\">\n <p class=\"muted\" style=\"margin: 0 0 8px; font-size: 12px; text-transform: uppercase;\">From</p>\n <p style=\"margin: 0; font-weight: 600;\">");
out.push_str(&escape_html(&invoice.seller_name));
out.push_str("</p>\n <p style=\"margin: 4px 0; white-space: pre-line;\">");
out.push_str(&escape_html(&invoice.seller_address));
out.push_str("</p>\n <p style=\"margin: 4px 0; font-size: 13px;\">");
if let Some(gstin) = &invoice.seller_gstin {
out.push_str(&format!("GSTIN: {}<br>", escape_html(gstin)));
}
if let Some(pan) = &invoice.seller_pan {
out.push_str(&format!("PAN: {}", escape_html(pan)));
}
out.push_str("</p>\n </div>\n <div class=\"card\">\n <p class=\"muted\" style=\"margin: 0 0 8px; font-size: 12px; text-transform: uppercase;\">Bill To</p>\n <p style=\"margin: 0; font-weight: 600;\">");
out.push_str(&escape_html(invoice.customer_name.as_deref().unwrap_or("N/A")));
out.push_str("</p>\n <p style=\"margin: 4px 0; white-space: pre-line;\">");
out.push_str(&escape_html(
invoice.customer_billing_address.as_deref().unwrap_or("N/A"),
));
out.push_str("</p>\n <p style=\"margin: 4px 0; font-size: 13px;\">");
if let Some(gstin) = &invoice.customer_gstin {
out.push_str(&format!("GSTIN: {}<br>", escape_html(gstin)));
}
if let Some(state) = &invoice.place_of_supply_state {
out.push_str(&format!("Place of Supply: {}", escape_html(state)));
}
out.push_str("</p>\n </div>\n </div>\n\n <table>\n <thead>\n <tr>\n <th style=\"width: 4%;\">#</th>\n <th>Description</th>\n <th>HSN/SAC</th>\n <th class=\"num\">Qty</th>\n <th class=\"num\">Rate</th>\n <th class=\"num\">Amount</th>\n </tr>\n </thead>\n <tbody>\n");
for line in lines {
out.push_str(&format!(
" <tr>\n <td>{}</td>\n <td>{}</td>\n <td>{}</td>\n <td class=\"num\">{}</td>\n <td class=\"num\">{}</td>\n <td class=\"num\">{}</td>\n </tr>\n",
line.line_number,
escape_html(&line.description),
escape_html(line.hsn_sac_code.as_deref().unwrap_or("")),
format_qty(line.quantity),
format_inr(line.unit_price_paise),
format_inr(line.subtotal_paise()),
));
}
out.push_str(" </tbody>\n </table>\n\n <div class=\"totals\">\n <div class=\"row\"><span>Subtotal</span><span class=\"num\">");
out.push_str(&format_inr(totals.subtotal));
out.push_str("</span></div>\n");
if totals.discount > 0 {
out.push_str(&format!(
" <div class=\"row\"><span>Discount</span><span class=\"num\">-{}</span></div>\n",
format_inr(totals.discount),
));
}
out.push_str(&format!(
" <div class=\"row\"><span>Taxable value</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.taxable_value),
));
if totals.cgst > 0 {
out.push_str(&format!(
" <div class=\"row\"><span>CGST</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.cgst),
));
out.push_str(&format!(
" <div class=\"row\"><span>SGST</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.sgst),
));
}
if totals.igst > 0 {
out.push_str(&format!(
" <div class=\"row\"><span>IGST</span><span class=\"num\">{}</span></div>\n",
format_inr(totals.igst),
));
}
out.push_str(&format!(
" <div class=\"row grand\"><span>Total ({})</span><span class=\"num\">{}</span></div>\n",
invoice.currency,
format_inr(totals.total),
));
out.push_str(" </div>\n\n <p class=\"muted\" style=\"margin-top: 32px; font-size: 12px;\">\n This is a computer-generated invoice. No signature is required.\n </p>\n</div>\n</body>\n</html>\n");
out
}
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn format_inr(paise: i64) -> String {
let sign = if paise < 0 { "-" } else { "" };
let abs = paise.unsigned_abs();
let rupees = abs / 100;
let paise = abs % 100;
let rupees_str = {
let s = rupees.to_string();
let mut out = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i != 0 && i % 3 == 0 {
out.insert(0, ',');
}
out.insert(0, c);
}
out
};
format!("{}{}.{:02}", sign, rupees_str, paise)
}
fn format_qty(q: f64) -> String {
if (q.fract() - 0.0).abs() < 0.001 {
format!("{}", q.trunc() as i64)
} else {
format!("{:.3}", q)
}
}
#[allow(dead_code)]
fn _today_anchor() -> DateTime<Utc> {
Utc::now()
}