feat(invoices): Persian line items + elegant client-side PDF download

- Translate persisted English invoice line labels/descriptions/reasons to
  Persian at display time (new lib/invoice-labels.ts), covering both new and
  historical invoices without a data migration.
- Generate a styled, RTL Persian invoice PDF on the client (lib/invoice-pdf.ts)
  with the Abrban logo, line-item table and totals, via html2canvas + jsPDF.
- Wire both the user and admin invoice pages to the translator and new download.
- Add invoices.pdf dictionary keys (fa/en).
- Remove the now-dead ASCII-only backend PDF endpoints and generateInvoicePdf.
- Add frontend/.npmrc (npmmirror registry + high timeouts) for Iran-network installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-19 19:59:22 +03:30
parent 78ed95b29b
commit 4d64fda227
11 changed files with 677 additions and 133 deletions
+108
View File
@@ -0,0 +1,108 @@
// Invoice line labels/descriptions are generated on the backend as English
// strings (e.g. "Extra CPU (0.5 → 1)", "Billing cycle: monthly"). These helpers
// translate those persisted strings to Persian at display time, so both new and
// historical invoices render in Persian without a data migration. Unknown
// strings fall back to the original text untouched.
const CYCLE_FA: Record<string, string> = {
monthly: 'ماهانه',
yearly: 'سالانه',
hourly: 'ساعتی',
};
const ADDON_FA: Record<string, string> = {
'Redis addon': 'سرویس Redis',
'RabbitMQ addon': 'سرویس RabbitMQ',
'Elasticsearch addon': 'سرویس Elasticsearch',
'Custom domain + SSL': 'دامنهٔ اختصاصی + SSL',
};
const EXTRA_RESOURCE_FA: Record<string, string> = {
CPU: 'پردازندهٔ اضافه',
memory: 'حافظهٔ اضافه',
replicas: 'تعداد نمونه‌های اضافه',
'database storage': 'فضای پایگاه‌دادهٔ اضافه',
'app storage': 'فضای ذخیره‌سازی اپ اضافه',
};
/** Translate the prorate suffix "(prorated X/Y days)" → "(محاسبهٔ نسبی X از Y روز)". */
function translateProrateSuffix(text: string): { base: string; suffix: string } {
const match = text.match(/\s*\(prorated\s+(\d+)\/(\d+)\s+days\)\s*$/i);
if (!match) return { base: text, suffix: '' };
return {
base: text.slice(0, match.index).trim(),
suffix: ` (محاسبهٔ نسبی ${match[1]} از ${match[2]} روز)`,
};
}
/** Translate a persisted invoice line label to Persian. */
export function translateInvoiceLabel(raw?: string): string {
if (!raw) return '-';
const { base, suffix } = translateProrateSuffix(raw);
return translateBaseLabel(base) + suffix;
}
function translateBaseLabel(label: string): string {
// Application payment: <name>
let m = label.match(/^Application payment:\s*(.+)$/);
if (m) return `پرداخت اپلیکیشن: ${m[1]}`;
// Renewal for <name>
m = label.match(/^Renewal for\s+(.+)$/);
if (m) return `تمدید سرویس: ${m[1]}`;
// Resource upgrade for <name>
m = label.match(/^Resource upgrade for\s+(.+)$/);
if (m) return `ارتقای منابع: ${m[1]}`;
// Extra <resource> (A → B)
m = label.match(/^Extra (CPU|memory|replicas|database storage|app storage)(?:\s*\((.+?)\s*→\s*(.+?)\))?$/);
if (m) {
const name = EXTRA_RESOURCE_FA[m[1]] ?? `${m[1]} اضافه`;
return m[2] ? `${name} (از ${m[2]} به ${m[3]})` : name;
}
// Database (<type>)
m = label.match(/^Database\s*\((.+)\)$/);
if (m) return `پایگاه‌داده (${m[1]})`;
// Fixed addons
if (ADDON_FA[label]) return ADDON_FA[label];
return label;
}
const REASON_FA: Record<string, string> = {
deploy: 'راه‌اندازی اپلیکیشن',
renewal: 'تمدید سرویس',
upgrade: 'ارتقای منابع',
wallet_topup: 'شارژ کیف‌پول',
manual: 'صدور دستی',
};
/** Translate an invoice reason enum to Persian. */
export function translateInvoiceReason(reason?: string): string {
if (!reason) return '-';
return REASON_FA[reason] ?? reason;
}
/** Translate a persisted invoice line description to Persian. */
export function translateInvoiceDescription(raw?: string): string {
if (!raw) return '';
let text = raw;
// Billing cycle: <cycle>[; initiated by <role>]
let m = text.match(/^Billing cycle:\s*(\w+)(?:;\s*initiated by\s*(.+))?$/);
if (m) {
const cycle = CYCLE_FA[m[1]] ?? m[1];
text = `دورهٔ پرداخت: ${cycle}`;
if (m[2]) text += `؛ ثبت توسط ${m[2]}`;
return text;
}
// Prorated for <n> hours
m = text.match(/^Prorated for\s+(\d+)\s+hours$/);
if (m) return `محاسبهٔ نسبی برای ${m[1]} ساعت`;
return text;
}
+270
View File
@@ -0,0 +1,270 @@
// Client-side invoice PDF generation. Builds a styled, RTL Persian invoice
// document off-screen in the live DOM (so the Peyda webfont renders correctly),
// rasterizes it with html2canvas, and emits a paginated A4 PDF via jsPDF.
import type { Invoice, InvoiceStatus } from '@/types';
import type { Dictionary } from '@/i18n/dictionaries/fa';
import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from './invoice-labels';
type InvoiceDict = Dictionary['dashboard']['invoices'];
const STATUS_KIND: Record<InvoiceStatus, InvoicePdfData['statusKind']> = {
draft: 'neutral',
issued: 'unpaid',
partially_paid: 'unpaid',
paid: 'paid',
void: 'neutral',
failed: 'failed',
};
/** Assemble the PDF view-model from an invoice + dictionary, translating to Persian when locale is fa. */
export function buildInvoicePdfData(
invoice: Invoice,
opts: { inv: InvoiceDict; appName: string; locale: string },
): InvoicePdfData {
const { inv, appName, locale } = opts;
const isFa = locale.startsWith('fa');
const pdf = inv.pdf;
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
const formatDate = (value?: string) => (value ? new Date(value).toLocaleString(locale) : '-');
const withToman = (amount: number) => `${formatPrice(amount)} ${inv.toman}`;
const tr = (label?: string) => (isFa ? translateInvoiceLabel(label) : label ?? '-');
const trDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : desc ?? '');
const trReason = (reason?: string) => (isFa ? translateInvoiceReason(reason) : reason ?? '-');
const meta: InvoicePdfMeta[] = [
{ label: pdf.customer, value: invoice.user?.email || invoice.userId },
{ label: pdf.application, value: invoice.application?.name || trReason(invoice.reason) },
{ label: pdf.createdAt, value: formatDate(invoice.createdAt) },
{ label: inv.method, value: invoice.paymentMethod || '-' },
];
if (invoice.paidAt) meta.push({ label: pdf.paidAt, value: formatDate(invoice.paidAt) });
if (invoice.gatewayTrackingCode) meta.push({ label: inv.tracking, value: invoice.gatewayTrackingCode });
return {
fileName: `${invoice.invoiceNumber}.pdf`,
documentTitle: pdf.documentTitle,
brandName: appName,
tagline: pdf.tagline,
invoiceNumber: invoice.invoiceNumber,
invoiceNumberLabel: pdf.invoiceNumberLabel,
statusLabel: (inv.status as Record<string, string>)[invoice.status] ?? invoice.status,
statusKind: STATUS_KIND[invoice.status],
meta,
lineHeaderIndex: pdf.colIndex,
lineHeaderDesc: pdf.colDesc,
lineHeaderAmount: pdf.colAmount,
lines: (invoice.lines || []).map((line) => ({
label: tr(line.label),
description: trDesc(line.description),
amount: withToman(Number(line.amount)),
})),
totals: [
{ label: pdf.subtotal, value: withToman(invoice.subtotal) },
{ label: inv.paidLabel, value: withToman(invoice.paidAmount) },
{ label: inv.due, value: withToman(invoice.dueAmount) },
{ label: inv.total, value: withToman(invoice.total), emphasize: true },
],
generatedAt: pdf.generatedAt.replace('{date}', formatDate(new Date().toISOString())),
footerNote: pdf.footer,
};
}
export interface InvoicePdfLine {
label: string;
description?: string;
amount: string;
}
export interface InvoicePdfMeta {
label: string;
value: string;
}
export interface InvoicePdfTotal {
label: string;
value: string;
emphasize?: boolean;
}
export interface InvoicePdfData {
fileName: string;
documentTitle: string;
brandName: string;
tagline: string;
invoiceNumber: string;
invoiceNumberLabel: string;
statusLabel: string;
statusKind: 'paid' | 'unpaid' | 'failed' | 'neutral';
meta: InvoicePdfMeta[];
lineHeaderIndex: string;
lineHeaderDesc: string;
lineHeaderAmount: string;
lines: InvoicePdfLine[];
totals: InvoicePdfTotal[];
generatedAt: string;
footerNote: string;
}
const STATUS_COLORS: Record<InvoicePdfData['statusKind'], { bg: string; fg: string }> = {
paid: { bg: '#dcfce7', fg: '#15803d' },
unpaid: { bg: '#fef9c3', fg: '#a16207' },
failed: { bg: '#fee2e2', fg: '#b91c1c' },
neutral: { bg: '#e2e8f0', fg: '#475569' },
};
const esc = (value: string) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
export function buildInvoiceHtml(data: InvoicePdfData): string {
const status = STATUS_COLORS[data.statusKind];
const metaRows = data.meta
.map(
(m) => `
<div style="display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid #f1f5f9;">
<span style="color:#64748b;font-size:13px;">${esc(m.label)}</span>
<span style="color:#0f172a;font-size:13px;font-weight:600;">${esc(m.value)}</span>
</div>`,
)
.join('');
const lineRows = data.lines
.map(
(line, i) => `
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:14px 12px;text-align:center;color:#94a3b8;font-size:13px;width:40px;">${i + 1}</td>
<td style="padding:14px 12px;">
<div style="color:#0f172a;font-size:14px;font-weight:600;">${esc(line.label)}</div>
${line.description ? `<div style="color:#64748b;font-size:12px;margin-top:4px;">${esc(line.description)}</div>` : ''}
</td>
<td style="padding:14px 12px;text-align:left;color:#0f172a;font-size:14px;font-weight:700;white-space:nowrap;">${esc(line.amount)}</td>
</tr>`,
)
.join('');
const totalRows = data.totals
.map(
(t) => `
<div style="display:flex;justify-content:space-between;gap:12px;padding:${t.emphasize ? '12px 0' : '8px 0'};${
t.emphasize ? 'border-top:2px solid #e2e8f0;margin-top:6px;' : ''
}">
<span style="color:${t.emphasize ? '#0f172a' : '#64748b'};font-size:${t.emphasize ? '15px' : '13px'};font-weight:${
t.emphasize ? 700 : 500
};">${esc(t.label)}</span>
<span style="color:${t.emphasize ? '#1d4ed8' : '#0f172a'};font-size:${t.emphasize ? '16px' : '14px'};font-weight:700;white-space:nowrap;">${esc(
t.value,
)}</span>
</div>`,
)
.join('');
return `
<div style="box-sizing:border-box;width:794px;padding:48px;background:#ffffff;color:#0f172a;font-family:var(--font-peyda),Tahoma,sans-serif;direction:rtl;">
<!-- header -->
<div style="display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:24px;border-bottom:3px solid #1d4ed8;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:52px;height:52px;border-radius:14px;background:linear-gradient(135deg,#3b82f6,#1d4ed8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(37,99,235,0.3);">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>
</div>
<div>
<div style="font-size:24px;font-weight:900;color:#0f172a;">${esc(data.brandName)}</div>
<div style="font-size:12px;color:#64748b;margin-top:2px;">${esc(data.tagline)}</div>
</div>
</div>
<div style="text-align:left;">
<div style="font-size:20px;font-weight:800;color:#1d4ed8;">${esc(data.documentTitle)}</div>
<div style="font-size:12px;color:#64748b;margin-top:6px;">${esc(data.invoiceNumberLabel)}</div>
<div style="font-size:14px;font-weight:700;color:#0f172a;direction:ltr;">${esc(data.invoiceNumber)}</div>
<div style="display:inline-block;margin-top:10px;padding:4px 14px;border-radius:999px;background:${status.bg};color:${status.fg};font-size:12px;font-weight:700;">${esc(
data.statusLabel,
)}</div>
</div>
</div>
<!-- meta -->
<div style="margin-top:24px;background:#f8fafc;border:1px solid #f1f5f9;border-radius:16px;padding:8px 20px;">
${metaRows}
</div>
<!-- line items -->
<div style="margin-top:28px;">
<table style="width:100%;border-collapse:collapse;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
<thead>
<tr style="background:#1d4ed8;color:#ffffff;">
<th style="padding:12px;font-size:12px;font-weight:700;text-align:center;width:40px;">${esc(data.lineHeaderIndex)}</th>
<th style="padding:12px;font-size:12px;font-weight:700;text-align:right;">${esc(data.lineHeaderDesc)}</th>
<th style="padding:12px;font-size:12px;font-weight:700;text-align:left;white-space:nowrap;">${esc(data.lineHeaderAmount)}</th>
</tr>
</thead>
<tbody>${lineRows}</tbody>
</table>
</div>
<!-- totals -->
<div style="margin-top:24px;display:flex;justify-content:flex-start;">
<div style="width:300px;background:#f8fafc;border:1px solid #f1f5f9;border-radius:16px;padding:8px 20px;">
${totalRows}
</div>
</div>
<!-- footer -->
<div style="margin-top:40px;padding-top:20px;border-top:1px solid #e2e8f0;display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:11px;color:#94a3b8;">${esc(data.generatedAt)}</span>
<span style="font-size:11px;color:#94a3b8;">${esc(data.footerNote)}</span>
</div>
</div>`;
}
export async function downloadInvoicePdf(data: InvoicePdfData): Promise<void> {
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas'),
import('jspdf'),
]);
const host = document.createElement('div');
host.style.position = 'fixed';
host.style.left = '-10000px';
host.style.top = '0';
host.style.zIndex = '-1';
host.innerHTML = buildInvoiceHtml(data);
document.body.appendChild(host);
try {
const target = host.firstElementChild as HTMLElement;
const canvas = await html2canvas(target, {
scale: 2,
backgroundColor: '#ffffff',
useCORS: true,
logging: false,
});
const pdf = new jsPDF({ unit: 'pt', format: 'a4', orientation: 'portrait' });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const imgWidth = pageWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const imgData = canvas.toDataURL('image/png');
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
while (heightLeft > 0) {
position -= pageHeight;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
pdf.save(data.fileName);
} finally {
document.body.removeChild(host);
}
}