fitlien-services/functions/src/payments/phonepe/invoice/invoiceService.ts
Allen T J e12cbf4148
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m21s
phonepe (#50)
Co-authored-by: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com>
Reviewed-on: #50
2025-05-23 09:31:52 +00:00

387 lines
12 KiB
TypeScript

import { getAdmin, getLogger } from "../../../shared/config";
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { format } from 'date-fns';
import { sendEmailWithAttachmentUtil } from "../../../utils/emailService";
import { jsPDF } from "jspdf";
import autoTable from 'jspdf-autotable';
const admin = getAdmin();
const logger = getLogger();
export interface InvoiceData {
invoiceNumber: string;
businessName: string;
address: string;
gstNumber?: string;
customerName: string;
phoneNumber: string;
email: string;
planName: string;
amount: number;
transactionId: string;
paymentDate: Date;
paymentMethod: string;
}
export interface EmailOptions {
recipientEmail: string;
recipientName?: string;
subject?: string;
customHtml?: string;
additionalData?: {
gymName?: string;
planName?: string;
amount?: number;
transactionId?: string;
paymentDate?: Date;
paymentMethod?: string;
};
}
export class InvoiceService {
async generateInvoice(data: InvoiceData): Promise<string> {
try {
const tempFilePath = path.join(os.tmpdir(), `invoice_${data.invoiceNumber}.pdf`);
const hasGst = data.gstNumber && data.gstNumber.length > 0;
const baseAmount = hasGst ? data.amount / 1.18 : data.amount;
const sgst = hasGst ? baseAmount * 0.09 : 0;
const cgst = hasGst ? baseAmount * 0.09 : 0;
const formattedDate = format(data.paymentDate, 'dd/MM/yyyy');
const doc = new jsPDF();
doc.setFontSize(20);
doc.setFont('helvetica', 'bold');
doc.text(data.businessName, 15, 20);
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
const maxWidth = 170;
const lineHeight = 5;
const addressLines = doc.splitTextToSize(data.address, maxWidth);
if (addressLines.length <= 2) {
for (let i = 0; i < addressLines.length; i++) {
doc.text(addressLines[i], 15, 30 + (i * lineHeight));
}
} else {
doc.text(addressLines[0], 15, 30);
let secondLine = addressLines[1];
if (secondLine.length > 3) {
secondLine = secondLine.substring(0, secondLine.length - 3) + '...';
} else {
secondLine += '...';
}
doc.text(secondLine, 15, 35);
}
const gstYPosition = 40;
if (hasGst) {
doc.text(`GSTIN: ${data.gstNumber}`, 20, gstYPosition);
}
doc.setFontSize(24);
doc.setFont('helvetica', 'bold');
doc.text('RECEIPT', 195, 20, { align: 'right' });
doc.setFontSize(12);
doc.text(`Receipt #: ${data.invoiceNumber}`, 195, 30, { align: 'right' });
doc.text(`Date: ${formattedDate}`, 195, 40, { align: 'right' });
doc.setLineWidth(0.5);
doc.line(15, 45, 195, 45);
doc.setFontSize(12);
const receiptToBoxX = 15;
const receiptToBoxY = 50;
const receiptToBoxWidth = 100;
const receiptToBoxHeight = 36;
doc.setDrawColor(0, 0, 0);
doc.setLineWidth(0.5);
doc.rect(receiptToBoxX, receiptToBoxY, receiptToBoxWidth, receiptToBoxHeight);
doc.setFont('helvetica', 'bold');
doc.text('Receipt To:', 18, 60);
doc.setFont('helvetica', 'normal');
doc.text(data.customerName, 18, 70);
doc.text(`Phone: ${data.phoneNumber}`, 18, 75);
doc.text(`Email: ${data.email}`, 18, 80);
autoTable(doc, {
startY: 110,
margin: {left: 15, right: 15},
head: [
[
{content: 'No.', styles: {halign: 'center'}},
{content: 'Description', styles: {halign: 'left'}},
{content: 'HSN/SAC', styles: {halign: 'center'}},
{content: 'Amount (INR)', styles: {halign: 'right'}}
]
],
body: [
['1', `${data.planName} Subscription`, '999723', baseAmount.toFixed(2)]
],
headStyles: {
fillColor: [220, 220, 220],
textColor: [0, 0, 0],
fontStyle: 'bold',
lineWidth: 0.5,
lineColor: [0, 0, 0]
},
styles: {
halign: 'center',
lineWidth: 0.5,
lineColor: [0, 0, 0]
},
columnStyles: {
0: { halign: 'center', cellWidth: 20 },
1: { halign: 'left' },
2: { halign: 'center', cellWidth: 40 },
3: { halign: 'right', cellWidth: 40 }
},
theme: 'grid',
tableLineWidth: 0.5,
tableLineColor: [0, 0, 0],
});
const finalY = (doc as any).lastAutoTable.finalY + 20;
if (hasGst) {
doc.text('Taxable Amount:', 150, finalY, { align: 'right' });
doc.text(`${baseAmount.toFixed(2)} INR`, 195, finalY, { align: 'right' });
doc.text('SGST (9%):', 150, finalY + 10, { align: 'right' });
doc.text(`${sgst.toFixed(2)} INR`, 195, finalY + 10, { align: 'right' });
doc.text('CGST (9%):', 150, finalY + 20, { align: 'right' });
doc.text(`${cgst.toFixed(2)} INR`, 195, finalY + 20, { align: 'right' });
doc.setLineWidth(0.5);
doc.line(15, finalY + 25, 195, finalY + 25);
doc.setFont('helvetica', 'bold');
doc.text('Total Amount:', 150, finalY + 30, { align: 'right' });
doc.text(`${data.amount.toFixed(2)} INR`, 195, finalY + 30, { align: 'right' });
} else {
doc.setLineWidth(0.5);
doc.line(15, finalY - 5, 195, finalY - 5);
doc.setFont('helvetica', 'bold');
doc.text('Total Amount:', 150, finalY, { align: 'right' });
doc.text(`${data.amount.toFixed(2)} INR`, 195, finalY, { align: 'right' });
}
const paymentY = hasGst ? finalY + 50 : finalY + 20;
const boxX = 15;
const boxY = paymentY - 10;
const boxWidth = 100;
const boxHeight = 36;
doc.setDrawColor(0, 0, 0);
doc.setLineWidth(0.5);
doc.rect(boxX, boxY, boxWidth, boxHeight);
doc.setFont('helvetica', 'bold');
doc.text('Payment Information:', 18, paymentY);
doc.setFont('helvetica', 'normal');
doc.text(`Transaction ID: ${data.transactionId}`, 18, paymentY + 10);
doc.text(`Payment Method: ${data.paymentMethod}`, 18, paymentY + 15);
doc.text(`Payment Date: ${formattedDate}`, 18, paymentY + 20);
doc.setFontSize(12);
doc.setFont('helvetica', 'italic');
doc.text('Thank you for your business!', 105, 270, { align: 'center' });
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text('This is a computer-generated receipt and does not require a signature.', 105, 280, { align: 'center' });
fs.writeFileSync(tempFilePath, Buffer.from(doc.output('arraybuffer')));
const invoicePath = `invoices/${data.invoiceNumber}.pdf`;
const bucket = admin.storage().bucket();
await bucket.upload(tempFilePath, {
destination: invoicePath,
metadata: {
contentType: 'application/pdf',
},
});
fs.unlinkSync(tempFilePath);
return invoicePath;
} catch (error: any) {
logger.error('Error generating invoice:', error);
throw new Error(`Failed to generate invoice: ${error.message}`);
}
}
async getInvoiceDownloadUrl(invoicePath: string): Promise<string> {
try {
const bucket = admin.storage().bucket();
const file = bucket.file(invoicePath);
const expirationMs = 7 * 24 * 60 * 60 * 1000;
const [signedUrl] = await file.getSignedUrl({
action: 'read',
expires: Date.now() + expirationMs,
responseDisposition: `attachment; filename="${path.basename(invoicePath)}"`,
});
return signedUrl;
} catch (error: any) {
logger.error('Error getting invoice download URL:', error);
throw new Error(`Failed to get invoice download URL: ${error.message}`);
}
}
async updateInvoicePath(membershipId: string, paymentId: string, invoicePath: string): Promise<boolean> {
try {
const membershipPaymentsRef = admin.firestore()
.collection('membership_payments')
.doc(membershipId);
const docSnapshot = await membershipPaymentsRef.get();
if (!docSnapshot.exists) {
logger.error(`No membership payments found for membershipId: ${membershipId}`);
return false;
}
const data = docSnapshot.data();
const paymentsData = data?.payments || [];
let found = false;
for (let i = 0; i < paymentsData.length; i++) {
if (paymentsData[i].referenceNumber === paymentId ||
paymentsData[i].transactionId === paymentId) {
paymentsData[i].invoicePath = invoicePath;
found = true;
break;
}
}
if (!found) {
logger.error(`No payment found with ID: ${paymentId} in membership: ${membershipId}`);
return false;
}
await membershipPaymentsRef.update({
'payments': paymentsData,
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
});
logger.info(`Successfully updated invoice path for payment: ${paymentId}`);
return true;
} catch (error: any) {
logger.error('Error updating payment with invoice path:', error);
return false;
}
}
async sendInvoiceEmail(invoicePath: string, emailOptions: EmailOptions): Promise<boolean> {
try {
const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath);
const formattedDate = emailOptions.additionalData?.paymentDate
? new Date(emailOptions.additionalData.paymentDate).toLocaleDateString('en-GB')
: new Date().toLocaleDateString('en-GB');
const emailHtml = emailOptions.customHtml || `
<html>
<body>
<h2>Thank you for your payment</h2>
<p>Dear ${emailOptions.recipientName || 'Valued Customer'},</p>
<p>Thank you for your payment. Your membership has been successfully activated.</p>
<p>Please find attached your invoice for the payment.</p>
<p>Membership Details:</p>
<ul>
<li>Gym: ${emailOptions.additionalData?.gymName || 'Fitlien'}</li>
<li>Plan: ${emailOptions.additionalData?.planName || 'Membership'}</li>
<li>Amount: ₹${emailOptions.additionalData?.amount || '0'}</li>
<li>Transaction ID: ${emailOptions.additionalData?.transactionId || 'N/A'}</li>
<li>Date: ${formattedDate}</li>
<li>Payment Method: ${emailOptions.additionalData?.paymentMethod || 'Online'}</li>
</ul>
<p>If you have any questions, please contact us.</p>
<p>Regards,<br>Fitlien Team</p>
</body>
</html>
`;
await sendEmailWithAttachmentUtil(
emailOptions.recipientEmail,
emailOptions.subject || 'Your Fitlien Membership Invoice',
emailHtml,
downloadUrl,
`Invoice_${path.basename(invoicePath)}`
);
logger.info(`Invoice email sent to ${emailOptions.recipientEmail}`);
return true;
} catch (error: any) {
logger.error('Error sending invoice email:', error);
return false;
}
}
async processInvoice(
membershipId: string,
paymentId: string,
invoiceData: InvoiceData,
emailOptions?: EmailOptions
): Promise<{
success: boolean;
invoicePath?: string;
downloadUrl?: string;
emailSent: boolean;
error?: string;
}> {
try {
const invoicePath = await this.generateInvoice(invoiceData);
const updateSuccess = await this.updateInvoicePath(membershipId, paymentId, invoicePath);
if (!updateSuccess) {
return {
success: false,
invoicePath,
emailSent: false,
error: 'Failed to update payment with invoice path'
};
}
const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath);
let emailSent = false;
if (emailOptions && emailOptions.recipientEmail) {
emailSent = await this.sendInvoiceEmail(invoicePath, emailOptions);
}
return {
success: true,
invoicePath,
downloadUrl,
emailSent
};
} catch (error: any) {
logger.error('Error processing invoice:', error);
return {
success: false,
emailSent: false,
error: error.message
};
}
}
}