Creating a Full Stack Ecommerce Application from the Ground Up: My Experience (Part 2)

Creating a Full Stack Ecommerce Application from the Ground Up: My Experience (Part 2)

My reflections on creating E2E full-stack e-commerce app using React, Redux, Node.js, Express, Prisma, and Stripe (Part 2)

Oct 6, 2023ยท

10 min read

Introduction

In the previous article, I started sharing my experience working on a full-stack e-commerce application, discussing the lessons I learned, the technology choices I made, and more. If you haven't already, be sure to check out the first part! ๐Ÿ‘€

Today, I will continue to explore the remaining essential features that I didn't cover in the first part: processing payments and managing orders with Stripe, handling image uploads using Cloudinary, and testing the application with Cypress. So, without further ado, let's dive right in!

Note: if you want to access the full code or even try the project out for yourself, feel free to head over to my GitHub repo. You'll find both the source code and a link to the deployed project there.

Integrating Stripe for Payment Processing

So, the idea was as follows: users can add products to their locally stored cart and purchase them whenever they desire. When they choose to do so, they will be redirected to a dedicated checkout page that manages the payment process and collects essential customer information.

For this project, I didn't create my own checkout page. Instead, I used a ready-made solution from Stripe. All I have to do is to properly set it up.

When a customer decided to make a purchase, I sent a POST request to the server with the items they wanted and the quantity. The server then created a new checkout session and gave a unique URL for the users to go to and make their payment. If you're curious, you can learn more about it in the Stripe docs.

After setting up everything in the Stripe Dashboard, I began developing the backend to generate a new session whenever a user wishes to make a purchase:

export const createCheckoutSession = async (req: Request, res: Response, next: NextFunction) => {
    try {
        const session = await stripe.checkout.sessions.create({
            success_url: (process.env.NODE_ENV === "production" ? process.env.FRONTEND_URL : "http://localhost:5173") + "/checkout/success?id={CHECKOUT_SESSION_ID}",
            mode: "payment",
            line_items: req.body.lineItems,
            currency: "usd",
            metadata: {
                customerId: req.body.userId
            }
        });
        res.status(201).json({ sessionId: session.id });
    } catch (error) {
        next({ message: "Unable to create the checkout session", error });
    }
};

Then, on the frontend, I used the received session ID from the server to redirect the user to the appropriate page using redirectToCheckout():

// Send POST request with React Query to create the checkout session
const { mutateAsync: createCheckout } = useCreateCheckoutSessionMutation(token);

const handleCheckout = async () => {
    // Create an array of line items based on the products in the cart
    const lineItems = cartItems.map((cartItem) => ({
        price: cartItem.product.priceId,
        quantity: cartItem.quantity,
    }));
    // Pass the data necessary for checkout session
    const sessionData = {
        lineItems,
        userId: currentUser?.uid || "",
    };
    // Retrieve ID of the newly created session
    const { sessionId } = await createCheckout(sessionData);
    const stripe = await getStripe();
    // Redirect the user to the checkout page with unique session ID
    stripe?.redirectToCheckout({ sessionId });
};

You might have noticed that while creating the session on the backend, one of the parameters I passed was the success_url. This was the URL to which the customer was redirected upon completing their purchase. It displayed basic information about the purchase (such as the customer's name, address, total price, etc.) to confirm that the order had been placed.

Alright, great! Whenever a user wanted to buy something, we could process their payment. ๐Ÿ’ธ However, there's a major catch: if you look at the lineItems in the code snippet above, you'll notice that each product has its own price ID. Price IDs come from Price objects, which are created using the Stripe Dashboard or API. This allows you to store information about your products within Stripe.

Therefore, in order for checkout to work properly, each product must be linked with Stripe. To refresh your memory, here was what the database schema for the product looked like. To connect each product to Stripe, products saved the priceId from the matching Stripe product. Moreover, instead of using a randomly assigned id by the database, I used the product ID from Stripe as the main key for each product.

model Product {
  id            String    @unique
  name          String
  description   String    @db.Text
  price         Float
  stockQuantity Int
  image         String
  category      Category? @relation(fields: [categoryId], references: [id], onUpdate: Cascade)
  categoryId    Int?
  priceId       String    @unique
  createdAt     DateTime  @default(now())

  @@fulltext([name])
  @@fulltext([name, description])
}

And this was the part that I might have gotten completely wrong, or it could be quite annoying (please let me know if you have any insights). Essentially, if the admin wanted to create a new product, I needed to perform something like this:

export const createProduct = async (req: Request, res: Response, next: NextFunction) => {
    try {
        const image = req.image || "";
        // Create product in Stripe with given details
        const stripeProduct = await stripe.products.create({
            name: req.body.name,
            description: req.body.description,
            default_price_data: {
                currency: "usd",
                unit_amount: req.body.price * 100
            },
            images: [image]
        });

        // Create new product record in the database using
        // product ID and price ID issued by Stripe API
        const newProduct = await prisma.product.create({
            data: {
                stockQuantity: Number(req.body.stockQuantity),
                price: Number(req.body.price),
                id: stripeProduct.id,
                priceId: stripeProduct.default_price as string,
                image,
                name: req.body.name,
                description: req.body.description,
                category: {
                    connectOrCreate: {
                        where: {
                            name: req.body.category
                        },
                        create: {
                            name: req.body.category
                        }
                    }
                }
            }
        });

        res.status(201).json(newProduct);
    } catch (error) {
        next({ message: "Unable to create the product with given details", error });
    }
};

Instead of merely creating the product in the database, I first created it in Stripe, providing the required information and obtaining the product ID and price ID from Stripe. Only then could I create the new product in the database using those details. The same applied to other product operations like updating or deleting, which made the process quite annoying. Although it may not have been the best developer experience for me, it worked!

Now that I had the payment processing up and running, the next major feature to implement was order processing. ๐Ÿ›’ Once the customer completed their purchase, we needed to place an order with the purchased products in their account.

Managing Orders

The concept was quite simple: Stripe processed the payment to finalize the purchase, and then the user was redirected to the success page, indicating that the payment had been completed without any issues. Then, their order was added to their profile, where they could view all the orders they had made.

To do so, we used the webhooks offered by Stripe. You can learn more about it here. It covers everything from understanding what webhooks are and why you may need them, to how to set one up. However, if you simply want to dive right into setting up a webhook for your app, refer to this guide provided by Stripe.

Basically, Stripe generates event data and sends it to the server to notify about any activities related to the Stripe account. The purpose of a webhook is to listen to events sent by Stripe and respond accordingly. Thus, whenever a checkout is successful (or fails), or any other event occurs, Stripe sends a request to our webhook, notifying us.

My goal was to listen for the events I was interested in, such as a successful checkout (which indicated that I could create a new order). The code for the webhook appeared as follows:

export const webhook = async (req: Request, res: Response) => {
    const sig = req.headers["stripe-signature"] || "";
    let event;
    try {
        event = stripe.webhooks.constructEvent(req.body, sig, process.env.ENDPOINT_SECRET || "");
    } catch (err) {
        return res.status(400).json(err);
    }

    // Handle the event
    let order;
    if (event.type === "checkout.session.completed") {
        // Extract session details and line items from the event
        const session = event.data.object as Stripe.Checkout.Session;
        const lineItems = await stripe.checkout.sessions.listLineItems(session.id);

        const items = await Promise.all(lineItems.data.map(async (lineItem) => {
            // Find product details from the database based on the price ID
            const product = await prisma.product.findUnique({
                where: {
                    priceId: lineItem.price?.id 
                }
            });
            return { product, quantity: lineItem.quantity };
        }));


        // Create an order record in the database using session data
        order = await prisma.order.create({
            data: {
                amount: (session.amount_total || 0) / 100,
                userId: session.metadata?.customerId || "",
                items,
                country: session.customer_details?.address?.country || "",
                address: processOrderAddress(session.customer_details?.address as Stripe.Address | null),
                sessionId: session.id,
                createdAt: new Date(session.created)
            }
        });
    }

    if (order) {
        return res.status(201).json(order);
    }

    res.status(200).json({ message: "Event received and processed!" });
};

We received the event object from Stripe, but the only type of event we needed was checkout.session.completed (completed checkout session). When this was the case, we retrieved the session information from the objects, such as the products, located the corresponding row in the database for each product, and created the new order using the total price for the order, user's ID, session ID, and their address.

Now, if the user navigated to their personal dashboard within my application, they could view all the orders associated with their ID. This is how the entire process of payment processing worked:

Great! We've covered the most important aspects of the application! Now, I'd like to touch on a few other interesting features and details.

Admin Role

Throughout this series, I have already discussed the admin role and role-based system authorization. Essentially, the application included two roles: admin and user. In addition to using features accessible to users, such as purchasing products and updating their profile information, the admin could also create new products, delete products, and update them. Moreover, the admin had access to all the orders that had been placed on the website.

While developing the application, I believed it was crucial to create some type of user with enhanced capabilities for managing the website's workflow (particularly when creating the app for an actual client).

For instance, here is a comparison of how the dashboard appears to the admin and the regular user:

Image upload with Cloudinary

For instance, when a user wanted to update their profile picture, they needed to upload an image, which must be hosted somewhere. That was why I chose Cloudinary - it was easy to set up and use, and it completely eliminated any overhead.

On the frontend, I simply sent the file attached to the input field as form data. To process the form data, I used Multer, and to retrieve the uploaded image from the form and host it, I used Cloudinary. For any POST request that required image upload, I created middleware to handle this process:

export const processImageUpload = async (req: Request, res: Response, next: NextFunction) => {
    try {
        if (req?.file) {
            const imageUpload = await cloudinary.uploader.upload(req.file.path);
            req.image = imageUpload.secure_url;
        }
        next();
    } catch (error) {
        res.status(500).json({ message: "Unable to upload image" });
    }
};

๐Ÿš€ Everything is simple! Now we can get the URL, save it in the database, and use it later to show uploaded images on the website.

Testing the application with Cypress

Alright, we have reached the final stage of the project. Now it's time to discuss testing everything to gain an understanding of the system's stability.

I used end-to-end testing, where each application feature was tested from start to finish. Cypress takes an interesting approach to testing, as it offers a browser-based test runner that mimics user interaction with the application, such as clicking buttons, filling out forms, and so on. Cypress makes E2E testing truly convenient. All we need to do is define the behavior of each test (what the test runner will perform).

It was impossible to test the entire application from start to finish due to the numerous components involved. However, I focused on the most crucial features and tested them individually, taking various edge cases into account.

I tested the admin-related functions (CRUD-ing products), the entire authentication process, and customer-related features such as product sorting, filtering, searching, managing profiles, and so on.

This is an example of a defined test case in Cypress, where I tested the process of creating a new product:

it("Admin: create a new product", () => {
    cy.visit("/dashboard");
    cy.get("#create-product").click();
    cy.get(adminSel.productNameField).type("MacBook Pro");
    cy.get(adminSel.productDescriptionField).type("This is a MacBook Pro");
    cy.get(adminSel.productPriceField).type("1000");
    cy.get(adminSel.productQuantityField).type("10");
    cy.get(adminSel.productCategoryField).type("Electronics");
    cy.get(adminSel.productImageField).click().selectFile("cypress/fixtures/products/macbook.jpeg");
    cy.get("#submitProductForm").click();
    cy.wait(3000);
    cy.get(adminSel.firstProductTitle).should("have.text", "MacBook Pro");
});

I believe one of my primary mistakes when I initially began working with the application was neglecting test-driven development. This is the method in which tests for the system are written first, based on the software requirements. In this scenario, it would have been much simpler to test the system and identify any potential weaknesses.

Conclusion

๐ŸŽ‰ We've reached the end of our adventure together, exploring the ins and outs of this project. It has been a pleasure sharing with you not only the things that went well but also the lessons I learned from the mistakes I made along the way.

Personally, I believe I did a great job, particularly when comparing the knowledge I had at the beginning of the project to the knowledge I gained upon its completion.

If you enjoyed this blog post and would like to see more like it, just let me know! ๐Ÿ‘ As usual, if you encounter any problems along the way, you can refer to the complete source code available on my GitHub repository. If you have any questions or need assistance, don't hesitate to reach out to me.

Remember to follow me on Twitter/X, where I share daily updates. And feel free to connect with me on LinkedIn.

ย