- 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>
180 lines
7.7 KiB
Rust
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('&', "&")
|
|
.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()
|
|
}
|