From d5e6ab0f68b02aaf061a1987dce91b1a3afa0379 Mon Sep 17 00:00:00 2001 From: keyhan Date: Mon, 18 May 2026 22:01:44 +0330 Subject: [PATCH] Add invoice PDF download and unified payment. Co-authored-by: Cursor --- backend/src/billing/billing.controller.ts | 49 +++++--- backend/src/billing/billing.service.ts | 65 ++++++++++ .../src/app/dashboard/admin/invoices/page.tsx | 30 ++++- frontend/src/app/dashboard/invoices/page.tsx | 119 ++++++++++-------- 4 files changed, 191 insertions(+), 72 deletions(-) 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.invoiceNumber}

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

- {statusLabels[selectedInvoice.status]} +
+ {statusLabels[selectedInvoice.status]} + +
diff --git a/frontend/src/app/dashboard/invoices/page.tsx b/frontend/src/app/dashboard/invoices/page.tsx index ce3297f..cf26dc9 100644 --- a/frontend/src/app/dashboard/invoices/page.tsx +++ b/frontend/src/app/dashboard/invoices/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, ExternalLink } from 'lucide-react'; +import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react'; import { toast } from 'react-toastify'; import api from '@/lib/api'; import type { Invoice, InvoiceStatus } from '@/types'; @@ -69,39 +69,61 @@ export default function InvoicesPage() { queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] }); }; - const walletPayMutation = useMutation({ - mutationFn: (invoiceId: string) => api.post(`/billing/invoices/${invoiceId}/pay/wallet`).then((r) => r.data), - onSuccess: (data) => { - toast.success(data.effect ? 'Invoice paid and service updated' : 'Invoice paid'); - refresh(); - }, - onError: (err: any) => toast.error(err.response?.data?.message || 'Wallet payment failed'), - }); - - const gatewayPayMutation = useMutation({ - mutationFn: async ({ invoiceId, mixed }: { invoiceId: string; mixed: boolean }) => { - const callbackUrl = `${window.location.origin}/dashboard/invoices`; - const { data } = await api.post( - `/billing/invoices/${invoiceId}/pay/${mixed ? 'mixed' : 'gateway'}`, - { callbackUrl }, - ); - if (data.gatewayAmount === 0) return data; - const amount = data.gatewayAmount ?? data.amount; - const trackingCode = data.trackingCode; - if (!trackingCode || !amount) return data; - const verified = await api.post(`/billing/invoices/${invoiceId}/gateway/verify`, { - trackingCode, - amount, - }); - return verified.data; - }, + const verifyGatewayMutation = useMutation({ + mutationFn: ({ invoiceId, trackingCode, amount }: { invoiceId: string; trackingCode: string; amount: number }) => + api.post(`/billing/invoices/${invoiceId}/gateway/verify`, { trackingCode, amount }).then((r) => r.data), onSuccess: (data) => { toast.success(data.effect ? 'Payment complete and service updated' : 'Payment complete'); refresh(); + window.history.replaceState({}, '', window.location.pathname); }, onError: (err: any) => toast.error(err.response?.data?.message || 'Gateway payment failed'), }); + useEffect(() => { + const invoiceId = searchParams.get('invoiceId'); + const trackingCode = searchParams.get('trackingCode'); + const amount = Number(searchParams.get('amount') || 0); + const status = searchParams.get('status'); + if (invoiceId && trackingCode && amount > 0 && status === 'success' && !verifyGatewayMutation.isPending) { + setSelectedId(invoiceId); + verifyGatewayMutation.mutate({ invoiceId, trackingCode, amount }); + } + }, [searchParams, verifyGatewayMutation]); + + const payMutation = useMutation({ + mutationFn: async (invoiceId: string) => { + const callbackUrl = `${window.location.origin}/dashboard/invoices`; + const { data } = await api.post(`/billing/invoices/${invoiceId}/pay/mixed`, { callbackUrl }); + if (data.gatewayUrl && data.gatewayAmount > 0) { + window.location.href = data.gatewayUrl; + return data; + } + return data; + }, + onSuccess: (data) => { + if (data.gatewayUrl && data.gatewayAmount > 0) return; + toast.success(data.effect ? 'Invoice paid and service updated' : 'Invoice paid'); + refresh(); + }, + onError: (err: any) => toast.error(err.response?.data?.message || 'Payment failed'), + }); + + 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: () => 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') : '-'; const dueAmount = Number(selectedInvoice?.dueAmount || 0); @@ -192,7 +214,18 @@ export default function InvoicesPage() {

{selectedInvoice.invoiceNumber}

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

- {statusLabels[selectedInvoice.status]} +
+ {statusLabels[selectedInvoice.status]} + +
@@ -221,38 +254,20 @@ export default function InvoicesPage() { {isPayable && (
-

Payment options

- +

Payment

{walletBalance < dueAmount && (

- 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.

)} -
)}