Skip to content

feat: send pdf invoice after buyer payment. #81

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'

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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'


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