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:
@@ -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)' })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
fetch-timeout=600000
|
||||
fetch-retries=8
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=600000
|
||||
Generated
+233
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.application?.name || reasonLabel(invoice.reason)} · {formatDate(invoice.createdAt)}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-900">{invoice.user?.email || invoice.userId}</p>
|
||||
@@ -193,7 +191,7 @@ export default function AdminInvoicesPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || reasonLabel(selectedInvoice.reason)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabel(selectedInvoice.status)}</span>
|
||||
@@ -241,8 +239,8 @@ export default function AdminInvoicesPage() {
|
||||
{(selectedInvoice.lines || []).map((line) => (
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3 flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
<p className="text-sm font-medium text-gray-900">{lineLabel(line.label)}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{lineDesc(line.description)}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold">{formatPrice(line.amount)} {t.common.currencyShort}</span>
|
||||
</div>
|
||||
|
||||
@@ -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<InvoiceStatus, string> = {
|
||||
@@ -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<string, string>)[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() {
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}
|
||||
{invoice.application?.name || reasonLabel(invoice.reason)} · {formatDate(invoice.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={statusClasses[invoice.status]}>{statusLabel(invoice.status)}</span>
|
||||
@@ -208,7 +205,7 @@ export default function InvoicesPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || reasonLabel(selectedInvoice.reason)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabel(selectedInvoice.status)}</span>
|
||||
@@ -238,8 +235,8 @@ export default function InvoicesPage() {
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
<p className="text-sm font-medium text-gray-900">{lineLabel(line.label)}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{lineDesc(line.description)}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">{formatPrice(line.amount)} {t.common.currencyShort}</span>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'لاگها',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user