From 38d5092ee3283dc779d0b9bb573fa451be79206c Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Mon, 12 May 2025 13:13:27 +0530 Subject: [PATCH 01/10] Update webhook.ts --- functions/src/payments/phonepe/webhook.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/functions/src/payments/phonepe/webhook.ts b/functions/src/payments/phonepe/webhook.ts index d8696dc..e5d87ab 100644 --- a/functions/src/payments/phonepe/webhook.ts +++ b/functions/src/payments/phonepe/webhook.ts @@ -10,6 +10,13 @@ export const phonePeWebhook = onRequest({ region: '#{SERVICES_RGN}#' }, async (request: Request, response) => { try { + + logger.info('Received webhook request', { + headers: request.headers, + body: request.body, + method: request.method + }); + const authHeader = request.headers['authorization'] as string; const username = process.env.PHONEPE_WEBHOOK_USERNAME; const password = process.env.PHONEPE_WEBHOOK_PASSWORD; -- 2.43.0 From 6d64f1e4d73341b77bb0df73614a57902d146be2 Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Wed, 14 May 2025 18:34:58 +0530 Subject: [PATCH 02/10] Logging and client profile change --- functions/src/clientRegistration/clientRegistration.ts | 2 +- functions/src/payments/phonepe/webhook.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/functions/src/clientRegistration/clientRegistration.ts b/functions/src/clientRegistration/clientRegistration.ts index 8ed51fb..fe2fafc 100644 --- a/functions/src/clientRegistration/clientRegistration.ts +++ b/functions/src/clientRegistration/clientRegistration.ts @@ -82,7 +82,7 @@ export const registerClient = onRequest({ phoneNumber: formattedPhoneNumber, }; - await admin.firestore().collection('client_profile').doc(clientUid).set(clientData); + await admin.firestore().collection('client_profiles').doc(clientUid).set(clientData); return res.status(201).json({ success: true, diff --git a/functions/src/payments/phonepe/webhook.ts b/functions/src/payments/phonepe/webhook.ts index e5d87ab..9f522f9 100644 --- a/functions/src/payments/phonepe/webhook.ts +++ b/functions/src/payments/phonepe/webhook.ts @@ -27,14 +27,12 @@ export const phonePeWebhook = onRequest({ return; } - // Calculate expected authorization value const credentialString = `${username}:${password}`; const expectedAuth = crypto .createHash('sha256') .update(credentialString) .digest('hex'); - // PhonePe may send the header with a prefix like "SHA256 " or just the hash const receivedAuth = authHeader.replace(/^SHA256\s+/i, ''); if (receivedAuth.toLowerCase() !== expectedAuth.toLowerCase()) { -- 2.43.0 From e8710074c41a1e047b7eaa5a81f3dd4efd8829a4 Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Mon, 19 May 2025 12:52:06 +0530 Subject: [PATCH 03/10] Invoice --- firebase.json | 2 +- functions/src/index.ts | 2 +- functions/src/payments/phonepe/checkStatus.ts | 146 +++++++- functions/src/payments/phonepe/index.ts | 2 + .../payments/phonepe/invoice/directInvoice.ts | 106 ++++++ .../payments/phonepe/invoice/getInvoiceUrl.ts | 63 ++++ .../src/payments/phonepe/invoice/index.ts | 12 + .../phonepe/invoice/invoiceService.ts | 327 +++++++++++++++++ .../phonepe/invoice/processInvoice.ts | 83 +++++ .../phonepe/invoice/sendInvoiceEmail.ts | 91 +++++ functions/src/payments/phonepe/paymentData.ts | 130 +++++++ functions/src/payments/phonepe/webhook.ts | 338 +++++++++++++++++- functions/src/utils/emailService.ts | 70 ++++ package-lock.json | 208 ++++++++++- package.json | 7 +- 15 files changed, 1533 insertions(+), 54 deletions(-) create mode 100644 functions/src/payments/phonepe/invoice/directInvoice.ts create mode 100644 functions/src/payments/phonepe/invoice/getInvoiceUrl.ts create mode 100644 functions/src/payments/phonepe/invoice/index.ts create mode 100644 functions/src/payments/phonepe/invoice/invoiceService.ts create mode 100644 functions/src/payments/phonepe/invoice/processInvoice.ts create mode 100644 functions/src/payments/phonepe/invoice/sendInvoiceEmail.ts create mode 100644 functions/src/payments/phonepe/paymentData.ts create mode 100644 functions/src/utils/emailService.ts diff --git a/firebase.json b/firebase.json index d8682a0..dfc4226 100644 --- a/firebase.json +++ b/firebase.json @@ -25,7 +25,7 @@ "port": 5005 }, "firestore": { - "port": 8085 + "port": 8086 }, "storage": { "port": 9199 diff --git a/functions/src/index.ts b/functions/src/index.ts index 1c5b36c..19fa4e0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,6 +3,6 @@ export { sendEmailMessage, sendEmailWithAttachment, sendEmailSES } from './email export { sendSMSMessage } from './sms'; export { accessFile } from './storage'; export { processNotificationOnCreate } from './notifications'; -export { createCashfreeLink, createCashfreeOrder, verifyCashfreePayment, checkPhonePePaymentStatus, createPhonePeOrder, phonePeWebhook } from './payments'; +export * from './payments'; export { getPlaceDetails, getPlacesAutocomplete } from './places'; export { registerClient } from './clientRegistration'; diff --git a/functions/src/payments/phonepe/checkStatus.ts b/functions/src/payments/phonepe/checkStatus.ts index df2fa36..22a9c25 100644 --- a/functions/src/payments/phonepe/checkStatus.ts +++ b/functions/src/payments/phonepe/checkStatus.ts @@ -3,9 +3,13 @@ import { onRequest } from "firebase-functions/v2/https"; import { Request} from "firebase-functions/v2/https"; import { getCorsHandler } from "../../shared/middleware"; import { getAdmin, getLogger } from "../../shared/config"; +import { updatePaymentDataAfterSuccess } from "./paymentData"; +import { InvoiceService } from "./invoice/invoiceService"; + const admin = getAdmin(); const logger = getLogger(); const corsHandler = getCorsHandler(); +const invoiceService = new InvoiceService(); export const checkPhonePePaymentStatus = onRequest({ region: '#{SERVICES_RGN}#' @@ -80,30 +84,144 @@ export const checkPhonePePaymentStatus = onRequest({ .limit(1) .get(); - if (orderQuery.empty) { + if (orderQuery.empty) { logger.error(`No payment order found with PhonePe orderId: ${merchantOrderId}`); response.status(404).json({ - success: false, - error: 'Payment order not found', - message: `No record found for PhonePe order ID: ${merchantOrderId}` + success: false, + error: 'Payment order not found', + message: `No record found for PhonePe order ID: ${merchantOrderId}` }); return; - } + } - const orderDoc = orderQuery.docs[0]; + const orderDoc = orderQuery.docs[0]; + const orderData = orderDoc.data(); - await orderDoc.ref.update({ + await orderDoc.ref.update({ orderStatus: statusResponse.data.state || 'UNKNOWN', lastChecked: new Date(), statusResponse: statusResponse.data - }); - logger.info('PhonePe status response data:', JSON.stringify(statusResponse.data)); + }); + + if (statusResponse.data.state === 'COMPLETED') { + try { + // Update payment data + const paymentUpdateSuccess = await updatePaymentDataAfterSuccess( + merchantOrderId, + statusResponse.data.orderId, + statusResponse.data + ); + + if (paymentUpdateSuccess) { + // Extract membership ID from metaInfo + const membershipId = orderData.metaInfo?.membershipId; + + if (membershipId) { + try { + // Get user data for invoice + const membershipDoc = await admin.firestore() + .collection('memberships') + .doc(membershipId) + .get(); + + if (membershipDoc.exists) { + const membershipData = membershipDoc.data(); + const userId = membershipData?.userId; + + // Get user details + const userDoc = await admin.firestore() + .collection('users') + .doc(userId) + .get(); + + if (userDoc.exists) { + const userData = userDoc.data(); + + // Get gym details + const gymId = orderData.metaInfo?.gymId || membershipData?.gymId; + let gymName = 'Fitlien'; + let gymAddress = ''; + let gstNumber = ''; + + if (gymId) { + const gymDoc = await admin.firestore() + .collection('gyms') + .doc(gymId) + .get(); + + if (gymDoc.exists) { + const gymData = gymDoc.data(); + gymName = gymData?.name || 'Fitlien'; + gymAddress = gymData?.address || ''; + gstNumber = gymData?.gstNumber || ''; + } + } + + // Generate invoice data + const invoiceData = { + invoiceNumber: `INV-${merchantOrderId.substring(0, 8)}`, + businessName: gymName, + address: gymAddress, + gstNumber: gstNumber, + customerName: userData?.displayName || `${membershipData?.['first-name'] || ''} ${membershipData?.['last-name'] || ''}`.trim(), + phoneNumber: membershipData?.['phone-number'] || orderData.metaInfo?.phoneNumber || '', + email: membershipData?.['email'] || '', + planName: orderData.metaInfo?.planName || 'Membership', + amount: orderData.amount, + transactionId: statusResponse.data.orderId, + paymentDate: new Date(), + paymentMethod: 'Online' + }; + + const invoicePath = await invoiceService.generateInvoice(invoiceData); + + // Update payment record with invoice path + await admin.firestore() + .collection('membership_payments') + .doc(membershipId) + .get() + .then(async (doc) => { + if (doc.exists) { + const paymentsData = doc.data()?.payments || []; + for (let i = 0; i < paymentsData.length; i++) { + if (paymentsData[i].referenceNumber === merchantOrderId || + paymentsData[i].transactionId === statusResponse.data.orderId) { + paymentsData[i].invoicePath = invoicePath; + break; + } + } + + await doc.ref.update({ + 'payments': paymentsData, + 'updatedAt': admin.firestore.FieldValue.serverTimestamp(), + }); + } + }); + + logger.info(`Generated invoice for payment: ${merchantOrderId}, path: ${invoicePath}`); + } + } + } catch (invoiceError) { + logger.error('Error generating invoice:', invoiceError); + // Continue processing - don't fail the response + } + } + } + + logger.info(`Payment data updated for completed payment: ${merchantOrderId}`); + } catch (paymentUpdateError) { + logger.error('Error updating payment data:', paymentUpdateError); + // Continue processing - don't fail the response + } + } - response.json({ - success: true, - state: statusResponse.data.state, - data: statusResponse.data - }); + logger.info('PhonePe status response data:', JSON.stringify(statusResponse.data)); + + response.json({ + success: true, + state: statusResponse.data.state, + data: statusResponse.data + }); } catch (authError: any) { logger.error('Authentication error:', authError); diff --git a/functions/src/payments/phonepe/index.ts b/functions/src/payments/phonepe/index.ts index 258f121..050d5b2 100644 --- a/functions/src/payments/phonepe/index.ts +++ b/functions/src/payments/phonepe/index.ts @@ -1,3 +1,5 @@ export { createPhonePeOrder } from './createPhonepeOrder'; export { checkPhonePePaymentStatus } from './checkStatus'; export { phonePeWebhook } from './webhook'; +export { updatePaymentDataAfterSuccess } from './paymentData'; +export * from './invoice'; diff --git a/functions/src/payments/phonepe/invoice/directInvoice.ts b/functions/src/payments/phonepe/invoice/directInvoice.ts new file mode 100644 index 0000000..93969f5 --- /dev/null +++ b/functions/src/payments/phonepe/invoice/directInvoice.ts @@ -0,0 +1,106 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { getCorsHandler } from "../../../shared/middleware"; +import { getAdmin, getLogger } from "../../../shared/config"; +import { InvoiceService, InvoiceData } from "./invoiceService"; + +const admin = getAdmin(); +const logger = getLogger(); +const corsHandler = getCorsHandler(); +const invoiceService = new InvoiceService(); + +export const directGenerateInvoice = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response) => { + return corsHandler(request, response, async () => { + try { + const authHeader = request.headers.authorization || ''; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + response.status(401).json({ error: 'Unauthorized' }); + return; + } + + const idToken = authHeader.split('Bearer ')[1]; + + try { + await admin.auth().verifyIdToken(idToken); + + const { + invoiceNumber, + businessName, + address, + gstNumber, + customerName, + phoneNumber, + email, + planName, + amount, + transactionId, + paymentDate, + paymentMethod, + sendEmail, + emailOptions + } = request.body; + + if (!invoiceNumber || !businessName || !customerName || !amount || !transactionId) { + response.status(400).json({ + success: false, + error: 'Missing required fields' + }); + return; + } + + const invoiceData: InvoiceData = { + invoiceNumber, + businessName, + address: address || '', + gstNumber, + customerName, + phoneNumber: phoneNumber || '', + email: email || '', + planName: planName || 'Membership', + amount: parseFloat(amount), + transactionId, + paymentDate: paymentDate ? new Date(paymentDate) : new Date(), + paymentMethod: paymentMethod || 'Online' + }; + + // Generate the invoice without updating any payment records + const invoicePath = await invoiceService.generateInvoice(invoiceData); + const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath); + + // Send email if requested + let emailSent = false; + if (sendEmail && email) { + emailSent = await invoiceService.sendInvoiceEmail(invoicePath, { + recipientEmail: email, + recipientName: customerName, + ...emailOptions + }); + } + + response.json({ + success: true, + invoicePath, + downloadUrl, + emailSent + }); + + } catch (authError: any) { + logger.error('Authentication error:', authError); + response.status(401).json({ + success: false, + error: 'Invalid authentication token', + details: authError.message + }); + } + } catch (error: any) { + logger.error('Error generating invoice:', error); + response.status(500).json({ + success: false, + error: 'Failed to generate invoice', + details: error.message + }); + } + }); +}); diff --git a/functions/src/payments/phonepe/invoice/getInvoiceUrl.ts b/functions/src/payments/phonepe/invoice/getInvoiceUrl.ts new file mode 100644 index 0000000..038a14c --- /dev/null +++ b/functions/src/payments/phonepe/invoice/getInvoiceUrl.ts @@ -0,0 +1,63 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { getCorsHandler } from "../../../shared/middleware"; +import { getAdmin, getLogger } from "../../../shared/config"; +import { InvoiceService } from "./invoiceService"; + +const admin = getAdmin(); +const logger = getLogger(); +const corsHandler = getCorsHandler(); +const invoiceService = new InvoiceService(); + +export const getInvoiceUrl = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response) => { + return corsHandler(request, response, async () => { + try { + const authHeader = request.headers.authorization || ''; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + response.status(401).json({ error: 'Unauthorized' }); + return; + } + + const idToken = authHeader.split('Bearer ')[1]; + + try { + await admin.auth().verifyIdToken(idToken); + + const { invoicePath } = request.query; + + if (!invoicePath) { + response.status(400).json({ + success: false, + error: 'Missing invoice path' + }); + return; + } + + // Get a download URL for the invoice + const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath as string); + + response.json({ + success: true, + downloadUrl + }); + + } catch (authError: any) { + logger.error('Authentication error:', authError); + response.status(401).json({ + success: false, + error: 'Invalid authentication token', + details: authError.message + }); + } + } catch (error: any) { + logger.error('Error getting invoice URL:', error); + response.status(500).json({ + success: false, + error: 'Failed to get invoice URL', + details: error.message + }); + } + }); +}); diff --git a/functions/src/payments/phonepe/invoice/index.ts b/functions/src/payments/phonepe/invoice/index.ts new file mode 100644 index 0000000..3d423aa --- /dev/null +++ b/functions/src/payments/phonepe/invoice/index.ts @@ -0,0 +1,12 @@ +import { getInvoiceUrl } from './getInvoiceUrl'; +import { InvoiceService } from './invoiceService'; +import { processInvoice } from './processInvoice'; +import { sendInvoiceEmail } from './sendInvoiceEmail'; + +// Export all invoice-related functions +export { + getInvoiceUrl, + InvoiceService, + processInvoice, + sendInvoiceEmail +}; diff --git a/functions/src/payments/phonepe/invoice/invoiceService.ts b/functions/src/payments/phonepe/invoice/invoiceService.ts new file mode 100644 index 0000000..032b259 --- /dev/null +++ b/functions/src/payments/phonepe/invoice/invoiceService.ts @@ -0,0 +1,327 @@ +import { getAdmin, getLogger } from "../../../shared/config"; +import PDFDocument from 'pdfkit'; +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"; + +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 { + try { + const tempFilePath = path.join(os.tmpdir(), `invoice_${data.invoiceNumber}.pdf`); + const doc = new PDFDocument({ margin: 50 }); + + // Create a write stream to the temporary file + const writeStream = fs.createWriteStream(tempFilePath); + doc.pipe(writeStream); + + // Check if GST is applicable + 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; + + // Add business details + doc.fontSize(20).font('Helvetica-Bold').text(data.businessName, 50, 50); + doc.fontSize(12).font('Helvetica').text(data.address, 50, 75, { width: 250 }); + if (hasGst) { + doc.text(`GSTIN: ${data.gstNumber}`, 50, 115); + } + + // Add invoice title and details + doc.fontSize(24).font('Helvetica-Bold').text('RECEIPT', 400, 50, { align: 'right' }); + doc.fontSize(12).font('Helvetica').text(`Receipt #: ${data.invoiceNumber}`, 400, 80, { align: 'right' }); + doc.text(`Date: ${format(data.paymentDate, 'dd/MM/yyyy')}`, 400, 95, { align: 'right' }); + + // Add customer details + doc.rect(50, 150, 500, 80).stroke(); + doc.fontSize(12).font('Helvetica-Bold').text('Receipt To:', 60, 160); + doc.fontSize(12).font('Helvetica').text(data.customerName, 60, 175); + doc.text(`Phone: ${data.phoneNumber}`, 60, 190); + doc.text(`Email: ${data.email}`, 60, 205); + + // Add table header + const tableTop = 260; + doc.rect(50, tableTop, 500, 30).stroke(); + doc.fontSize(12).font('Helvetica-Bold').text('No.', 60, tableTop + 10, { width: 30, align: 'center' }); + doc.text('Description', 100, tableTop + 10, { width: 250 }); + doc.text('HSN/SAC', 350, tableTop + 10, { width: 80, align: 'center' }); + doc.text('Amount (INR)', 430, tableTop + 10, { width: 100, align: 'right' }); + + // Add table row + const rowTop = tableTop + 30; + doc.rect(50, rowTop, 500, 30).stroke(); + doc.fontSize(12).font('Helvetica').text('1', 60, rowTop + 10, { width: 30, align: 'center' }); + doc.text(`${data.planName} Subscription`, 100, rowTop + 10, { width: 250 }); + doc.text('999723', 350, rowTop + 10, { width: 80, align: 'center' }); + doc.text(baseAmount.toFixed(2), 430, rowTop + 10, { width: 100, align: 'right' }); + + // Add totals + let currentY = rowTop + 50; + + if (hasGst) { + doc.fontSize(12).font('Helvetica').text('Taxable Amount:', 350, currentY, { width: 100, align: 'right' }); + doc.text(`${baseAmount.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); + currentY += 20; + + doc.text('SGST (9%):', 350, currentY, { width: 100, align: 'right' }); + doc.text(`${sgst.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); + currentY += 20; + + doc.text('CGST (9%):', 350, currentY, { width: 100, align: 'right' }); + doc.text(`${cgst.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); + currentY += 20; + } + + doc.moveTo(350, currentY).lineTo(550, currentY).stroke(); + currentY += 10; + + doc.fontSize(12).font('Helvetica-Bold').text('Total Amount:', 350, currentY, { width: 100, align: 'right' }); + doc.text(`${data.amount.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); + + // Add payment information + currentY += 40; + doc.rect(50, currentY, 500, 80).stroke(); + doc.fontSize(12).font('Helvetica-Bold').text('Payment Information:', 60, currentY + 10); + doc.fontSize(12).font('Helvetica').text(`Transaction ID: ${data.transactionId}`, 60, currentY + 30); + doc.text(`Payment Method: ${data.paymentMethod}`, 60, currentY + 45); + doc.text(`Payment Date: ${format(data.paymentDate, 'dd/MM/yyyy')}`, 60, currentY + 60); + + // Add footer + currentY += 100; + doc.fontSize(12).font('Helvetica-Oblique').text('Thank you for your business!', 50, currentY, { align: 'center' }); + doc.fontSize(10).font('Helvetica').text('This is a computer-generated receipt and does not require a signature.', 50, currentY + 20, { align: 'center' }); + + // Finalize the PDF + doc.end(); + + // Wait for the file to be written + await new Promise((resolve, reject) => { + writeStream.on('finish', () => resolve()); + writeStream.on('error', reject); + }); + + // Upload to Firebase Storage using admin SDK + const invoicePath = `invoices/${data.invoiceNumber}.pdf`; + const bucket = admin.storage().bucket(); + await bucket.upload(tempFilePath, { + destination: invoicePath, + metadata: { + contentType: 'application/pdf', + }, + }); + + // Clean up the temporary file + fs.unlinkSync(tempFilePath); + + // Return the storage path + 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 { + try { + // Using admin SDK to generate a signed URL + const bucket = admin.storage().bucket(); + const file = bucket.file(invoicePath); + + const expirationMs = 7 * 24 * 60 * 60 * 1000; // 7 days + + 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 { + 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 || []; + + // Find the payment by referenceNumber or transactionId + 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 { + try { + // Get the download URL for the invoice + const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath); + + // Format the date + const formattedDate = emailOptions.additionalData?.paymentDate + ? new Date(emailOptions.additionalData.paymentDate).toLocaleDateString('en-GB') + : new Date().toLocaleDateString('en-GB'); + + // Create email HTML content if not provided + const emailHtml = emailOptions.customHtml || ` + + +

Thank you for your payment

+

Dear ${emailOptions.recipientName || 'Valued Customer'},

+

Thank you for your payment. Your membership has been successfully activated.

+

Please find attached your invoice for the payment.

+

Membership Details:

+
    +
  • Gym: ${emailOptions.additionalData?.gymName || 'Fitlien'}
  • +
  • Plan: ${emailOptions.additionalData?.planName || 'Membership'}
  • +
  • Amount: ₹${emailOptions.additionalData?.amount || '0'}
  • +
  • Transaction ID: ${emailOptions.additionalData?.transactionId || 'N/A'}
  • +
  • Date: ${formattedDate}
  • +
  • Payment Method: ${emailOptions.additionalData?.paymentMethod || 'Online'}
  • +
+

If you have any questions, please contact us.

+

Regards,
Fitlien Team

+ + + `; + + // Send the email with attachment + 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 { + // Generate the invoice + const invoicePath = await this.generateInvoice(invoiceData); + + // Update the payment record with the invoice path + const updateSuccess = await this.updateInvoicePath(membershipId, paymentId, invoicePath); + + if (!updateSuccess) { + return { + success: false, + invoicePath, + emailSent: false, + error: 'Failed to update payment with invoice path' + }; + } + + // Get a download URL for the invoice + const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath); + + // Send email if email options are provided + 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 + }; + } + } +} diff --git a/functions/src/payments/phonepe/invoice/processInvoice.ts b/functions/src/payments/phonepe/invoice/processInvoice.ts new file mode 100644 index 0000000..1164d89 --- /dev/null +++ b/functions/src/payments/phonepe/invoice/processInvoice.ts @@ -0,0 +1,83 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { getCorsHandler } from "../../../shared/middleware"; +import { getAdmin, getLogger } from "../../../shared/config"; +import { InvoiceService } from "./invoiceService"; + +const admin = getAdmin(); +const logger = getLogger(); +const corsHandler = getCorsHandler(); +const invoiceService = new InvoiceService(); + +export const processInvoice = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response) => { + return corsHandler(request, response, async () => { + try { + const authHeader = request.headers.authorization || ''; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + response.status(401).json({ error: 'Unauthorized' }); + return; + } + + const idToken = authHeader.split('Bearer ')[1]; + + try { + await admin.auth().verifyIdToken(idToken); + + const { + membershipId, + paymentId, + invoiceData, + emailOptions + } = request.body; + + if (!membershipId || !paymentId || !invoiceData) { + response.status(400).json({ + success: false, + error: 'Missing required fields' + }); + return; + } + + const result = await invoiceService.processInvoice( + membershipId, + paymentId, + invoiceData, + emailOptions + ); + + if (!result.success) { + response.status(400).json({ + success: false, + error: result.error || 'Failed to process invoice' + }); + return; + } + + response.json({ + success: true, + message: 'Invoice processed successfully', + invoicePath: result.invoicePath, + downloadUrl: result.downloadUrl, + emailSent: result.emailSent + }); + + } catch (authError: any) { + logger.error('Authentication error:', authError); + response.status(401).json({ + success: false, + error: 'Invalid authentication token', + details: authError.message + }); + } + } catch (error: any) { + logger.error('Error processing invoice:', error); + response.status(500).json({ + success: false, + error: 'Failed to process invoice', + details: error.message + }); + } + }); +}); diff --git a/functions/src/payments/phonepe/invoice/sendInvoiceEmail.ts b/functions/src/payments/phonepe/invoice/sendInvoiceEmail.ts new file mode 100644 index 0000000..2af677a --- /dev/null +++ b/functions/src/payments/phonepe/invoice/sendInvoiceEmail.ts @@ -0,0 +1,91 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { getCorsHandler } from "../../../shared/middleware"; +import { getAdmin, getLogger } from "../../../shared/config"; +import { InvoiceService, EmailOptions } from "./invoiceService"; + +const admin = getAdmin(); +const logger = getLogger(); +const corsHandler = getCorsHandler(); +const invoiceService = new InvoiceService(); + +export const sendInvoiceEmail = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response) => { + return corsHandler(request, response, async () => { + try { + const authHeader = request.headers.authorization || ''; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + response.status(401).json({ error: 'Unauthorized' }); + return; + } + + const idToken = authHeader.split('Bearer ')[1]; + + try { + await admin.auth().verifyIdToken(idToken); + + const { + invoicePath, + recipientEmail, + recipientName, + subject, + customHtml, + gymName, + planName, + amount, + transactionId, + paymentDate, + paymentMethod + } = request.body; + + if (!invoicePath || !recipientEmail) { + response.status(400).json({ + success: false, + error: 'Missing required fields' + }); + return; + } + + const emailOptions: EmailOptions = { + recipientEmail, + recipientName, + subject, + customHtml, + additionalData: { + gymName, + planName, + amount, + transactionId, + paymentDate: paymentDate ? new Date(paymentDate) : undefined, + paymentMethod + } + }; + + const emailSent = await invoiceService.sendInvoiceEmail(invoicePath, emailOptions); + const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath); + + response.json({ + success: true, + message: emailSent ? 'Invoice email sent successfully' : 'Failed to send email but URL generated', + downloadUrl + }); + + } catch (authError: any) { + logger.error('Authentication error:', authError); + response.status(401).json({ + success: false, + error: 'Invalid authentication token', + details: authError.message + }); + } + } catch (error: any) { + logger.error('Error sending invoice email:', error); + response.status(500).json({ + success: false, + error: 'Failed to send invoice email', + details: error.message + }); + } + }); +}); diff --git a/functions/src/payments/phonepe/paymentData.ts b/functions/src/payments/phonepe/paymentData.ts new file mode 100644 index 0000000..b6eca48 --- /dev/null +++ b/functions/src/payments/phonepe/paymentData.ts @@ -0,0 +1,130 @@ +import { getAdmin, getLogger } from "../../shared/config"; + +const admin = getAdmin(); +const logger = getLogger(); + +// Define an interface for the payment data to avoid type errors +interface PaymentData { + id: string; + date: string; + dateTimestamp: FirebaseFirestore.Timestamp; + amount: any; + paymentMethod: string; + referenceNumber: string; + discount: any; + transactionId: string; + createdAt: Date; + invoicePath?: string; // Make this optional +} + +export async function updatePaymentDataAfterSuccess( + merchantOrderId: string, + orderId: string, + paymentDetails: any, + invoicePath?: string +): Promise { + try { + // Get the payment order from Firestore + const orderQuery = await admin.firestore() + .collection('payment_orders') + .where('merchantOrderId', '==', merchantOrderId) + .limit(1) + .get(); + + if (orderQuery.empty) { + logger.error(`No payment order found with merchantOrderId: ${merchantOrderId}`); + return false; + } + + const orderDoc = orderQuery.docs[0]; + const orderData = orderDoc.data(); + + // Extract membership ID from metaInfo + const membershipId = orderData.metaInfo?.membershipId; + if (!membershipId) { + logger.error(`No membershipId found in metaInfo for order: ${merchantOrderId}`); + return false; + } + + const isoDate = new Date().toLocaleDateString('en-GB').split('/').join('-'); // DD-MM-YYYY format + const dateTimestamp = admin.firestore.Timestamp.now(); + + // Create payment data object with proper typing + const paymentData: PaymentData = { + id: admin.firestore().collection('_').doc().id, // Generate a UUID + date: isoDate, + dateTimestamp: dateTimestamp, + amount: orderData.amount, + paymentMethod: 'Online', + referenceNumber: merchantOrderId, + discount: orderData.metaInfo?.discount || null, + transactionId: orderId, + createdAt: new Date() + }; + + // Add invoice path if provided + if (invoicePath) { + paymentData.invoicePath = invoicePath; + } + + // Get reference to membership payments document + const membershipPaymentsRef = admin.firestore() + .collection('membership_payments') + .doc(membershipId); + + const docSnapshot = await membershipPaymentsRef.get(); + + // Update or create the membership payments document + if (docSnapshot.exists) { + await membershipPaymentsRef.update({ + 'payments': admin.firestore.FieldValue.arrayUnion(paymentData), + 'updatedAt': admin.firestore.FieldValue.serverTimestamp(), + 'updatedBy': orderData.userId, + }); + } else { + await membershipPaymentsRef.set({ + 'membershipId': membershipId, + 'payments': [paymentData], + 'createdAt': admin.firestore.FieldValue.serverTimestamp(), + 'createdBy': orderData.userId, + 'updatedAt': admin.firestore.FieldValue.serverTimestamp(), + 'updatedBy': orderData.userId, + }); + } + + // Update membership status + await updateMembershipStatus(membershipId, orderData.userId); + + logger.info(`Successfully updated payment data for membership: ${membershipId}`); + return true; + } catch (error: any) { + logger.error('Error updating payment data:', error); + return false; + } +} + +async function updateMembershipStatus(membershipId: string, userId: string): Promise { + try { + const membershipDoc = await admin.firestore() + .collection('memberships') + .doc(membershipId) + .get(); + + if (!membershipDoc.exists) { + throw new Error(`Membership not found for id: ${membershipId}`); + } + + await admin.firestore() + .collection('memberships') + .doc(membershipId) + .update({ + 'status': 'ACTIVE', // Assuming this matches your InvitationStatus.active + 'updatedAt': admin.firestore.FieldValue.serverTimestamp(), + }); + + logger.info(`Successfully updated membership status for: ${membershipId}`); + } catch (error: any) { + logger.error('Error updating membership status:', error); + throw error; + } +} diff --git a/functions/src/payments/phonepe/webhook.ts b/functions/src/payments/phonepe/webhook.ts index 9f522f9..fef3bbb 100644 --- a/functions/src/payments/phonepe/webhook.ts +++ b/functions/src/payments/phonepe/webhook.ts @@ -2,25 +2,30 @@ import { onRequest } from "firebase-functions/v2/https"; import { Request } from "firebase-functions/v2/https"; import { getAdmin, getLogger } from "../../shared/config"; import crypto from "crypto"; +import { updatePaymentDataAfterSuccess } from "./paymentData"; +import { InvoiceService } from "./invoice/invoiceService"; +import * as path from 'path'; +import { sendEmailWithAttachmentUtil } from "../../utils/emailService"; +import { format } from 'date-fns'; const admin = getAdmin(); const logger = getLogger(); +const invoiceService = new InvoiceService(); export const phonePeWebhook = onRequest({ region: '#{SERVICES_RGN}#' }, async (request: Request, response) => { try { - logger.info('Received webhook request', { headers: request.headers, body: request.body, method: request.method }); - + const authHeader = request.headers['authorization'] as string; const username = process.env.PHONEPE_WEBHOOK_USERNAME; const password = process.env.PHONEPE_WEBHOOK_PASSWORD; - + if (!authHeader || !username || !password) { logger.error('Missing authorization header or webhook credentials'); response.status(401).json({ error: 'Unauthorized' }); @@ -32,9 +37,9 @@ export const phonePeWebhook = onRequest({ .createHash('sha256') .update(credentialString) .digest('hex'); - + const receivedAuth = authHeader.replace(/^SHA256\s+/i, ''); - + if (receivedAuth.toLowerCase() !== expectedAuth.toLowerCase()) { logger.error('Invalid webhook authorization'); response.status(401).json({ error: 'Invalid authorization' }); @@ -42,32 +47,34 @@ export const phonePeWebhook = onRequest({ } const { event, payload } = request.body; - + if (!event || !payload || !payload.merchantOrderId || !payload.orderId) { logger.error('Invalid webhook payload', request.body); response.status(400).json({ error: 'Invalid payload' }); return; } - - logger.info(`Received PhonePe webhook: ${event}`, { + + logger.info(`Received PhonePe webhook: ${event}`, { merchantOrderId: payload.merchantOrderId, orderId: payload.orderId, state: payload.state }); - + const orderQuery = await admin.firestore() .collection('payment_orders') .where('orderId', '==', payload.orderId) .limit(1) .get(); - + + let orderDoc; + if (orderQuery.empty) { const merchantOrderQuery = await admin.firestore() .collection('payment_orders') .where('merchantOrderId', '==', payload.merchantOrderId) .limit(1) .get(); - + if (merchantOrderQuery.empty) { logger.error(`No payment order found for PhonePe orderId: ${payload.orderId} or merchantOrderId: ${payload.merchantOrderId}`); response.status(404).json({ @@ -76,30 +83,325 @@ export const phonePeWebhook = onRequest({ }); return; } - - const orderDoc = merchantOrderQuery.docs[0]; + + orderDoc = merchantOrderQuery.docs[0]; await orderDoc.ref.update({ orderStatus: payload.state || 'UNKNOWN', lastUpdated: new Date(), webhookEvent: event, webhookData: payload }); - + logger.info(`Updated order status via webhook for merchantOrderId: ${payload.merchantOrderId} to ${payload.state}`); } else { - const orderDoc = orderQuery.docs[0]; + orderDoc = orderQuery.docs[0]; await orderDoc.ref.update({ orderStatus: payload.state || 'UNKNOWN', lastUpdated: new Date(), webhookEvent: event, webhookData: payload }); - + logger.info(`Updated order status via webhook for orderId: ${payload.orderId} to ${payload.state}`); } - + + if (payload.state === 'COMPLETED') { + try { + const paymentUpdateSuccess = await updatePaymentDataAfterSuccess( + payload.merchantOrderId, + payload.orderId, + payload + ); + + if (paymentUpdateSuccess) { + const orderData = orderDoc.data(); + + const membershipId = orderData.metaInfo?.membershipId; + + if (membershipId) { + try { + const membershipDoc = await admin.firestore() + .collection('memberships') + .doc(membershipId) + .get(); + + if (membershipDoc.exists) { + const membershipData = membershipDoc.data(); + const userId = membershipData?.userId; + + const userDoc = await admin.firestore() + .collection('users') + .doc(userId) + .get(); + + if (userDoc.exists) { + const userData = userDoc.data(); + + const gymId = orderData.metaInfo?.gymId || membershipData?.gymId; + let gymName = 'Fitlien'; + let gymAddress = ''; + let subscriptionName = ''; + let gymOwnerEmail = ''; + let gymPhoneNumber = ''; + let paymentType = orderData.metaInfo?.paymentType || 'Gym Membership'; + let trainerId = orderData.metaInfo?.trainerId; + let trainerData = null; + + const discountPercentage = orderData.metaInfo?.discount || 0; + const hasDiscount = discountPercentage > 0; + const isFreeplan = discountPercentage === 100; + const originalAmount = hasDiscount ? + orderData.amount / (1 - discountPercentage / 100) : + orderData.amount; + const discountText = isFreeplan ? + " (Free Plan)" : + hasDiscount ? ` (${discountPercentage.toFixed(0)}% discount applied)` : + ''; + const amountSaved = hasDiscount ? + originalAmount - orderData.amount : + 0; + + if (gymId) { + const gymDoc = await admin.firestore() + .collection('gyms') + .doc(gymId) + .get(); + + if (gymDoc.exists) { + const gymData = gymDoc.data(); + gymName = gymData?.name || 'Fitlien'; + gymAddress = gymData?.address || ''; + subscriptionName = gymData?.subscriptions?.name || ''; + gymPhoneNumber = gymData?.phoneNumber || ''; + + if (gymData?.userId) { + const gymOwnerDoc = await admin.firestore() + .collection('users') + .doc(gymData.userId) + .get(); + + if (gymOwnerDoc.exists) { + const gymOwnerData = gymOwnerDoc.data(); + gymOwnerEmail = gymOwnerData?.email || ''; + } + } + } + } + + if (paymentType === 'Gym Membership with Personal Training' && trainerId) { + try { + const trainerDoc = await admin.firestore() + .collection('trainer_profiles') + .doc(trainerId) + .get(); + + if (trainerDoc.exists) { + trainerData = trainerDoc.data(); + } + } catch (trainerError) { + logger.error('Error fetching trainer data:', trainerError); + } + } + + const invoiceNumber = `INV-${payload.merchantOrderId.substring(0, 8)}`; + const invoiceData = { + invoiceNumber, + businessName: gymName, + address: gymAddress, + gstNumber: userData?.gstNumber, + customerName: userData?.displayName || `${membershipData?.fields?.['first-name'] || ''} ${membershipData?.fields?.['last-name'] || ''}`.trim(), + phoneNumber: membershipData?.fields?.['phone-number'] || orderData.metaInfo?.phoneNumber || '', + email: membershipData?.fields?.['email'] || '', + planName: orderData.metaInfo?.planName || subscriptionName || paymentType, + amount: orderData.amount, + transactionId: payload.orderId, + paymentDate: new Date(), + paymentMethod: 'Online' + }; + + const invoicePath = await invoiceService.generateInvoice(invoiceData); + + await admin.firestore() + .collection('membership_payments') + .doc(membershipId) + .get() + .then(async (doc) => { + if (doc.exists) { + const paymentsData = doc.data()?.payments || []; + for (let i = 0; i < paymentsData.length; i++) { + if (paymentsData[i].referenceNumber === payload.merchantOrderId || + paymentsData[i].transactionId === payload.orderId) { + paymentsData[i].invoicePath = invoicePath; + break; + } + } + + await doc.ref.update({ + 'payments': paymentsData, + 'updatedAt': admin.firestore.FieldValue.serverTimestamp(), + }); + } + }); + + logger.info(`Generated invoice for payment: ${payload.merchantOrderId}, path: ${invoicePath}`); + + const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath); + + const formattedDate = format(new Date(), 'dd/MM/yyyy'); + + if (membershipData?.fields?.['email']) { + try { + const emailSubject = isFreeplan + ? `Free Plan Assigned - ${gymName}` + : `New Membership - ${gymName}`; + + const customerEmailHtml = ` + + +

${isFreeplan ? 'Free Plan Assigned' : 'Thank you for your payment'}

+

Dear ${invoiceData.customerName},

+

${isFreeplan ? 'Your free membership has been successfully activated.' : 'Thank you for your payment. Your membership has been successfully activated.'}

+

Please find attached your invoice for the ${isFreeplan ? 'membership' : 'payment'}.

+

Membership Details:

+
    +
  • Gym: ${gymName}
  • + ${trainerData ? `
  • Trainer: ${trainerData.fullName || 'Your Personal Trainer'}
  • ` : ''} +
  • Plan: ${invoiceData.planName}
  • + ${hasDiscount ? `
  • Original Price: ₹${originalAmount.toFixed(2)}
  • ` : ''} + ${hasDiscount ? `
  • Discount: ${discountPercentage.toFixed(1)}%
  • ` : ''} + ${hasDiscount ? `
  • You Save: ₹${amountSaved.toFixed(2)}
  • ` : ''} +
  • Amount: ₹${orderData.amount.toFixed(2)}${discountText}
  • +
  • Transaction ID: ${payload.merchantOrderId}
  • +
  • Date: ${formattedDate}
  • + ${isFreeplan ? '
  • Payment Method: Online}
  • ' : ''} +
+

If you have any questions, please contact us.

+

Regards,
Fitlien Team

+ + + `; + + await sendEmailWithAttachmentUtil( + membershipData?.fields?.['email'], + emailSubject, + customerEmailHtml, + downloadUrl, + `Invoice_${path.basename(invoicePath)}` + ); + + logger.info(`Invoice email sent to ${membershipData?.fields?.['email']} for payment: ${payload.merchantOrderId}`); + } catch (emailError) { + logger.error('Error sending customer invoice email:', emailError); + } + } + + if (gymOwnerEmail) { + try { + const ownerEmailSubject = isFreeplan + ? `Free Plan Assigned${paymentType === 'Gym Membership with Personal Training' ? ' with Personal Training' : ''} - ${gymName}` + : `New Membership${paymentType === 'Gym Membership with Personal Training' ? ' with Personal Training' : ''} - ${gymName}`; + + const gymOwnerEmailHtml = ` + + +

${isFreeplan ? 'Free Plan Assigned' : `New ${paymentType} Booking Received`}

+

Dear Gym Owner,

+

${isFreeplan ? 'A free membership' : 'A new membership'}${paymentType === 'Gym Membership with Personal Training' ? ' with personal training' : ''} has been ${isFreeplan ? 'assigned' : 'received'} for your gym.

+

Customer Details:

+
    +
  • Name: ${invoiceData.customerName}
  • +
  • Email: ${invoiceData.email}
  • +
  • Phone: ${invoiceData.phoneNumber}
  • +
+

Booking Details:

+
    +
  • Type: ${invoiceData.planName}
  • + ${trainerData ? `
  • Trainer: ${trainerData.fullName || 'Personal Trainer'}
  • ` : ''} + ${hasDiscount ? `
  • Original Price: ₹${originalAmount.toFixed(2)}
  • ` : ''} + ${hasDiscount ? `
  • Discount: ${discountPercentage.toFixed(1)}%
  • ` : ''} + ${hasDiscount ? `
  • Amount Saved by Customer: ₹${amountSaved.toFixed(2)}
  • ` : ''} +
  • Amount: ₹${orderData.amount.toFixed(2)}${discountText}
  • +
  • Transaction ID: ${payload.merchantOrderId}
  • +
  • Date: ${formattedDate}
  • +
+

Please find the invoice attached.

+

Regards,
Fitlien Team

+ + + `; + + await sendEmailWithAttachmentUtil( + gymOwnerEmail, + ownerEmailSubject, + gymOwnerEmailHtml, + downloadUrl, + `Invoice_${path.basename(invoicePath)}` + ); + + logger.info(`Invoice email sent to gym owner (${gymOwnerEmail}) for payment: ${payload.merchantOrderId}`); + } catch (ownerEmailError) { + logger.error('Error sending gym owner invoice email:', ownerEmailError); + } + } + + if (paymentType === 'Gym Membership with Personal Training' && trainerData && trainerData.email) { + try { + const trainerEmailHtml = ` + + +

New Personal Training Client

+

Dear ${trainerData.fullName || 'Trainer'},

+

A new client has signed up for personal training with you at ${gymName}.

+

Client Details:

+
    +
  • Name: ${invoiceData.customerName}
  • +
  • Email: ${invoiceData.email}
  • +
  • Phone: ${invoiceData.phoneNumber}
  • +
+

Booking Details:

+
    +
  • Type: Personal Training Membership
  • + ${hasDiscount ? `
  • Original Price: ₹${originalAmount.toFixed(2)}
  • ` : ''} + ${hasDiscount ? `
  • Discount: ${discountPercentage.toFixed(1)}%
  • ` : ''} +
  • Amount: ₹${orderData.amount.toFixed(2)}${discountText}
  • +
  • Transaction ID: ${payload.merchantOrderId}
  • +
  • Date: ${formattedDate}
  • +
+

Please find the invoice attached.

+

Regards,
Fitlien Team

+ + + `; + + await sendEmailWithAttachmentUtil( + trainerData.email, + `New Personal Training Client - ${gymName}`, + trainerEmailHtml, + downloadUrl, + `Invoice_${path.basename(invoicePath)}` + ); + + logger.info(`Invoice email sent to trainer (${trainerData.email}) for payment: ${payload.merchantOrderId}`); + } catch (trainerEmailError) { + logger.error('Error sending trainer invoice email:', trainerEmailError); + } + } + } + } + } catch (invoiceError) { + logger.error('Error generating invoice:', invoiceError); + } + } + } + + logger.info(`Payment data updated for completed payment: ${payload.merchantOrderId}`); + } catch (paymentUpdateError) { + logger.error('Error updating payment data:', paymentUpdateError); + } + } + response.status(200).json({ success: true }); - + } catch (error: any) { logger.error('PhonePe webhook processing error:', error); response.status(500).json({ diff --git a/functions/src/utils/emailService.ts b/functions/src/utils/emailService.ts new file mode 100644 index 0000000..b7f70a8 --- /dev/null +++ b/functions/src/utils/emailService.ts @@ -0,0 +1,70 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as https from 'https'; +import { getLogger } from "../shared/config"; +import formData from 'form-data'; +import Mailgun from 'mailgun.js'; +const { convert } = require('html-to-text'); + +const mailgun = new Mailgun(formData); +const logger = getLogger(); + +export async function sendEmailWithAttachmentUtil( + toAddress: string, + subject: string, + message: string, + fileUrl: string, + fileName?: string +): Promise { + try { + const tempFilePath = path.join(os.tmpdir(), fileName || 'attachment.pdf'); + await new Promise((resolve, reject) => { + const file = fs.createWriteStream(tempFilePath); + https.get(fileUrl, (res) => { + res.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + }).on('error', (err) => { + fs.unlink(tempFilePath, () => {}); + reject(err); + }); + }); + + try { + const client = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY! }); + const options = { + wordwrap: 130, + }; + const textMessage = convert(message, options); + const fileBuffer = fs.readFileSync(tempFilePath); + const attachmentFilename = fileName || path.basename(fileUrl.split('?')[0]); + + const data = { + from: process.env.MAILGUN_FROM_ADDRESS, + to: toAddress, + subject: subject, + text: textMessage, + html: message, + attachment: { + data: fileBuffer, + filename: attachmentFilename, + contentType: 'application/pdf', + } + }; + + const result = await client.messages.create(process.env.MAILGUN_SERVER!, data); + fs.unlinkSync(tempFilePath); + logger.info('Email with attachment from URL sent successfully'); + return { success: true, result }; + } catch (e) { + logger.error(`Error while sending E-mail. Error: ${e}`); + throw e; + } + } catch (error) { + logger.error('Error sending email with attachment from URL:', error); + throw error; + } +} diff --git a/package-lock.json b/package-lock.json index 6c1f8fe..b0bc379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,10 +6,20 @@ "": { "dependencies": { "@types/busboy": "^1.5.4", - "busboy": "^1.6.0" - }, - "devDependencies": { - "@types/long": "^5.0.0" + "@types/nodemailer": "^6.4.17", + "@types/pdfkit": "^0.13.9", + "busboy": "^1.6.0", + "date-fns": "^4.1.0", + "nodemailer": "^7.0.3", + "pdfkit": "^0.17.1" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "dependencies": { + "tslib": "^2.8.0" } }, "node_modules/@types/busboy": { @@ -20,16 +30,6 @@ "@types/node": "*" } }, - "node_modules/@types/long": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/long/-/long-5.0.0.tgz", - "integrity": "sha512-eQs9RsucA/LNjnMoJvWG/nXa7Pot/RbBzilF/QRIU/xRl+0ApxrSUFsV5lmf01SvSlqMzJ7Zwxe440wmz2SJGA==", - "deprecated": "This is a stub types definition. long provides its own type definitions, so you do not need this installed.", - "dev": true, - "dependencies": { - "long": "*" - } - }, "node_modules/@types/node": { "version": "22.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", @@ -38,6 +38,49 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pdfkit": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.13.9.tgz", + "integrity": "sha512-RDG8Yb1zT7I01FfpwK7nMSA433XWpblMqSCtA5vJlSyavWZb303HUYPCel6JTiDDFqwGLvtAnYbH8N/e0Cb89g==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -49,11 +92,110 @@ "node": ">=10.16.0" } }, - "node_modules/long": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", - "integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==", - "dev": true + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==" + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, + "node_modules/pdfkit": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.1.tgz", + "integrity": "sha512-Kkf1I9no14O/uo593DYph5u3QwiMfby7JsBSErN1WqeyTgCBNJE3K4pXBn3TgkdKUIVu+buSl4bYUNC+8Up4xg==", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^2.0.4", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "node_modules/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==" }, "node_modules/streamsearch": { "version": "1.1.0", @@ -63,10 +205,38 @@ "node": ">=10.0.0" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } } } } diff --git a/package.json b/package.json index 474c153..073e12e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,11 @@ { "dependencies": { "@types/busboy": "^1.5.4", - "busboy": "^1.6.0" + "@types/nodemailer": "^6.4.17", + "@types/pdfkit": "^0.13.9", + "busboy": "^1.6.0", + "date-fns": "^4.1.0", + "nodemailer": "^7.0.3", + "pdfkit": "^0.17.1" } } -- 2.43.0 From 2d55e1f4615726887ab90a2c555c20ee64e9e131 Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Mon, 19 May 2025 13:18:52 +0530 Subject: [PATCH 04/10] Update webhook.ts --- functions/src/payments/phonepe/webhook.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/functions/src/payments/phonepe/webhook.ts b/functions/src/payments/phonepe/webhook.ts index 9a5f497..05e4ee9 100644 --- a/functions/src/payments/phonepe/webhook.ts +++ b/functions/src/payments/phonepe/webhook.ts @@ -142,7 +142,6 @@ export const phonePeWebhook = onRequest({ let gymAddress = ''; let subscriptionName = ''; let gymOwnerEmail = ''; - let gymPhoneNumber = ''; let paymentType = orderData.metaInfo?.paymentType || 'Gym Membership'; let trainerId = orderData.metaInfo?.trainerId; let trainerData = null; @@ -172,7 +171,6 @@ export const phonePeWebhook = onRequest({ gymName = gymData?.name || 'Fitlien'; gymAddress = gymData?.address || ''; subscriptionName = gymData?.subscriptions?.name || ''; - gymPhoneNumber = gymData?.phoneNumber || ''; if (gymData?.userId) { const gymOwnerDoc = await admin.firestore() -- 2.43.0 From 2d6b14663b15be002b000bc9b3c0c59b90a90724 Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Mon, 19 May 2025 14:28:51 +0530 Subject: [PATCH 05/10] changed pdf to use encoding supported package --- functions/package-lock.json | 286 ++++++++++++++++ functions/package.json | 2 + .../phonepe/invoice/invoiceService.ts | 305 ++++++++++++------ 3 files changed, 498 insertions(+), 95 deletions(-) diff --git a/functions/package-lock.json b/functions/package-lock.json index b53c64d..9feb2ad 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -22,12 +22,14 @@ "mailgun.js": "^10.4.0", "node-fetch": "^2.7.0", "pdfjs-dist": "^5.0.375", + "pdfmake": "^0.2.20", "twilio": "^5.4.0" }, "devDependencies": { "@types/long": "^5.0.0", "@types/mime-types": "^2.1.4", "@types/node": "^22.13.14", + "@types/pdfmake": "^0.2.11", "firebase-functions-test": "^3.1.0", "typescript": "^5.8.2" }, @@ -1260,6 +1262,52 @@ "tslib": "^2.1.0" } }, + "node_modules/@foliojs-fork/fontkit": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.2.tgz", + "integrity": "sha512-IfB5EiIb+GZk+77TRB86AHroVaqfq8JRFlUbz0WEwsInyCG0epX2tCPOy+UfaWPju30DeVoUAXfzWXmhn753KA==", + "dependencies": { + "@foliojs-fork/restructure": "^2.0.2", + "brotli": "^1.2.0", + "clone": "^1.0.4", + "deep-equal": "^1.0.0", + "dfa": "^1.2.0", + "tiny-inflate": "^1.0.2", + "unicode-properties": "^1.2.2", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/linebreak": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/linebreak/-/linebreak-1.1.2.tgz", + "integrity": "sha512-ZPohpxxbuKNE0l/5iBJnOAfUaMACwvUIKCvqtWGKIMv1lPYoNjYXRfhi9FeeV9McBkBLxsMFWTVVhHJA8cyzvg==", + "dependencies": { + "base64-js": "1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/linebreak/node_modules/base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + }, + "node_modules/@foliojs-fork/pdfkit": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@foliojs-fork/pdfkit/-/pdfkit-0.15.3.tgz", + "integrity": "sha512-Obc0Wmy3bm7BINFVvPhcl2rnSSK61DQrlHU8aXnAqDk9LCjWdUOPwhgD8Ywz5VtuFjRxmVOM/kQ/XLIBjDvltw==", + "dependencies": { + "@foliojs-fork/fontkit": "^1.9.2", + "@foliojs-fork/linebreak": "^1.1.1", + "crypto-js": "^4.2.0", + "jpeg-exif": "^1.1.4", + "png-js": "^1.0.0" + } + }, + "node_modules/@foliojs-fork/restructure": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/restructure/-/restructure-2.0.2.tgz", + "integrity": "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA==" + }, "node_modules/@google-cloud/firestore": { "version": "7.11.0", "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.0.tgz", @@ -2787,6 +2835,25 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/pdfkit": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.13.9.tgz", + "integrity": "sha512-RDG8Yb1zT7I01FfpwK7nMSA433XWpblMqSCtA5vJlSyavWZb303HUYPCel6JTiDDFqwGLvtAnYbH8N/e0Cb89g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pdfmake": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@types/pdfmake/-/pdfmake-0.2.11.tgz", + "integrity": "sha512-gglgMQhnG6C2kco13DJlvokqTxL+XKxHwCejElH8fSCNF9ZCkRK6Mzo011jQ0zuug+YlIgn6BpcpZrARyWdW3Q==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/pdfkit": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -3261,6 +3328,14 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.24.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", @@ -3483,6 +3558,14 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3625,6 +3708,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -3653,6 +3741,25 @@ } } }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3677,6 +3784,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3712,6 +3835,11 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -4351,6 +4479,14 @@ "resolved": "", "link": true }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", @@ -4950,6 +5086,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5772,6 +5923,11 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6366,6 +6522,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6456,6 +6635,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6548,6 +6732,31 @@ "@napi-rs/canvas": "^0.1.67" } }, + "node_modules/pdfmake": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.2.20.tgz", + "integrity": "sha512-bGbxbGFP5p8PWNT3Phsu1ZcRLnRfF6jmnuKTkgmt6i5PZzSdX6JaB+NeTz9q+aocfW8SE9GUjL3o/5GroBqGcQ==", + "dependencies": { + "@foliojs-fork/linebreak": "^1.1.2", + "@foliojs-fork/pdfkit": "^0.15.3", + "iconv-lite": "^0.6.3", + "xmldoc": "^2.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pdfmake/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -6599,6 +6808,11 @@ "node": ">=8" } }, + "node_modules/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6789,6 +7003,25 @@ "node": ">= 6" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7023,6 +7256,20 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7424,6 +7671,11 @@ "node": ">=8" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7582,6 +7834,24 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7842,6 +8112,22 @@ "node": ">=6.0" } }, + "node_modules/xmldoc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.1.tgz", + "integrity": "sha512-sOOqgsjl3PU6iBw+fBUGAkTCE+JFK+sBaOL3pnZgzqk2/yvOD7RlFmZtDRJAEBzdpOYxSXyOQH4mjubdfs3MSg==", + "dependencies": { + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/xmldoc/node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/functions/package.json b/functions/package.json index f83c0fd..bbfdd3f 100644 --- a/functions/package.json +++ b/functions/package.json @@ -29,12 +29,14 @@ "mailgun.js": "^10.4.0", "node-fetch": "^2.7.0", "pdfjs-dist": "^5.0.375", + "pdfmake": "^0.2.20", "twilio": "^5.4.0" }, "devDependencies": { "@types/long": "^5.0.0", "@types/mime-types": "^2.1.4", "@types/node": "^22.13.14", + "@types/pdfmake": "^0.2.11", "firebase-functions-test": "^3.1.0", "typescript": "^5.8.2" }, diff --git a/functions/src/payments/phonepe/invoice/invoiceService.ts b/functions/src/payments/phonepe/invoice/invoiceService.ts index 032b259..8f2db0b 100644 --- a/functions/src/payments/phonepe/invoice/invoiceService.ts +++ b/functions/src/payments/phonepe/invoice/invoiceService.ts @@ -1,10 +1,13 @@ import { getAdmin, getLogger } from "../../../shared/config"; -import PDFDocument from 'pdfkit'; 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(); @@ -43,99 +46,223 @@ export class InvoiceService { async generateInvoice(data: InvoiceData): Promise { try { const tempFilePath = path.join(os.tmpdir(), `invoice_${data.invoiceNumber}.pdf`); - const doc = new PDFDocument({ margin: 50 }); - // Create a write stream to the temporary file - const writeStream = fs.createWriteStream(tempFilePath); - doc.pipe(writeStream); - - // Check if GST is applicable 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; - // Add business details - doc.fontSize(20).font('Helvetica-Bold').text(data.businessName, 50, 50); - doc.fontSize(12).font('Helvetica').text(data.address, 50, 75, { width: 250 }); - if (hasGst) { - doc.text(`GSTIN: ${data.gstNumber}`, 50, 115); - } + const formattedDate = format(data.paymentDate, 'dd/MM/yyyy'); - // Add invoice title and details - doc.fontSize(24).font('Helvetica-Bold').text('RECEIPT', 400, 50, { align: 'right' }); - doc.fontSize(12).font('Helvetica').text(`Receipt #: ${data.invoiceNumber}`, 400, 80, { align: 'right' }); - doc.text(`Date: ${format(data.paymentDate, 'dd/MM/yyyy')}`, 400, 95, { align: 'right' }); + 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' + } + }; - // Add customer details - doc.rect(50, 150, 500, 80).stroke(); - doc.fontSize(12).font('Helvetica-Bold').text('Receipt To:', 60, 160); - doc.fontSize(12).font('Helvetica').text(data.customerName, 60, 175); - doc.text(`Phone: ${data.phoneNumber}`, 60, 190); - doc.text(`Email: ${data.email}`, 60, 205); + const pdfDoc = pdfMake.createPdf(docDefinition); - // Add table header - const tableTop = 260; - doc.rect(50, tableTop, 500, 30).stroke(); - doc.fontSize(12).font('Helvetica-Bold').text('No.', 60, tableTop + 10, { width: 30, align: 'center' }); - doc.text('Description', 100, tableTop + 10, { width: 250 }); - doc.text('HSN/SAC', 350, tableTop + 10, { width: 80, align: 'center' }); - doc.text('Amount (INR)', 430, tableTop + 10, { width: 100, align: 'right' }); - - // Add table row - const rowTop = tableTop + 30; - doc.rect(50, rowTop, 500, 30).stroke(); - doc.fontSize(12).font('Helvetica').text('1', 60, rowTop + 10, { width: 30, align: 'center' }); - doc.text(`${data.planName} Subscription`, 100, rowTop + 10, { width: 250 }); - doc.text('999723', 350, rowTop + 10, { width: 80, align: 'center' }); - doc.text(baseAmount.toFixed(2), 430, rowTop + 10, { width: 100, align: 'right' }); - - // Add totals - let currentY = rowTop + 50; - - if (hasGst) { - doc.fontSize(12).font('Helvetica').text('Taxable Amount:', 350, currentY, { width: 100, align: 'right' }); - doc.text(`${baseAmount.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); - currentY += 20; - - doc.text('SGST (9%):', 350, currentY, { width: 100, align: 'right' }); - doc.text(`${sgst.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); - currentY += 20; - - doc.text('CGST (9%):', 350, currentY, { width: 100, align: 'right' }); - doc.text(`${cgst.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); - currentY += 20; - } - - doc.moveTo(350, currentY).lineTo(550, currentY).stroke(); - currentY += 10; - - doc.fontSize(12).font('Helvetica-Bold').text('Total Amount:', 350, currentY, { width: 100, align: 'right' }); - doc.text(`${data.amount.toFixed(2)} INR`, 450, currentY, { width: 100, align: 'right' }); - - // Add payment information - currentY += 40; - doc.rect(50, currentY, 500, 80).stroke(); - doc.fontSize(12).font('Helvetica-Bold').text('Payment Information:', 60, currentY + 10); - doc.fontSize(12).font('Helvetica').text(`Transaction ID: ${data.transactionId}`, 60, currentY + 30); - doc.text(`Payment Method: ${data.paymentMethod}`, 60, currentY + 45); - doc.text(`Payment Date: ${format(data.paymentDate, 'dd/MM/yyyy')}`, 60, currentY + 60); - - // Add footer - currentY += 100; - doc.fontSize(12).font('Helvetica-Oblique').text('Thank you for your business!', 50, currentY, { align: 'center' }); - doc.fontSize(10).font('Helvetica').text('This is a computer-generated receipt and does not require a signature.', 50, currentY + 20, { align: 'center' }); - - // Finalize the PDF - doc.end(); - - // Wait for the file to be written await new Promise((resolve, reject) => { - writeStream.on('finish', () => resolve()); - writeStream.on('error', reject); + pdfDoc.getBuffer((buffer) => { + fs.writeFile(tempFilePath, buffer, (err) => { + if (err) reject(err); + else resolve(); + }); + }); }); - // Upload to Firebase Storage using admin SDK const invoicePath = `invoices/${data.invoiceNumber}.pdf`; const bucket = admin.storage().bucket(); await bucket.upload(tempFilePath, { @@ -145,10 +272,8 @@ export class InvoiceService { }, }); - // Clean up the temporary file fs.unlinkSync(tempFilePath); - // Return the storage path return invoicePath; } catch (error: any) { logger.error('Error generating invoice:', error); @@ -158,11 +283,10 @@ export class InvoiceService { async getInvoiceDownloadUrl(invoicePath: string): Promise { try { - // Using admin SDK to generate a signed URL const bucket = admin.storage().bucket(); const file = bucket.file(invoicePath); - const expirationMs = 7 * 24 * 60 * 60 * 1000; // 7 days + const expirationMs = 7 * 24 * 60 * 60 * 1000; const [signedUrl] = await file.getSignedUrl({ action: 'read', @@ -193,7 +317,6 @@ export class InvoiceService { const data = docSnapshot.data(); const paymentsData = data?.payments || []; - // Find the payment by referenceNumber or transactionId let found = false; for (let i = 0; i < paymentsData.length; i++) { if (paymentsData[i].referenceNumber === paymentId || @@ -224,15 +347,12 @@ export class InvoiceService { async sendInvoiceEmail(invoicePath: string, emailOptions: EmailOptions): Promise { try { - // Get the download URL for the invoice const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath); - // Format the date const formattedDate = emailOptions.additionalData?.paymentDate ? new Date(emailOptions.additionalData.paymentDate).toLocaleDateString('en-GB') : new Date().toLocaleDateString('en-GB'); - // Create email HTML content if not provided const emailHtml = emailOptions.customHtml || ` @@ -255,7 +375,6 @@ export class InvoiceService { `; - // Send the email with attachment await sendEmailWithAttachmentUtil( emailOptions.recipientEmail, emailOptions.subject || 'Your Fitlien Membership Invoice', @@ -285,10 +404,8 @@ export class InvoiceService { error?: string; }> { try { - // Generate the invoice const invoicePath = await this.generateInvoice(invoiceData); - // Update the payment record with the invoice path const updateSuccess = await this.updateInvoicePath(membershipId, paymentId, invoicePath); if (!updateSuccess) { @@ -300,10 +417,8 @@ export class InvoiceService { }; } - // Get a download URL for the invoice const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath); - // Send email if email options are provided let emailSent = false; if (emailOptions && emailOptions.recipientEmail) { emailSent = await this.sendInvoiceEmail(invoicePath, emailOptions); -- 2.43.0 From b11cefbab49cdbb8a4bb96993e34b492cfc588bb Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Tue, 20 May 2025 13:32:51 +0530 Subject: [PATCH 06/10] Update invoiceService.ts --- functions/src/payments/phonepe/invoice/invoiceService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/payments/phonepe/invoice/invoiceService.ts b/functions/src/payments/phonepe/invoice/invoiceService.ts index 8f2db0b..eb8a1e5 100644 --- a/functions/src/payments/phonepe/invoice/invoiceService.ts +++ b/functions/src/payments/phonepe/invoice/invoiceService.ts @@ -7,7 +7,7 @@ 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; +(pdfMake as any).vfs = (pdfFonts as any).pdfMake ? (pdfFonts as any).pdfMake.vfs : pdfFonts.vfs; const admin = getAdmin(); const logger = getLogger(); -- 2.43.0 From 3ef81f8273ed112482a8645a1480a5cc4ab3289a Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Tue, 20 May 2025 13:32:54 +0530 Subject: [PATCH 07/10] Update index.ts --- functions/src/payments/phonepe/invoice/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/functions/src/payments/phonepe/invoice/index.ts b/functions/src/payments/phonepe/invoice/index.ts index 3d423aa..daa2183 100644 --- a/functions/src/payments/phonepe/invoice/index.ts +++ b/functions/src/payments/phonepe/invoice/index.ts @@ -2,11 +2,12 @@ import { getInvoiceUrl } from './getInvoiceUrl'; import { InvoiceService } from './invoiceService'; import { processInvoice } from './processInvoice'; import { sendInvoiceEmail } from './sendInvoiceEmail'; +import { directGenerateInvoice } from './directInvoice'; -// Export all invoice-related functions export { getInvoiceUrl, InvoiceService, processInvoice, - sendInvoiceEmail + sendInvoiceEmail, + directGenerateInvoice, }; -- 2.43.0 From d8dfd8a6f27acc29c49dfdfa972f6838ba206ba6 Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Tue, 20 May 2025 13:50:04 +0530 Subject: [PATCH 08/10] pdf changes --- .../payments/phonepe/invoice/invoiceService.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/functions/src/payments/phonepe/invoice/invoiceService.ts b/functions/src/payments/phonepe/invoice/invoiceService.ts index eb8a1e5..c1edf9b 100644 --- a/functions/src/payments/phonepe/invoice/invoiceService.ts +++ b/functions/src/payments/phonepe/invoice/invoiceService.ts @@ -4,10 +4,18 @@ 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 as any).pdfMake ? (pdfFonts as any).pdfMake.vfs : pdfFonts.vfs; +let pdfMake: any; +let pdfFonts: any; + +function initPdfLibraries() { + if (!pdfMake) { + pdfMake = require('pdfmake/build/pdfmake'); + pdfFonts = require('pdfmake/build/vfs_fonts'); + pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs; + } + return pdfMake; +} const admin = getAdmin(); const logger = getLogger(); @@ -45,6 +53,7 @@ export interface EmailOptions { export class InvoiceService { async generateInvoice(data: InvoiceData): Promise { try { + const pdfMake = initPdfLibraries(); const tempFilePath = path.join(os.tmpdir(), `invoice_${data.invoiceNumber}.pdf`); const hasGst = data.gstNumber && data.gstNumber.length > 0; @@ -255,7 +264,7 @@ export class InvoiceService { const pdfDoc = pdfMake.createPdf(docDefinition); await new Promise((resolve, reject) => { - pdfDoc.getBuffer((buffer) => { + pdfDoc.getBuffer((buffer: Buffer) => { fs.writeFile(tempFilePath, buffer, (err) => { if (err) reject(err); else resolve(); -- 2.43.0 From 762e6b77e5ed5516555490688a24a1a383437565 Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Tue, 20 May 2025 14:15:36 +0530 Subject: [PATCH 09/10] Update invoiceService.ts --- .../src/payments/phonepe/invoice/invoiceService.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/functions/src/payments/phonepe/invoice/invoiceService.ts b/functions/src/payments/phonepe/invoice/invoiceService.ts index c1edf9b..59bb0fd 100644 --- a/functions/src/payments/phonepe/invoice/invoiceService.ts +++ b/functions/src/payments/phonepe/invoice/invoiceService.ts @@ -5,17 +5,10 @@ import * as path from 'path'; import { format } from 'date-fns'; import { sendEmailWithAttachmentUtil } from "../../../utils/emailService"; -let pdfMake: any; -let pdfFonts: any; +const pdfMake = require('pdfmake/build/pdfmake'); +const pdfFonts = require('pdfmake/build/vfs_fonts'); +pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs; -function initPdfLibraries() { - if (!pdfMake) { - pdfMake = require('pdfmake/build/pdfmake'); - pdfFonts = require('pdfmake/build/vfs_fonts'); - pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs; - } - return pdfMake; -} const admin = getAdmin(); const logger = getLogger(); @@ -53,7 +46,6 @@ export interface EmailOptions { export class InvoiceService { async generateInvoice(data: InvoiceData): Promise { try { - const pdfMake = initPdfLibraries(); const tempFilePath = path.join(os.tmpdir(), `invoice_${data.invoiceNumber}.pdf`); const hasGst = data.gstNumber && data.gstNumber.length > 0; -- 2.43.0 From 06c5f018c316536236bba9847bd74052a86b30f4 Mon Sep 17 00:00:00 2001 From: AllenTJ7 <163137620+AllenTJ7@users.noreply.github.com> Date: Tue, 20 May 2025 14:32:59 +0530 Subject: [PATCH 10/10] changed pdf again --- functions/package-lock.json | 210 ++++++++++++ functions/package.json | 2 + .../phonepe/invoice/invoiceService.ts | 298 +++++------------- 3 files changed, 299 insertions(+), 211 deletions(-) diff --git a/functions/package-lock.json b/functions/package-lock.json index 9feb2ad..e5b23b4 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -18,6 +18,8 @@ "form-data": "^4.0.1", "functions": "file:", "html-to-text": "^9.0.5", + "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "long": "^5.3.2", "mailgun.js": "^10.4.0", "node-fetch": "^2.7.0", @@ -1101,6 +1103,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", @@ -2859,6 +2869,12 @@ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -2924,6 +2940,12 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "optional": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3066,6 +3088,17 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3248,6 +3281,15 @@ "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "optional": true, + "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", @@ -3379,6 +3421,17 @@ "node-int64": "^0.4.0" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer": { "version": "4.9.2", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", @@ -3494,6 +3547,25 @@ ], "peer": true }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "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", @@ -3659,6 +3731,17 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/core-js": { + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz", + "integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -3713,6 +3796,15 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -3888,6 +3980,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -4269,6 +4370,11 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4833,6 +4939,19 @@ "node": ">=14" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -6047,6 +6166,31 @@ "node": ">=10" } }, + "node_modules/jspdf": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", + "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", + "dependencies": { + "@babel/runtime": "^7.26.7", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz", + "integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==", + "peerDependencies": { + "jspdf": "^2 || ^3" + } + }, "node_modules/jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", @@ -6765,6 +6909,12 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6960,6 +7110,15 @@ "node": ">=0.4.x" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7003,6 +7162,12 @@ "node": ">= 6" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "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", @@ -7108,6 +7273,15 @@ "node": ">=14" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7431,6 +7605,15 @@ "node": ">=10" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -7579,6 +7762,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", @@ -7671,6 +7863,15 @@ "node": ">=8" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -7931,6 +8132,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", diff --git a/functions/package.json b/functions/package.json index bbfdd3f..b9f1c89 100644 --- a/functions/package.json +++ b/functions/package.json @@ -25,6 +25,8 @@ "form-data": "^4.0.1", "functions": "file:", "html-to-text": "^9.0.5", + "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "long": "^5.3.2", "mailgun.js": "^10.4.0", "node-fetch": "^2.7.0", diff --git a/functions/src/payments/phonepe/invoice/invoiceService.ts b/functions/src/payments/phonepe/invoice/invoiceService.ts index 59bb0fd..95ffa7d 100644 --- a/functions/src/payments/phonepe/invoice/invoiceService.ts +++ b/functions/src/payments/phonepe/invoice/invoiceService.ts @@ -4,12 +4,8 @@ import * as os from 'os'; import * as path from 'path'; import { format } from 'date-fns'; import { sendEmailWithAttachmentUtil } from "../../../utils/emailService"; - -const pdfMake = require('pdfmake/build/pdfmake'); -const pdfFonts = require('pdfmake/build/vfs_fonts'); -pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs; - - +import { jsPDF } from "jspdf"; +import 'jspdf-autotable'; const admin = getAdmin(); const logger = getLogger(); @@ -54,216 +50,96 @@ export class InvoiceService { const cgst = hasGst ? baseAmount * 0.09 : 0; const formattedDate = format(data.paymentDate, 'dd/MM/yyyy'); + const doc = new jsPDF(); - 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' } + doc.setFontSize(20); + doc.setFont('helvetica', 'bold'); + doc.text(data.businessName, 20, 20); + + doc.setFontSize(12); + doc.setFont('helvetica', 'normal'); + doc.text(data.address, 20, 30); + + if (hasGst) { + doc.text(`GSTIN: ${data.gstNumber}`, 20, 40); + } + + doc.setFontSize(24); + doc.setFont('helvetica', 'bold'); + doc.text('RECEIPT', 190, 20, { align: 'right' }); + + doc.setFontSize(12); + doc.text(`Receipt #: ${data.invoiceNumber}`, 190, 30, { align: 'right' }); + doc.text(`Date: ${formattedDate}`, 190, 40, { align: 'right' }); + + doc.line(20, 45, 190, 45); + + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text('Receipt To:', 20, 60); + + doc.setFont('helvetica', 'normal'); + doc.text(data.customerName, 20, 70); + doc.text(`Phone: ${data.phoneNumber}`, 20, 80); + doc.text(`Email: ${data.email}`, 20, 90); + + (doc as any).autoTable({ + startY: 110, + head: [['No.', 'Description', 'HSN/SAC', 'Amount (INR)']], + body: [ + ['1', `${data.planName} Subscription`, '999723', baseAmount.toFixed(2)] ], - 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' + headStyles: { fillColor: [220, 220, 220], textColor: [0, 0, 0], fontStyle: 'bold' }, + styles: { halign: 'center' }, + columnStyles: { + 0: { halign: 'center', cellWidth: 20 }, + 1: { halign: 'left' }, + 2: { halign: 'center', cellWidth: 40 }, + 3: { halign: 'right', cellWidth: 40 } } - }; - - const pdfDoc = pdfMake.createPdf(docDefinition); - - await new Promise((resolve, reject) => { - pdfDoc.getBuffer((buffer: Buffer) => { - fs.writeFile(tempFilePath, buffer, (err) => { - if (err) reject(err); - else resolve(); - }); - }); }); + const finalY = (doc as any).lastAutoTable.finalY + 20; + + if (hasGst) { + doc.text('Taxable Amount:', 150, finalY, { align: 'right' }); + doc.text(`${baseAmount.toFixed(2)} INR`, 190, finalY, { align: 'right' }); + + doc.text('SGST (9%):', 150, finalY + 10, { align: 'right' }); + doc.text(`${sgst.toFixed(2)} INR`, 190, finalY + 10, { align: 'right' }); + + doc.text('CGST (9%):', 150, finalY + 20, { align: 'right' }); + doc.text(`${cgst.toFixed(2)} INR`, 190, finalY + 20, { align: 'right' }); + + doc.setFont('helvetica', 'bold'); + doc.text('Total Amount:', 150, finalY + 30, { align: 'right' }); + doc.text(`${data.amount.toFixed(2)} INR`, 190, finalY + 30, { align: 'right' }); + } else { + doc.setFont('helvetica', 'bold'); + doc.text('Total Amount:', 150, finalY, { align: 'right' }); + doc.text(`${data.amount.toFixed(2)} INR`, 190, finalY, { align: 'right' }); + } + + const paymentY = hasGst ? finalY + 50 : finalY + 20; + + doc.setFont('helvetica', 'bold'); + doc.text('Payment Information:', 20, paymentY); + + doc.setFont('helvetica', 'normal'); + doc.text(`Transaction ID: ${data.transactionId}`, 20, paymentY + 10); + doc.text(`Payment Method: ${data.paymentMethod}`, 20, paymentY + 20); + doc.text(`Payment Date: ${formattedDate}`, 20, paymentY + 30); + + 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, { -- 2.43.0