Compare commits
No commits in common. "ecbe9d184bd96b2f3688eaa6f4f27712923da930" and "f8b3930fcc7376b6594d100d5dadf679675ebca4" have entirely different histories.
ecbe9d184b
...
f8b3930fcc
@ -1,64 +0,0 @@
|
||||
name: Deploy FitLien services to Dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Clean install
|
||||
run: npm clean-install
|
||||
|
||||
- name: Copy .env.example to .env
|
||||
run: cp functions/.env.example functions/.env
|
||||
|
||||
- name: Replace variables in .env
|
||||
run: |
|
||||
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env
|
||||
sed -i "s/#{TWILIO_AUTH_TOKEN}#/${{ secrets.TWILIO_AUTH_TOKEN }}/" functions/.env
|
||||
sed -i "s/#{TWILIO_PHONE_NUMBER}#/${{ secrets.TWILIO_PHONE_NUMBER }}/" functions/.env
|
||||
sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/" functions/.env
|
||||
sed -i "s/#{GOOGLE_MAPS_API_KEY}#/${{ secrets.GOOGLE_MAPS_API_KEY }}/" functions/.env
|
||||
sed -i "s/#{SES_FROM_EMAIL}#/${{ vars.SES_FROM_EMAIL }}/" functions/.env
|
||||
sed -i "s/#{SES_REPLY_TO_EMAIL}#/${{ vars.SES_REPLY_TO_EMAIL }}/" functions/.env
|
||||
sed -i "s/#{AWS_ACCESS_KEY_ID}#/${{ secrets.AWS_ACCESS_KEY_ID }}/" functions/.env
|
||||
sed -i "s/#{AWS_SECRET_ACCESS_KEY}#/${{ secrets.AWS_SECRET_ACCESS_KEY }}/" functions/.env
|
||||
sed -i "s/#{AWS_REGION}#/${{ secrets.AWS_REGION }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_CLIENT_ID}#/${{ secrets.PHONEPE_CLIENT_ID }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_CLIENT_SECRET}#/${{ secrets.PHONEPE_CLIENT_SECRET }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_API_URL}#/${{ secrets.PHONEPE_API_URL }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_WEBHOOK_USERNAME}#/${{ secrets.PHONEPE_WEBHOOK_USERNAME }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_WEBHOOK_PASSWORD}#/${{ secrets.PHONEPE_WEBHOOK_PASSWORD }}/" functions/.env
|
||||
|
||||
cat functions/.env
|
||||
- name: "Replace #{SERVICES_RGN}# in all .ts files"
|
||||
run: |
|
||||
find . -type f -name "*.ts" -exec sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/g" {} +
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm install -g typescript
|
||||
cd functions
|
||||
npm install
|
||||
npx tsc
|
||||
cd ..
|
||||
ls -la
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
curl -sL firebase.tools | upgrade=true bash
|
||||
firebase use --token ${{ secrets.FIREBASE_TOKEN }} debug
|
||||
firebase deploy --token "${{ secrets.FIREBASE_TOKEN }}" --force --non-interactive
|
||||
@ -1,64 +0,0 @@
|
||||
name: Deploy FitLien services to QA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- qa
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to QA
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Clean install
|
||||
run: npm clean-install
|
||||
|
||||
- name: Copy .env.example to .env
|
||||
run: cp functions/.env.example functions/.env
|
||||
|
||||
- name: Replace variables in .env
|
||||
run: |
|
||||
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env
|
||||
sed -i "s/#{TWILIO_AUTH_TOKEN}#/${{ secrets.TWILIO_AUTH_TOKEN }}/" functions/.env
|
||||
sed -i "s/#{TWILIO_PHONE_NUMBER}#/${{ secrets.TWILIO_PHONE_NUMBER }}/" functions/.env
|
||||
sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/" functions/.env
|
||||
sed -i "s/#{GOOGLE_MAPS_API_KEY}#/${{ secrets.GOOGLE_MAPS_API_KEY }}/" functions/.env
|
||||
sed -i "s/#{SES_FROM_EMAIL}#/${{ vars.SES_FROM_EMAIL }}/" functions/.env
|
||||
sed -i "s/#{SES_REPLY_TO_EMAIL}#/${{ vars.SES_REPLY_TO_EMAIL }}/" functions/.env
|
||||
sed -i "s/#{AWS_ACCESS_KEY_ID}#/${{ secrets.AWS_ACCESS_KEY_ID }}/" functions/.env
|
||||
sed -i "s/#{AWS_SECRET_ACCESS_KEY}#/${{ secrets.AWS_SECRET_ACCESS_KEY }}/" functions/.env
|
||||
sed -i "s/#{AWS_REGION}#/${{ secrets.AWS_REGION }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_CLIENT_ID}#/${{ secrets.PHONEPE_CLIENT_ID }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_CLIENT_SECRET}#/${{ secrets.PHONEPE_CLIENT_SECRET }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_API_URL}#/${{ secrets.PHONEPE_API_URL }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_WEBHOOK_USERNAME}#/${{ secrets.PHONEPE_WEBHOOK_USERNAME }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_WEBHOOK_PASSWORD}#/${{ secrets.PHONEPE_WEBHOOK_PASSWORD }}/" functions/.env
|
||||
|
||||
cat functions/.env
|
||||
- name: "Replace #{SERVICES_RGN}# in all .ts files"
|
||||
run: |
|
||||
find . -type f -name "*.ts" -exec sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/g" {} +
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm install -g typescript
|
||||
cd functions
|
||||
npm install
|
||||
npx tsc
|
||||
cd ..
|
||||
ls -la
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
curl -sL firebase.tools | upgrade=true bash
|
||||
firebase use --token ${{ secrets.FIREBASE_TOKEN }} qa
|
||||
firebase deploy --token "${{ secrets.FIREBASE_TOKEN }}" --force --non-interactive
|
||||
@ -1,64 +0,0 @@
|
||||
name: Deploy FitLien services
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Clean install
|
||||
run: npm clean-install
|
||||
|
||||
- name: Copy .env.example to .env
|
||||
run: cp functions/.env.example functions/.env
|
||||
|
||||
- name: Replace variables in .env
|
||||
run: |
|
||||
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env
|
||||
sed -i "s/#{TWILIO_AUTH_TOKEN}#/${{ secrets.TWILIO_AUTH_TOKEN }}/" functions/.env
|
||||
sed -i "s/#{TWILIO_PHONE_NUMBER}#/${{ secrets.TWILIO_PHONE_NUMBER }}/" functions/.env
|
||||
sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/" functions/.env
|
||||
sed -i "s/#{GOOGLE_MAPS_API_KEY}#/${{ secrets.GOOGLE_MAPS_API_KEY }}/" functions/.env
|
||||
sed -i "s/#{SES_FROM_EMAIL}#/${{ vars.SES_FROM_EMAIL }}/" functions/.env
|
||||
sed -i "s/#{SES_REPLY_TO_EMAIL}#/${{ vars.SES_REPLY_TO_EMAIL }}/" functions/.env
|
||||
sed -i "s/#{AWS_ACCESS_KEY_ID}#/${{ secrets.AWS_ACCESS_KEY_ID }}/" functions/.env
|
||||
sed -i "s/#{AWS_SECRET_ACCESS_KEY}#/${{ secrets.AWS_SECRET_ACCESS_KEY }}/" functions/.env
|
||||
sed -i "s/#{AWS_REGION}#/${{ secrets.AWS_REGION }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_CLIENT_ID}#/${{ secrets.PHONEPE_CLIENT_ID }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_CLIENT_SECRET}#/${{ secrets.PHONEPE_CLIENT_SECRET }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_API_URL}#/${{ secrets.PHONEPE_API_URL }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_WEBHOOK_USERNAME}#/${{ secrets.PHONEPE_WEBHOOK_USERNAME }}/" functions/.env
|
||||
sed -i "s/#{PHONEPE_WEBHOOK_PASSWORD}#/${{ secrets.PHONEPE_WEBHOOK_PASSWORD }}/" functions/.env
|
||||
|
||||
cat functions/.env
|
||||
- name: "Replace #{SERVICES_RGN}# in all .ts files"
|
||||
run: |
|
||||
find . -type f -name "*.ts" -exec sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/g" {} +
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
npm install -g typescript
|
||||
cd functions
|
||||
npm install
|
||||
npx tsc
|
||||
cd ..
|
||||
ls -la
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
curl -sL firebase.tools | upgrade=true bash
|
||||
firebase use --token ${{ secrets.FIREBASE_TOKEN }} release
|
||||
firebase deploy --token "${{ secrets.FIREBASE_TOKEN }}" --force --non-interactive
|
||||
@ -1,15 +1,12 @@
|
||||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json",
|
||||
"database": "(default)"
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"functions": [
|
||||
{
|
||||
"source": "functions",
|
||||
"codebase": "default",
|
||||
"timeoutSeconds": 540,
|
||||
"memory": "1GiB",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git",
|
||||
@ -25,17 +22,17 @@
|
||||
},
|
||||
"emulators": {
|
||||
"functions": {
|
||||
"port": 5005
|
||||
"port": 5001
|
||||
},
|
||||
"firestore": {
|
||||
"port": 8086
|
||||
"port": 8079
|
||||
},
|
||||
"storage": {
|
||||
"port": 9199
|
||||
},
|
||||
"ui": {
|
||||
"enabled": true,
|
||||
"port": 4008
|
||||
"port": 4000
|
||||
},
|
||||
"auth": {
|
||||
"port": 9099
|
||||
@ -44,4 +41,4 @@
|
||||
"remoteconfig": {
|
||||
"template": "remoteconfig.template.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
MAILGUN_API_KEY=#{MAILGUN_API_KEY}#
|
||||
MAILGUN_SERVER=#{MAILGUN_SERVER}#
|
||||
MAILGUN_FROM_ADDRESS=#{MAILGUN_FROM_ADDRESS}#
|
||||
TWILIO_ACCOUNT_SID=#{TWILIO_ACCOUNT_SID}#
|
||||
TWILIO_AUTH_TOKEN=#{TWILIO_AUTH_TOKEN}#
|
||||
TWILIO_PHONE_NUMBER=#{TWILIO_PHONE_NUMBER}#
|
||||
@ -5,17 +8,7 @@ SERVICES_RGN=#{SERVICES_RGN}#
|
||||
CASHFREE_CLIENT_ID=#{CASHFREE_CLIENT_ID}#
|
||||
CASHFREE_CLIENT_SECRET=#{CASHFREE_CLIENT_SECRET}#
|
||||
GOOGLE_MAPS_API_KEY=#{GOOGLE_MAPS_API_KEY}#
|
||||
FITLIENHOST=#{FITLIENHOST}#
|
||||
CASHFREE_URL=#{CASHFREE_URL}#
|
||||
CASHFREE_LINK_URL=#{CASHFREE_LINK_URL}#
|
||||
CASHFREE_LINK_NOTIFY_URL=#{CASHFREE_LINK_NOTIFY_URL}#
|
||||
PHONEPE_CLIENT_ID=#{PHONEPE_CLIENT_ID}#
|
||||
PHONEPE_CLIENT_SECRET=#{PHONEPE_CLIENT_SECRET}#
|
||||
PHONEPE_API_URL=#{PHONEPE_API_URL}#
|
||||
PHONEPE_WEBHOOK_USERNAME=#{PHONEPE_WEBHOOK_USERNAME}#
|
||||
PHONEPE_WEBHOOK_PASSWORD=#{PHONEPE_WEBHOOK_PASSWORD}#
|
||||
|
||||
SES_FROM_EMAIL=#{SES_FROM_EMAIL}#
|
||||
SES_REPLY_TO_EMAIL=#{SES_REPLY_TO_EMAIL}#
|
||||
AWS_ACCESS_KEY_ID=#{AWS_ACCESS_KEY_ID}#
|
||||
AWS_SECRET_ACCESS_KEY=#{AWS_SECRET_ACCESS_KEY}#
|
||||
AWS_REGION=#{AWS_REGION}#
|
||||
|
||||
2336
functions/package-lock.json
generated
2336
functions/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,30 +15,23 @@
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.798.0",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.8.4",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"firebase-admin": "^12.6.0",
|
||||
"firebase-functions": "^6.0.1",
|
||||
"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",
|
||||
"long": "^4.0.0",
|
||||
"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"
|
||||
},
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
import { getCorsHandler } from "../shared/middleware";
|
||||
import { getAdmin, getLogger } from "../shared/config";
|
||||
|
||||
const corsHandler = getCorsHandler();
|
||||
const admin = getAdmin();
|
||||
const logger = getLogger();
|
||||
|
||||
export const registerClient = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (req, res) => {
|
||||
return corsHandler(req, res, async () => {
|
||||
try {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed. Please use POST.' });
|
||||
}
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized. Missing or invalid authorization header.' });
|
||||
}
|
||||
const idToken = authHeader.split('Bearer ')[1];
|
||||
try {
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
const uid = decodedToken.uid;
|
||||
const userDoc = await admin.firestore().collection('users').doc(uid).get();
|
||||
|
||||
if (!userDoc.exists) {
|
||||
return res.status(403).json({ error: 'Forbidden. User not found.' });
|
||||
}
|
||||
|
||||
const userData = userDoc.data();
|
||||
if (!userData || !userData.roles || !userData.roles.includes('gym_owner')) {
|
||||
return res.status(403).json({ error: 'Forbidden. Only gym owners can register clients.' });
|
||||
}
|
||||
const gymUser = req.body;
|
||||
if (!gymUser.phoneNumber) {
|
||||
return res.status(400).json({ error: 'Phone number is required' });
|
||||
}
|
||||
|
||||
const isdCode = gymUser.isdCode || '91';
|
||||
const formattedPhoneNumber = gymUser.phoneNumber.startsWith('+')
|
||||
? gymUser.phoneNumber
|
||||
: `${isdCode}${gymUser.phoneNumber}`;
|
||||
|
||||
let clientUid;
|
||||
try {
|
||||
const userRecord = await admin.auth().getUserByPhoneNumber(formattedPhoneNumber)
|
||||
.catch(() => null);
|
||||
|
||||
if (userRecord) {
|
||||
clientUid = userRecord.uid;
|
||||
} else {
|
||||
const newUser = await admin.auth().createUser({
|
||||
phoneNumber: formattedPhoneNumber,
|
||||
displayName: gymUser.name || '',
|
||||
email: gymUser.email || null,
|
||||
});
|
||||
clientUid = newUser.uid;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating authentication user:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Failed to create authentication user',
|
||||
details: error
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
gymUser.uid = clientUid;
|
||||
gymUser.registeredBy = uid;
|
||||
|
||||
if (gymUser.name) {
|
||||
gymUser.normalizedName = gymUser.name.toLowerCase();
|
||||
}
|
||||
|
||||
if (gymUser.dateOfBirth && !(typeof gymUser.dateOfBirth === 'string')) {
|
||||
gymUser.dateOfBirth = new Date(gymUser.dateOfBirth).toISOString();
|
||||
}
|
||||
|
||||
const clientData = {
|
||||
...gymUser,
|
||||
phoneNumber: formattedPhoneNumber,
|
||||
};
|
||||
|
||||
await admin.firestore().collection('client_profiles').doc(clientUid).set(clientData);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: 'Client registered successfully',
|
||||
clientId: clientUid
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating client profile:', error);
|
||||
|
||||
try {
|
||||
if (!gymUser.uid) {
|
||||
await admin.auth().deleteUser(clientUid);
|
||||
}
|
||||
} catch (deleteError) {
|
||||
logger.error('Error deleting auth user after failed profile creation:', deleteError);
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to create client profile',
|
||||
details: error
|
||||
});
|
||||
}
|
||||
|
||||
} catch (authError) {
|
||||
logger.error('Authentication error:', authError);
|
||||
return res.status(401).json({ error: 'Unauthorized. Invalid token.' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error in client registration:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
details: error
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export { registerClient } from './clientRegistration';
|
||||
@ -1 +0,0 @@
|
||||
export { sendEmailSES } from './sendEmailSES';
|
||||
@ -1,208 +0,0 @@
|
||||
import { getLogger } from "../shared/config";
|
||||
import { getCorsHandler } from "../shared/middleware";
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
import { Request } from "firebase-functions/v2/https";
|
||||
import { SESClient } from "@aws-sdk/client-ses";
|
||||
import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses";
|
||||
import { HttpsError } from "firebase-functions/v2/https";
|
||||
import * as mime from 'mime-types';
|
||||
import axios from 'axios';
|
||||
|
||||
const logger = getLogger();
|
||||
const corsHandler = getCorsHandler();
|
||||
|
||||
interface EmailRequest {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
attachments?: Attachment[];
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
filename: string;
|
||||
content: string | Buffer; // Base64 encoded string or Buffer
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
const stripHtml = (html: string): string => {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
async function sendSimpleEmail(data: EmailRequest, recipients: string[]) {
|
||||
const ses = new SESClient({
|
||||
region: 'ap-south-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
|
||||
}
|
||||
});
|
||||
|
||||
const command = new SendEmailCommand({
|
||||
Source: data.from,
|
||||
Destination: { ToAddresses: recipients },
|
||||
Message: {
|
||||
Subject: { Data: data.subject },
|
||||
Body: {
|
||||
Html: { Data: data.html },
|
||||
Text: { Data: data.text || stripHtml(data.html) }
|
||||
}
|
||||
},
|
||||
ReplyToAddresses: data.replyTo ? [data.replyTo] : undefined,
|
||||
});
|
||||
|
||||
const result = await ses.send(command);
|
||||
return { messageId: result.MessageId };
|
||||
}
|
||||
|
||||
async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) {
|
||||
const ses = new SESClient({
|
||||
region: 'ap-south-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
|
||||
}
|
||||
});
|
||||
|
||||
const boundary = `boundary_${Math.random().toString(16).substr(2)}`;
|
||||
let rawMessage = `From: ${data.from}\n`;
|
||||
rawMessage += `To: ${recipients.join(', ')}\n`;
|
||||
rawMessage += `Subject: ${data.subject}\n`;
|
||||
rawMessage += `MIME-Version: 1.0\n`;
|
||||
rawMessage += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
|
||||
|
||||
// Add email body (multipart/alternative)
|
||||
rawMessage += `--${boundary}\n`;
|
||||
rawMessage += `Content-Type: multipart/alternative; boundary="alt_${boundary}"\n\n`;
|
||||
|
||||
// Text part
|
||||
if (data.text) {
|
||||
rawMessage += `--alt_${boundary}\n`;
|
||||
rawMessage += `Content-Type: text/plain; charset=UTF-8\n\n`;
|
||||
rawMessage += `${data.text}\n\n`;
|
||||
}
|
||||
|
||||
// HTML part
|
||||
rawMessage += `--alt_${boundary}\n`;
|
||||
rawMessage += `Content-Type: text/html; charset=UTF-8\n\n`;
|
||||
rawMessage += `${data.html}\n\n`;
|
||||
|
||||
// Close alternative part
|
||||
rawMessage += `--alt_${boundary}--\n\n`;
|
||||
|
||||
// Add attachments
|
||||
for (const attachment of data.attachments || []) {
|
||||
const contentType = attachment.contentType ||
|
||||
mime.lookup(attachment.filename) ||
|
||||
'application/octet-stream';
|
||||
|
||||
rawMessage += `--${boundary}\n`;
|
||||
rawMessage += `Content-Type: ${contentType}; name="${attachment.filename}"\n`;
|
||||
rawMessage += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
|
||||
rawMessage += `Content-Transfer-Encoding: base64\n\n`;
|
||||
|
||||
const contentBuffer = typeof attachment.content === 'string'
|
||||
? Buffer.from(attachment.content, 'base64')
|
||||
: attachment.content;
|
||||
|
||||
rawMessage += contentBuffer.toString('base64') + '\n\n';
|
||||
}
|
||||
|
||||
// Close message
|
||||
rawMessage += `--${boundary}--`;
|
||||
|
||||
const command = new SendRawEmailCommand({
|
||||
RawMessage: { Data: Buffer.from(rawMessage) }
|
||||
});
|
||||
|
||||
const result = await ses.send(command);
|
||||
return { messageId: result.MessageId };
|
||||
}
|
||||
|
||||
async function downloadFileFromUrl(url: string): Promise<Buffer> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: 'arraybuffer' });
|
||||
return Buffer.from(response.data);
|
||||
} catch (error) {
|
||||
logger.error(`Error downloading file from URL: ${error}`);
|
||||
throw new Error(`Failed to download file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendEmailSES = onRequest({
|
||||
region: 'asia-south1'
|
||||
}, (request: Request, response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const toAddress = request.body.toAddress;
|
||||
const subject = request.body.subject;
|
||||
const message = request.body.message;
|
||||
|
||||
// Initialize data with basic fields
|
||||
const data: EmailRequest = {
|
||||
to: toAddress,
|
||||
html: message,
|
||||
subject: subject,
|
||||
text: stripHtml(message),
|
||||
from: process.env.SES_FROM_EMAIL || 'support@fitlien.com',
|
||||
replyTo: process.env.SES_REPLY_TO_EMAIL || 'support@fitlien.com',
|
||||
attachments: request.body.attachments as Attachment[] || []
|
||||
};
|
||||
|
||||
// Handle file URL if provided
|
||||
if (request.body.fileUrl && request.body.fileName) {
|
||||
logger.info(`Downloading attachment from URL: ${request.body.fileUrl}`);
|
||||
try {
|
||||
const fileContent = await downloadFileFromUrl(request.body.fileUrl);
|
||||
|
||||
// If attachments array doesn't exist, create it
|
||||
if (!data.attachments) {
|
||||
data.attachments = [];
|
||||
}
|
||||
|
||||
// Add the downloaded file as an attachment
|
||||
data.attachments.push({
|
||||
filename: request.body.fileName,
|
||||
content: fileContent,
|
||||
contentType: mime.lookup(request.body.fileName) || 'application/octet-stream'
|
||||
});
|
||||
|
||||
logger.info(`Successfully downloaded attachment: ${request.body.fileName}`);
|
||||
} catch (downloadError) {
|
||||
logger.error(`Failed to download attachment: ${downloadError}`);
|
||||
throw new Error(`Failed to process attachment: ${downloadError}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.to || !data.subject || !data.html || !data.from) {
|
||||
throw new HttpsError(
|
||||
'invalid-argument',
|
||||
'Missing required email fields'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Sending Email '${data.subject}' to '${data.to}' from '${data.from}'`);
|
||||
const recipients = Array.isArray(data.to) ? data.to : [data.to];
|
||||
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
const messageResult = await sendEmailWithAttachments(data, recipients);
|
||||
response.status(200).json(messageResult);
|
||||
} else {
|
||||
const messageResult = await sendSimpleEmail(data, recipients);
|
||||
response.status(200).json(messageResult);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Error while sending E-mail. Error: ${e}`);
|
||||
console.error(`Error while sending E-mail. Error: ${e}`);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: 'Error while sending E-mail'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,19 +1,782 @@
|
||||
import { setGlobalOptions } from "firebase-functions/v2";
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
import { Request } from "firebase-functions/v2/https";
|
||||
import * as admin from 'firebase-admin';
|
||||
import * as express from "express";
|
||||
import * as logger from "firebase-functions/logger";
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as https from 'https';
|
||||
import cors from 'cors';
|
||||
import axios from "axios";
|
||||
import { getStorage } from 'firebase-admin/storage';
|
||||
import { onDocumentCreated } from "firebase-functions/firestore";
|
||||
const formData = require('form-data');
|
||||
const Mailgun = require('mailgun.js');
|
||||
const { convert } = require('html-to-text');
|
||||
const twilio = require('twilio');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
setGlobalOptions({
|
||||
region: "#{SERVICES_RGN}#",
|
||||
memory: "1GiB",
|
||||
timeoutSeconds: 540,
|
||||
minInstances: 0,
|
||||
maxInstances: 10,
|
||||
concurrency: 80
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp();
|
||||
}
|
||||
|
||||
const corsHandler = cors({ origin: true });
|
||||
|
||||
export const sendEmailWithAttachment = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const { toAddress, subject, message, fileUrl, fileName } = request.body;
|
||||
|
||||
if (!toAddress || !subject || !message || !fileUrl) {
|
||||
response.status(400).json({
|
||||
error: 'Missing required fields (toAddress, subject, message, fileUrl)'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tempFilePath = path.join(os.tmpdir(), fileName || 'attachment.pdf');
|
||||
await new Promise<void>((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 mailgun = new Mailgun(formData);
|
||||
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');
|
||||
response.json({ success: true, result });
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Error while sending E-mail. Error: ${e}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending email with attachment from URL:', error);
|
||||
response.status(500).json({ success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export * from './shared/config';
|
||||
export { sendEmailSES } from './email';
|
||||
export { sendSMSMessage } from './sms';
|
||||
export { accessFile } from './storage';
|
||||
export { processNotificationOnCreate } from './notifications';
|
||||
export * from './payments';
|
||||
export { getPlaceDetails, getPlacesAutocomplete } from './places';
|
||||
export { registerClient } from './clientRegistration';
|
||||
export const accessFile = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
|
||||
try {
|
||||
const filePath = request.query.path as string;
|
||||
if (!filePath) {
|
||||
response.status(400).send('File path is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const expirationMs = 60 * 60 * 1000;
|
||||
|
||||
const bucket = getStorage().bucket();
|
||||
const file = bucket.file(filePath);
|
||||
|
||||
const [exists] = await file.exists();
|
||||
if (!exists) {
|
||||
response.status(404).send('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const [signedUrl] = await file.getSignedUrl({
|
||||
action: 'read',
|
||||
expires: Date.now() + expirationMs,
|
||||
responseDisposition: `attachment; filename="${path.basename(filePath)}"`,
|
||||
});
|
||||
|
||||
response.redirect(signedUrl);
|
||||
logger.info(`File access redirect for ${filePath}`);
|
||||
} catch (error) {
|
||||
logger.error('Error accessing file:', error);
|
||||
response.status(500).send('Error accessing file');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const sendEmailMessage = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
|
||||
const mailgun = new Mailgun(formData);
|
||||
const mailGunClient = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY });
|
||||
|
||||
const toAddress = request.body.toAddress;
|
||||
const subject = request.body.subject;
|
||||
const message = request.body.message;
|
||||
const options = {
|
||||
wordwrap: 130,
|
||||
};
|
||||
|
||||
const textMessage = convert(message, options);
|
||||
mailGunClient.messages.create(process.env.MAILGUN_SERVER, {
|
||||
from: process.env.MAILGUN_FROM_ADDRESS,
|
||||
to: toAddress,
|
||||
subject: subject,
|
||||
text: textMessage,
|
||||
html: message
|
||||
}).then((res: any) => {
|
||||
logger.info(res);
|
||||
response.send(res);
|
||||
}).catch((err: any) => {
|
||||
logger.error(err);
|
||||
response.send(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const sendSMSMessage = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
|
||||
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
|
||||
const { to, body } = request.body;
|
||||
client.messages
|
||||
.create({
|
||||
body: body,
|
||||
from: process.env.TWILIO_PHONE_NUMBER,
|
||||
to: to
|
||||
})
|
||||
.then((message: any) => {
|
||||
logger.info('SMS sent successfully:', message.sid);
|
||||
response.json({ success: true, messageId: message.sid });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
logger.error('Error sending SMS:', error);
|
||||
response.status(500).json({ success: false, error: error.message });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const processNotificationOnCreate = onDocumentCreated({
|
||||
region: '#{SERVICES_RGN}#',
|
||||
document: 'notifications/{notificationId}'
|
||||
}, async (event) => {
|
||||
try {
|
||||
const notification = event.data?.data();
|
||||
const notificationId = event.params.notificationId;
|
||||
|
||||
if (!notification) {
|
||||
logger.error(`No data found for notification ${notificationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (notification.notificationSent === true) {
|
||||
logger.info(`Notification ${notificationId} already sent, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let userId = null;
|
||||
let fcmToken = null;
|
||||
|
||||
if (notification.userId) {
|
||||
userId = notification.userId;
|
||||
const userDoc = await admin.firestore().collection('users').doc(userId).get();
|
||||
if (userDoc.exists) {
|
||||
fcmToken = userDoc.data()?.fcmToken;
|
||||
}
|
||||
} else if (notification.clientId) {
|
||||
userId = notification.clientId;
|
||||
const userDoc = await admin.firestore().collection('users').doc(userId).get();
|
||||
if (userDoc.exists) {
|
||||
fcmToken = userDoc.data()?.fcmToken;
|
||||
}
|
||||
} else if (notification.invitorId) {
|
||||
userId = notification.invitorId;
|
||||
const userDoc = await admin.firestore().collection('users').doc(userId).get();
|
||||
if (userDoc.exists) {
|
||||
fcmToken = userDoc.data()?.fcmToken;
|
||||
}
|
||||
} else if (notification.phoneNumber) {
|
||||
const userQuery = await admin
|
||||
.firestore()
|
||||
.collection('users')
|
||||
.where('phoneNumber', '==', notification.phoneNumber)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (!userQuery.empty) {
|
||||
const userDoc = userQuery.docs[0];
|
||||
userId = userDoc.id;
|
||||
fcmToken = userDoc.data()?.fcmToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fcmToken) {
|
||||
logger.error(`FCM token not found for notification ${notificationId}`);
|
||||
await admin.firestore().collection('notifications').doc(notificationId).update({
|
||||
notificationError: 'FCM token not found for user',
|
||||
updatedAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let title = 'New Notification';
|
||||
let body = notification.message || 'You have a new notification';
|
||||
let data: Record<string, string> = {
|
||||
type: notification.type,
|
||||
};
|
||||
|
||||
switch (notification.type) {
|
||||
case 'day_pass_entry':
|
||||
const isAccepted = notification.status === 'ACCEPTED';
|
||||
title = isAccepted ? 'Day Pass Approved' : 'Day Pass Denied';
|
||||
body = notification.message || (isAccepted ?
|
||||
`Your day pass has been approved` :
|
||||
`Your day pass has been denied`);
|
||||
data.gymName = notification.gymName || '';
|
||||
break;
|
||||
|
||||
case 'trainer_assigned_to_client':
|
||||
title = 'Trainer Assigned';
|
||||
body = notification.message || `${notification.trainerName} has been assigned as your trainer`;
|
||||
data.trainerName = notification.trainerName || '';
|
||||
data.membershipId = notification.membershipId || '';
|
||||
break;
|
||||
|
||||
case 'client_invitations':
|
||||
if (notification.userId || notification.invitorId) {
|
||||
const isAccept = notification.status === 'ACCEPTED';
|
||||
title = isAccept ? 'Invitation Accepted' : 'Invitation Rejected';
|
||||
body = notification.message || (isAccept ?
|
||||
`The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been accepted` :
|
||||
`The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been rejected`);
|
||||
} else if (notification.phoneNumber) {
|
||||
let invitationStatus;
|
||||
if (notification.status === 'ACCEPTED') {
|
||||
invitationStatus = 'accepted';
|
||||
title = 'Invitation Accepted';
|
||||
body = notification.message ||
|
||||
`You have accepted the invitation from ${notification.name}`;
|
||||
} else if (notification.status === 'REJECTED') {
|
||||
invitationStatus = 'rejected';
|
||||
title = 'Invitation Rejected';
|
||||
body = notification.message ||
|
||||
`You have rejected the invitation from ${notification.name}`;
|
||||
} else if (notification.status === 'PENDING') {
|
||||
invitationStatus = 'pending';
|
||||
title = 'New Invitation';
|
||||
body = notification.message ||
|
||||
`You have a new invitation pending from ${notification.name}`;
|
||||
} else {
|
||||
invitationStatus = 'unknown';
|
||||
title = 'Invitation Update';
|
||||
body = notification.message || 'There is an update to your invitation';
|
||||
}
|
||||
data.status = invitationStatus;
|
||||
}
|
||||
|
||||
data.gymName = notification.gymName || '';
|
||||
data.clientEmail = notification.clientEmail || '';
|
||||
data.clientName = notification.name || '';
|
||||
data.invitationId = notification.invitationId || '';
|
||||
data.subscriptionName = notification.subscriptionName || '';
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.info(`Using default handling for notification type: ${notification.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const message: admin.messaging.Message = {
|
||||
notification: {
|
||||
title: title,
|
||||
body: body,
|
||||
},
|
||||
data: data,
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
channelId: 'notifications_channel',
|
||||
priority: 'high',
|
||||
defaultSound: true,
|
||||
defaultVibrateTimings: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
|
||||
},
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
token: fcmToken,
|
||||
};
|
||||
|
||||
try {
|
||||
const fcmResponse = await admin.messaging().send(message);
|
||||
logger.info(`FCM notification sent successfully: ${fcmResponse}`);
|
||||
|
||||
await admin.firestore().collection('notifications').doc(notificationId).update({
|
||||
notificationSent: true,
|
||||
sentAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error sending notification ${notificationId}:`, error);
|
||||
|
||||
await admin.firestore().collection('notifications').doc(notificationId).update({
|
||||
notificationError: error instanceof Error ? error.message : String(error),
|
||||
updatedAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing notification:', error);
|
||||
}
|
||||
});
|
||||
|
||||
export const createCashfreeOrder = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.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 {
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
const uid = decodedToken.uid;
|
||||
|
||||
const {
|
||||
amount,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
productInfo,
|
||||
userId,
|
||||
gymId,
|
||||
orderId,
|
||||
webHostUrl,
|
||||
} = request.body;
|
||||
|
||||
if (!amount || !customerEmail || !customerPhone) {
|
||||
response.status(400).json({ error: 'Missing required fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('Cashfree credentials not configured');
|
||||
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const hashKey = `hash_${Date.now()}_${uid.substring(0, 1)}_${orderId}`;
|
||||
let apiUrl = process.env.CASHFREE_URL;
|
||||
try {
|
||||
const cashfreeResponse = await axios.post(
|
||||
apiUrl!,
|
||||
{
|
||||
order_id: orderId,
|
||||
hash_key: hashKey,
|
||||
order_amount: amount,
|
||||
order_currency: 'INR',
|
||||
customer_details: {
|
||||
customer_id: uid,
|
||||
customer_name: customerName || 'Fitlien User',
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customerPhone
|
||||
},
|
||||
order_meta: {
|
||||
return_url: `https://${webHostUrl}?order_id=${orderId}&hash_key=${hashKey}&user_id=${userId}&gym_id=${gymId}#/payment-status-screen`,
|
||||
},
|
||||
order_note: productInfo || 'Fitlien Membership'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-api-version': '2022-09-01',
|
||||
'x-client-id': clientId,
|
||||
'x-client-secret': clientSecret,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await admin.firestore().collection('payment_orders').doc(orderId).set({
|
||||
userId: uid,
|
||||
amount: amount,
|
||||
customerEmail: customerEmail,
|
||||
customerPhone: customerPhone,
|
||||
orderStatus: 'CREATED',
|
||||
paymentGateway: 'Cashfree',
|
||||
createdAt: new Date(),
|
||||
hashKey: hashKey,
|
||||
clientId: userId,
|
||||
gymId: gymId,
|
||||
orderId: orderId,
|
||||
...cashfreeResponse.data
|
||||
});
|
||||
} catch (firestoreError) {
|
||||
logger.error('Error storing order in Firestore:', firestoreError);
|
||||
}
|
||||
|
||||
response.json({
|
||||
success: true,
|
||||
order_id: cashfreeResponse.data.order_id,
|
||||
payment_session_id: cashfreeResponse.data.payment_session_id
|
||||
});
|
||||
|
||||
logger.info(`Cashfree order created: ${orderId}`);
|
||||
} catch (axiosError: any) {
|
||||
logger.error('Cashfree API error:', axiosError);
|
||||
response.status(axiosError.response?.status || 500).json({
|
||||
success: false,
|
||||
error: 'Payment gateway error',
|
||||
details: axiosError.response?.data || axiosError.message,
|
||||
code: axiosError.code
|
||||
});
|
||||
}
|
||||
} catch (authError) {
|
||||
logger.error('Authentication error:', authError);
|
||||
response.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid authentication token'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Cashfree order creation error:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create payment order',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const createCashfreeLink = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.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 {
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
const uid = decodedToken.uid;
|
||||
|
||||
const {
|
||||
amount,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
productInfo,
|
||||
userId,
|
||||
gymId,
|
||||
orderId
|
||||
} = request.body;
|
||||
|
||||
if (!amount || !customerEmail || !customerPhone) {
|
||||
response.status(400).json({ error: 'Missing required fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
const expirationDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const expirationString = expirationDate.toISOString();
|
||||
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||
let apiUrl = process.env.CASHFREE_LINK_URL;
|
||||
console.log(`API URL: ${apiUrl}`);
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('Cashfree credentials not configured');
|
||||
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const linkId = uuidv4();
|
||||
try {
|
||||
const requestHeqaders = {
|
||||
'x-client-id': clientId,
|
||||
'x-client-secret': clientSecret,
|
||||
'x-api-version': '2025-01-01',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
console.log(`Header: ${JSON.stringify(requestHeqaders)}`);
|
||||
const requestBody = {
|
||||
"link_id": linkId,
|
||||
"link_amount": amount,
|
||||
"link_currency": "INR",
|
||||
"link_purpose": productInfo,
|
||||
"customer_details": {
|
||||
"customer_phone": customerPhone,
|
||||
"customer_email": customerEmail,
|
||||
"customer_name": customerName,
|
||||
},
|
||||
"link_partial_payments": false,
|
||||
"link_notify": {
|
||||
"send_sms": true,
|
||||
"send_email": true
|
||||
},
|
||||
"link_expiry_time": expirationString,
|
||||
"link_notes": {
|
||||
"order_id": orderId,
|
||||
"gym_id": gymId,
|
||||
"user_id": userId
|
||||
}
|
||||
};
|
||||
console.log(`Body: ${JSON.stringify(requestBody)}`);
|
||||
const cashfreeResponse = await axios.post(apiUrl!, requestBody, {
|
||||
headers: requestHeqaders
|
||||
});
|
||||
|
||||
try {
|
||||
await admin.firestore().collection('payment_links').doc(orderId).set({
|
||||
requestUserId: uid,
|
||||
amount: amount,
|
||||
customerEmail: customerEmail,
|
||||
customerPhone: customerPhone,
|
||||
userId: userId,
|
||||
gymId: gymId,
|
||||
orderId: orderId,
|
||||
...cashfreeResponse.data,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (firestoreError) {
|
||||
logger.error('Error storing order in Firestore:', firestoreError);
|
||||
}
|
||||
|
||||
response.json({
|
||||
success: true,
|
||||
linkId: linkId,
|
||||
linkUrl: cashfreeResponse.data.link_url,
|
||||
linkExpiryTime: cashfreeResponse.data.link_expiry_time,
|
||||
linkStatus: cashfreeResponse.data.link_status,
|
||||
linkQRCode: cashfreeResponse.data.link_qrcode
|
||||
});
|
||||
|
||||
} catch (axiosError: any) {
|
||||
logger.error('Cashfree API error:', axiosError);
|
||||
response.status(axiosError.response?.status || 500).json({
|
||||
success: false,
|
||||
error: 'Payment gateway error',
|
||||
details: axiosError.response?.data || axiosError.message,
|
||||
code: axiosError.code
|
||||
});
|
||||
}
|
||||
} catch (authError) {
|
||||
logger.error('Authentication error:', authError);
|
||||
response.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid authentication token'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Cashfree order creation error:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create payment order',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
export const verifyCashfreePayment = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
|
||||
try {
|
||||
const orderId = request.body.order_id || request.query.order_id;
|
||||
|
||||
if (!orderId) {
|
||||
response.status(400).json({ error: 'Order ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||
|
||||
const apiUrl = process.env.CASHFREE_URL;
|
||||
|
||||
const cashfreeResponse = await axios.get(
|
||||
apiUrl!,
|
||||
{
|
||||
headers: {
|
||||
'x-api-version': '2022-09-01',
|
||||
'x-client-id': clientId,
|
||||
'x-client-secret': clientSecret
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await admin.firestore().collection('payment_orders').doc(orderId).update({
|
||||
orderStatus: cashfreeResponse.data.order_status,
|
||||
paymentDetails: cashfreeResponse.data,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
if (request.headers['x-webhook-source'] === 'cashfree') {
|
||||
response.status(200).send('OK');
|
||||
return;
|
||||
}
|
||||
|
||||
response.json({
|
||||
status: cashfreeResponse.data.order_status,
|
||||
paymentDetails: cashfreeResponse.data
|
||||
});
|
||||
|
||||
logger.info(`Cashfree payment verified: ${orderId}`);
|
||||
} catch (error: any) {
|
||||
logger.error('Cashfree payment verification error:', error);
|
||||
response.status(500).json({
|
||||
error: 'Failed to verify payment status',
|
||||
details: error.response?.data || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const getPlacesAutocomplete = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const { input, location, radius, types, components, sessiontoken } = request.query;
|
||||
|
||||
if (!input) {
|
||||
response.status(400).json({
|
||||
error: 'Input parameter is required for autocomplete'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.error('Google Places API key is not configured');
|
||||
response.status(500).json({ error: 'Server configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = 'https://maps.googleapis.com/maps/api/place/autocomplete/json';
|
||||
const params: any = {
|
||||
key: apiKey,
|
||||
input: input
|
||||
};
|
||||
|
||||
if (location && radius) {
|
||||
params.location = location;
|
||||
params.radius = radius;
|
||||
}
|
||||
|
||||
if (types) {
|
||||
params.types = types;
|
||||
}
|
||||
|
||||
if (components) {
|
||||
params.components = components;
|
||||
}
|
||||
|
||||
if (sessiontoken) {
|
||||
params.sessiontoken = sessiontoken;
|
||||
}
|
||||
|
||||
const result = await axios.get(url, { params });
|
||||
|
||||
logger.info('Google Places Autocomplete API request completed successfully');
|
||||
response.json(result.data);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching place autocomplete suggestions:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const getPlaceDetails = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const { place_id, fields } = request.query;
|
||||
|
||||
if (!place_id) {
|
||||
response.status(400).json({
|
||||
error: 'place_id parameter is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.error('Google Places API key is not configured');
|
||||
response.status(500).json({ error: 'Server configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = 'https://maps.googleapis.com/maps/api/place/details/json';
|
||||
const params: any = {
|
||||
key: apiKey,
|
||||
place_id: place_id,
|
||||
fields: fields || 'geometry'
|
||||
};
|
||||
|
||||
const result = await axios.get(url, { params });
|
||||
logger.info('Google Places Details API request completed successfully');
|
||||
response.json(result.data);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching place details:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export { processNotificationOnCreate } from './processNotification';
|
||||
@ -1,222 +0,0 @@
|
||||
import { onDocumentCreated } from "firebase-functions/v2/firestore";
|
||||
import { getLogger } from "../shared/config";
|
||||
import { getAdmin } from "../shared/config";
|
||||
import * as admin from 'firebase-admin';
|
||||
|
||||
const app = getAdmin();
|
||||
const logger = getLogger();
|
||||
|
||||
interface NotificationData {
|
||||
notificationSent?: boolean;
|
||||
userId?: string;
|
||||
clientId?: string;
|
||||
invitorId?: string;
|
||||
phoneNumber?: string;
|
||||
message?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
gymName?: string;
|
||||
trainerName?: string;
|
||||
membershipId?: string;
|
||||
subscriptionName?: string;
|
||||
name?: string;
|
||||
clientEmail?: string;
|
||||
invitationId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const processNotificationOnCreate = onDocumentCreated({
|
||||
region: '#{SERVICES_RGN}#',
|
||||
document: 'notifications/{notificationId}'
|
||||
}, async (event) => {
|
||||
try {
|
||||
const notificationSnapshot = event.data;
|
||||
const notificationId = event.params.notificationId;
|
||||
|
||||
if (!notificationSnapshot) {
|
||||
logger.error(`No data found for notification ${notificationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = notificationSnapshot.data() as NotificationData;
|
||||
if (notification.notificationSent === true) {
|
||||
logger.info(`Notification ${notificationId} already sent, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { fcmToken } = await getUserAndFCMToken(notification);
|
||||
if (!fcmToken) {
|
||||
logger.error(`FCM token not found for notification ${notificationId}`);
|
||||
await updateNotificationWithError(notificationId, 'FCM token not found for user');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = prepareNotificationMessage(notification, fcmToken);
|
||||
try {
|
||||
const fcmResponse = await app.messaging().send({
|
||||
...message,
|
||||
token: fcmToken
|
||||
});
|
||||
|
||||
logger.info(`FCM notification sent successfully: ${fcmResponse}`);
|
||||
await markNotificationAsSent(notificationId);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Error sending notification ${notificationId}:`, error);
|
||||
await updateNotificationWithError(notificationId, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing notification:', error);
|
||||
}
|
||||
});
|
||||
|
||||
async function getUserAndFCMToken(notification: NotificationData): Promise<{ userId: string | null; fcmToken: string | null }> {
|
||||
let userId: string | null = null;
|
||||
let fcmToken: string | null = null;
|
||||
|
||||
if (notification.userId) {
|
||||
userId = notification.userId;
|
||||
fcmToken = await getFCMTokenFromUserDoc(userId);
|
||||
} else if (notification.clientId) {
|
||||
userId = notification.clientId;
|
||||
fcmToken = await getFCMTokenFromUserDoc(userId);
|
||||
} else if (notification.invitorId) {
|
||||
userId = notification.invitorId;
|
||||
fcmToken = await getFCMTokenFromUserDoc(userId);
|
||||
} else if (notification.phoneNumber) {
|
||||
const userQuery = await app
|
||||
.firestore()
|
||||
.collection('users')
|
||||
.where('phoneNumber', '==', notification.phoneNumber)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (!userQuery.empty) {
|
||||
const userDoc = userQuery.docs[0];
|
||||
userId = userDoc.id;
|
||||
fcmToken = userDoc.data()?.fcmToken;
|
||||
}
|
||||
}
|
||||
|
||||
return { userId, fcmToken };
|
||||
}
|
||||
|
||||
async function getFCMTokenFromUserDoc(userId: string): Promise<string | null> {
|
||||
const userDoc = await app.firestore().collection('users').doc(userId).get();
|
||||
return userDoc.exists ? userDoc.data()?.fcmToken : null;
|
||||
}
|
||||
|
||||
function prepareNotificationMessage(notification: NotificationData, fcmToken: string): admin.messaging.Message {
|
||||
let title = 'New Notification';
|
||||
let body = notification.message || 'You have a new notification';
|
||||
let data: Record<string, string> = {
|
||||
type: notification.type || 'general',
|
||||
};
|
||||
|
||||
switch (notification.type) {
|
||||
case 'day_pass_entry':
|
||||
const isAccepted = notification.status === 'ACCEPTED';
|
||||
title = isAccepted ? 'Day Pass Approved' : 'Day Pass Denied';
|
||||
body = notification.message || (isAccepted ?
|
||||
'Your day pass has been approved' :
|
||||
'Your day pass has been denied');
|
||||
data.gymName = notification.gymName || '';
|
||||
break;
|
||||
|
||||
case 'trainer_assigned_to_client':
|
||||
title = 'Trainer Assigned';
|
||||
body = notification.message || `${notification.trainerName} has been assigned as your trainer`;
|
||||
data.trainerName = notification.trainerName || '';
|
||||
data.membershipId = notification.membershipId || '';
|
||||
break;
|
||||
|
||||
case 'client_invitations':
|
||||
if (notification.userId || notification.invitorId) {
|
||||
const isAccept = notification.status === 'ACCEPTED';
|
||||
title = isAccept ? 'Invitation Accepted' : 'Invitation Rejected';
|
||||
body = notification.message || (isAccept ?
|
||||
`The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been accepted` :
|
||||
`The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been rejected`);
|
||||
} else if (notification.phoneNumber) {
|
||||
const invitationStatus = getInvitationStatus(notification.status);
|
||||
title = getInvitationTitle(invitationStatus);
|
||||
body = notification.message || getInvitationBody(invitationStatus, notification.name);
|
||||
data.status = invitationStatus;
|
||||
}
|
||||
data.gymName = notification.gymName || '';
|
||||
data.clientEmail = notification.clientEmail || '';
|
||||
data.clientName = notification.name || '';
|
||||
data.invitationId = notification.invitationId || '';
|
||||
data.subscriptionName = notification.subscriptionName || '';
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.info(`Using default handling for notification type: ${notification.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const notificationMessage: admin.messaging.Message = {
|
||||
notification: { title, body },
|
||||
data,
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
channelId: 'notifications_channel',
|
||||
priority: 'high',
|
||||
defaultSound: true,
|
||||
defaultVibrateTimings: true,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
|
||||
},
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
token: fcmToken,
|
||||
};
|
||||
return notificationMessage;
|
||||
}
|
||||
|
||||
function getInvitationStatus(status?: string): string {
|
||||
if (status === 'ACCEPTED') return 'accepted';
|
||||
if (status === 'REJECTED') return 'rejected';
|
||||
if (status === 'PENDING') return 'pending';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getInvitationTitle(status: string): string {
|
||||
switch (status) {
|
||||
case 'accepted': return 'Invitation Accepted';
|
||||
case 'rejected': return 'Invitation Rejected';
|
||||
case 'pending': return 'New Invitation';
|
||||
default: return 'Invitation Update';
|
||||
}
|
||||
}
|
||||
|
||||
function getInvitationBody(status: string, name?: string): string {
|
||||
switch (status) {
|
||||
case 'accepted': return `You have accepted the invitation from ${name}`;
|
||||
case 'rejected': return `You have rejected the invitation from ${name}`;
|
||||
case 'pending': return `You have a new invitation pending from ${name}`;
|
||||
default: return 'There is an update to your invitation';
|
||||
}
|
||||
}
|
||||
|
||||
async function markNotificationAsSent(notificationId: string): Promise<void> {
|
||||
await app.firestore().collection('notifications').doc(notificationId).update({
|
||||
notificationSent: true,
|
||||
sentAt: app.firestore.FieldValue.serverTimestamp()
|
||||
});
|
||||
}
|
||||
|
||||
async function updateNotificationWithError(notificationId: string, error: string): Promise<void> {
|
||||
await app.firestore().collection('notifications').doc(notificationId).update({
|
||||
notificationError: error,
|
||||
updatedAt: app.firestore.FieldValue.serverTimestamp()
|
||||
});
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
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 axios from "axios";
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const corsHandler = getCorsHandler();
|
||||
const admin = getAdmin();
|
||||
const logger = getLogger();
|
||||
|
||||
interface CashfreeLinkRequest {
|
||||
amount: number;
|
||||
customerName?: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
productInfo?: string;
|
||||
userId?: string;
|
||||
gymId?: string;
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
interface CashfreeLinkResponse {
|
||||
link_id: string;
|
||||
link_url: string;
|
||||
link_expiry_time: string;
|
||||
link_status: string;
|
||||
link_qrcode: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const createCashfreeLink = 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];
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
const uid = decodedToken.uid;
|
||||
|
||||
const linkRequest = request.body as CashfreeLinkRequest;
|
||||
if (!linkRequest.amount || !linkRequest.customerEmail || !linkRequest.customerPhone) {
|
||||
response.status(400).json({ error: 'Missing required fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('Cashfree credentials not configured');
|
||||
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const expirationDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const expirationString = expirationDate.toISOString();
|
||||
const apiUrl = process.env.CASHFREE_LINK_URL;
|
||||
const linkId = uuidv4();
|
||||
|
||||
const requestHeaders = {
|
||||
'x-client-id': clientId,
|
||||
'x-client-secret': clientSecret,
|
||||
'x-api-version': '2025-01-01',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const requestBody = {
|
||||
link_id: linkId,
|
||||
link_amount: linkRequest.amount,
|
||||
link_currency: "INR",
|
||||
link_purpose: linkRequest.productInfo,
|
||||
customer_details: {
|
||||
customer_phone: linkRequest.customerPhone,
|
||||
customer_email: linkRequest.customerEmail,
|
||||
customer_name: linkRequest.customerName,
|
||||
},
|
||||
link_partial_payments: false,
|
||||
link_notify: {
|
||||
send_sms: true,
|
||||
send_email: true
|
||||
},
|
||||
link_expiry_time: expirationString,
|
||||
link_notes: {
|
||||
order_id: linkRequest.orderId,
|
||||
gym_id: linkRequest.gymId,
|
||||
user_id: linkRequest.userId
|
||||
}
|
||||
};
|
||||
|
||||
const cashfreeResponse = await axios.post<CashfreeLinkResponse>(
|
||||
apiUrl!,
|
||||
requestBody,
|
||||
{ headers: requestHeaders }
|
||||
);
|
||||
|
||||
await admin.firestore().collection('payment_links').doc(linkRequest.orderId).set({
|
||||
requestUserId: uid,
|
||||
amount: linkRequest.amount,
|
||||
customerEmail: linkRequest.customerEmail,
|
||||
customerPhone: linkRequest.customerPhone,
|
||||
userId: linkRequest.userId,
|
||||
gymId: linkRequest.gymId,
|
||||
orderId: linkRequest.orderId,
|
||||
...cashfreeResponse.data,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
response.json({
|
||||
success: true,
|
||||
linkId: linkId,
|
||||
linkUrl: cashfreeResponse.data.link_url,
|
||||
linkExpiryTime: cashfreeResponse.data.link_expiry_time,
|
||||
linkStatus: cashfreeResponse.data.link_status,
|
||||
linkQRCode: cashfreeResponse.data.link_qrcode
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Cashfree link creation error:', error);
|
||||
const statusCode = error.response?.status || 500;
|
||||
response.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'Failed to create payment link',
|
||||
details: error.response?.data || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,118 +0,0 @@
|
||||
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 axios from "axios";
|
||||
const admin = getAdmin();
|
||||
const logger = getLogger();
|
||||
const corsHandler = getCorsHandler();
|
||||
interface CashfreeOrderRequest {
|
||||
amount: number;
|
||||
customerName?: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
productInfo?: string;
|
||||
userId?: string;
|
||||
gymId?: string;
|
||||
orderId: string;
|
||||
webHostUrl: string;
|
||||
}
|
||||
|
||||
interface CashfreeOrderResponse {
|
||||
order_id: string;
|
||||
payment_session_id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const createCashfreeOrder = 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];
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
const uid = decodedToken.uid;
|
||||
|
||||
const orderRequest = request.body as CashfreeOrderRequest;
|
||||
if (!orderRequest.amount || !orderRequest.customerEmail || !orderRequest.customerPhone) {
|
||||
response.status(400).json({ error: 'Missing required fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('Cashfree credentials not configured');
|
||||
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const hashKey = `hash_${Date.now()}_${uid.substring(0, 1)}_${orderRequest.orderId}`;
|
||||
const apiUrl = process.env.CASHFREE_URL;
|
||||
|
||||
const cashfreeResponse = await axios.post<CashfreeOrderResponse>(
|
||||
apiUrl!,
|
||||
{
|
||||
order_id: orderRequest.orderId,
|
||||
hash_key: hashKey,
|
||||
order_amount: orderRequest.amount,
|
||||
order_currency: 'INR',
|
||||
customer_details: {
|
||||
customer_id: uid,
|
||||
customer_name: orderRequest.customerName || 'Fitlien User',
|
||||
customer_email: orderRequest.customerEmail,
|
||||
customer_phone: orderRequest.customerPhone
|
||||
},
|
||||
order_meta: {
|
||||
return_url: `https://${orderRequest.webHostUrl}?order_id=${orderRequest.orderId}&hash_key=${hashKey}&user_id=${orderRequest.userId}&gym_id=${orderRequest.gymId}#/payment-status-screen`,
|
||||
},
|
||||
order_note: orderRequest.productInfo || 'Fitlien Membership'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-api-version': '2022-09-01',
|
||||
'x-client-id': clientId,
|
||||
'x-client-secret': clientSecret,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await admin.firestore().collection('payment_orders').doc(orderRequest.orderId).set({
|
||||
userId: uid,
|
||||
amount: orderRequest.amount,
|
||||
customerEmail: orderRequest.customerEmail,
|
||||
customerPhone: orderRequest.customerPhone,
|
||||
orderStatus: 'CREATED',
|
||||
paymentGateway: 'Cashfree',
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
hashKey: hashKey,
|
||||
clientId: orderRequest.userId,
|
||||
gymId: orderRequest.gymId,
|
||||
orderId: orderRequest.orderId,
|
||||
...cashfreeResponse.data
|
||||
});
|
||||
|
||||
response.json({
|
||||
success: true,
|
||||
order_id: cashfreeResponse.data.order_id,
|
||||
payment_session_id: cashfreeResponse.data.payment_session_id
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Cashfree order creation error:', error);
|
||||
const statusCode = error.response?.status || 500;
|
||||
response.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'Failed to create payment order',
|
||||
details: error.response?.data || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,3 +0,0 @@
|
||||
export { createCashfreeLink } from './createLink';
|
||||
export { verifyCashfreePayment } from './verifyPayment';
|
||||
export { createCashfreeOrder } from './createOrder';
|
||||
@ -1,65 +0,0 @@
|
||||
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 axios from "axios";
|
||||
const logger = getLogger();
|
||||
const corsHandler = getCorsHandler();
|
||||
const admin = getAdmin();
|
||||
interface CashfreePaymentResponse {
|
||||
order_status: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const verifyCashfreePayment = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const orderId = request.body.order_id || request.query.order_id;
|
||||
if (!orderId) {
|
||||
response.status(400).json({ error: 'Order ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||
const apiUrl = `${process.env.CASHFREE_URL}/${orderId}`;
|
||||
|
||||
const cashfreeResponse = await axios.get<CashfreePaymentResponse>(
|
||||
apiUrl!,
|
||||
{
|
||||
headers: {
|
||||
'x-api-version': '2022-09-01',
|
||||
'x-client-id': clientId,
|
||||
'x-client-secret': clientSecret
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await admin.firestore().collection('payment_orders').doc(orderId).update({
|
||||
orderStatus: cashfreeResponse.data.order_status,
|
||||
paymentDetails: cashfreeResponse.data,
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp()
|
||||
});
|
||||
|
||||
if (request.headers['x-webhook-source'] === 'cashfree') {
|
||||
response.status(200).send('OK');
|
||||
return;
|
||||
}
|
||||
|
||||
response.json({
|
||||
status: cashfreeResponse.data.order_status,
|
||||
paymentDetails: cashfreeResponse.data
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Cashfree payment verification error:', error);
|
||||
const statusCode = error.response?.status || 500;
|
||||
response.status(statusCode).json({
|
||||
error: 'Failed to verify payment status',
|
||||
details: error.response?.data || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,2 +0,0 @@
|
||||
export * from './cashfree';
|
||||
export * from './phonepe';
|
||||
@ -1,257 +0,0 @@
|
||||
import axios from "axios";
|
||||
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}#'
|
||||
}, 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 merchantOrderId = request.query.merchantOrderId as string;
|
||||
if (!merchantOrderId) {
|
||||
response.status(400).json({ error: 'Missing merchant order ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const details = request.query.details === 'true';
|
||||
const errorContext = request.query.errorContext === 'true';
|
||||
|
||||
const clientId = process.env.PHONEPE_CLIENT_ID;
|
||||
const clientSecret = process.env.PHONEPE_CLIENT_SECRET;
|
||||
const apiUrl = process.env.PHONEPE_API_URL;
|
||||
const clientVersion = process.env.PHONEPE_CLIENT_VERSION || '1';
|
||||
|
||||
if (!clientId || !clientSecret || !apiUrl) {
|
||||
logger.error('PhonePe credentials not configured');
|
||||
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenResponse = await axios.post(
|
||||
`${apiUrl}/v1/oauth/token`,
|
||||
{
|
||||
client_id: clientId,
|
||||
client_version: clientVersion,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const accessToken = tokenResponse.data.access_token;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (details) queryParams.append('details', 'true');
|
||||
if (errorContext) queryParams.append('errorContext', 'true');
|
||||
const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
|
||||
|
||||
const statusResponse = await axios.get(
|
||||
`${apiUrl}/checkout/v2/order/${merchantOrderId}/status${queryString}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `O-Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const orderQuery = await admin.firestore()
|
||||
.collection('payment_orders')
|
||||
.where('merchantOrderId', '==', merchantOrderId)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
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}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const orderDoc = orderQuery.docs[0];
|
||||
const orderData = orderDoc.data();
|
||||
|
||||
await orderDoc.ref.update({
|
||||
orderStatus: statusResponse.data.state || 'UNKNOWN',
|
||||
lastChecked: new Date(),
|
||||
statusResponse: 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
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (authError.response) {
|
||||
logger.error('API error details:', {
|
||||
status: authError.response.status,
|
||||
data: authError.response.data
|
||||
});
|
||||
|
||||
response.status(authError.response.status).json({
|
||||
success: false,
|
||||
error: 'API error',
|
||||
details: authError.response.data
|
||||
});
|
||||
} else {
|
||||
response.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid authentication token or API error',
|
||||
message: authError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('PhonePe payment status check error:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to check payment status',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,133 +0,0 @@
|
||||
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 axios from "axios";
|
||||
const admin = getAdmin();
|
||||
const logger = getLogger();
|
||||
const corsHandler = getCorsHandler();
|
||||
|
||||
export const createPhonePeOrder = 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 {
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
const uid = decodedToken.uid;
|
||||
|
||||
const {
|
||||
merchantOrderId,
|
||||
amount,
|
||||
expireAfter,
|
||||
metaInfo,
|
||||
paymentFlow
|
||||
} = request.body;
|
||||
|
||||
if (!merchantOrderId || !amount || !paymentFlow || !expireAfter) {
|
||||
response.status(400).json({ error: 'Missing required fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentFlow.type || !paymentFlow.merchantUrls || !paymentFlow.merchantUrls.redirectUrl) {
|
||||
response.status(400).json({ error: 'Invalid payment flow configuration' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = process.env.PHONEPE_CLIENT_ID;
|
||||
const clientSecret = process.env.PHONEPE_CLIENT_SECRET;
|
||||
const apiUrl = process.env.PHONEPE_API_URL;
|
||||
const clientVersion = process.env.PHONEPE_CLIENT_VERSION || '1';
|
||||
|
||||
if (!clientId || !clientSecret || !apiUrl) {
|
||||
logger.error('PhonePe credentials not configured');
|
||||
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await axios.post(
|
||||
`${apiUrl}/v1/oauth/token`,
|
||||
{
|
||||
client_id: clientId,
|
||||
client_version: clientVersion,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'client_credentials',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const accessToken = tokenResponse.data.access_token;
|
||||
|
||||
const paymentResponse = await axios.post(
|
||||
`${apiUrl}/checkout/v2/pay`,
|
||||
request.body,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `O-Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await admin.firestore().collection('payment_orders').doc(merchantOrderId).set({
|
||||
userId: uid,
|
||||
amount: amount / 100,
|
||||
orderStatus: paymentResponse.data.state || 'PENDING',
|
||||
paymentGateway: 'PhonePe',
|
||||
createdAt: new Date(),
|
||||
merchantOrderId: merchantOrderId,
|
||||
paymentUrl: paymentResponse.data.redirectUrl,
|
||||
orderId: paymentResponse.data.orderId,
|
||||
expireAt: new Date(paymentResponse.data.expireAt),
|
||||
// rawResponse: paymentResponse.data,
|
||||
metaInfo: metaInfo || {}
|
||||
});
|
||||
} catch (firestoreError) {
|
||||
logger.error('Error storing order in Firestore:', firestoreError);
|
||||
}
|
||||
|
||||
response.json({
|
||||
...paymentResponse.data,
|
||||
merchantOrderId: merchantOrderId
|
||||
});
|
||||
|
||||
logger.info(`PhonePe order created: ${merchantOrderId}`);
|
||||
} catch (apiError: any) {
|
||||
logger.error('PhonePe API error:', apiError);
|
||||
response.status(apiError.response?.status || 500).json({
|
||||
success: false,
|
||||
error: 'Payment gateway error',
|
||||
details: apiError.response?.data || apiError.message,
|
||||
code: apiError.code
|
||||
});
|
||||
}
|
||||
} catch (authError) {
|
||||
logger.error('Authentication error:', authError);
|
||||
response.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid authentication token'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('PhonePe order creation error:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create payment order',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,5 +0,0 @@
|
||||
export { createPhonePeOrder } from './createPhonepeOrder';
|
||||
export { checkPhonePePaymentStatus } from './checkStatus';
|
||||
export { phonePeWebhook } from './webhook';
|
||||
export { updatePaymentDataAfterSuccess } from './paymentData';
|
||||
export * from './invoice';
|
||||
@ -1,104 +0,0 @@
|
||||
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'
|
||||
};
|
||||
|
||||
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||
const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,62 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,13 +0,0 @@
|
||||
import { getInvoiceUrl } from './getInvoiceUrl';
|
||||
import { InvoiceService } from './invoiceService';
|
||||
import { processInvoice } from './processInvoice';
|
||||
import { sendInvoiceEmail } from './sendInvoiceEmail';
|
||||
import { directGenerateInvoice } from './directInvoice';
|
||||
|
||||
export {
|
||||
getInvoiceUrl,
|
||||
InvoiceService,
|
||||
processInvoice,
|
||||
sendInvoiceEmail,
|
||||
directGenerateInvoice,
|
||||
};
|
||||
@ -1,386 +0,0 @@
|
||||
import { getAdmin, getLogger } from "../../../shared/config";
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { format } from 'date-fns';
|
||||
import { sendEmailWithAttachmentUtil } from "../../../utils/emailService";
|
||||
import { jsPDF } from "jspdf";
|
||||
import autoTable from 'jspdf-autotable';
|
||||
const admin = getAdmin();
|
||||
const logger = getLogger();
|
||||
|
||||
export interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
businessName: string;
|
||||
address: string;
|
||||
gstNumber?: string;
|
||||
customerName: string;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
planName: string;
|
||||
amount: number;
|
||||
transactionId: string;
|
||||
paymentDate: Date;
|
||||
paymentMethod: string;
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
recipientEmail: string;
|
||||
recipientName?: string;
|
||||
subject?: string;
|
||||
customHtml?: string;
|
||||
additionalData?: {
|
||||
gymName?: string;
|
||||
planName?: string;
|
||||
amount?: number;
|
||||
transactionId?: string;
|
||||
paymentDate?: Date;
|
||||
paymentMethod?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class InvoiceService {
|
||||
async generateInvoice(data: InvoiceData): Promise<string> {
|
||||
try {
|
||||
const tempFilePath = path.join(os.tmpdir(), `invoice_${data.invoiceNumber}.pdf`);
|
||||
|
||||
const hasGst = data.gstNumber && data.gstNumber.length > 0;
|
||||
const baseAmount = hasGst ? data.amount / 1.18 : data.amount;
|
||||
const sgst = hasGst ? baseAmount * 0.09 : 0;
|
||||
const cgst = hasGst ? baseAmount * 0.09 : 0;
|
||||
|
||||
const formattedDate = format(data.paymentDate, 'dd/MM/yyyy');
|
||||
const doc = new jsPDF();
|
||||
|
||||
doc.setFontSize(20);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(data.businessName, 15, 20);
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
const maxWidth = 170;
|
||||
const lineHeight = 5;
|
||||
|
||||
const addressLines = doc.splitTextToSize(data.address, maxWidth);
|
||||
|
||||
if (addressLines.length <= 2) {
|
||||
for (let i = 0; i < addressLines.length; i++) {
|
||||
doc.text(addressLines[i], 15, 30 + (i * lineHeight));
|
||||
}
|
||||
} else {
|
||||
doc.text(addressLines[0], 15, 30);
|
||||
|
||||
let secondLine = addressLines[1];
|
||||
if (secondLine.length > 3) {
|
||||
secondLine = secondLine.substring(0, secondLine.length - 3) + '...';
|
||||
} else {
|
||||
secondLine += '...';
|
||||
}
|
||||
doc.text(secondLine, 15, 35);
|
||||
}
|
||||
|
||||
const gstYPosition = 40;
|
||||
|
||||
if (hasGst) {
|
||||
doc.text(`GSTIN: ${data.gstNumber}`, 20, gstYPosition);
|
||||
}
|
||||
|
||||
doc.setFontSize(24);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('RECEIPT', 195, 20, { align: 'right' });
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.text(`Receipt #: ${data.invoiceNumber}`, 195, 30, { align: 'right' });
|
||||
doc.text(`Date: ${formattedDate}`, 195, 40, { align: 'right' });
|
||||
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(15, 45, 195, 45);
|
||||
|
||||
doc.setFontSize(12);
|
||||
const receiptToBoxX = 15;
|
||||
const receiptToBoxY = 50;
|
||||
const receiptToBoxWidth = 100;
|
||||
const receiptToBoxHeight = 36;
|
||||
|
||||
doc.setDrawColor(0, 0, 0);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(receiptToBoxX, receiptToBoxY, receiptToBoxWidth, receiptToBoxHeight);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Receipt To:', 18, 60);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(data.customerName, 18, 70);
|
||||
doc.text(`Phone: ${data.phoneNumber}`, 18, 75);
|
||||
doc.text(`Email: ${data.email}`, 18, 80);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: 110,
|
||||
margin: {left: 15, right: 15},
|
||||
head: [
|
||||
[
|
||||
{content: 'No.', styles: {halign: 'center'}},
|
||||
{content: 'Description', styles: {halign: 'left'}},
|
||||
{content: 'HSN/SAC', styles: {halign: 'center'}},
|
||||
{content: 'Amount (INR)', styles: {halign: 'right'}}
|
||||
]
|
||||
],
|
||||
body: [
|
||||
['1', `${data.planName} Subscription`, '999723', baseAmount.toFixed(2)]
|
||||
],
|
||||
headStyles: {
|
||||
fillColor: [220, 220, 220],
|
||||
textColor: [0, 0, 0],
|
||||
fontStyle: 'bold',
|
||||
lineWidth: 0.5,
|
||||
lineColor: [0, 0, 0]
|
||||
},
|
||||
styles: {
|
||||
halign: 'center',
|
||||
lineWidth: 0.5,
|
||||
lineColor: [0, 0, 0]
|
||||
},
|
||||
columnStyles: {
|
||||
0: { halign: 'center', cellWidth: 20 },
|
||||
1: { halign: 'left' },
|
||||
2: { halign: 'center', cellWidth: 40 },
|
||||
3: { halign: 'right', cellWidth: 40 }
|
||||
},
|
||||
theme: 'grid',
|
||||
tableLineWidth: 0.5,
|
||||
tableLineColor: [0, 0, 0],
|
||||
|
||||
});
|
||||
|
||||
const finalY = (doc as any).lastAutoTable.finalY + 20;
|
||||
|
||||
if (hasGst) {
|
||||
doc.text('Taxable Amount:', 150, finalY, { align: 'right' });
|
||||
doc.text(`${baseAmount.toFixed(2)} INR`, 195, finalY, { align: 'right' });
|
||||
|
||||
doc.text('SGST (9%):', 150, finalY + 10, { align: 'right' });
|
||||
doc.text(`${sgst.toFixed(2)} INR`, 195, finalY + 10, { align: 'right' });
|
||||
|
||||
doc.text('CGST (9%):', 150, finalY + 20, { align: 'right' });
|
||||
doc.text(`${cgst.toFixed(2)} INR`, 195, finalY + 20, { align: 'right' });
|
||||
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(15, finalY + 25, 195, finalY + 25);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Total Amount:', 150, finalY + 30, { align: 'right' });
|
||||
doc.text(`${data.amount.toFixed(2)} INR`, 195, finalY + 30, { align: 'right' });
|
||||
} else {
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(15, finalY - 5, 195, finalY - 5);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Total Amount:', 150, finalY, { align: 'right' });
|
||||
doc.text(`${data.amount.toFixed(2)} INR`, 195, finalY, { align: 'right' });
|
||||
}
|
||||
|
||||
const paymentY = hasGst ? finalY + 50 : finalY + 20;
|
||||
|
||||
const boxX = 15;
|
||||
const boxY = paymentY - 10;
|
||||
const boxWidth = 100;
|
||||
const boxHeight = 36;
|
||||
|
||||
doc.setDrawColor(0, 0, 0);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.rect(boxX, boxY, boxWidth, boxHeight);
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Payment Information:', 18, paymentY);
|
||||
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Transaction ID: ${data.transactionId}`, 18, paymentY + 10);
|
||||
doc.text(`Payment Method: ${data.paymentMethod}`, 18, paymentY + 15);
|
||||
doc.text(`Payment Date: ${formattedDate}`, 18, paymentY + 20);
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'italic');
|
||||
doc.text('Thank you for your business!', 105, 270, { align: 'center' });
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('This is a computer-generated receipt and does not require a signature.', 105, 280, { align: 'center' });
|
||||
|
||||
fs.writeFileSync(tempFilePath, Buffer.from(doc.output('arraybuffer')));
|
||||
|
||||
const invoicePath = `invoices/${data.invoiceNumber}.pdf`;
|
||||
const bucket = admin.storage().bucket();
|
||||
await bucket.upload(tempFilePath, {
|
||||
destination: invoicePath,
|
||||
metadata: {
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
});
|
||||
|
||||
fs.unlinkSync(tempFilePath);
|
||||
|
||||
return invoicePath;
|
||||
} catch (error: any) {
|
||||
logger.error('Error generating invoice:', error);
|
||||
throw new Error(`Failed to generate invoice: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getInvoiceDownloadUrl(invoicePath: string): Promise<string> {
|
||||
try {
|
||||
const bucket = admin.storage().bucket();
|
||||
const file = bucket.file(invoicePath);
|
||||
|
||||
const expirationMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const [signedUrl] = await file.getSignedUrl({
|
||||
action: 'read',
|
||||
expires: Date.now() + expirationMs,
|
||||
responseDisposition: `attachment; filename="${path.basename(invoicePath)}"`,
|
||||
});
|
||||
|
||||
return signedUrl;
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting invoice download URL:', error);
|
||||
throw new Error(`Failed to get invoice download URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateInvoicePath(membershipId: string, paymentId: string, invoicePath: string): Promise<boolean> {
|
||||
try {
|
||||
const membershipPaymentsRef = admin.firestore()
|
||||
.collection('membership_payments')
|
||||
.doc(membershipId);
|
||||
|
||||
const docSnapshot = await membershipPaymentsRef.get();
|
||||
|
||||
if (!docSnapshot.exists) {
|
||||
logger.error(`No membership payments found for membershipId: ${membershipId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = docSnapshot.data();
|
||||
const paymentsData = data?.payments || [];
|
||||
|
||||
let found = false;
|
||||
for (let i = 0; i < paymentsData.length; i++) {
|
||||
if (paymentsData[i].referenceNumber === paymentId ||
|
||||
paymentsData[i].transactionId === paymentId) {
|
||||
paymentsData[i].invoicePath = invoicePath;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
logger.error(`No payment found with ID: ${paymentId} in membership: ${membershipId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await membershipPaymentsRef.update({
|
||||
'payments': paymentsData,
|
||||
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
logger.info(`Successfully updated invoice path for payment: ${paymentId}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating payment with invoice path:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendInvoiceEmail(invoicePath: string, emailOptions: EmailOptions): Promise<boolean> {
|
||||
try {
|
||||
const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath);
|
||||
|
||||
const formattedDate = emailOptions.additionalData?.paymentDate
|
||||
? new Date(emailOptions.additionalData.paymentDate).toLocaleDateString('en-GB')
|
||||
: new Date().toLocaleDateString('en-GB');
|
||||
|
||||
const emailHtml = emailOptions.customHtml || `
|
||||
<html>
|
||||
<body>
|
||||
<h2>Thank you for your payment</h2>
|
||||
<p>Dear ${emailOptions.recipientName || 'Valued Customer'},</p>
|
||||
<p>Thank you for your payment. Your membership has been successfully activated.</p>
|
||||
<p>Please find attached your invoice for the payment.</p>
|
||||
<p>Membership Details:</p>
|
||||
<ul>
|
||||
<li>Gym: ${emailOptions.additionalData?.gymName || 'Fitlien'}</li>
|
||||
<li>Plan: ${emailOptions.additionalData?.planName || 'Membership'}</li>
|
||||
<li>Amount: ₹${emailOptions.additionalData?.amount || '0'}</li>
|
||||
<li>Transaction ID: ${emailOptions.additionalData?.transactionId || 'N/A'}</li>
|
||||
<li>Date: ${formattedDate}</li>
|
||||
<li>Payment Method: ${emailOptions.additionalData?.paymentMethod || 'Online'}</li>
|
||||
</ul>
|
||||
<p>If you have any questions, please contact us.</p>
|
||||
<p>Regards,<br>Fitlien Team</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await sendEmailWithAttachmentUtil(
|
||||
emailOptions.recipientEmail,
|
||||
emailOptions.subject || 'Your Fitlien Membership Invoice',
|
||||
emailHtml,
|
||||
downloadUrl,
|
||||
`Invoice_${path.basename(invoicePath)}`
|
||||
);
|
||||
|
||||
logger.info(`Invoice email sent to ${emailOptions.recipientEmail}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
logger.error('Error sending invoice email:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async processInvoice(
|
||||
membershipId: string,
|
||||
paymentId: string,
|
||||
invoiceData: InvoiceData,
|
||||
emailOptions?: EmailOptions
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
invoicePath?: string;
|
||||
downloadUrl?: string;
|
||||
emailSent: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const invoicePath = await this.generateInvoice(invoiceData);
|
||||
|
||||
const updateSuccess = await this.updateInvoicePath(membershipId, paymentId, invoicePath);
|
||||
|
||||
if (!updateSuccess) {
|
||||
return {
|
||||
success: false,
|
||||
invoicePath,
|
||||
emailSent: false,
|
||||
error: 'Failed to update payment with invoice path'
|
||||
};
|
||||
}
|
||||
|
||||
const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath);
|
||||
|
||||
let emailSent = false;
|
||||
if (emailOptions && emailOptions.recipientEmail) {
|
||||
emailSent = await this.sendInvoiceEmail(invoicePath, emailOptions);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invoicePath,
|
||||
downloadUrl,
|
||||
emailSent
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('Error processing invoice:', error);
|
||||
return {
|
||||
success: false,
|
||||
emailSent: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,91 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,122 +0,0 @@
|
||||
import { getAdmin, getLogger } from "../../shared/config";
|
||||
|
||||
const admin = getAdmin();
|
||||
const logger = getLogger();
|
||||
|
||||
interface PaymentData {
|
||||
id: string;
|
||||
date: string;
|
||||
dateTimestamp: FirebaseFirestore.Timestamp;
|
||||
amount: any;
|
||||
paymentMethod: string;
|
||||
referenceNumber: string;
|
||||
discount: any;
|
||||
transactionId: string;
|
||||
createdAt: Date;
|
||||
invoicePath?: string;
|
||||
}
|
||||
|
||||
export async function updatePaymentDataAfterSuccess(
|
||||
merchantOrderId: string,
|
||||
orderId: string,
|
||||
paymentDetails: any,
|
||||
invoicePath?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
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();
|
||||
|
||||
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('-');
|
||||
const dateTimestamp = admin.firestore.Timestamp.now();
|
||||
|
||||
const paymentData: PaymentData = {
|
||||
id: admin.firestore().collection('_').doc().id,
|
||||
date: isoDate,
|
||||
dateTimestamp: dateTimestamp,
|
||||
amount: orderData.amount,
|
||||
paymentMethod: 'Online',
|
||||
referenceNumber: merchantOrderId,
|
||||
discount: orderData.metaInfo?.discount || null,
|
||||
transactionId: orderId,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
if (invoicePath) {
|
||||
paymentData.invoicePath = invoicePath;
|
||||
}
|
||||
|
||||
const membershipPaymentsRef = admin.firestore()
|
||||
.collection('membership_payments')
|
||||
.doc(membershipId);
|
||||
|
||||
const docSnapshot = await membershipPaymentsRef.get();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@ -1,625 +0,0 @@
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialString = `${username}:${password}`;
|
||||
const expectedAuth = crypto
|
||||
.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' });
|
||||
return;
|
||||
}
|
||||
|
||||
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}`, {
|
||||
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({
|
||||
success: false,
|
||||
error: 'Payment order not found'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
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}`);
|
||||
}
|
||||
|
||||
logger.info(`Checking payment state`, {
|
||||
state: payload.state,
|
||||
stateType: typeof payload.state,
|
||||
stateLength: payload.state ? payload.state.length : 0,
|
||||
stateUpperCase: payload.state ? payload.state.toUpperCase() : null,
|
||||
stateComparison: payload.state === 'COMPLETED'
|
||||
});
|
||||
|
||||
if (payload.state && payload.state.trim().toUpperCase() === 'COMPLETED') {
|
||||
try {
|
||||
logger.info(`Starting payment update process for merchantOrderId: ${payload.merchantOrderId}`);
|
||||
|
||||
const orderData = orderDoc.data();
|
||||
const membershipId = orderData.metaInfo?.membershipId;
|
||||
const bookingId = orderData.metaInfo?.bookingId;
|
||||
|
||||
if (bookingId) {
|
||||
await processDayPassBooking(payload, orderData, bookingId);
|
||||
} else if (membershipId) {
|
||||
const paymentUpdateSuccess = await updatePaymentDataAfterSuccess(
|
||||
payload.merchantOrderId,
|
||||
payload.orderId,
|
||||
payload
|
||||
);
|
||||
|
||||
logger.info(`Payment update result for membershipId: ${membershipId}`, {
|
||||
success: paymentUpdateSuccess,
|
||||
orderId: payload.orderId
|
||||
});
|
||||
|
||||
if (paymentUpdateSuccess) {
|
||||
await processMembershipPayment(payload, orderData, membershipId);
|
||||
}
|
||||
} else {
|
||||
logger.error(`No membershipId or bookingId found in metaInfo for order: ${payload.merchantOrderId}`);
|
||||
}
|
||||
|
||||
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({
|
||||
success: false,
|
||||
error: 'Failed to process webhook',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function processDayPassBooking(payload: any, orderData: any, bookingId: string) {
|
||||
try {
|
||||
logger.info(`Processing day pass booking for bookingId: ${bookingId}`);
|
||||
|
||||
const bookingRef = admin.firestore().collection('day_pass_bookings').doc(bookingId);
|
||||
const bookingDoc = await bookingRef.get();
|
||||
|
||||
if (!bookingDoc.exists) {
|
||||
logger.error(`Day pass booking not found for bookingId: ${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await bookingRef.update({
|
||||
status: 'ACCEPTED',
|
||||
paymentDetails: {
|
||||
transactionId: payload.orderId,
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
amount: orderData.amount,
|
||||
paymentDate: new Date(),
|
||||
paymentMethod: 'PhonePe'
|
||||
},
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp()
|
||||
});
|
||||
|
||||
logger.info(`Updated day pass booking status to 'Accepted' for bookingId: ${bookingId}`);
|
||||
|
||||
const bookingData = bookingDoc.data();
|
||||
const gymId = orderData.metaInfo?.gymId || bookingData?.gymId;
|
||||
|
||||
if (gymId) {
|
||||
try {
|
||||
const gymDoc = await admin.firestore().collection('gyms').doc(gymId).get();
|
||||
let gymName = 'Fitlien';
|
||||
let gymAddress = '';
|
||||
let gymOwnerEmail = '';
|
||||
|
||||
if (gymDoc.exists) {
|
||||
const gymData = gymDoc.data();
|
||||
gymName = gymData?.name || 'Fitlien';
|
||||
gymAddress = gymData?.address || '';
|
||||
|
||||
if (gymData?.userId) {
|
||||
const gymOwnerDoc = await admin.firestore()
|
||||
.collection('users')
|
||||
.doc(gymData.userId)
|
||||
.get();
|
||||
|
||||
if (gymOwnerDoc.exists) {
|
||||
const gymOwnerData = gymOwnerDoc.data();
|
||||
gymOwnerEmail = gymOwnerData?.email || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invoiceNumber = `INV-${payload.merchantOrderId.substring(0, 8)}`;
|
||||
|
||||
logger.info(`Generated invoice number for day pass: ${invoiceNumber}`);
|
||||
|
||||
const invoiceData = {
|
||||
invoiceNumber,
|
||||
businessName: gymName,
|
||||
address: gymAddress,
|
||||
gstNumber: orderData.metaInfo?.gstNumber,
|
||||
customerName: orderData.metaInfo?.customerName || bookingData?.customerName || '',
|
||||
phoneNumber: orderData.metaInfo?.customerPhone || bookingData?.phoneNumber || '',
|
||||
email: orderData.metaInfo?.customerEmail || bookingData?.email || '',
|
||||
planName: 'Day Pass',
|
||||
amount: orderData.amount,
|
||||
transactionId: payload.orderId,
|
||||
paymentDate: new Date(),
|
||||
paymentMethod: 'Online'
|
||||
};
|
||||
|
||||
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||
logger.info(`Day pass invoice generated successfully at path: ${invoicePath}`);
|
||||
|
||||
await bookingRef.update({
|
||||
invoicePath: invoicePath,
|
||||
invoiceNumber: invoiceNumber
|
||||
});
|
||||
|
||||
logger.info(`Updated day pass booking with invoice path: ${invoicePath}`);
|
||||
|
||||
const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||
const formattedDate = format(new Date(), 'dd/MM/yyyy');
|
||||
|
||||
if (gymOwnerEmail) {
|
||||
logger.info(`Preparing to send day pass invoice email to gym owner: ${gymOwnerEmail}`);
|
||||
try {
|
||||
const ownerEmailSubject = `New Day Pass Payment - ${gymName}`;
|
||||
|
||||
const gymOwnerEmailHtml = `
|
||||
<html>
|
||||
<body>
|
||||
<h2>New Day Pass Payment Received</h2>
|
||||
<p>Dear Gym Owner,</p>
|
||||
<p>A new day pass payment has been received for your gym.</p>
|
||||
<p>Customer Details:</p>
|
||||
<ul>
|
||||
<li>Name: ${invoiceData.customerName}</li>
|
||||
<li>Phone: ${invoiceData.phoneNumber}</li>
|
||||
</ul>
|
||||
<p>Day Pass Details:</p>
|
||||
<ul>
|
||||
<li>Service: Day Pass</li>
|
||||
<li>Amount: ₹${orderData.amount.toFixed(2)}</li>
|
||||
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||
<li>Date: ${formattedDate}</li>
|
||||
</ul>
|
||||
<p>Please find the invoice attached.</p>
|
||||
<p>Regards,<br>Fitlien Team</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await sendEmailWithAttachmentUtil(
|
||||
gymOwnerEmail,
|
||||
ownerEmailSubject,
|
||||
gymOwnerEmailHtml,
|
||||
downloadUrl,
|
||||
`Invoice_${path.basename(invoicePath)}`
|
||||
);
|
||||
|
||||
logger.info(`Day pass invoice email sent to gym owner (${gymOwnerEmail}) for payment: ${payload.merchantOrderId}`);
|
||||
} catch (ownerEmailError) {
|
||||
logger.error('Error sending gym owner day pass invoice email:', ownerEmailError);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (invoiceError) {
|
||||
logger.error('Error generating day pass invoice:', invoiceError);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error processing day pass booking:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function processMembershipPayment(payload: any, orderData: any, membershipId: string) {
|
||||
logger.info(`Processing membership for completed payment`, {
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
orderId: payload.orderId,
|
||||
membershipId: membershipId || 'not-provided'
|
||||
});
|
||||
|
||||
if (membershipId) {
|
||||
try {
|
||||
logger.info(`Fetching membership data for membershipId: ${membershipId}`);
|
||||
const membershipDoc = await admin.firestore()
|
||||
.collection('memberships')
|
||||
.doc(membershipId)
|
||||
.get();
|
||||
|
||||
if (membershipDoc.exists) {
|
||||
logger.info(`Membership data retrieved successfully for membershipId: ${membershipId}`);
|
||||
|
||||
const membershipData = membershipDoc.data();
|
||||
const uid = membershipData?.userId;
|
||||
|
||||
logger.info(`Fetching user data for uid(Client): ${uid}`);
|
||||
const userDoc = await admin.firestore()
|
||||
.collection('client_profiles')
|
||||
.doc(uid)
|
||||
.get();
|
||||
if (userDoc.exists) {
|
||||
logger.info(`User data retrieved successfully for uid(Client): ${uid}`);
|
||||
|
||||
logger.info(`Starting invoice generation process for payment: ${payload.merchantOrderId}`);
|
||||
|
||||
const userData = userDoc.data();
|
||||
|
||||
const gymId = orderData.metaInfo?.gymId || membershipData?.gymId;
|
||||
let gymName = 'Fitlien';
|
||||
let gymAddress = '';
|
||||
let subscriptionName = '';
|
||||
let gymOwnerEmail = '';
|
||||
let paymentType = orderData.metaInfo?.paymentType || 'Gym Membership';
|
||||
let trainerId = orderData.metaInfo?.trainerId;
|
||||
let trainerData = null;
|
||||
let emailCustomer = membershipData?.fields?.['email'] || membershipData?.fields?.['Email Address'];
|
||||
|
||||
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 = membershipData?.subscription?.normalizedName || '';
|
||||
|
||||
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)}`;
|
||||
|
||||
logger.info(`Generated invoice number: ${invoiceNumber}`);
|
||||
|
||||
logger.info(`Preparing invoice data for generation`, {
|
||||
invoiceNumber,
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
gymName: gymName
|
||||
});
|
||||
const invoiceData = {
|
||||
invoiceNumber,
|
||||
businessName: gymName,
|
||||
address: gymAddress,
|
||||
gstNumber: userData?.gstNumber,
|
||||
customerName: userData?.displayName || `${membershipData?.fields?.['first-name'] || ''} ${membershipData?.fields?.['last-name'] || ''}`.trim() || membershipData?.fields?.['First Name'] || '',
|
||||
phoneNumber: membershipData?.fields?.['phone-number'] || membershipData?.fields?.['Phone Number'] || orderData.metaInfo?.phoneNumber || '',
|
||||
email: membershipData?.fields?.['email'] || membershipData?.fields?.['Email Address'] || '',
|
||||
planName: orderData.metaInfo?.planName || subscriptionName,
|
||||
amount: orderData.amount,
|
||||
transactionId: payload.orderId,
|
||||
paymentDate: new Date(),
|
||||
paymentMethod: 'Online'
|
||||
};
|
||||
|
||||
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||
logger.info(`Invoice generated successfully at path: ${invoicePath}`);
|
||||
|
||||
logger.info(`Updating membership payment with invoice path`, {
|
||||
membershipId,
|
||||
invoicePath
|
||||
});
|
||||
|
||||
await admin.firestore()
|
||||
.collection('membership_payments')
|
||||
.doc(membershipId)
|
||||
.get()
|
||||
.then(async (doc) => {
|
||||
if (doc.exists) {
|
||||
logger.info(`Found membership payment document for membershipId: ${membershipId}`);
|
||||
|
||||
const paymentsData = doc.data()?.payments || [];
|
||||
let paymentFound = false;
|
||||
|
||||
for (let i = 0; i < paymentsData.length; i++) {
|
||||
if (paymentsData[i].referenceNumber === payload.merchantOrderId ||
|
||||
paymentsData[i].transactionId === payload.orderId) {
|
||||
paymentsData[i].invoicePath = invoicePath;
|
||||
paymentFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Payment record ${paymentFound ? 'found' : 'not found'} in membership payments`, {
|
||||
membershipId,
|
||||
merchantOrderId: payload.merchantOrderId,
|
||||
orderId: payload.orderId
|
||||
});
|
||||
|
||||
await doc.ref.update({
|
||||
'payments': paymentsData,
|
||||
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
logger.info(`Successfully updated membership payment with invoice path`, {
|
||||
membershipId,
|
||||
invoicePath
|
||||
});
|
||||
} else {
|
||||
logger.warn(`No membership payment document found for membershipId: ${membershipId}`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Generated invoice for payment: ${payload.merchantOrderId}, path: ${invoicePath}`);
|
||||
|
||||
logger.info(`Getting download URL for invoice: ${invoicePath}`);
|
||||
const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||
logger.info(`Generated download URL for invoice: ${invoicePath}`);
|
||||
|
||||
const formattedDate = format(new Date(), 'dd/MM/yyyy');
|
||||
|
||||
if (emailCustomer) {
|
||||
logger.info(`Preparing to send invoice email to customer: ${membershipData?.fields?.['email']}`);
|
||||
try {
|
||||
const emailSubject = isFreeplan
|
||||
? `Free Plan Assigned - ${gymName}`
|
||||
: `New Membership - ${gymName}`;
|
||||
|
||||
const customerEmailHtml = `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${isFreeplan ? 'Free Plan Assigned' : 'Thank you for your payment'}</h2>
|
||||
<p>Dear ${invoiceData.customerName},</p>
|
||||
<p>${isFreeplan ? 'Your free membership has been successfully activated.' : 'Thank you for your payment. Your membership has been successfully activated.'}</p>
|
||||
<p>Please find attached your invoice for the ${isFreeplan ? 'membership' : 'payment'}.</p>
|
||||
<p>Membership Details:</p>
|
||||
<ul>
|
||||
<li>Gym: ${gymName}</li>
|
||||
${trainerData ? `<li>Trainer: ${trainerData.fullName || 'Your Personal Trainer'}</li>` : ''}
|
||||
<li>Plan: ${invoiceData.planName}</li>
|
||||
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||
${hasDiscount ? `<li>You Save: ₹${amountSaved.toFixed(2)}</li>` : ''}
|
||||
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||
<li>Date: ${formattedDate}</li>
|
||||
${isFreeplan ? '<li>Payment Method: Online}</li>' : ''}
|
||||
</ul>
|
||||
<p>If you have any questions, please contact us.</p>
|
||||
<p>Regards,<br>Fitlien Team</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
await sendEmailWithAttachmentUtil(
|
||||
emailCustomer,
|
||||
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) {
|
||||
logger.info(`Preparing to send invoice email to gym owner: ${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 = `
|
||||
<html>
|
||||
<body>
|
||||
<h2>${isFreeplan ? 'Free Plan Assigned' : `New ${paymentType} Booking Received`}</h2>
|
||||
<p>Dear Gym Owner,</p>
|
||||
<p>${isFreeplan ? 'A free membership' : 'A new membership'}${paymentType === 'Gym Membership with Personal Training' ? ' with personal training' : ''} has been ${isFreeplan ? 'assigned' : 'received'} for your gym.</p>
|
||||
<p>Customer Details:</p>
|
||||
<ul>
|
||||
<li>Name: ${invoiceData.customerName}</li>
|
||||
<li>Email: ${invoiceData.email}</li>
|
||||
<li>Phone: ${invoiceData.phoneNumber}</li>
|
||||
</ul>
|
||||
<p>Booking Details:</p>
|
||||
<ul>
|
||||
<li>Type: ${invoiceData.planName}</li>
|
||||
${trainerData ? `<li>Trainer: ${trainerData.fullName || 'Personal Trainer'}</li>` : ''}
|
||||
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||
${hasDiscount ? `<li>Amount Saved by Customer: ₹${amountSaved.toFixed(2)}</li>` : ''}
|
||||
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||
<li>Date: ${formattedDate}</li>
|
||||
</ul>
|
||||
<p>Please find the invoice attached.</p>
|
||||
<p>Regards,<br>Fitlien Team</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<html>
|
||||
<body>
|
||||
<h2>New Personal Training Client</h2>
|
||||
<p>Dear ${trainerData.fullName || 'Trainer'},</p>
|
||||
<p>A new client has signed up for personal training with you at ${gymName}.</p>
|
||||
<p>Client Details:</p>
|
||||
<ul>
|
||||
<li>Name: ${invoiceData.customerName}</li>
|
||||
<li>Email: ${invoiceData.email}</li>
|
||||
<li>Phone: ${invoiceData.phoneNumber}</li>
|
||||
</ul>
|
||||
<p>Booking Details:</p>
|
||||
<ul>
|
||||
<li>Type: Personal Training Membership</li>
|
||||
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||
<li>Date: ${formattedDate}</li>
|
||||
</ul>
|
||||
<p>Please find the invoice attached.</p>
|
||||
<p>Regards,<br>Fitlien Team</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
import { Request } from "firebase-functions/v2/https";
|
||||
import * as express from "express";
|
||||
import { getLogger } from "../shared/config";
|
||||
import { getCorsHandler } from "../shared/middleware";
|
||||
import axios from "axios";
|
||||
|
||||
const logger = getLogger();
|
||||
const corsHandler = getCorsHandler();
|
||||
|
||||
export const getPlacesAutocomplete = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const { input, location, radius, types, components, sessiontoken } = request.query;
|
||||
|
||||
if (!input) {
|
||||
response.status(400).json({
|
||||
error: 'Input parameter is required for autocomplete'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.error('Google Places API key is not configured');
|
||||
response.status(500).json({ error: 'Server configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = 'https://maps.googleapis.com/maps/api/place/autocomplete/json';
|
||||
const params: any = {
|
||||
key: apiKey,
|
||||
input: input
|
||||
};
|
||||
|
||||
if (location && radius) {
|
||||
params.location = location;
|
||||
params.radius = radius;
|
||||
}
|
||||
|
||||
if (types) {
|
||||
params.types = types;
|
||||
}
|
||||
|
||||
if (components) {
|
||||
params.components = components;
|
||||
}
|
||||
|
||||
if (sessiontoken) {
|
||||
params.sessiontoken = sessiontoken;
|
||||
}
|
||||
|
||||
const result = await axios.get(url, { params });
|
||||
|
||||
logger.info('Google Places Autocomplete API request completed successfully');
|
||||
response.json(result.data);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching place autocomplete suggestions:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,51 +0,0 @@
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
import { Request } from "firebase-functions/v2/https";
|
||||
import * as express from "express";
|
||||
import axios from "axios";
|
||||
|
||||
const { getCorsHandler } = require('../shared/middleware');
|
||||
const corsHandler = getCorsHandler();
|
||||
const { getLogger } = require('../shared/config');
|
||||
const logger = getLogger();
|
||||
|
||||
|
||||
export const getPlaceDetails = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response: express.Response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const { place_id, fields } = request.query;
|
||||
|
||||
if (!place_id) {
|
||||
response.status(400).json({
|
||||
error: 'place_id parameter is required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.error('Google Places API key is not configured');
|
||||
response.status(500).json({ error: 'Server configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = 'https://maps.googleapis.com/maps/api/place/details/json';
|
||||
const params: any = {
|
||||
key: apiKey,
|
||||
place_id: place_id,
|
||||
fields: fields || 'geometry'
|
||||
};
|
||||
|
||||
const result = await axios.get(url, { params });
|
||||
logger.info('Google Places Details API request completed successfully');
|
||||
response.json(result.data);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching place details:', error);
|
||||
response.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,2 +0,0 @@
|
||||
export { getPlaceDetails } from './details';
|
||||
export { getPlacesAutocomplete } from './autocomplete';
|
||||
@ -1,9 +0,0 @@
|
||||
import * as admin from 'firebase-admin';
|
||||
import * as logger from 'firebase-functions/logger';
|
||||
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp();
|
||||
}
|
||||
|
||||
export const getAdmin = () => admin;
|
||||
export const getLogger = () => logger;
|
||||
@ -1,3 +0,0 @@
|
||||
import cors from 'cors';
|
||||
|
||||
export const getCorsHandler = () => cors({ origin: true });
|
||||
@ -1 +0,0 @@
|
||||
export { sendSMSMessage } from './sendSMS';
|
||||
@ -1,77 +0,0 @@
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
import { Request } from "firebase-functions/v2/https";
|
||||
import { getCorsHandler } from "../shared/middleware";
|
||||
import { getLogger } from "../shared/config";
|
||||
import twilio from 'twilio';
|
||||
|
||||
const corsHandler = getCorsHandler();
|
||||
const logger = getLogger();
|
||||
|
||||
// Initialize Twilio client
|
||||
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
|
||||
|
||||
interface SMSRequest {
|
||||
to: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const sendSMSMessage = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const { to, body } = request.body as SMSRequest;
|
||||
|
||||
// Input validation
|
||||
if (!to || !body) {
|
||||
logger.error('Missing required SMS parameters');
|
||||
response.status(400).json({
|
||||
success: false,
|
||||
error: 'Both "to" and "body" parameters are required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate phone number format (basic check)
|
||||
if (!/^\+?[1-9]\d{1,14}$/.test(to)) {
|
||||
logger.error('Invalid phone number format', { to });
|
||||
response.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid phone number format'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send SMS
|
||||
const message = await twilioClient.messages.create({
|
||||
body: body,
|
||||
from: process.env.TWILIO_PHONE_NUMBER,
|
||||
to: to
|
||||
});
|
||||
|
||||
logger.info('SMS sent successfully', {
|
||||
messageId: message.sid,
|
||||
to: to,
|
||||
length: body.length
|
||||
});
|
||||
|
||||
response.json({
|
||||
success: true,
|
||||
messageId: message.sid,
|
||||
timestamp: message.dateCreated
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Error sending SMS:', error);
|
||||
|
||||
const statusCode = error.status === 401 ? 401 : 500;
|
||||
|
||||
response.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
moreInfo: error.moreInfo
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,45 +0,0 @@
|
||||
import { onRequest } from "firebase-functions/v2/https";
|
||||
import { Request } from "firebase-functions/v2/https";
|
||||
import * as path from 'path';
|
||||
import { getCorsHandler } from "../shared/middleware";
|
||||
import { getLogger, getAdmin } from "../shared/config";
|
||||
|
||||
const corsHandler = getCorsHandler();
|
||||
const admin = getAdmin();
|
||||
const logger = getLogger();
|
||||
|
||||
export const accessFile = onRequest({
|
||||
region: '#{SERVICES_RGN}#'
|
||||
}, async (request: Request, response) => {
|
||||
return corsHandler(request, response, async () => {
|
||||
try {
|
||||
const filePath = request.query.path as string;
|
||||
if (!filePath) {
|
||||
response.status(400).send('File path is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const expirationMs = 60 * 60 * 1000;
|
||||
const bucket = admin.storage().bucket();
|
||||
const file = bucket.file(filePath);
|
||||
|
||||
const [exists] = await file.exists();
|
||||
if (!exists) {
|
||||
response.status(404).send('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const [signedUrl] = await file.getSignedUrl({
|
||||
action: 'read',
|
||||
expires: Date.now() + expirationMs,
|
||||
responseDisposition: `attachment; filename="${path.basename(filePath)}"`,
|
||||
});
|
||||
|
||||
response.redirect(signedUrl);
|
||||
logger.info(`File access redirect for ${filePath}`);
|
||||
} catch (error) {
|
||||
logger.error('Error accessing file:', error);
|
||||
response.status(500).send('Error accessing file');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export { accessFile } from './accessFile';
|
||||
@ -1,195 +0,0 @@
|
||||
|
||||
import { getLogger } from "../shared/config";
|
||||
import { SESClient } from "@aws-sdk/client-ses";
|
||||
import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses";
|
||||
import * as mime from 'mime-types';
|
||||
import axios from 'axios';
|
||||
|
||||
const logger = getLogger();
|
||||
|
||||
interface EmailRequest {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
attachments?: Attachment[];
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
filename: string;
|
||||
content: string | Buffer; // Base64 encoded string or Buffer
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
const stripHtml = (html: string): string => {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
async function sendSimpleEmail(data: EmailRequest, recipients: string[]) {
|
||||
const ses = new SESClient({
|
||||
region: 'ap-south-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
|
||||
}
|
||||
});
|
||||
|
||||
const command = new SendEmailCommand({
|
||||
Source: data.from,
|
||||
Destination: { ToAddresses: recipients },
|
||||
Message: {
|
||||
Subject: { Data: data.subject },
|
||||
Body: {
|
||||
Html: { Data: data.html },
|
||||
Text: { Data: data.text || stripHtml(data.html) }
|
||||
}
|
||||
},
|
||||
ReplyToAddresses: data.replyTo ? [data.replyTo] : undefined,
|
||||
});
|
||||
|
||||
const result = await ses.send(command);
|
||||
return { messageId: result.MessageId };
|
||||
}
|
||||
|
||||
async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) {
|
||||
const ses = new SESClient({
|
||||
region: 'ap-south-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
|
||||
}
|
||||
});
|
||||
|
||||
const boundary = `boundary_${Math.random().toString(16).substr(2)}`;
|
||||
let rawMessage = `From: ${data.from}\n`;
|
||||
rawMessage += `To: ${recipients.join(', ')}\n`;
|
||||
rawMessage += `Subject: ${data.subject}\n`;
|
||||
rawMessage += `MIME-Version: 1.0\n`;
|
||||
rawMessage += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
|
||||
|
||||
// Add email body (multipart/alternative)
|
||||
rawMessage += `--${boundary}\n`;
|
||||
rawMessage += `Content-Type: multipart/alternative; boundary="alt_${boundary}"\n\n`;
|
||||
|
||||
// Text part
|
||||
if (data.text) {
|
||||
rawMessage += `--alt_${boundary}\n`;
|
||||
rawMessage += `Content-Type: text/plain; charset=UTF-8\n\n`;
|
||||
rawMessage += `${data.text}\n\n`;
|
||||
}
|
||||
|
||||
// HTML part
|
||||
rawMessage += `--alt_${boundary}\n`;
|
||||
rawMessage += `Content-Type: text/html; charset=UTF-8\n\n`;
|
||||
rawMessage += `${data.html}\n\n`;
|
||||
|
||||
// Close alternative part
|
||||
rawMessage += `--alt_${boundary}--\n\n`;
|
||||
|
||||
// Add attachments
|
||||
for (const attachment of data.attachments || []) {
|
||||
const contentType = attachment.contentType ||
|
||||
mime.lookup(attachment.filename) ||
|
||||
'application/octet-stream';
|
||||
|
||||
rawMessage += `--${boundary}\n`;
|
||||
rawMessage += `Content-Type: ${contentType}; name="${attachment.filename}"\n`;
|
||||
rawMessage += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
|
||||
rawMessage += `Content-Transfer-Encoding: base64\n\n`;
|
||||
|
||||
const contentBuffer = typeof attachment.content === 'string'
|
||||
? Buffer.from(attachment.content, 'base64')
|
||||
: attachment.content;
|
||||
|
||||
rawMessage += contentBuffer.toString('base64') + '\n\n';
|
||||
}
|
||||
|
||||
// Close message
|
||||
rawMessage += `--${boundary}--`;
|
||||
|
||||
const command = new SendRawEmailCommand({
|
||||
RawMessage: { Data: Buffer.from(rawMessage) }
|
||||
});
|
||||
|
||||
const result = await ses.send(command);
|
||||
return { messageId: result.MessageId };
|
||||
}
|
||||
|
||||
async function downloadFileFromUrl(url: string): Promise<Buffer> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: 'arraybuffer' });
|
||||
return Buffer.from(response.data);
|
||||
} catch (error) {
|
||||
logger.error(`Error downloading file from URL: ${error}`);
|
||||
throw new Error(`Failed to download file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailWithAttachmentUtil(
|
||||
toAddress: string,
|
||||
subject: string,
|
||||
message: string,
|
||||
fileUrl: string,
|
||||
fileName?: string
|
||||
): Promise<any> {
|
||||
try {
|
||||
logger.info(`Sending email with attachment to: ${toAddress}`);
|
||||
|
||||
// Initialize data with basic fields
|
||||
const data: EmailRequest = {
|
||||
to: toAddress,
|
||||
html: message,
|
||||
subject: subject,
|
||||
text: stripHtml(message),
|
||||
from: process.env.SES_FROM_EMAIL || 'support@fitlien.com',
|
||||
replyTo: process.env.SES_REPLY_TO_EMAIL || 'support@fitlien.com',
|
||||
attachments: []
|
||||
};
|
||||
|
||||
// Handle file URL if provided
|
||||
if (fileUrl && fileName) {
|
||||
logger.info(`Downloading attachment from URL: ${fileUrl}`);
|
||||
try {
|
||||
const fileContent = await downloadFileFromUrl(fileUrl);
|
||||
|
||||
// Add the downloaded file as an attachment
|
||||
data.attachments!.push({
|
||||
filename: fileName,
|
||||
content: fileContent,
|
||||
contentType: mime.lookup(fileName) || 'application/octet-stream'
|
||||
});
|
||||
|
||||
logger.info(`Successfully downloaded attachment: ${fileName}`);
|
||||
} catch (downloadError) {
|
||||
logger.error(`Failed to download attachment: ${downloadError}`);
|
||||
throw new Error(`Failed to process attachment: ${downloadError}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.to || !data.subject || !data.html || !data.from) {
|
||||
throw new Error('Missing required email fields');
|
||||
}
|
||||
|
||||
logger.info(`Sending Email '${data.subject}' to '${data.to}' from '${data.from}'`);
|
||||
const recipients = Array.isArray(data.to) ? data.to : [data.to];
|
||||
|
||||
let result;
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
result = await sendEmailWithAttachments(data, recipients);
|
||||
} else {
|
||||
result = await sendSimpleEmail(data, recipients);
|
||||
}
|
||||
|
||||
logger.info('Email sent successfully via SES');
|
||||
return { success: true, result };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error sending email with attachment via SES:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,7 @@
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2017",
|
||||
"types": [],
|
||||
"skipLibCheck": true
|
||||
"target": "es2017"
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"include": [
|
||||
|
||||
208
package-lock.json
generated
208
package-lock.json
generated
@ -6,20 +6,10 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@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"
|
||||
"busboy": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/long": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/busboy": {
|
||||
@ -30,6 +20,16 @@
|
||||
"@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,49 +38,6 @@
|
||||
"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",
|
||||
@ -92,110 +49,11 @@
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"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/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/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
@ -205,38 +63,10 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@types/busboy": "^1.5.4",
|
||||
"busboy": "^1.6.0",
|
||||
"date-fns": "^4.1.0"
|
||||
"busboy": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/long": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user