Skip to content

Commit

Permalink
Merge pull request #81 from atlp-rwanda/feat-send-pdf-invoice
Browse files Browse the repository at this point in the history
feat: send pdf invoice after buyer payment.
  • Loading branch information
leandreAlly authored Jul 26, 2024
2 parents 1d98af8 + b8ad48b commit d71149f
Show file tree
Hide file tree
Showing 12 changed files with 1,670 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
58 changes: 58 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,60 @@ 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 = [];

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

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);

emitNotification(notifications);

const buyerEmail = {
to: buyer.email,
subject: "Order Confirmation",
html: HTML_TEMPLATE(
`Dear ${buyer.firstName}, Thank you for your purchase! Your order has been successfully placed and is now being processed. You will receive a confirmation email with your order details shortly. We appreciate your business and are excited to deliver your items soon. If you have any questions or need further assistance, please feel free to contact our customer support. Happy shopping!`,
"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);
});
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"jsx": "react",
/* Visit https://aka.ms/tsconfig to read more about this file */

/* Projects */
Expand Down

0 comments on commit d71149f

Please sign in to comment.