diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index c03685a..4944211 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -9,11 +9,13 @@ 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'; @@ -162,30 +164,26 @@ 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) { return this.billingService.getInvoiceForUser(id, req.user); } - @Post('invoices/:id/pay/wallet') - @ApiOperation({ summary: 'Pay invoice from wallet balance' }) - async payInvoiceWallet(@Request() req: any, @Param('id') id: string) { - const result = await this.billingService.payInvoiceWithWallet(id, req.user); - const effect = await this.completePaidInvoiceEffect(result.invoice); - return { ...result, effect }; - } - - @Post('invoices/:id/pay/gateway') - @ApiOperation({ summary: 'Initiate direct gateway payment for invoice' }) - async initiateInvoiceGateway( - @Request() req: any, - @Param('id') id: string, - @Body() dto: InitiateInvoicePaymentDto, - ) { - return this.billingService.initiateInvoiceGatewayPayment(id, req.user, dto.callbackUrl); - } - @Post('invoices/:id/pay/mixed') @ApiOperation({ summary: 'Pay invoice with wallet first, then gateway for the remaining amount' }) async initiateInvoiceMixed( @@ -366,6 +364,21 @@ 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 f4749a5..23d1fd3 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -490,6 +490,71 @@ 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/src/app/dashboard/admin/invoices/page.tsx b/frontend/src/app/dashboard/admin/invoices/page.tsx index 3548730..6c20fd1 100644 --- a/frontend/src/app/dashboard/admin/invoices/page.tsx +++ b/frontend/src/app/dashboard/admin/invoices/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { FileText, Search, User, Wallet, CreditCard, XCircle } from 'lucide-react'; +import { FileText, Search, User, Wallet, CreditCard, XCircle, Download } from 'lucide-react'; import { toast } from 'react-toastify'; import api from '@/lib/api'; import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types'; @@ -62,6 +62,21 @@ export default function AdminInvoicesPage() { onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update invoice status'), }); + 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); + }, + onError: () => toast.error('Failed to download invoice PDF'), + }); + const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US'); const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-'; @@ -170,7 +185,18 @@ export default function AdminInvoicesPage() {
{selectedInvoice.application?.name || selectedInvoice.reason}
- {statusLabels[selectedInvoice.status]} +{selectedInvoice.application?.name || selectedInvoice.reason}
- Wallet is short by {formatPrice(dueAmount - walletBalance)} Toman. Use mixed payment to cover the delta by gateway. + Wallet balance covers {formatPrice(walletBalance)} Toman. The remaining {formatPrice(dueAmount - walletBalance)} Toman will be paid through the gateway.
)} -