Skip to content

Commit

Permalink
feat(send pdf invoice to client email): send pdf invoice after payment
Browse files Browse the repository at this point in the history
  • Loading branch information
Angemichel12 committed Jul 26, 2024
1 parent 1d98af8 commit 6a05a30
Show file tree
Hide file tree
Showing 12 changed files with 1,674 additions and 34 deletions.
1,418 changes: 1,391 additions & 27 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"keywords": [],
"license": "ISC",
"dependencies": {
"@react-pdf/renderer": "^3.4.4",
"@sendgrid/mail": "^8.1.3",
"@types/jsonwebtoken": "^9.0.6",
"@types/nodemailer": "^6.4.14",
Expand All @@ -52,6 +53,7 @@
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
"passport-stub": "^1.1.1",
"pdf-creator-node": "^2.3.5",
"pg": "^8.11.5",
"pg-hstore": "^2.3.4",
"randomatic": "^3.1.1",
Expand Down Expand Up @@ -102,6 +104,8 @@
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.11.5",
"@types/randomatic": "^3.1.5",
"@types/react": "^18.3.3",
"@types/react-pdf": "^7.0.0",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
Expand Down
7 changes: 7 additions & 0 deletions src/controllers/paymentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
MTN_MOMO_TARGET_ENVIRONMENT,
} from "../utils/keys";
import { getToken } from "../utils/momoMethods";
import { findUserById } from "../services/user.services";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: "2024-04-10",
Expand Down Expand Up @@ -158,6 +159,10 @@ const checkout_success = async (req: Request, res: Response) => {
const cart = (await cartService.findCartByUserIdService(
payerId,
)) as cartModelAttributes;
const user = await findUserById(payerId);
if (!user || !user.email) {
throw new Error("User email not found");
}

if (session.payment_status === "paid") {
const sessionById: PaymentDetails = await getPaymentBySession(session.id);
Expand All @@ -170,10 +175,12 @@ const checkout_success = async (req: Request, res: Response) => {
orderId: order.id,
sessionId: session.id,
};

await recordPaymentDetails(paymentDetails);
} else {
order = await readOrderById(sessionById.orderId!);
}

return sendResponse(
res,
200,
Expand Down
26 changes: 19 additions & 7 deletions src/helpers/nodemailer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import nodemailer from "nodemailer";
import { EMAIL, PASSWORD, SENDER_NAME } from "../utils/keys";
import { Attachment } from "nodemailer/lib/mailer";

export interface MailOptions {
to: string;
subject: string;
html: any;
attachments?: Attachment[];
}

const sender = nodemailer.createTransport({
Expand All @@ -18,18 +20,28 @@ const sender = nodemailer.createTransport({
},
});

export async function sendEmail({ to, subject, html }: MailOptions) {
const mailOptions = {
export async function sendEmail({
to,
subject,
html,
attachments,
}: MailOptions) {
const mailOptions: nodemailer.SendMailOptions = {
from: `"${SENDER_NAME}" <${EMAIL}>`,
to,
subject,
html,
attachments,
};

sender.sendMail(mailOptions, (error) => {
if (error) {
console.log("EMAILING USER FAILED:", error);
return;
}
return new Promise((resolve, reject) => {
sender.sendMail(mailOptions, (error, info) => {
if (error) {
console.log("EMAILING USER FAILED:", error);
reject(error);
} else {
resolve(info);
}
});
});
}
149 changes: 149 additions & 0 deletions src/services/invoiceService.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React from "react";
import {
Document,
Page,
Text,
View,
Image,
StyleSheet,
} from "@react-pdf/renderer";
import { generatePDF } from "../utils/pdfGenerator";
import { cartItem } from "../types/cart";
import { UserModelAttributes } from "../types/model";

interface InvoiceData {
products: cartItem[];
clientAddress: UserModelAttributes;
companyAddress: string;
logoUrl?: string;
invoiceNumber: string;
date: string;
}

const styles = StyleSheet.create({
page: { padding: 30, fontFamily: "Helvetica" },
header: {
flexDirection: "row",
marginBottom: 30,
alignItems: "center",
justifyContent: "space-between",
},
logo: { width: 60, height: 60 },
title: { fontSize: 24, fontWeight: "bold" },
invoiceInfo: { flexDirection: "column", alignItems: "flex-end" },
invoiceInfoText: { fontSize: 10, marginBottom: 5 },
addressSection: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 30,
},
addressBlock: { width: "45%" },
addressTitle: { fontSize: 12, fontWeight: "bold", marginBottom: 5 },
address: { fontSize: 10, lineHeight: 1.5 },
table: { flexGrow: 1 },
tableHeader: {
flexDirection: "row",
borderBottomColor: "#bff0fd",
backgroundColor: "#bff0fd",
borderBottomWidth: 1,
alignItems: "center",
height: 24,
textAlign: "center",
fontStyle: "bold",
},
tableRow: {
flexDirection: "row",
borderBottomColor: "#bff0fd",
borderBottomWidth: 1,
alignItems: "center",
height: 24,
},
tableHeaderCell: { width: "20%", fontSize: 10 },
tableCell: { width: "20%", textAlign: "center", fontSize: 10 },
productImage: { width: 20, height: 20, borderRadius: 10 },
total: { marginTop: 30, flexDirection: "row", justifyContent: "flex-end" },
totalText: { fontWeight: "bold", fontSize: 14 },
totalAmount: { fontWeight: "bold", fontSize: 14, marginLeft: 10 },
});

const InvoiceDocument: React.FC<{ data: InvoiceData }> = ({ data }) => (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<Image style={styles.logo} src={data.logoUrl} />
<View style={styles.invoiceInfo}>
<Text style={styles.title}>Invoice</Text>
<Text style={styles.invoiceInfoText}>
Date: {new Date(Date.now()).toLocaleString()}
</Text>
</View>
</View>

{/* Addresses */}
<View style={styles.addressSection}>
<View style={styles.addressBlock}>
<Text style={styles.addressTitle}>Bill To:</Text>
<Text style={styles.address}>
Fullname: {data.clientAddress.firstName}{" "}
{data.clientAddress.lastName}
</Text>
<Text style={styles.address}>Email: {data.clientAddress.email}</Text>
<Text style={styles.address}>
Phone Number: {data.clientAddress.phoneNumber}
</Text>
<Text style={styles.address}>
Address: {data.clientAddress.addressLine1},{" "}
{data.clientAddress.addressLine2}
</Text>
</View>
<View style={styles.addressBlock}>
<Text style={styles.addressTitle}>From:</Text>
<Text style={styles.address}>ShopTrove</Text>
<Text style={styles.address}>Address: {data.companyAddress}</Text>
<Text style={styles.address}>Email:[email protected]</Text>
<Text style={styles.address}>Phone Number: +250 788888888</Text>
</View>
</View>

{/* Product Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={styles.tableHeaderCell}>Image</Text>
<Text style={styles.tableHeaderCell}>Name</Text>
<Text style={styles.tableHeaderCell}>Price</Text>
<Text style={styles.tableHeaderCell}>Quantity</Text>
<Text style={styles.tableHeaderCell}>Total</Text>
</View>
{data.products.map((product, index) => (
<View style={styles.tableRow} key={index}>
<View style={styles.tableCell}>
<Image style={styles.productImage} src={product.image} />
</View>
<Text style={styles.tableCell}>{product.name}</Text>
<Text style={styles.tableCell}>{product.price.toFixed(2)} Rwf</Text>
<Text style={styles.tableCell}>{product.quantity}</Text>
<Text style={styles.tableCell}>
{(product.price * product.quantity).toFixed(2)} Rwf
</Text>
</View>
))}
</View>

{/* Total */}
<View style={styles.total}>
<Text style={styles.totalText}>Total:</Text>
<Text style={styles.totalAmount}>
{data.products
.reduce((sum, product) => sum + product.price * product.quantity, 0)
.toFixed(2)}{" "}
Rwf
</Text>
</View>
</Page>
</Document>
);

export async function generateInvoice(data: InvoiceData): Promise<Buffer> {
return await generatePDF(<InvoiceDocument data={data} />);
}
5 changes: 5 additions & 0 deletions src/services/payment.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,15 @@ export const orderItems = async (cart: cartModelAttributes) => {
await insert_function<salesModelAttributes>("Sales", "create", sale_data);
myEmitter.emit(EventName.PRRODUCT_BOUGHT, product.id, order);
}

// Emit ORDERS_COMPLETED event only once per order
myEmitter.emit(EventName.ORDERS_COMPLETED, order, cart.products);

await database_models.Cart.update(
{ products: [], total: 0 },
{ where: { id: cart.id } },
);

return await read_function<OrderModelAttributes>("Order", "findOne", {
where: { id: order.id },
include: [
Expand Down
7 changes: 7 additions & 0 deletions src/services/user.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ export const findAllUsers = async () => {
await read_function<UserModelAttributes>("User", "findAll"),
);
};
export const findUserById = async (
id: string,
): Promise<UserModelAttributes | null> => {
return await database_models.User.findOne({
where: { id },
});
};
14 changes: 14 additions & 0 deletions src/utils/generateInvoicePdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { generateInvoice } from "../services/invoiceService";

const generateInvoicePdf = async (invoiceData: any): Promise<Buffer> => {
let invoicePdf: Buffer;
try {
invoicePdf = await generateInvoice(invoiceData);
} catch (pdfError) {
console.error("Error generating PDF:", pdfError);
throw new Error("Failed to generate invoice PDF");
}
return invoicePdf;
};

export default generateInvoicePdf;
1 change: 1 addition & 0 deletions src/utils/nodeEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const EventName = {
ORDER_PENDING: "ORDER_PENDING",
ORDERS_DELIVERED: "ORDERS_DELIVERED",
ORDERS_CANCELED: "ORDERS_CANCELED",
ORDERS_COMPLETED: "ORDERS_COMPLETED",
};

export const notificationPage = async (req: Request, res: Response) => {
Expand Down
62 changes: 62 additions & 0 deletions src/utils/notificationListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Notification as DBNotification } from "../database/models/notification"
import { emitNotification } from "./socket.util";
import { OrderModelAttributes } from "../types/model";
import HTML_TEMPLATE from "./mail-template";
import { cartItem } from "../types/cart";
import generateInvoicePdf from "./generateInvoicePdf";

export const myEventListener = () => {
myEmitter.on(
Expand Down Expand Up @@ -292,4 +294,64 @@ export const myEventListener = () => {
await Promise.all([sendEmail(emailingData)]);
},
);
myEmitter.on(
EventName.ORDERS_COMPLETED,
async (order: OrderModelAttributes, products: cartItem[]) => {
try {
const buyerId = order?.buyerId;
const buyer = await User.findOne({ where: { id: buyerId } });

if (!buyer) {
console.log("Buyer not found");
return;
}

const notifications = [];

// Generate invoice for the entire order
const invoiceData = {
products: products,
clientAddress: buyer,
companyAddress: "Kigali, Rwanda",
logoUrl: "https://i.imghippo.com/files/8YpmR1721977307.png",
};
const invoicePdf = await generateInvoicePdf(invoiceData);

// Create buyer notification
const buyerNotification = {
userId: buyerId,
message:
"Your order has been placed successfully. You can now track your order!",
unread: true,
};

const buyerNotificationRecord =
await DBNotification.create(buyerNotification);
notifications.push(buyerNotificationRecord);

// Emit all notifications at once
emitNotification(notifications);

// Send email to the buyer with invoice
const buyerEmail = {
to: buyer.email,
subject: "Order Confirmation",
html: HTML_TEMPLATE(
buyerNotificationRecord.message,
"Order Confirmation",
),
attachments: [
{
filename: "invoice.pdf",
content: invoicePdf,
},
],
};

await sendEmail(buyerEmail);
} catch (error) {
console.error("Error processing order pending event:", error);
}
},
);
};
14 changes: 14 additions & 0 deletions src/utils/pdfGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from "react";
import { renderToStream } from "@react-pdf/renderer";

export async function generatePDF(
element: React.ReactElement,
): Promise<Buffer> {
const stream = await renderToStream(element);
return new Promise((resolve, reject) => {
const chunks: Uint8Array[] = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
}
Loading

0 comments on commit 6a05a30

Please sign in to comment.