443 lines
14 KiB
TypeScript
443 lines
14 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 * as pdfMake from 'pdfmake/build/pdfmake';
|
|
import * as pdfFonts from 'pdfmake/build/vfs_fonts';
|
|
|
|
(pdfMake as any).vfs = pdfFonts.vfs;
|
|
|
|
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 docDefinition: any = {
|
|
content: [
|
|
{
|
|
columns: [
|
|
[
|
|
{ text: data.businessName, style: 'businessName' },
|
|
{ text: data.address, style: 'businessAddress' },
|
|
hasGst ? { text: `GSTIN: ${data.gstNumber}`, style: 'businessDetails' } : {}
|
|
],
|
|
[
|
|
{ text: 'RECEIPT', style: 'invoiceTitle', alignment: 'right' },
|
|
{ text: `Receipt #: ${data.invoiceNumber}`, style: 'invoiceDetails', alignment: 'right' },
|
|
{ text: `Date: ${formattedDate}`, style: 'invoiceDetails', alignment: 'right' }
|
|
]
|
|
]
|
|
},
|
|
{ canvas: [{ type: 'line', x1: 0, y1: 5, x2: 515, y2: 5, lineWidth: 0.5 }] },
|
|
{ text: '', margin: [0, 10] },
|
|
|
|
{
|
|
style: 'customerBox',
|
|
table: {
|
|
widths: ['*'],
|
|
body: [
|
|
[
|
|
{
|
|
stack: [
|
|
{ text: 'Receipt To:', style: 'customerTitle' },
|
|
{ text: data.customerName, style: 'customerDetails' },
|
|
{ text: `Phone: ${data.phoneNumber}`, style: 'customerDetails' },
|
|
{ text: `Email: ${data.email}`, style: 'customerDetails' }
|
|
],
|
|
margin: [10, 10]
|
|
}
|
|
]
|
|
]
|
|
},
|
|
layout: 'lightHorizontalLines'
|
|
},
|
|
{ text: '', margin: [0, 10] },
|
|
|
|
{
|
|
table: {
|
|
headerRows: 1,
|
|
widths: [30, '*', 80, 100],
|
|
body: [
|
|
[
|
|
{ text: 'No.', style: 'tableHeader', alignment: 'center' },
|
|
{ text: 'Description', style: 'tableHeader' },
|
|
{ text: 'HSN/SAC', style: 'tableHeader', alignment: 'center' },
|
|
{ text: 'Amount (INR)', style: 'tableHeader', alignment: 'right' }
|
|
],
|
|
[
|
|
{ text: '1', alignment: 'center' },
|
|
{ text: `${data.planName} Subscription` },
|
|
{ text: '999723', alignment: 'center' },
|
|
{ text: baseAmount.toFixed(2), alignment: 'right' }
|
|
]
|
|
]
|
|
}
|
|
},
|
|
{ text: '', margin: [0, 10] },
|
|
|
|
{
|
|
columns: [
|
|
{ width: '*', text: '' },
|
|
{
|
|
width: 'auto',
|
|
table: {
|
|
widths: [100, 100],
|
|
body: hasGst ? [
|
|
[
|
|
{ text: 'Taxable Amount:', alignment: 'right' },
|
|
{ text: `${baseAmount.toFixed(2)} INR`, alignment: 'right' }
|
|
],
|
|
[
|
|
{ text: 'SGST (9%):', alignment: 'right' },
|
|
{ text: `${sgst.toFixed(2)} INR`, alignment: 'right' }
|
|
],
|
|
[
|
|
{ text: 'CGST (9%):', alignment: 'right' },
|
|
{ text: `${cgst.toFixed(2)} INR`, alignment: 'right' }
|
|
],
|
|
[
|
|
{ text: 'Total Amount:', style: 'totalAmount', alignment: 'right' },
|
|
{ text: `${data.amount.toFixed(2)} INR`, style: 'totalAmount', alignment: 'right' }
|
|
]
|
|
] : [
|
|
[
|
|
{ text: 'Total Amount:', style: 'totalAmount', alignment: 'right' },
|
|
{ text: `${data.amount.toFixed(2)} INR`, style: 'totalAmount', alignment: 'right' }
|
|
]
|
|
]
|
|
},
|
|
layout: {
|
|
hLineWidth: function(i: number, node: any) {
|
|
return (i === node.table.body.length - 1) ? 0.5 : 0;
|
|
},
|
|
vLineWidth: function() { return 0; }
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{ text: '', margin: [0, 20] },
|
|
|
|
{
|
|
style: 'paymentBox',
|
|
table: {
|
|
widths: ['*'],
|
|
body: [
|
|
[
|
|
{
|
|
stack: [
|
|
{ text: 'Payment Information:', style: 'paymentTitle' },
|
|
{ text: `Transaction ID: ${data.transactionId}`, style: 'paymentDetails' },
|
|
{ text: `Payment Method: ${data.paymentMethod}`, style: 'paymentDetails' },
|
|
{ text: `Payment Date: ${formattedDate}`, style: 'paymentDetails' }
|
|
],
|
|
margin: [10, 10]
|
|
}
|
|
]
|
|
]
|
|
},
|
|
layout: 'lightHorizontalLines'
|
|
},
|
|
{ text: '', margin: [0, 20] },
|
|
|
|
{ text: 'Thank you for your business!', style: 'footer', alignment: 'center' },
|
|
{ text: 'This is a computer-generated receipt and does not require a signature.', style: 'disclaimer', alignment: 'center' }
|
|
],
|
|
styles: {
|
|
businessName: {
|
|
fontSize: 20,
|
|
bold: true,
|
|
margin: [0, 0, 0, 5]
|
|
},
|
|
businessAddress: {
|
|
fontSize: 12,
|
|
margin: [0, 0, 0, 5]
|
|
},
|
|
businessDetails: {
|
|
fontSize: 12
|
|
},
|
|
invoiceTitle: {
|
|
fontSize: 24,
|
|
bold: true
|
|
},
|
|
invoiceDetails: {
|
|
fontSize: 12,
|
|
margin: [0, 5, 0, 0]
|
|
},
|
|
customerBox: {
|
|
margin: [0, 10, 0, 10]
|
|
},
|
|
customerTitle: {
|
|
fontSize: 12,
|
|
bold: true,
|
|
margin: [0, 0, 0, 5]
|
|
},
|
|
customerDetails: {
|
|
fontSize: 12,
|
|
margin: [0, 2, 0, 0]
|
|
},
|
|
tableHeader: {
|
|
fontSize: 12,
|
|
bold: true,
|
|
margin: [0, 5, 0, 5]
|
|
},
|
|
totalAmount: {
|
|
fontSize: 12,
|
|
bold: true
|
|
},
|
|
paymentBox: {
|
|
margin: [0, 10, 0, 10]
|
|
},
|
|
paymentTitle: {
|
|
fontSize: 12,
|
|
bold: true,
|
|
margin: [0, 0, 0, 5]
|
|
},
|
|
paymentDetails: {
|
|
fontSize: 12,
|
|
margin: [0, 2, 0, 0]
|
|
},
|
|
footer: {
|
|
fontSize: 12,
|
|
italics: true,
|
|
margin: [0, 0, 0, 5]
|
|
},
|
|
disclaimer: {
|
|
fontSize: 10
|
|
}
|
|
},
|
|
defaultStyle: {
|
|
font: 'Helvetica'
|
|
}
|
|
};
|
|
|
|
const pdfDoc = pdfMake.createPdf(docDefinition);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
pdfDoc.getBuffer((buffer) => {
|
|
fs.writeFile(tempFilePath, buffer, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|