From 4d64fda22704ebe17cc406cff088c0b3b3f81832 Mon Sep 17 00:00:00 2001 From: keyhan Date: Fri, 19 Jun 2026 19:59:22 +0330 Subject: [PATCH] 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 --- backend/src/billing/billing.controller.ts | 31 -- backend/src/billing/billing.service.ts | 65 ----- frontend/.npmrc | 5 + frontend/package-lock.json | 236 ++++++++++++++- frontend/package.json | 2 + .../[lang]/dashboard/admin/invoices/page.tsx | 28 +- .../app/[lang]/dashboard/invoices/page.tsx | 35 ++- frontend/src/i18n/dictionaries/en.ts | 15 + frontend/src/i18n/dictionaries/fa.ts | 15 + frontend/src/lib/invoice-labels.ts | 108 +++++++ frontend/src/lib/invoice-pdf.ts | 270 ++++++++++++++++++ 11 files changed, 677 insertions(+), 133 deletions(-) create mode 100644 frontend/.npmrc create mode 100644 frontend/src/lib/invoice-labels.ts create mode 100644 frontend/src/lib/invoice-pdf.ts diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 495656f..67fcc6d 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -9,13 +9,11 @@ import { Query, UseGuards, Request, - Res, BadRequestException, Inject, forwardRef, ForbiddenException, } from '@nestjs/common'; -import { Response } from 'express'; import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { BillingService } from './billing.service'; @@ -174,20 +172,6 @@ export class BillingController { }); } - @Get('invoices/:id/pdf') - @ApiOperation({ summary: 'Download invoice as PDF' }) - async downloadInvoicePdf( - @Request() req: any, - @Param('id') id: string, - @Res() res: Response, - ) { - const invoice = await this.billingService.getInvoiceForUser(id, req.user); - const pdf = this.billingService.generateInvoicePdf(invoice); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoiceNumber}.pdf"`); - res.send(pdf); - } - @Get('invoices/:id') @ApiOperation({ summary: 'Get one invoice with line items and transactions' }) async getInvoice(@Request() req: any, @Param('id') id: string) { @@ -372,21 +356,6 @@ export class BillingController { return this.billingService.getInvoiceForUser(id, req.user); } - @Get('admin/invoices/:id/pdf') - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Download invoice PDF (Admin)' }) - async downloadAdminInvoicePdf( - @Request() req: any, - @Param('id') id: string, - @Res() res: Response, - ) { - const invoice = await this.billingService.getInvoiceForUser(id, req.user); - const pdf = this.billingService.generateInvoicePdf(invoice); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${invoice.invoiceNumber}.pdf"`); - res.send(pdf); - } - @Patch('admin/invoices/:id/status') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Update invoice status with reason (Admin)' }) diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 7455b62..ef54cac 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -521,71 +521,6 @@ export class BillingService { return this.invoiceRepo.save(invoice); } - generateInvoicePdf(invoice: Invoice): Buffer { - const formatAmount = (amount: number) => `${Number(amount || 0).toLocaleString('en-US')} Toman`; - const formatDate = (date?: Date) => (date ? new Date(date).toLocaleString('en-US') : '-'); - const clean = (value: unknown) => - String(value ?? '-') - .replace(/[^\x20-\x7E]/g, '?') - .replace(/[\\()]/g, (match) => `\\${match}`); - - const lines = [ - 'CloudHost Invoice', - `Invoice Number: ${invoice.invoiceNumber}`, - `Status: ${invoice.status}`, - `Payment Method: ${invoice.paymentMethod || '-'}`, - `Customer: ${invoice.user?.email || invoice.userId}`, - `Application: ${invoice.application?.name || invoice.applicationId || '-'}`, - `Created At: ${formatDate(invoice.createdAt)}`, - `Paid At: ${formatDate(invoice.paidAt)}`, - '', - 'Line Items:', - ...(invoice.lines || []).map( - (line) => `- ${line.label}${line.description ? ` (${line.description})` : ''}: ${formatAmount(Number(line.amount))}`, - ), - '', - `Subtotal: ${formatAmount(Number(invoice.subtotal))}`, - `Total: ${formatAmount(Number(invoice.total))}`, - `Paid: ${formatAmount(Number(invoice.paidAmount))}`, - `Due: ${formatAmount(Number(invoice.dueAmount))}`, - `Gateway Tracking: ${invoice.gatewayTrackingCode || '-'}`, - ].map(clean); - - const content = [ - 'BT', - '/F1 12 Tf', - '50 790 Td', - '16 TL', - ...lines.map((line, index) => `${index === 0 ? '' : 'T* '}(${line}) Tj`), - 'ET', - ].join('\n'); - - const objects = [ - '1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n', - '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n', - '3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n', - '4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n', - `5 0 obj\n<< /Length ${Buffer.byteLength(content, 'utf8')} >>\nstream\n${content}\nendstream\nendobj\n`, - ]; - - let pdf = '%PDF-1.4\n'; - const offsets = [0]; - for (const object of objects) { - offsets.push(Buffer.byteLength(pdf, 'utf8')); - pdf += object; - } - - const xrefOffset = Buffer.byteLength(pdf, 'utf8'); - pdf += `xref\n0 ${objects.length + 1}\n`; - pdf += '0000000000 65535 f \n'; - for (let i = 1; i < offsets.length; i += 1) { - pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`; - } - pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; - - return Buffer.from(pdf, 'utf8'); - } - /** * Calculate cost for an existing Application entity. * Used by lifecycle service for auto-renew. diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..f363a1f --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,5 @@ +registry=https://registry.npmmirror.com +fetch-timeout=600000 +fetch-retries=8 +fetch-retry-mintimeout=20000 +fetch-retry-maxtimeout=600000 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d25e4f6..cef11a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,8 @@ "axios": "^1.17.0", "clsx": "^2.1.0", "framer-motion": "^12.40.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "lenis": "^1.3.23", "lucide-react": "^1.18.0", "next": "16.2.9", @@ -308,9 +310,9 @@ "license": "Apache-2.0" }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1761,6 +1763,19 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1811,6 +1826,13 @@ "meshoptimizer": "~1.1.1" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -2384,6 +2406,18 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -2395,6 +2429,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -2779,6 +2824,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2995,6 +3049,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3073,6 +3147,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -3105,6 +3191,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3273,6 +3368,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -3976,6 +4081,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4448,6 +4564,19 @@ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", "license": "Apache-2.0" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4539,6 +4668,12 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5106,6 +5241,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6007,6 +6159,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6046,6 +6204,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6192,6 +6357,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -6289,6 +6464,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6374,6 +6556,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6689,6 +6881,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -6924,6 +7126,16 @@ "react": ">=17.0" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -6945,6 +7157,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/three": { "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", @@ -7411,6 +7632,15 @@ "node": ">= 4" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b27d936..3e96de6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "axios": "^1.17.0", "clsx": "^2.1.0", "framer-motion": "^12.40.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "lenis": "^1.3.23", "lucide-react": "^1.18.0", "next": "16.2.9", diff --git a/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx b/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx index bb5a58c..bffd9f1 100644 --- a/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/invoices/page.tsx @@ -6,6 +6,8 @@ import { FileText, Search, User, Wallet, CreditCard, XCircle, Download } from 'l import { notify } from '@/lib/notify'; import api from '@/lib/api'; import { useT, useLocale } from '@/i18n/I18nProvider'; +import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels'; +import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf'; import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types'; import { Select } from '@/components/ui/select'; @@ -59,18 +61,14 @@ export default function AdminInvoicesPage() { onError: (err: any) => notify.error(err, inv.statusUpdateFailed), }); + const isFa = locale.startsWith('fa'); + const lineLabel = (label?: string) => (isFa ? translateInvoiceLabel(label) : (label ?? '-')); + const lineDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : (desc ?? '')); + const reasonLabel = (reason?: string) => (isFa ? translateInvoiceReason(reason) : (reason ?? '-')); + const downloadPdfMutation = useMutation({ - mutationFn: async (invoice: Invoice) => { - const { data } = await api.get(`/billing/admin/invoices/${invoice.id}/pdf`, { responseType: 'blob' }); - const url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' })); - const link = document.createElement('a'); - link.href = url; - link.download = `${invoice.invoiceNumber}.pdf`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - }, + mutationFn: (invoice: Invoice) => + downloadInvoicePdf(buildInvoicePdfData(invoice, { inv, appName: t.common.appName, locale })), onError: () => notify.error(inv.downloadFailed), }); @@ -162,7 +160,7 @@ export default function AdminInvoicesPage() { >

{invoice.invoiceNumber}

-

{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}

+

{invoice.application?.name || reasonLabel(invoice.reason)} · {formatDate(invoice.createdAt)}

{invoice.user?.email || invoice.userId}

@@ -193,7 +191,7 @@ export default function AdminInvoicesPage() {

{selectedInvoice.invoiceNumber}

-

{selectedInvoice.application?.name || selectedInvoice.reason}

+

{selectedInvoice.application?.name || reasonLabel(selectedInvoice.reason)}

{statusLabel(selectedInvoice.status)} @@ -241,8 +239,8 @@ export default function AdminInvoicesPage() { {(selectedInvoice.lines || []).map((line) => (
-

{line.label}

- {line.description &&

{line.description}

} +

{lineLabel(line.label)}

+ {line.description &&

{lineDesc(line.description)}

}
{formatPrice(line.amount)} {t.common.currencyShort}
diff --git a/frontend/src/app/[lang]/dashboard/invoices/page.tsx b/frontend/src/app/[lang]/dashboard/invoices/page.tsx index 6c7fe56..4b97473 100644 --- a/frontend/src/app/[lang]/dashboard/invoices/page.tsx +++ b/frontend/src/app/[lang]/dashboard/invoices/page.tsx @@ -7,6 +7,8 @@ import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } f import { notify } from '@/lib/notify'; import api from '@/lib/api'; import { useT, useLocale } from '@/i18n/I18nProvider'; +import { translateInvoiceLabel, translateInvoiceDescription, translateInvoiceReason } from '@/lib/invoice-labels'; +import { downloadInvoicePdf, buildInvoicePdfData } from '@/lib/invoice-pdf'; import type { Invoice, InvoiceStatus } from '@/types'; const statusClasses: Record = { @@ -22,7 +24,11 @@ export default function InvoicesPage() { const t = useT(); const inv = t.dashboard.invoices; const locale = useLocale(); + const isFa = locale.startsWith('fa'); const statusLabel = (s: InvoiceStatus) => (inv.status as Record)[s] ?? s; + const lineLabel = (label?: string) => (isFa ? translateInvoiceLabel(label) : (label ?? '-')); + const lineDesc = (desc?: string) => (isFa ? translateInvoiceDescription(desc) : (desc ?? '')); + const reasonLabel = (reason?: string) => (isFa ? translateInvoiceReason(reason) : (reason ?? '-')); const queryClient = useQueryClient(); const searchParams = useSearchParams(); const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all'); @@ -105,23 +111,14 @@ export default function InvoicesPage() { onError: (err: any) => notify.error(err, inv.paymentFailed), }); - const downloadPdfMutation = useMutation({ - mutationFn: async (invoice: Invoice) => { - const { data } = await api.get(`/billing/invoices/${invoice.id}/pdf`, { responseType: 'blob' }); - const url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' })); - const link = document.createElement('a'); - link.href = url; - link.download = `${invoice.invoiceNumber}.pdf`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - }, - onError: () => notify.error(inv.downloadFailed), - }); - const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US'); const formatDate = (value?: string) => value ? new Date(value).toLocaleString(locale) : '-'; + + const downloadPdfMutation = useMutation({ + mutationFn: (invoice: Invoice) => + downloadInvoicePdf(buildInvoicePdfData(invoice, { inv, appName: t.common.appName, locale })), + onError: () => notify.error(inv.downloadFailed), + }); const dueAmount = Number(selectedInvoice?.dueAmount || 0); const walletBalance = Number(walletData?.balance || 0); const isPayable = selectedInvoice?.status === 'issued' || selectedInvoice?.status === 'partially_paid'; @@ -182,7 +179,7 @@ export default function InvoicesPage() {

{invoice.invoiceNumber}

- {invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)} + {invoice.application?.name || reasonLabel(invoice.reason)} · {formatDate(invoice.createdAt)}

{statusLabel(invoice.status)} @@ -208,7 +205,7 @@ export default function InvoicesPage() {

{selectedInvoice.invoiceNumber}

-

{selectedInvoice.application?.name || selectedInvoice.reason}

+

{selectedInvoice.application?.name || reasonLabel(selectedInvoice.reason)}

{statusLabel(selectedInvoice.status)} @@ -238,8 +235,8 @@ export default function InvoicesPage() {
-

{line.label}

- {line.description &&

{line.description}

} +

{lineLabel(line.label)}

+ {line.description &&

{lineDesc(line.description)}

}
{formatPrice(line.amount)} {t.common.currencyShort}
diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index a0ecadf..6b463eb 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -705,6 +705,21 @@ const en: Dictionary = { statusUpdated: 'Invoice status updated', statusUpdateFailed: 'Failed to update invoice status', reasonRequired: 'Reason is required for manual status changes', + pdf: { + documentTitle: 'Invoice', + tagline: 'Cloud infrastructure, in your control', + invoiceNumberLabel: 'Invoice number', + colIndex: '#', + colDesc: 'Description', + colAmount: 'Amount', + customer: 'Customer', + application: 'Application', + createdAt: 'Issued on', + paidAt: 'Paid on', + subtotal: 'Subtotal', + generatedAt: 'This document was generated automatically on {date}.', + footer: 'Abrban — abrban.com', + }, }, logs: { title: 'Logs', diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index 74041e3..b341d86 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -704,6 +704,21 @@ const fa = { statusUpdated: 'وضعیت فاکتور به‌روزرسانی شد', statusUpdateFailed: 'به‌روزرسانی وضعیت فاکتور ناموفق بود', reasonRequired: 'برای تغییر دستی وضعیت، ذکر دلیل الزامی است', + pdf: { + documentTitle: 'فاکتور', + tagline: 'زیرساخت ابری، در کنترل تو', + invoiceNumberLabel: 'شمارهٔ فاکتور', + colIndex: 'ردیف', + colDesc: 'شرح اقلام', + colAmount: 'مبلغ', + customer: 'مشتری', + application: 'اپلیکیشن', + createdAt: 'تاریخ صدور', + paidAt: 'تاریخ پرداخت', + subtotal: 'جمع جزء', + generatedAt: 'این سند در تاریخ {date} به‌صورت خودکار صادر شده است.', + footer: 'ابربان — abrban.com', + }, }, logs: { title: 'لاگ‌ها', diff --git a/frontend/src/lib/invoice-labels.ts b/frontend/src/lib/invoice-labels.ts new file mode 100644 index 0000000..8694413 --- /dev/null +++ b/frontend/src/lib/invoice-labels.ts @@ -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 = { + monthly: 'ماهانه', + yearly: 'سالانه', + hourly: 'ساعتی', +}; + +const ADDON_FA: Record = { + 'Redis addon': 'سرویس Redis', + 'RabbitMQ addon': 'سرویس RabbitMQ', + 'Elasticsearch addon': 'سرویس Elasticsearch', + 'Custom domain + SSL': 'دامنهٔ اختصاصی + SSL', +}; + +const EXTRA_RESOURCE_FA: Record = { + 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: + let m = label.match(/^Application payment:\s*(.+)$/); + if (m) return `پرداخت اپلیکیشن: ${m[1]}`; + + // Renewal for + m = label.match(/^Renewal for\s+(.+)$/); + if (m) return `تمدید سرویس: ${m[1]}`; + + // Resource upgrade for + m = label.match(/^Resource upgrade for\s+(.+)$/); + if (m) return `ارتقای منابع: ${m[1]}`; + + // Extra (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 () + 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 = { + 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: [; initiated by ] + 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 hours + m = text.match(/^Prorated for\s+(\d+)\s+hours$/); + if (m) return `محاسبهٔ نسبی برای ${m[1]} ساعت`; + + return text; +} diff --git a/frontend/src/lib/invoice-pdf.ts b/frontend/src/lib/invoice-pdf.ts new file mode 100644 index 0000000..365e4d4 --- /dev/null +++ b/frontend/src/lib/invoice-pdf.ts @@ -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 = { + 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)[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 = { + 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, '&') + .replace(//g, '>'); + +export function buildInvoiceHtml(data: InvoicePdfData): string { + const status = STATUS_COLORS[data.statusKind]; + + const metaRows = data.meta + .map( + (m) => ` +
+ ${esc(m.label)} + ${esc(m.value)} +
`, + ) + .join(''); + + const lineRows = data.lines + .map( + (line, i) => ` + + ${i + 1} + +
${esc(line.label)}
+ ${line.description ? `
${esc(line.description)}
` : ''} + + ${esc(line.amount)} + `, + ) + .join(''); + + const totalRows = data.totals + .map( + (t) => ` +
+ ${esc(t.label)} + ${esc( + t.value, + )} +
`, + ) + .join(''); + + return ` +
+ +
+
+
+ +
+
+
${esc(data.brandName)}
+
${esc(data.tagline)}
+
+
+
+
${esc(data.documentTitle)}
+
${esc(data.invoiceNumberLabel)}
+
${esc(data.invoiceNumber)}
+
${esc( + data.statusLabel, + )}
+
+
+ + +
+ ${metaRows} +
+ + +
+ + + + + + + + + ${lineRows} +
${esc(data.lineHeaderIndex)}${esc(data.lineHeaderDesc)}${esc(data.lineHeaderAmount)}
+
+ + +
+
+ ${totalRows} +
+
+ + +
+ ${esc(data.generatedAt)} + ${esc(data.footerNote)} +
+
`; +} + +export async function downloadInvoicePdf(data: InvoicePdfData): Promise { + 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); + } +}