Skip to content

Commit 9ab5f2a

Browse files
committed
feat(send pdf invoice to client email): send pdf invoice after payment
1 parent 4779839 commit 9ab5f2a

10 files changed

+1596
-34
lines changed

package-lock.json

+1,391-27
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"keywords": [],
2828
"license": "ISC",
2929
"dependencies": {
30+
"@react-pdf/renderer": "^3.4.4",
3031
"@sendgrid/mail": "^8.1.3",
3132
"@types/jsonwebtoken": "^9.0.6",
3233
"@types/nodemailer": "^6.4.14",
@@ -52,6 +53,7 @@
5253
"passport-google-oauth20": "^2.0.0",
5354
"passport-local": "^1.0.0",
5455
"passport-stub": "^1.1.1",
56+
"pdf-creator-node": "^2.3.5",
5557
"pg": "^8.11.5",
5658
"pg-hstore": "^2.3.4",
5759
"randomatic": "^3.1.1",
@@ -102,6 +104,8 @@
102104
"@types/passport-local": "^1.0.38",
103105
"@types/pg": "^8.11.5",
104106
"@types/randomatic": "^3.1.5",
107+
"@types/react": "^18.3.3",
108+
"@types/react-pdf": "^7.0.0",
105109
"@types/supertest": "^6.0.2",
106110
"@types/swagger-jsdoc": "^6.0.4",
107111
"@types/swagger-ui-express": "^4.1.6",

src/controllers/paymentController.ts

+17
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import {
3232
MTN_MOMO_TARGET_ENVIRONMENT,
3333
} from "../utils/keys";
3434
import { getToken } from "../utils/momoMethods";
35+
import { findUserById } from "../services/user.services";
36+
import sendInvoiceEmail from "../helpers/sendInvoice";
37+
import generateInvoicePdf from "../utils/generateInvoicePdf";
3538

3639
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
3740
apiVersion: "2024-04-10",
@@ -158,6 +161,10 @@ const checkout_success = async (req: Request, res: Response) => {
158161
const cart = (await cartService.findCartByUserIdService(
159162
payerId,
160163
)) as cartModelAttributes;
164+
const user = await findUserById(payerId);
165+
if (!user || !user.email) {
166+
throw new Error("User email not found");
167+
}
161168

162169
if (session.payment_status === "paid") {
163170
const sessionById: PaymentDetails = await getPaymentBySession(session.id);
@@ -170,10 +177,20 @@ const checkout_success = async (req: Request, res: Response) => {
170177
orderId: order.id,
171178
sessionId: session.id,
172179
};
180+
const invoiceData = {
181+
products: cart.products,
182+
clientAddress: "Kigali, Rwanda",
183+
companyAddress: "Kigali, Rwanda",
184+
logoUrl:
185+
"https://shoptroveonline.com/cdn/shop/files/Shotrove_main_logo_1_1ee086e0-b75a-4022-9c17-4698fc190cf5.png",
186+
};
187+
const invoicePdf = await generateInvoicePdf(invoiceData);
188+
await sendInvoiceEmail(user, invoicePdf);
173189
await recordPaymentDetails(paymentDetails);
174190
} else {
175191
order = await readOrderById(sessionById.orderId!);
176192
}
193+
177194
return sendResponse(
178195
res,
179196
200,

src/helpers/nodemailer.ts

+19-7
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import nodemailer from "nodemailer";
22
import { EMAIL, PASSWORD, SENDER_NAME } from "../utils/keys";
3+
import { Attachment } from "nodemailer/lib/mailer";
34

45
export interface MailOptions {
56
to: string;
67
subject: string;
78
html: any;
9+
attachments?: Attachment[];
810
}
911

1012
const sender = nodemailer.createTransport({
@@ -18,18 +20,28 @@ const sender = nodemailer.createTransport({
1820
},
1921
});
2022

21-
export async function sendEmail({ to, subject, html }: MailOptions) {
22-
const mailOptions = {
23+
export async function sendEmail({
24+
to,
25+
subject,
26+
html,
27+
attachments,
28+
}: MailOptions) {
29+
const mailOptions: nodemailer.SendMailOptions = {
2330
from: `"${SENDER_NAME}" <${EMAIL}>`,
2431
to,
2532
subject,
2633
html,
34+
attachments,
2735
};
2836

29-
sender.sendMail(mailOptions, (error) => {
30-
if (error) {
31-
console.log("EMAILING USER FAILED:", error);
32-
return;
33-
}
37+
return new Promise((resolve, reject) => {
38+
sender.sendMail(mailOptions, (error, info) => {
39+
if (error) {
40+
console.log("EMAILING USER FAILED:", error);
41+
reject(error);
42+
} else {
43+
resolve(info);
44+
}
45+
});
3446
});
3547
}

src/helpers/sendInvoice.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { sendEmail } from "./nodemailer";
2+
3+
const sendInvoiceEmail = async (
4+
user: { email: string },
5+
invoicePdf: Buffer,
6+
): Promise<void> => {
7+
try {
8+
await sendEmail({
9+
to: user.email,
10+
subject: "Your Order Invoice",
11+
html: `<h1>Thank you for your order!</h1><p>Please find your invoice attached.</p>`,
12+
attachments: [
13+
{
14+
filename: "invoice.pdf",
15+
content: invoicePdf,
16+
},
17+
],
18+
});
19+
} catch (emailError) {
20+
console.error("Error sending email:", emailError);
21+
throw new Error("Failed to send invoice email");
22+
}
23+
};
24+
25+
export default sendInvoiceEmail;

src/services/invoiceService.tsx

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from "react";
2+
import {
3+
Document,
4+
Page,
5+
Text,
6+
View,
7+
Image,
8+
StyleSheet,
9+
} from "@react-pdf/renderer";
10+
import { generatePDF } from "../utils/pdfGenerator";
11+
import { cartItem } from "../types/cart";
12+
13+
interface InvoiceData {
14+
products: cartItem[];
15+
clientAddress: string;
16+
companyAddress: string;
17+
logoUrl?: string;
18+
}
19+
20+
const styles = StyleSheet.create({
21+
page: { padding: 30 },
22+
header: { flexDirection: "row", marginBottom: 20, alignItems: "center" },
23+
logo: { width: 50, height: 50 },
24+
title: { fontSize: 24, marginLeft: 20 },
25+
addressSection: {
26+
flexDirection: "row",
27+
justifyContent: "space-between",
28+
marginBottom: 20,
29+
},
30+
addressTitle: { fontWeight: "bold", marginBottom: 5 },
31+
address: { fontSize: 10 },
32+
table: { flexGrow: 1 },
33+
tableRow: {
34+
flexDirection: "row",
35+
borderBottomWidth: 1,
36+
borderBottomColor: "#000",
37+
alignItems: "center",
38+
},
39+
tableHeader: { width: "20%", padding: 5, fontWeight: "bold" },
40+
tableCell: { width: "20%", padding: 5 },
41+
productImage: { width: 30, height: 30 },
42+
total: { marginTop: 20, alignItems: "flex-end" },
43+
totalText: { fontWeight: "bold" },
44+
});
45+
46+
const InvoiceDocument: React.FC<{ data: InvoiceData }> = ({ data }) => (
47+
<Document>
48+
<Page size="A4" style={styles.page}>
49+
{/* Header */}
50+
<View style={styles.header}>
51+
<Image style={styles.logo} src={data.logoUrl} />
52+
<Text style={styles.title}>Invoice</Text>
53+
</View>
54+
55+
{/* Addresses */}
56+
<View style={styles.addressSection}>
57+
<View>
58+
<Text style={styles.addressTitle}>Bill To:</Text>
59+
<Text style={styles.address}>{data.clientAddress}</Text>
60+
</View>
61+
<View>
62+
<Text style={styles.addressTitle}>From:</Text>
63+
<Text style={styles.address}>{data.companyAddress}</Text>
64+
</View>
65+
</View>
66+
67+
{/* Product Table */}
68+
<View style={styles.table}>
69+
<View style={styles.tableRow}>
70+
<Text style={styles.tableHeader}>Image</Text>
71+
<Text style={styles.tableHeader}>Name</Text>
72+
<Text style={styles.tableHeader}>Price</Text>
73+
<Text style={styles.tableHeader}>Quantity</Text>
74+
<Text style={styles.tableHeader}>Total</Text>
75+
</View>
76+
{data.products.map((product, index) => (
77+
<View style={styles.tableRow} key={index}>
78+
<Image style={styles.productImage} src={product.image} />
79+
<Text style={styles.tableCell}>{product.name}</Text>
80+
<Text style={styles.tableCell}>${product.price.toFixed(2)}</Text>
81+
<Text style={styles.tableCell}>{product.quantity}</Text>
82+
<Text style={styles.tableCell}>
83+
${(product.price * product.quantity).toFixed(2)}
84+
</Text>
85+
</View>
86+
))}
87+
</View>
88+
89+
{/* Total */}
90+
<View style={styles.total}>
91+
<Text style={styles.totalText}>
92+
Total: $
93+
{data.products
94+
.reduce((sum, product) => sum + product.price * product.quantity, 0)
95+
.toFixed(2)}
96+
</Text>
97+
</View>
98+
</Page>
99+
</Document>
100+
);
101+
102+
export async function generateInvoice(data: InvoiceData): Promise<Buffer> {
103+
return await generatePDF(<InvoiceDocument data={data} />);
104+
}

src/services/user.services.ts

+7
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,10 @@ export const findAllUsers = async () => {
1313
await read_function<UserModelAttributes>("User", "findAll"),
1414
);
1515
};
16+
export const findUserById = async (
17+
id: string,
18+
): Promise<UserModelAttributes | null> => {
19+
return await database_models.User.findOne({
20+
where: { id },
21+
});
22+
};

src/utils/generateInvoicePdf.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { generateInvoice } from "../services/invoiceService";
2+
3+
const generateInvoicePdf = async (invoiceData: any): Promise<Buffer> => {
4+
let invoicePdf: Buffer;
5+
try {
6+
invoicePdf = await generateInvoice(invoiceData);
7+
} catch (pdfError) {
8+
console.error("Error generating PDF:", pdfError);
9+
throw new Error("Failed to generate invoice PDF");
10+
}
11+
return invoicePdf;
12+
};
13+
14+
export default generateInvoicePdf;

src/utils/pdfGenerator.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from "react";
2+
import { renderToStream } from "@react-pdf/renderer";
3+
4+
export async function generatePDF(
5+
element: React.ReactElement,
6+
): Promise<Buffer> {
7+
const stream = await renderToStream(element);
8+
return new Promise((resolve, reject) => {
9+
const chunks: Uint8Array[] = [];
10+
stream.on("data", (chunk) => chunks.push(chunk));
11+
stream.on("end", () => resolve(Buffer.concat(chunks)));
12+
stream.on("error", reject);
13+
});
14+
}

tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"compilerOptions": {
3+
"jsx": "react",
34
/* Visit https://aka.ms/tsconfig to read more about this file */
45

56
/* Projects */

0 commit comments

Comments
 (0)