Build a Payment Dashboard API

Build a full stack fintech app with NestJS, Prisma, and Stripe.

Introduction

30 Second Summary

Every time you tap "pay" in an app, a series of events fires behind the scenes. Your payment travels from a frontend to a backend API, then to a payment processor, and the result flows back to update a dashboard.

In this project, you will build that entire chain from scratch. You will create a full stack payment dashboard with a backend API, a database, Stripe payment processing, and a React frontend.

What You'll Build

A full stack payment dashboard powered by a NestJS API connected to PostgreSQL via Prisma ORM, with Stripe Checkout payments and a React frontend.

By the end of this project, you'll have:

  • A NestJS backend API with REST endpoints, dependency injection, and Prisma ORM connected to PostgreSQL with versioned migrations.
  • A Stripe Checkout integration that creates payment sessions and processes webhook events to update your database.
  • A React dashboard frontend that fetches and displays transaction history from your API.
  • Secret Mission: Add transaction filtering and a refund button using the Stripe Refunds API.

Are there any prerequisites?

This project includes full setup instructions for every tool you need. No prior experience with NestJS, Prisma, or Stripe is required.

Before We Start

Before We Start...

Set Up Your Development Environment

Your payment dashboard needs three core technologies to run. Node.js powers the backend server. PostgreSQL stores transaction data. Stripe handles payment processing.

In this step, you will install each tool and create the accounts your project needs. By the end, your machine will be fully ready to start building.

In this step, get ready to:
  • Install Node.js, VS Code, and the NestJS CLI.
  • Set up PostgreSQL and create your project database.
  • Create a Stripe account and locate your test API keys.
Install Node.js, VS Code, and the NestJS CLI

Node.js is the JavaScript runtime your NestJS backend will run on. Your project requires version 20 or higher.

  • Check if Node.js is already installed by running this command:
node --version

✔️ I see a version number

Node.js is ready. Make sure the number starts with v20 or higher (for example, v20.18.0). If it is lower than 20, follow the instructions in the other tab to install the latest version.

ⓧ Command not found

  • Open https://nodejs.org in your browser.
  • Download the LTS version (this is the stable, recommended release).
  • Run the installer and accept the default settings at each prompt.
  • Close and reopen your terminal after the installation finishes.
  • Run node --version again to confirm it now prints a version number starting with v20 or higher.

Still seeing command not found?

On Windows, make sure you closed and reopened your Command Prompt after installing. The installer updates your PATH, but existing terminals do not pick up the change automatically.

On macOS, if you installed via the .pkg file, open a new Terminal window and try again.

help me troubleshoot my Node.js installation

Next, you need VS Code as your code editor. If you already have VS Code installed, skip ahead to the NestJS CLI section below.

  • If you do not have VS Code, open https://code.visualstudio.com in your browser and download the installer for your operating system.
  • Run the installer and accept the default settings.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

The NestJS CLI is a command-line tool that generates project files and modules for you. It saves you from manually creating every file.

  • Install the NestJS CLI globally by running this command:
npm i -g @nestjs/cli

What does this command do?

The -g flag installs the package globally on your machine, not just inside a single project. This means you can run the nest command from any directory.

The npm command comes bundled with Node.js, so it is already available after your Node.js installation.

  • Verify the NestJS CLI installed correctly by running:
nest --version

You should see a version number like 11.0.21.

Seeing a permission error?

On macOS or Linux, you may see an EACCES error. Try prefixing the install command with sudo and entering your computer password when prompted.

On Windows, make sure you opened your Command Prompt or PowerShell as Administrator. Right-click the app and select Run as administrator.

help me fix my NestJS CLI installation error

Install PostgreSQL and create your database

PostgreSQL is the relational database your API will use to store transaction records. You need it installed and running locally before you can connect Prisma to it in a later step.

  • Check if PostgreSQL is already installed by running this command:
psql --version

✔️ I see a version number

PostgreSQL is installed. Make sure the PostgreSQL server is running before you continue.

Is your server running?

On macOS with Homebrew, run brew services list and check that postgresql shows as started. If not, start it with brew services start postgresql.

On Windows, the PostgreSQL service starts automatically after installation. You can confirm by searching for Services in the Start menu and checking that the postgresql service shows Running.

ⓧ It's not installed yet

macOS

The easiest way to install PostgreSQL on macOS is with Homebrew.

  • If you do not have Homebrew, install it first by visiting https://brew.sh and following the instructions.
  • Install PostgreSQL by running this command:
brew install postgresql@17
  • Start the PostgreSQL server by running:
brew services start postgresql@17

Homebrew sets up PostgreSQL with your macOS username as the default superuser. No separate password is needed for local connections.

Windows

  • Open https://www.postgresql.org/download/windows/ in your browser.
  • Click Download the installer to go to the EDB downloads page.
  • Download the latest version for Windows x86-64.
  • Run the installer. Accept the default installation directory.
  • On the Select Components screen, make sure PostgreSQL Server, pgAdmin 4, and Command Line Tools are all checked.
  • Set a superuser password when prompted. Remember this password. You will need it to connect to the database.
  • Keep the port as the default 5432.
  • Click Next through the remaining screens and finish the installation.
Add psql to your PATH

The Windows installer does not add psql to your system PATH by default. You need to do this manually so you can run psql from any Command Prompt.

  • Press the Windows key and search for Environment Variables.
  • Click Edit the system environment variables.
  • Click Environment Variables.
  • Under System variables, select Path and click Edit.
  • Click New and add the path to your PostgreSQL bin folder. The default is:
C:\Program Files\PostgreSQL\17\bin
  • Click OK on all open dialogs to save.
  • Close and reopen your Command Prompt.
  • Run psql --version to confirm it now works.

Now you will create the database that your API will use to store transactions.

  • Connect to PostgreSQL by running this command:
psql -U postgres

What about the password prompt?

On Windows, you will be prompted for the superuser password you set during installation. Type it and press Enter. The characters will not appear on screen as you type. This is normal.

On macOS with Homebrew, the default user matches your macOS username. Run psql postgres instead if psql -U postgres asks for a password you do not have.

  • Create the payment_dashboard database by running this SQL command inside the psql shell:
CREATE DATABASE payment_dashboard;

What does this command do?

This creates a new, empty database called payment_dashboard on your local PostgreSQL server. Your NestJS API will connect to this database to read and write transaction records.

You should see CREATE DATABASE printed as confirmation.

  • Exit the psql shell by typing:
\q

Connection refused or role does not exist?

If you see connection refused, your PostgreSQL server is not running. On macOS, run brew services start postgresql@17. On Windows, open the Services app and start the postgresql service.

If you see role "postgres" does not exist on macOS, try psql postgres instead. Homebrew creates a default role matching your macOS username, not postgres.

help me connect to my local PostgreSQL database

Create a Stripe account and find your test API keys

Stripe is the payment provider your API will use to create Checkout Sessions and process payments. You need a free Stripe account and your test API keys before you can build the payments module.

  • Open https://dashboard.stripe.com/register in your browser.
  • Fill in your email, full name, and a password to create your account.
  • After signing in, look at the top-left corner of the Stripe Dashboard.
  • Confirm that Test mode is toggled on. The toggle area should appear orange. If it shows blue (Live mode), click the toggle to switch to Test mode.
  • Click Developers in the sidebar or top navigation.
  • Click API keys.
  • You will see two keys under the Standard keys section. The Publishable key starts with pk_test_. The Secret key starts with sk_test_.
  • Keep this browser tab open. You will need both keys in the next step when you configure your environment variables.

What is Stripe Test Mode?

Test Mode lets you simulate payments without using real money. All transactions use test card numbers and no actual charges are made.

Test mode and Live mode each have their own separate API keys. Your test keys will never accidentally charge a real customer.

Your development environment is fully set up. Next, you will scaffold the NestJS project and explore the architecture that makes NestJS different from plain Express.

Scaffold the NestJS Project and Understand the Architecture

Your development environment is all set up. Now it's time to create the NestJS application that powers your payment dashboard.

NestJS structures applications using Modules, Controllers, and Services. In this step, you'll scaffold the project, explore what each generated file does, and configure environment variables for your database and Stripe credentials.

In this step, get ready to:
  • Scaffold a new NestJS project and explore its architecture.
  • Start the development server and verify it runs.
  • Configure environment variables for your database and Stripe keys.
Create the NestJS project

The NestJS CLI generates a complete project structure with all the boilerplate you need. Every generated file has a specific role in the application's architecture.

  • In your terminal, move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your NestJS project by running this command:
nest new payment-dashboard-api
  • The CLI asks which package manager to use. Select npm and press Enter.
  • Wait for the installation to finish. This takes about a minute.

You should see a success message in your terminal once the project is created.

  • Move into your new project folder by running:
cd payment-dashboard-api
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the payment-dashboard-api folder, then click Open.

Expand the src/ folder in the VS Code Explorer sidebar. The CLI generated four key files for you.

What did the CLI just create?

  • main.ts is the entry point. It bootstraps the application by calling NestFactory.create() and starts the HTTP server on port 3000.
  • app.module.ts is the root module. It registers all feature modules, controllers, and services that make up the application.
  • app.controller.ts handles incoming HTTP requests. Right now it has a single route that returns "Hello World!".
  • app.service.ts contains business logic. The controller delegates work to the service instead of handling it directly.

What is the Module-Controller-Service pattern?

NestJS organizes every feature into three layers. Controllers handle HTTP requests and responses. Services contain the actual business logic like database queries and API calls. Modules group related controllers and services together.

This separation of concerns means each layer has one job. You can swap out a service without touching the controller, or reuse a module across different applications.

Seeing 'nest: command not found'?

  • Make sure you installed the NestJS CLI globally in Step 1 with npm i -g @nestjs/cli.
  • Close and reopen your terminal, then try the nest new command again.

Still stuck? Help me troubleshoot why the nest command is not found on my system.

Start the development server

Your project is scaffolded, but it's not running yet. Let's start the development server and confirm everything works.

  • Start the NestJS development server by running this command:
npm run start:dev

Your terminal shows log output as the server starts. Leave it running.

  • Open your browser and navigate to http://localhost:3000.
  • You should see Hello World! displayed on the page.

What just happened under the hood?

When you ran npm run start:dev, NestJS bootstrapped the application through a specific sequence.

  • NestFactory.create() reads your root module and builds the application.
  • It registers all modules, resolves the dependency injection container, and wires controllers to services.
  • An Express HTTP server starts on port 3000 and begins listening for requests.

What is Dependency Injection?

Dependency Injection (DI) means that a class doesn't create its own dependencies. Instead, the framework provides them automatically.

For example, AppController needs AppService to work. Instead of the controller creating the service itself, NestJS creates an instance and passes it in. This makes your code easier to test and swap out.

Not seeing Hello World?

  • Check your terminal for error messages. A common issue is another process already using port 3000.
  • Make sure you are inside the payment-dashboard-api folder when you run the command.
  • Try stopping the server with Ctrl+C and running npm run start:dev again.

Still stuck? Help me debug why my NestJS server is not responding at localhost:3000.

Configure environment variables

Your application needs to connect to PostgreSQL and Stripe. Both require credentials that should never be hardcoded in your source files.

Environment variables store these secrets in a separate .env file that stays on your machine and out of version control.

  • In your terminal, press Ctrl+C to stop the development server.
  • Install the NestJS configuration package by running:
npm install @nestjs/config

You should see a confirmation message showing the package was added.

Install failed or timed out?

  • Check your internet connection and try running the command again.
  • Make sure you are inside the payment-dashboard-api folder, not the Desktop.

Still stuck? Help me troubleshoot why npm install is failing in my NestJS project.

  • In VS Code's Explorer sidebar, right-click in the payment-dashboard-api root folder area.
  • Select New File and name it .env.
  • Paste the following content into your new .env file:
DATABASE_URL="postgresql://postgres:your-password-here@localhost:5432/payment_dashboard"
STRIPE_SECRET_KEY="sk_test_your-stripe-secret-key-here"
STRIPE_WEBHOOK_SECRET="whsec_your-webhook-secret-here"
  • Replace your-password-here with the password you set during PostgreSQL installation.
  • Replace sk_test_your-stripe-secret-key-here with your Stripe test Secret key from the Stripe Dashboard.
  • Leave STRIPE_WEBHOOK_SECRET as the placeholder for now. You will set this up later.
  • Save the file.

What does each variable do?

  • DATABASE_URL tells Prisma how to connect to your local PostgreSQL database.
  • STRIPE_SECRET_KEY authenticates your server with the Stripe API. The sk_test_ prefix means it only works in test mode.
  • STRIPE_WEBHOOK_SECRET verifies that incoming webhook requests actually come from Stripe.

✔️ Awesome, I've got everything!

Great. Double check you've saved your .env file.

ⓧ I'd like to double check the full code

DATABASE_URL="postgresql://postgres:your-password-here@localhost:5432/payment_dashboard"
STRIPE_SECRET_KEY="sk_test_your-stripe-secret-key-here"
STRIPE_WEBHOOK_SECRET="whsec_your-webhook-secret-here"
  • Open src/app.module.ts in VS Code.
  • Replace the entire file contents with the following code:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    // Load environment variables from .env and make them available globally
    ConfigModule.forRoot({ isGlobal: true }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

What does this code do?

  • ConfigModule.forRoot() reads your .env file and loads every variable into the NestJS configuration system.
  • The isGlobal: true option makes configuration available to every module in your application. Without it, each module would need to import ConfigModule separately.

Why use environment variables instead of hardcoding values?

Secrets like API keys and database passwords must never appear in your source code. If you push code to GitHub with a hardcoded key, anyone can see it.

Environment variables also let you run the same code in different environments. Your local machine uses a local database URL. A staging server uses a different one. The code stays the same.

  • Save src/app.module.ts.
  • Restart the development server by running:
npm run start:dev
  • Refresh http://localhost:3000 in your browser. You should still see Hello World! confirming the configuration loaded without errors.

Seeing an error when the server starts?

  • Check that the import path is exactly @nestjs/config with no typos.
  • Confirm you ran npm install @nestjs/config before editing the module file.
  • Make sure your .env file is in the project root (next to package.json), not inside the src/ folder.

Still stuck? Help me fix a NestJS startup error after adding ConfigModule.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Your NestJS project is scaffolded, running, and configured with environment variables. Next up, you'll connect Prisma ORM to your PostgreSQL database and design the data model for transactions.

Set Up Prisma and Design the Database Schema

Your NestJS app is running and serving "Hello World" at http://localhost:3000. That proves the framework works, but right now it has no way to store or retrieve data.

In this step, you will connect your app to PostgreSQL using Prisma, a schema-first ORM. You will design a Transaction data model, apply your first database migration, and wire Prisma into your NestJS app as a global service.

In this step, get ready to:
  • Install Prisma and initialize the database connection.
  • Define a Transaction model with an enum for payment statuses.
  • Run your first migration and create a global PrismaService for your app.
Install Prisma and initialize the project

Your app needs to talk to a database, but writing raw SQL queries for every operation is tedious and error-prone. An ORM (Object-Relational Mapper) solves this by letting you define your database tables as TypeScript objects.

  • Install Prisma as a development dependency by running this command:
npm install prisma --save-dev
  • Install the Prisma runtime packages by running:
npm install @prisma/client @prisma/adapter-pg pg

What did you just install?

The prisma package is the CLI tool you use during development to create migrations and generate code.

The @prisma/client package is the auto-generated query builder your app uses at runtime. @prisma/adapter-pg and pg are the PostgreSQL driver packages that Prisma 7 uses to connect to your database.

Seeing permission errors?

Make sure your terminal is inside the payment-dashboard-api folder. Run ls and confirm you see package.json in the output.

help me fix npm install permission errors

  • Initialize Prisma in your project by running this command:
npx prisma init --datasource-provider postgresql

What does this command do?

This creates a prisma/ directory with a schema.prisma file. This file is where you define your database tables and relationships. It also updates your .env file with a DATABASE_URL placeholder.

  • Open the .env file in the root of your project.
  • Confirm that your DATABASE_URL line points to your local payment_dashboard database. If Prisma replaced it with a generic placeholder, update it so it looks like this:
DATABASE_URL="postgresql://postgres:your-password-here@localhost:5432/payment_dashboard"

What do these parts mean?

  • postgres is the default PostgreSQL username.
  • your-password-here is the password you set when you installed PostgreSQL. Replace it with your actual password.
  • localhost:5432 is the default address and port PostgreSQL runs on.
  • payment_dashboard is the database you created in Step 1.
  • Save .env.

Not sure what password you used?

The password is whatever you entered during the PostgreSQL installer in Step 1. If you cannot remember it, you can uninstall and reinstall PostgreSQL with a new password.

help me reset my PostgreSQL password

Why Prisma over TypeORM?

TypeORM is another popular ORM for TypeScript projects. It uses decorators on your classes to define the database schema, which means your schema is scattered across multiple files.

Prisma takes a schema-first approach. You define your entire data model in one schema.prisma file, and Prisma generates a fully type-safe client from it. This single source of truth makes migrations more reliable and catches type errors at compile time instead of runtime.

Define the TransactionStatus enum

Every payment goes through a lifecycle. It starts as pending, then completes, fails, or gets refunded. You will model these states as an enum so the database enforces valid statuses at the type level.

  • Open prisma/schema.prisma in VS Code.
  • You should see a generator block and a datasource block that Prisma created for you. Verify they match this:
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

What do these blocks do?

The generator block tells Prisma to create a TypeScript client in the generated/prisma folder. This client is what your app imports to query the database.

The datasource block points Prisma at your PostgreSQL database using the DATABASE_URL from your .env file.

  • Below the datasource block, add the TransactionStatus enum by pasting this code:
enum TransactionStatus {
  PENDING
  COMPLETED
  FAILED
  REFUNDED
}

Why use an enum instead of a plain string?

A plain string field would accept any value, including typos like "complted" or "Pending". An enum restricts the column to only the four values you defined. This gives you type safety in TypeScript and data integrity in PostgreSQL at the same time.

Add the Transaction model

The Transaction model maps to a database table that stores every payment your app processes. Each field becomes a column in PostgreSQL.

  • Below the TransactionStatus enum, add the Transaction model by pasting this code:
model Transaction {
  id              String            @id @default(uuid())
  stripeSessionId String            @unique
  amount          Int
  currency        String            @default("usd")
  status          TransactionStatus @default(PENDING)
  customerEmail   String?
  description     String
  createdAt       DateTime          @default(now())
  updatedAt       DateTime          @updatedAt
}

What does each field do?

  • id is a UUID (universally unique identifier) generated automatically. UUIDs are preferred over auto-incrementing integers because they do not reveal how many transactions exist in your system.
  • stripeSessionId links each transaction to a Stripe Checkout Session. The @unique constraint prevents duplicate records for the same payment.
  • amount is stored as an integer in cents. Storing $19.99 as 1999 avoids floating-point precision bugs that plague financial software.
  • customerEmail is marked with ? to make it optional, since not every checkout session captures an email.
  • updatedAt uses @updatedAt so Prisma automatically sets this timestamp whenever the record changes.
  • Save prisma/schema.prisma.

✔️ Awesome, I've got everything!

Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum TransactionStatus {
  PENDING
  COMPLETED
  FAILED
  REFUNDED
}

model Transaction {
  id              String            @id @default(uuid())
  stripeSessionId String            @unique
  amount          Int
  currency        String            @default("usd")
  status          TransactionStatus @default(PENDING)
  customerEmail   String?
  description     String
  createdAt       DateTime          @default(now())
  updatedAt       DateTime          @updatedAt
}

Seeing red underlines in schema.prisma?

Your editor may not recognise Prisma syntax by default. Search for the "Prisma" extension in the VS Code extensions panel and install it. This adds syntax highlighting and error checking for .prisma files.

help me fix Prisma schema errors in VS Code

Run the migration and create the Prisma service

Your schema defines what the database should look like. A migration is a versioned script that applies those changes to the actual database. This is how teams collaborate on database changes without overwriting each other's work.

  • Create and apply your first migration by running this command:
npx prisma migrate dev --name init

What does this command do?

Prisma compares your schema.prisma file against the current state of your PostgreSQL database. It generates a SQL migration file inside prisma/migrations/ and then runs that SQL to create the Transaction table and TransactionStatus enum in your database.

  • You should see output that includes Your database is now in sync with your schema confirming the migration was applied.

Seeing a database connection error?

Check that PostgreSQL is running on your machine. Open a new terminal and run psql -U postgres to test the connection.

Also verify that the password in your DATABASE_URL inside .env matches the password you set during PostgreSQL installation.

help me debug my Prisma database connection error

  • Generate the Prisma Client by running this command:
npx prisma generate

What does Prisma generate?

This creates a fully type-safe TypeScript client in the generated/prisma/ folder. When your app imports PrismaClient, it gets auto-complete and compile-time checks for every table and column you defined in the schema. You will never write a query that references a column that does not exist.

Now you need a NestJS service that wraps the Prisma Client and handles the database connection lifecycle. This service will be available to every module in your app.

  • Create a new folder called prisma inside your src/ directory.
  • Create a new file called prisma.service.ts inside src/prisma/ and paste the following code:
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../../generated/prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  // Connect to the database when the module initializes
  async onModuleInit() {
    await this.$connect();
  }

  // Disconnect when the app shuts down
  async onModuleDestroy() {
    await this.$disconnect();
  }
}

What does this service do?

PrismaService extends PrismaClient, which means it inherits every query method Prisma generated (like transaction.create() and transaction.findMany()).

The OnModuleInit and OnModuleDestroy interfaces are NestJS lifecycle hooks. They ensure the database connection opens when the app starts and closes cleanly when the app stops.

  • Save src/prisma/prisma.service.ts.
  • Create another new file called prisma.module.ts inside src/prisma/ and paste the following code:
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

// @Global() makes PrismaService available to every module
// without needing to import PrismaModule in each one
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Why is this module marked @Global()?

In NestJS, a service is only available inside the module that declares it. Since almost every feature module (transactions, payments) needs database access, marking PrismaModule as @Global() means you register it once in the root module and every other module can inject PrismaService automatically.

  • Save src/prisma/prisma.module.ts.

The last piece is registering PrismaModule in your root module so NestJS knows about it.

  • Open src/app.module.ts.
  • Replace the entire contents of the file with this code:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
  ],
})
export class AppModule {}

What changed in app.module.ts?

You removed the default AppController and AppService that the NestJS scaffold created. You will replace them with feature-specific modules in the next steps.

Adding PrismaModule to the imports array registers it with the NestJS dependency injection container. Because PrismaModule is @Global(), every future module gets access to PrismaService for free.

  • Save src/app.module.ts.
  • Check your terminal where npm run start:dev is running. You should see NestJS recompile without errors.

Seeing a module import error?

Make sure the prisma.service.ts and prisma.module.ts files are inside src/prisma/. The import path in app.module.ts is ./prisma/prisma.module, which expects that exact folder structure.

Also confirm the import path in prisma.service.ts is ../../generated/prisma/client. This path is relative to the file's location inside src/prisma/.

help me fix NestJS module import errors for PrismaModule

✔️ Awesome, I've got everything!

Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../../generated/prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
  ],
})
export class AppModule {}

Your database is connected, your schema is defined, and Prisma is wired into your NestJS app as a global service. Next up, you will build the Transactions module with proper DTOs, validation, and REST endpoints to create and query transaction data.

Build the Transactions Module with DTOs and Validation

Your database is connected and your Transaction model is ready. Now you need an API layer that lets clients create, read, and update transactions.

NestJS organizes features into modules, where each module groups a controller (handles HTTP requests) and a service (handles business logic). In this step, you will build the Transactions module with proper input validation so your API rejects malformed data before it ever touches the database.

In this step, get ready to:
  • Generate the Transactions module, controller, and service using the NestJS CLI.
  • Create a DTO with validation decorators and enable the global ValidationPipe.
  • Implement CRUD endpoints and test them with curl.
Generate the Transactions module files

Every domain concept in a NestJS application gets its own module. Transactions, payments, and users each live in isolated modules with their own controller and service. This is feature-based architecture.

  • Generate the Transactions module, controller, and service by running these three commands one at a time:
nest generate module transactions
nest generate controller transactions
nest generate service transactions

What did the CLI just create?

Each command created a file inside a new src/transactions/ folder:

  • transactions.module.ts registers which controllers handle routes and which services provide business logic.
  • transactions.controller.ts receives incoming HTTP requests and delegates work to the service.
  • transactions.service.ts contains the actual business logic and database queries.

The CLI also auto-updated app.module.ts to import TransactionsModule. This is the Module-Controller-Service pattern. Controllers handle HTTP. Services handle logic. Modules organize features.

  • Confirm the files exist by running:
ls src/transactions/

You should see transactions.controller.ts, transactions.controller.spec.ts, transactions.module.ts, transactions.service.ts, and transactions.service.spec.ts listed in the output.

Seeing 'command not found: nest'?

Make sure you installed the NestJS CLI globally in Step 1 with npm i -g @nestjs/cli. Also confirm your terminal is inside the payment-dashboard-api folder.

Help me fix a 'command not found: nest' error in my terminal.

Create the DTO and set up validation

Before wiring up your endpoints, you need a way to validate incoming request data. A DTO (Data Transfer Object) is a class that defines the exact shape of data your API accepts. If a request does not match the DTO, NestJS rejects it automatically.

  • Install the validation packages by running:
npm install class-validator class-transformer
  • You should see added 2 packages (or similar) in the terminal output.

Install failing with permission errors?

Try running the command with administrator privileges. On Windows, open your terminal as Administrator. On macOS/Linux, prefix the command with sudo.

Help me fix npm install permission errors.

Now you will create the DTO file. The class-validator library provides decorators like @IsString() and @IsNumber() that declare what type each field must be.

  • Inside src/transactions/, create a new folder called dto.
  • Inside the dto folder, create a new file called create-transaction.dto.ts and paste the following code:
import {
  IsString,
  IsNumber,
  IsEnum,
  IsOptional,
  IsEmail,
} from 'class-validator';
import { TransactionStatus } from '../../../generated/prisma/client';

export class CreateTransactionDto {
  // The Stripe Checkout Session ID that links this transaction to Stripe
  @IsString()
  stripeSessionId: string;

  // Amount in cents (integer math avoids floating-point bugs)
  @IsNumber()
  amount: number;

  // Currency defaults to USD if not provided
  @IsString()
  @IsOptional()
  currency?: string;

  // Status defaults to PENDING if not provided
  @IsEnum(TransactionStatus)
  @IsOptional()
  status?: TransactionStatus;

  // Customer email is optional because Stripe may not always provide it
  @IsEmail()
  @IsOptional()
  customerEmail?: string;

  // A human-readable description of what was purchased
  @IsString()
  description: string;
}

What do these decorators do?

  • @IsString() and @IsNumber() enforce that the field value matches the expected type.
  • @IsEnum(TransactionStatus) restricts the value to one of the enum values you defined in your Prisma schema: PENDING, COMPLETED, FAILED, or REFUNDED.
  • @IsOptional() marks a field as not required. Without it, every field must be present or the request is rejected.
  • @IsEmail() checks that the string is a valid email format.

This DTO acts as a contract. Any request that does not match this shape gets rejected before your business logic ever runs.

  • Save create-transaction.dto.ts.

✔️ Awesome, I've got everything!

Great. Double check that you saved the file before moving on.

ⓧ I'd like to double check the full code

import {
  IsString,
  IsNumber,
  IsEnum,
  IsOptional,
  IsEmail,
} from 'class-validator';
import { TransactionStatus } from '../../../generated/prisma/client';

export class CreateTransactionDto {
  @IsString()
  stripeSessionId: string;

  @IsNumber()
  amount: number;

  @IsString()
  @IsOptional()
  currency?: string;

  @IsEnum(TransactionStatus)
  @IsOptional()
  status?: TransactionStatus;

  @IsEmail()
  @IsOptional()
  customerEmail?: string;

  @IsString()
  description: string;
}

The DTO defines what valid data looks like. Now you need to tell NestJS to actually enforce those rules on every incoming request. This is done with a ValidationPipe, which automatically checks request bodies against the DTO decorators.

  • Open src/main.ts in VS Code and replace its entire contents with the following code:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  // rawBody: true preserves the raw request body for Stripe webhook verification
  const app = await NestFactory.create(AppModule, {
    rawBody: true,
  });

  // Automatically validate all incoming requests against their DTOs
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );

  // Allow the React frontend (different port) to call this API
  app.enableCors();

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

What does this code do?

  • rawBody: true tells NestJS to keep the raw (unparsed) request body available. You will need this later for Stripe webhook signature verification.
  • whitelist: true strips any fields from the request body that are not defined in the DTO. This is a security best practice that prevents unexpected data from reaching your service.
  • transform: true automatically converts incoming data to the types declared in the DTO (for example, turning a string "100" into the number 100).
  • enableCors() allows your React frontend (running on a different port) to make requests to this API.
  • Save main.ts.
  • If npm run start:dev is still running, your terminal should show the app recompiling without errors.

Seeing a compilation error after saving?

Check that the ValidationPipe import comes from @nestjs/common, not @nestjs/core. Also verify that the parentheses and brackets around new ValidationPipe({ ... }) are balanced.

Help me debug a compilation error in my NestJS main.ts file.

✔️ Awesome, I've got everything!

Great. Double check that you saved the file before moving on.

ⓧ I'd like to double check the full code

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true,
  });
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );
  app.enableCors();
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Implement the service and controller

The CLI generated boilerplate files for your service and controller. Now you will replace them with the actual implementation. The service handles all database queries through the injected PrismaService. The controller maps HTTP endpoints to service methods.

  • Open src/transactions/transactions.service.ts and replace its entire contents with the following code:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTransactionDto } from './dto/create-transaction.dto';
import { TransactionStatus } from '../../generated/prisma/client';

@Injectable()
export class TransactionsService {
  // PrismaService is injected automatically by NestJS dependency injection
  constructor(private prisma: PrismaService) {}

  // Create a new transaction record in the database
  async create(data: CreateTransactionDto) {
    return this.prisma.transaction.create({
      data: {
        stripeSessionId: data.stripeSessionId,
        amount: data.amount,
        currency: data.currency ?? 'usd',
        status: data.status ?? TransactionStatus.PENDING,
        customerEmail: data.customerEmail,
        description: data.description,
      },
    });
  }

  // Retrieve all transactions, optionally filtered by status
  async findAll(status?: TransactionStatus) {
    const where = status ? { status } : {};
    return this.prisma.transaction.findMany({
      where,
      orderBy: { createdAt: 'desc' },
    });
  }

  // Find a single transaction by its database ID
  async findOne(id: string) {
    return this.prisma.transaction.findUnique({ where: { id } });
  }

  // Find a transaction by its Stripe session ID (used by webhook handler)
  async findBySessionId(stripeSessionId: string) {
    return this.prisma.transaction.findUnique({
      where: { stripeSessionId },
    });
  }

  // Update the status of a transaction (e.g. PENDING to COMPLETED)
  async updateStatus(id: string, status: TransactionStatus) {
    return this.prisma.transaction.update({
      where: { id },
      data: { status },
    });
  }
}

What does this service do?

  • constructor(private prisma: PrismaService) uses Dependency Injection. NestJS sees that the constructor needs a PrismaService and automatically provides one. You never call new PrismaService() yourself.
  • create() inserts a new transaction. The ?? operator provides defaults for optional fields like currency and status.
  • findAll() returns all transactions sorted by newest first. The optional status parameter enables filtering (you will use this in the React dashboard).
  • findBySessionId() looks up a transaction by its Stripe session ID. The webhook handler will use this to match Stripe events to your database records.
  • Save transactions.service.ts.

You won't see a visible change yet. The controller in the next chunk will expose these methods as HTTP endpoints.

✔️ Awesome, I've got everything!

Great. Double check that you saved the file before moving on.

ⓧ I'd like to double check the full code

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTransactionDto } from './dto/create-transaction.dto';
import { TransactionStatus } from '../../generated/prisma/client';

@Injectable()
export class TransactionsService {
  constructor(private prisma: PrismaService) {}

  async create(data: CreateTransactionDto) {
    return this.prisma.transaction.create({
      data: {
        stripeSessionId: data.stripeSessionId,
        amount: data.amount,
        currency: data.currency ?? 'usd',
        status: data.status ?? TransactionStatus.PENDING,
        customerEmail: data.customerEmail,
        description: data.description,
      },
    });
  }

  async findAll(status?: TransactionStatus) {
    const where = status ? { status } : {};
    return this.prisma.transaction.findMany({
      where,
      orderBy: { createdAt: 'desc' },
    });
  }

  async findOne(id: string) {
    return this.prisma.transaction.findUnique({ where: { id } });
  }

  async findBySessionId(stripeSessionId: string) {
    return this.prisma.transaction.findUnique({
      where: { stripeSessionId },
    });
  }

  async updateStatus(id: string, status: TransactionStatus) {
    return this.prisma.transaction.update({
      where: { id },
      data: { status },
    });
  }
}

Now create the controller. The controller maps REST endpoints to the service methods you just wrote. It handles HTTP routing and delegates all business logic to the service.

  • Open src/transactions/transactions.controller.ts and replace its entire contents with the following code:
import {
  Controller,
  Get,
  Post,
  Patch,
  Param,
  Body,
  Query,
} from '@nestjs/common';
import { TransactionsService } from './transactions.service';
import { CreateTransactionDto } from './dto/create-transaction.dto';
import { TransactionStatus } from '../../generated/prisma/client';

@Controller('transactions')
export class TransactionsController {
  constructor(private readonly transactionsService: TransactionsService) {}

  // POST /transactions - create a new transaction
  @Post()
  create(@Body() createTransactionDto: CreateTransactionDto) {
    return this.transactionsService.create(createTransactionDto);
  }

  // GET /transactions - list all transactions (optional ?status= filter)
  @Get()
  findAll(@Query('status') status?: TransactionStatus) {
    return this.transactionsService.findAll(status);
  }

  // GET /transactions/:id - get a single transaction by ID
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.transactionsService.findOne(id);
  }

  // PATCH /transactions/:id/status - update a transaction's status
  @Patch(':id/status')
  updateStatus(
    @Param('id') id: string,
    @Body('status') status: TransactionStatus,
  ) {
    return this.transactionsService.updateStatus(id, status);
  }
}

What does this code do?

  • @Controller('transactions') sets the base route. All endpoints in this controller start with /transactions.
  • @Post() creates a resource. @Get() reads resources. @Patch() partially updates a resource. These are standard RESTful conventions.
  • @Body() extracts the request body. @Param('id') extracts a URL parameter. @Query('status') extracts a query string value like ?status=COMPLETED.
  • The controller only validates and delegates. All database logic lives in the service. This separation of concerns makes each layer independently testable.
  • Save transactions.controller.ts.

✔️ Awesome, I've got everything!

Great. Double check that you saved the file before moving on.

ⓧ I'd like to double check the full code

import {
  Controller,
  Get,
  Post,
  Patch,
  Param,
  Body,
  Query,
} from '@nestjs/common';
import { TransactionsService } from './transactions.service';
import { CreateTransactionDto } from './dto/create-transaction.dto';
import { TransactionStatus } from '../../generated/prisma/client';

@Controller('transactions')
export class TransactionsController {
  constructor(private readonly transactionsService: TransactionsService) {}

  @Post()
  create(@Body() createTransactionDto: CreateTransactionDto) {
    return this.transactionsService.create(createTransactionDto);
  }

  @Get()
  findAll(@Query('status') status?: TransactionStatus) {
    return this.transactionsService.findAll(status);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.transactionsService.findOne(id);
  }

  @Patch(':id/status')
  updateStatus(
    @Param('id') id: string,
    @Body('status') status: TransactionStatus,
  ) {
    return this.transactionsService.updateStatus(id, status);
  }
}

The Payments module (which you will build next) needs access to the Transactions service to create and update transaction records. You need to export it from the Transactions module.

  • Open src/transactions/transactions.module.ts.
  • Find this block inside the @Module() decorator:
@Module({
  controllers: [TransactionsController],
  providers: [TransactionsService],
})
  • Add an exports array so the decorator now looks like this:
@Module({
  controllers: [TransactionsController],
  providers: [TransactionsService],
  exports: [TransactionsService],
})
  • Save transactions.module.ts.

Now test your API. Make sure npm run start:dev is running (start it if it is not). You will send a test transaction to your API and then retrieve it.

  • Open a second terminal window (keep the dev server running in the first).
  • Create a test transaction by running:
curl -X POST http://localhost:3000/transactions -H "Content-Type: application/json" -d "{\"stripeSessionId\": \"test_session_123\", \"amount\": 2500, \"description\": \"Test payment\"}"

You should see a JSON response with your new transaction, including an auto-generated id, status: "PENDING", and currency: "usd".

  • Retrieve all transactions by running:
curl http://localhost:3000/transactions

You should see a JSON array containing the transaction you just created.

  • Test validation by sending an invalid request with a missing required field:
curl -X POST http://localhost:3000/transactions -H "Content-Type: application/json" -d "{\"amount\": 1000}"

You should see a 400 Bad Request response with validation error messages. The DTO rejected the request because stripeSessionId and description were missing.

Getting a 500 error instead of data?

  • Check the terminal running npm run start:dev for the full error message. A common cause is a database connection issue. Verify that PostgreSQL is running and that your DATABASE_URL in .env is correct.
  • If you see Cannot find module errors, make sure the import paths match exactly. The Prisma client import should be ../../generated/prisma/client (relative to the file's location).
  • If curl itself is not found on Windows, use PowerShell's Invoke-RestMethod or install curl from curl.se.

Help me debug a 500 error from my NestJS transactions endpoint.

Your Transactions module is fully built and tested. You have a modular API that validates input, queries the database, and serves REST endpoints. Next up, you will integrate Stripe to create real payment sessions and handle webhook events that update these transactions automatically.

Integrate Stripe Payment Processing

Your Transactions API is live with full CRUD endpoints and validated DTOs. The next piece of the puzzle is connecting a real payment provider so transactions are created automatically when a customer pays.

In this step, you'll integrate Stripe into your API. Your server will create Checkout Sessions that redirect customers to a Stripe-hosted payment page. After the customer pays, Stripe sends a webhook back to your API to confirm the payment.

In this step, get ready to:
  • Install the Stripe SDK and scaffold a new Payments module.
  • Create a Checkout Session endpoint that generates Stripe payment links.
  • Handle Stripe webhook events to update transaction statuses automatically.
Install Stripe and scaffold the Payments module

Before writing any payment logic, you need the Stripe Node SDK and a dedicated NestJS module to house your payment code.

  • Install the Stripe SDK by running this command:
npm install stripe
  • Confirm Stripe was added by checking your package.json for a stripe entry under dependencies.

npm install failed?

Make sure your terminal is inside the payment-dashboard-api folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.

Help me debug my npm install issue

  • Scaffold the Payments module, controller, and service by running these three commands:
nest generate module payments
nest generate controller payments
nest generate service payments

What do these commands do?

Each command creates a file inside a new src/payments/ directory. The CLI also wires everything together automatically: the module gets added to app.module.ts, and the controller and service get registered inside the payments module.

  • Verify the CLI created three new files by running:
ls src/payments/
  • You should see payments.controller.ts, payments.module.ts, payments.service.ts, and a spec file in the output.

The CLI-generated module needs one addition. Your Payments module needs to access the TransactionsService to create and update transaction records when payments happen.

  • Open src/payments/payments.module.ts and replace its entire content with the following:
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { TransactionsModule } from '../transactions/transactions.module';

// Import TransactionsModule so PaymentsService can use TransactionsService
@Module({
  imports: [TransactionsModule],
  controllers: [PaymentsController],
  providers: [PaymentsService],
})
export class PaymentsModule {}

What does this code do?

The only change from the CLI-generated version is the imports: [TransactionsModule] line. This makes TransactionsService available for injection inside this module.

In the previous step, you exported TransactionsService from TransactionsModule. Importing that module here completes the connection.

  • Save payments.module.ts.

Now for the service. The PaymentsService is where Stripe lives in your application. It initializes the Stripe client using your secret key from environment variables.

  • Open src/payments/payments.service.ts and replace its entire content with this skeleton:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import { TransactionsService } from '../transactions/transactions.service';
import { TransactionStatus } from '../../generated/prisma/client';

@Injectable()
export class PaymentsService {
  // Private Stripe client instance
  private stripe: Stripe;

  constructor(
    private configService: ConfigService,
    private transactionsService: TransactionsService,
  ) {
    // Initialize Stripe with the secret key from environment variables
    this.stripe = new Stripe(
      this.configService.get<string>('STRIPE_SECRET_KEY'),
    );
  }
}

Why initialize Stripe inside a service?

The Stripe client is created in the constructor instead of globally so that NestJS's dependency injection container manages it. This keeps the dependency injectable, testable, and scoped to the Payments module.

In a real codebase, you could swap Stripe for another payment provider without touching any controllers. Only this service would change.

  • Save payments.service.ts.
  • Check your terminal where npm run start:dev is running. The server should recompile without errors.

Finally, confirm that app.module.ts was automatically updated by the CLI. The nest generate module command should have added PaymentsModule to the imports array.

  • Open src/app.module.ts and verify it looks like this:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { TransactionsModule } from './transactions/transactions.module';
import { PaymentsModule } from './payments/payments.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    TransactionsModule,
    PaymentsModule,
  ],
})
export class AppModule {}
  • If the CLI did not add the PaymentsModule import and entry, add them manually and save the file.

Server crashing after save?

If you see a dependency injection error, make sure TransactionsModule is in the imports array of payments.module.ts. NestJS cannot inject TransactionsService into PaymentsService unless the module that exports it is imported.

Help me debug my NestJS dependency injection error

Create the Checkout Session endpoint

Stripe's recommended approach for accepting payments is Checkout Sessions. Your server creates a session describing what the customer is buying, and Stripe returns a URL. You redirect the customer to that URL where Stripe handles the entire payment form.

Why Checkout Sessions instead of building a payment form?

Stripe Checkout handles PCI compliance, card validation, error messages, and supported payment methods. Building your own form means taking on all of that responsibility yourself.

The flow is: your server creates a session, Stripe returns a hosted URL, the customer pays on Stripe's page, then Stripe notifies your server via a webhook.

  • In src/payments/payments.service.ts, find the closing brace of the constructor, then add the following createCheckoutSession method below it:
  async createCheckoutSession(description: string, amount: number) {
    // Create a Stripe Checkout Session with the payment details
    const session = await this.stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price_data: {
            currency: 'usd',
            product_data: { name: description },
            unit_amount: amount,
          },
          quantity: 1,
        },
      ],
      mode: 'payment',
      success_url: 'http://localhost:5173?success=true',
      cancel_url: 'http://localhost:5173?canceled=true',
    });

    // Create a PENDING transaction record in the database
    await this.transactionsService.create({
      stripeSessionId: session.id,
      amount: amount,
      currency: 'usd',
      status: TransactionStatus.PENDING,
      description: description,
    });

    // Return the Stripe-hosted payment URL to the client
    return { url: session.url };
  }

What does this code do?

The method does two things. First, it tells Stripe to create a hosted payment page with the product name, price (in cents), and redirect URLs. Stripe returns a session object containing a url the customer visits to pay.

Second, it creates a PENDING transaction in your database. The transaction stays PENDING until Stripe confirms the payment via a webhook.

  • Save payments.service.ts.

Now the controller needs an endpoint that receives a description and amount from the client and calls createCheckoutSession.

  • Open src/payments/payments.controller.ts and replace its entire content with the following:
import { Controller, Post, Body } from '@nestjs/common';
import { PaymentsService } from './payments.service';

@Controller('payments')
export class PaymentsController {
  constructor(private readonly paymentsService: PaymentsService) {}

  // POST /payments/create-checkout-session
  @Post('create-checkout-session')
  createCheckoutSession(
    @Body() body: { description: string; amount: number },
  ) {
    return this.paymentsService.createCheckoutSession(
      body.description,
      body.amount,
    );
  }
}

What does this code do?

The @Controller('payments') decorator prefixes all routes with /payments. The @Post('create-checkout-session') decorator maps to POST /payments/create-checkout-session.

The controller only extracts the request body and delegates to the service. All Stripe logic stays in PaymentsService. This is the same separation of concerns pattern you used in the Transactions module.

  • Save payments.controller.ts and check that the server recompiles without errors.
  • Test the endpoint by running this command in a new terminal window:
curl -X POST http://localhost:3000/payments/create-checkout-session -H "Content-Type: application/json" -d "{\"description\": \"Test Payment\", \"amount\": 2000}"

You should see a JSON response containing a url field pointing to checkout.stripe.com. That URL is a Stripe-hosted payment page for a $20.00 charge (2000 cents).

Seeing an authentication error from Stripe?

Open your .env file and make sure STRIPE_SECRET_KEY contains your actual test secret key from the Stripe Dashboard. It should start with sk_test_. If you updated the key, restart the dev server so NestJS picks up the new environment variable.

Help me debug my Stripe authentication error

Handle Stripe webhook events

Creating a Checkout Session sends the customer to Stripe to pay. But how does your API find out whether the payment succeeded? It does not poll Stripe repeatedly. Instead, Stripe pushes the result to your server through a webhook.

What is a webhook?

A webhook is an HTTP callback. Instead of your server asking Stripe "did they pay yet?" in a loop, Stripe sends an HTTP POST request to your server the moment the payment status changes. This is asynchronous, event-driven architecture.

Stripe signs every webhook payload with a secret so you can verify it was not forged. Without signature verification, anyone could send fake "payment completed" events to your API.

  • In src/payments/payments.service.ts, find the closing brace of createCheckoutSession, then add the following handleWebhookEvent method below it:
  async handleWebhookEvent(payload: Buffer, signature: string) {
    // Read the webhook secret from environment variables
    const webhookSecret = this.configService.get<string>(
      'STRIPE_WEBHOOK_SECRET',
    );
    // Verify the webhook signature to prevent forged requests
    const event = this.stripe.webhooks.constructEvent(
      payload,
      signature,
      webhookSecret,
    );

    switch (event.type) {
      case 'checkout.session.completed': {
        // Payment succeeded - update transaction to COMPLETED
        const session = event.data.object as Stripe.Checkout.Session;
        const transaction =
          await this.transactionsService.findBySessionId(session.id);
        if (transaction) {
          await this.transactionsService.updateStatus(
            transaction.id,
            TransactionStatus.COMPLETED,
          );
        }
        break;
      }
      case 'checkout.session.expired': {
        // Payment expired or was abandoned - update transaction to FAILED
        const session = event.data.object as Stripe.Checkout.Session;
        const transaction =
          await this.transactionsService.findBySessionId(session.id);
        if (transaction) {
          await this.transactionsService.updateStatus(
            transaction.id,
            TransactionStatus.FAILED,
          );
        }
        break;
      }
    }
  }

What does this code do?

First, constructEvent verifies the webhook signature. If someone sends a fake event, this call throws an error and the request is rejected.

Then a switch statement routes each event type. When checkout.session.completed fires, the transaction is marked COMPLETED. When checkout.session.expired fires, it is marked FAILED. The method uses findBySessionId to match the Stripe session back to your database record.

  • Save payments.service.ts.

The controller needs a webhook endpoint that passes the raw request body and the Stripe signature header to the service. Stripe requires the raw (unparsed) body for signature verification. You enabled rawBody: true in main.ts earlier, so req.rawBody is available.

  • Open src/payments/payments.controller.ts and replace its entire content with the final version that includes both endpoints:
import { Controller, Post, Body, Req, Headers } from '@nestjs/common';
import { PaymentsService } from './payments.service';
import { Request } from 'express';

@Controller('payments')
export class PaymentsController {
  constructor(private readonly paymentsService: PaymentsService) {}

  // POST /payments/create-checkout-session
  @Post('create-checkout-session')
  createCheckoutSession(
    @Body() body: { description: string; amount: number },
  ) {
    return this.paymentsService.createCheckoutSession(
      body.description,
      body.amount,
    );
  }

  // POST /payments/webhook - receives Stripe webhook events
  @Post('webhook')
  handleWebhook(
    @Req() req: Request,
    @Headers('stripe-signature') signature: string,
  ) {
    return this.paymentsService.handleWebhookEvent(
      req.rawBody as Buffer,
      signature,
    );
  }
}

What does this code do?

The handleWebhook method uses @Req() to access the raw Express request object. The @Headers('stripe-signature') decorator extracts the signature Stripe attaches to every webhook call.

The req.rawBody as Buffer part is critical. Stripe's signature verification compares the raw bytes of the request against the signature. If NestJS parsed the body into JSON first, the bytes would not match and verification would fail every time.

  • Save payments.controller.ts and check that the server recompiles without errors in the terminal.

Seeing a compilation error?

Make sure the import line includes all four decorators: Controller, Post, Body, Req, Headers. A missing Req or Headers import causes a "not defined" error.

Also verify you have import { Request } from 'express' as a separate import. This is the Express Request type, not a NestJS import.

Help me fix my payments controller compilation error

Extra for Experts: How would you test webhooks locally?

Stripe provides a CLI tool that can forward webhook events to your local server. You would run stripe listen --forward-to localhost:3000/payments/webhook and the CLI gives you a whsec_ signing secret to put in your .env file. You will test this full flow in the next step when the React frontend is ready.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: payments.module.ts, payments.service.ts, and payments.controller.ts.

ⓧ I'd like to double check the full code

Here is the full content of each Payments file for comparison.

import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { TransactionsModule } from '../transactions/transactions.module';

@Module({
  imports: [TransactionsModule],
  controllers: [PaymentsController],
  providers: [PaymentsService],
})
export class PaymentsModule {}
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import { TransactionsService } from '../transactions/transactions.service';
import { TransactionStatus } from '../../generated/prisma/client';

@Injectable()
export class PaymentsService {
  private stripe: Stripe;

  constructor(
    private configService: ConfigService,
    private transactionsService: TransactionsService,
  ) {
    this.stripe = new Stripe(
      this.configService.get<string>('STRIPE_SECRET_KEY'),
    );
  }

  async createCheckoutSession(description: string, amount: number) {
    const session = await this.stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price_data: {
            currency: 'usd',
            product_data: { name: description },
            unit_amount: amount,
          },
          quantity: 1,
        },
      ],
      mode: 'payment',
      success_url: 'http://localhost:5173?success=true',
      cancel_url: 'http://localhost:5173?canceled=true',
    });

    await this.transactionsService.create({
      stripeSessionId: session.id,
      amount: amount,
      currency: 'usd',
      status: TransactionStatus.PENDING,
      description: description,
    });

    return { url: session.url };
  }

  async handleWebhookEvent(payload: Buffer, signature: string) {
    const webhookSecret = this.configService.get<string>(
      'STRIPE_WEBHOOK_SECRET',
    );
    const event = this.stripe.webhooks.constructEvent(
      payload,
      signature,
      webhookSecret,
    );

    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        const transaction =
          await this.transactionsService.findBySessionId(session.id);
        if (transaction) {
          await this.transactionsService.updateStatus(
            transaction.id,
            TransactionStatus.COMPLETED,
          );
        }
        break;
      }
      case 'checkout.session.expired': {
        const session = event.data.object as Stripe.Checkout.Session;
        const transaction =
          await this.transactionsService.findBySessionId(session.id);
        if (transaction) {
          await this.transactionsService.updateStatus(
            transaction.id,
            TransactionStatus.FAILED,
          );
        }
        break;
      }
    }
  }
}
import { Controller, Post, Body, Req, Headers } from '@nestjs/common';
import { PaymentsService } from './payments.service';
import { Request } from 'express';

@Controller('payments')
export class PaymentsController {
  constructor(private readonly paymentsService: PaymentsService) {}

  @Post('create-checkout-session')
  createCheckoutSession(
    @Body() body: { description: string; amount: number },
  ) {
    return this.paymentsService.createCheckoutSession(
      body.description,
      body.amount,
    );
  }

  @Post('webhook')
  handleWebhook(
    @Req() req: Request,
    @Headers('stripe-signature') signature: string,
  ) {
    return this.paymentsService.handleWebhookEvent(
      req.rawBody as Buffer,
      signature,
    );
  }
}

Also confirm app.module.ts includes PaymentsModule in its imports array.

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { TransactionsModule } from './transactions/transactions.module';
import { PaymentsModule } from './payments/payments.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    PrismaModule,
    TransactionsModule,
    PaymentsModule,
  ],
})
export class AppModule {}

Your API can now create payment sessions and react to payment events. Next up, you'll build a React frontend that lets users trigger payments and see their transaction history in a dashboard.

Build the React Dashboard Frontend

Your NestJS API is fully wired up. It can create Stripe Checkout Sessions, process webhooks, and store transactions in PostgreSQL. But right now you can only interact with it through curl or a REST client.

In this step, you will build a React frontend that displays your transaction history and lets you trigger test payments. This turns your backend into a complete full stack application.

In this step, get ready to:
  • Scaffold a React frontend with Vite and install dependencies.
  • Build a TransactionList component and a CheckoutButton component.
  • Assemble the dashboard layout and run an end-to-end test payment.
Scaffold the React frontend project

Modern full stack applications separate the frontend and backend into independent projects. Your NestJS API runs on port 3000. Your React app will run on port 5173. They communicate over HTTP.

Why two separate applications?

Keeping the frontend and backend as independent projects means each one can be developed, tested, and deployed on its own. The backend serves data through a REST API. The frontend fetches that data and renders it for the user.

This is exactly how production teams structure their applications. The two apps only depend on the API contract between them, not on each other's code.

  • Navigate to the parent directory of payment-dashboard-api by running this command:
cd ..
  • Scaffold a new React app with Vite by running this command:
npm create vite@latest payment-dashboard-ui -- --template react-ts

What does this command do?

This uses Vite's scaffolding tool to generate a React project with TypeScript. The --template react-ts flag tells Vite exactly which template to use, so it skips the interactive framework selection prompts.

  • Move into the new project and install the base dependencies by running these commands:
cd payment-dashboard-ui
npm install
  • Install axios for making HTTP requests to your API by running:
npm install axios

What is axios?

Axios is an HTTP client library for JavaScript. It simplifies making API calls by providing a clean interface for GET, POST, and other HTTP methods. You will use it to fetch transaction data from your NestJS API.

  • Start the Vite development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:5173. You should see the default Vite + React welcome page with a spinning logo.

Don't see the welcome page?

  • Check your terminal output for errors. If it says port 5173 is already in use, close the other process or note the alternative port Vite suggests.
  • Make sure you ran npm install before npm run dev. Without the install step, dependencies are missing.
  • Help me troubleshoot my Vite dev server not starting.

Keep this dev server running. You will need a second terminal window for the rest of this step.

What about CORS?

Your React app on port 5173 needs to make requests to your NestJS API on port 3000. Browsers block cross-origin requests by default for security. Your API already has app.enableCors() in main.ts, which allows the frontend to communicate freely with the backend during development.

Build the dashboard components

Your frontend needs two core components. The first displays all transactions from your API. The second triggers a Stripe Checkout payment.

  • Open a new terminal window.
  • Navigate to the payment-dashboard-ui folder.
  • Create a components directory inside src/ by running:
mkdir src/components
  • Create a new file at src/components/TransactionList.tsx and paste the following imports, interface, and status colors map:
import { useEffect, useState } from 'react';
import axios from 'axios';

// Define the shape of a transaction from the API
interface Transaction {
  id: string;
  stripeSessionId: string;
  amount: number;
  currency: string;
  status: string;
  customerEmail: string | null;
  description: string;
  createdAt: string;
  updatedAt: string;
}

// Map each transaction status to a badge color
const statusColors: Record<string, string> = {
  PENDING: '#f59e0b',
  COMPLETED: '#10b981',
  FAILED: '#ef4444',
  REFUNDED: '#6366f1',
};

What does this code do?

  • The Transaction interface defines the exact shape of data your API returns. TypeScript uses this to catch mistakes before you run the app.
  • The statusColors map links each status (PENDING, COMPLETED, FAILED, REFUNDED) to a hex color. You will use these to render color-coded badges in the table.
  • Below the statusColors map in the same file, add the component function:
// Main component that fetches and displays transactions
export default function TransactionList() {
  const [transactions, setTransactions] = useState<Transaction[]>([]);

  // Fetch transactions from the API when the component mounts
  useEffect(() => {
    axios
      .get<Transaction[]>('http://localhost:3000/transactions')
      .then((res) => setTransactions(res.data))
      .catch((err) => console.error('Failed to fetch transactions:', err));
  }, []);

  return (
    <div>
      <h2>Transactions</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th style={{ textAlign: 'left', padding: '8px' }}>Description</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Amount</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Email</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Date</th>
          </tr>
        </thead>
        <tbody>
          {transactions.map((tx) => (
            <tr key={tx.id} style={{ borderBottom: '1px solid #ddd' }}>
              <td style={{ padding: '8px' }}>{tx.description}</td>
              <td style={{ padding: '8px' }}>
                ${(tx.amount / 100).toFixed(2)} {tx.currency.toUpperCase()}
              </td>
              <td style={{ padding: '8px' }}>
                <span
                  style={{
                    backgroundColor: statusColors[tx.status] || '#999',
                    color: '#fff',
                    padding: '2px 8px',
                    borderRadius: '4px',
                    fontSize: '12px',
                  }}
                >
                  {tx.status}
                </span>
              </td>
              <td style={{ padding: '8px' }}>
                {tx.customerEmail || 'N/A'}
              </td>
              <td style={{ padding: '8px' }}>
                {new Date(tx.createdAt).toLocaleDateString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What does this component do?

  • The useEffect hook fires once when the component first appears on screen. It calls your NestJS API at GET /transactions and stores the response in state.
  • The (tx.amount / 100).toFixed(2) conversion turns cent amounts (like 2000) into dollar amounts (like $20.00). This matches the integer storage pattern your API uses.
  • Each status badge gets a background color from the statusColors map, making it easy to spot COMPLETED vs PENDING transactions at a glance.
  • Save src/components/TransactionList.tsx.

You will not see a change yet. The component is not imported into your app layout.

✔️ Awesome, I've got everything!

Make sure you have saved TransactionList.tsx before moving on.

ⓧ I'd like to double check the full code

Here is the complete TransactionList.tsx file.

import { useEffect, useState } from 'react';
import axios from 'axios';

// Define the shape of a transaction from the API
interface Transaction {
  id: string;
  stripeSessionId: string;
  amount: number;
  currency: string;
  status: string;
  customerEmail: string | null;
  description: string;
  createdAt: string;
  updatedAt: string;
}

// Map each transaction status to a badge color
const statusColors: Record<string, string> = {
  PENDING: '#f59e0b',
  COMPLETED: '#10b981',
  FAILED: '#ef4444',
  REFUNDED: '#6366f1',
};

// Main component that fetches and displays transactions
export default function TransactionList() {
  const [transactions, setTransactions] = useState<Transaction[]>([]);

  // Fetch transactions from the API when the component mounts
  useEffect(() => {
    axios
      .get<Transaction[]>('http://localhost:3000/transactions')
      .then((res) => setTransactions(res.data))
      .catch((err) => console.error('Failed to fetch transactions:', err));
  }, []);

  return (
    <div>
      <h2>Transactions</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th style={{ textAlign: 'left', padding: '8px' }}>Description</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Amount</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Email</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Date</th>
          </tr>
        </thead>
        <tbody>
          {transactions.map((tx) => (
            <tr key={tx.id} style={{ borderBottom: '1px solid #ddd' }}>
              <td style={{ padding: '8px' }}>{tx.description}</td>
              <td style={{ padding: '8px' }}>
                ${(tx.amount / 100).toFixed(2)} {tx.currency.toUpperCase()}
              </td>
              <td style={{ padding: '8px' }}>
                <span
                  style={{
                    backgroundColor: statusColors[tx.status] || '#999',
                    color: '#fff',
                    padding: '2px 8px',
                    borderRadius: '4px',
                    fontSize: '12px',
                  }}
                >
                  {tx.status}
                </span>
              </td>
              <td style={{ padding: '8px' }}>
                {tx.customerEmail || 'N/A'}
              </td>
              <td style={{ padding: '8px' }}>
                {new Date(tx.createdAt).toLocaleDateString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Create a new file at src/components/CheckoutButton.tsx and paste the following code:
import axios from 'axios';

// Button component that triggers a Stripe Checkout Session
export default function CheckoutButton() {
  // Send a checkout request to the API and redirect to Stripe
  const handleCheckout = async () => {
    try {
      const response = await axios.post(
        'http://localhost:3000/payments/create-checkout-session',
        {
          description: 'Test Payment',
          amount: 2000, // $20.00 in cents
        },
      );
      // Redirect the browser to the Stripe Checkout page
      window.location.href = response.data.url;
    } catch (error) {
      console.error('Checkout failed:', error);
    }
  };

  return (
    <button
      onClick={handleCheckout}
      style={{
        padding: '10px 20px',
        fontSize: '16px',
        cursor: 'pointer',
        marginBottom: '20px',
      }}
    >
      Make a Test Payment ($20.00)
    </button>
  );
}

What does this component do?

  • When the button is clicked, handleCheckout sends a POST request to your NestJS /payments/create-checkout-session endpoint with a hardcoded test description and amount.
  • Your API creates a Stripe Checkout Session and returns the Stripe-hosted URL. The browser then redirects to that URL so the user can complete payment on Stripe's secure page.
  • The amount 2000 is in cents, which means $20.00. This matches the integer-based amount pattern used throughout your backend.
  • Save src/components/CheckoutButton.tsx.

Seeing TypeScript errors in your editor?

  • If VS Code shows red underlines on the axios import, make sure you ran npm install axios inside the payment-dashboard-ui directory, not in the API directory.
  • Try restarting VS Code if errors persist after installing. The TypeScript language server sometimes needs a restart to pick up new packages.
  • Help me debug TypeScript errors in my React Vite project.
Assemble the layout and run an end-to-end test

Both components are ready. Now you will wire them into the main app layout, set up webhook forwarding, and test the complete payment flow from browser to database.

  • Open src/App.tsx in the payment-dashboard-ui project.
  • Replace the entire file contents with the following code:
import TransactionList from './components/TransactionList';
import CheckoutButton from './components/CheckoutButton';

// Main app layout with header, checkout button, and transaction table
function App() {
  return (
    <div style={{ maxWidth: '900px', margin: '0 auto', padding: '20px' }}>
      <h1>Payment Dashboard</h1>
      <CheckoutButton />
      <TransactionList />
    </div>
  );
}

export default App;

What does this layout do?

This replaces the default Vite welcome page with your dashboard. The layout centers everything in a 900px container. The CheckoutButton sits above the TransactionList so the user can trigger a payment and immediately see it appear in the table.

  • Save src/App.tsx.
  • Go back to your browser at http://localhost:5173. Vite hot-reloads automatically. You should see a Payment Dashboard heading, a Make a Test Payment ($20.00) button, and an empty Transactions table.

Dashboard not showing?

  • Check that the Vite dev server is still running in your first terminal. If it stopped, run npm run dev again from the payment-dashboard-ui directory.
  • Verify that the import paths in App.tsx match the exact file names in src/components/. File names are case-sensitive.
  • Help me fix my React dashboard layout not rendering.

Now it is time to test the complete payment flow. For Stripe webhooks to reach your local server, you need the Stripe CLI to forward events.

  • Verify that the Stripe CLI is installed by running:
stripe --version

✔️ I see a version number

The Stripe CLI is ready. Move on to the next instruction.

ⓧ Command not found

Install the Stripe CLI for your operating system.

macOS

  • Install the Stripe CLI with Homebrew by running:
brew install stripe/stripe-cli/stripe

Windows

  • Download the latest Windows zip file from the Stripe CLI GitHub releases page.
  • Unzip the file and add the folder containing stripe.exe to your system Path environment variable.
  • Close and reopen your terminal, then run stripe --version to confirm.
Windows antivirus warning

Windows antivirus scanners occasionally flag the Stripe CLI as unsafe. This is very likely a false positive. You can safely allow it.

  • Log in to your Stripe account from the CLI by running:
stripe login
  • Follow the prompt to open your browser and confirm the authentication.
  • Start forwarding Stripe webhook events to your local NestJS API by running:
stripe listen --forward-to localhost:3000/payments/webhook

The CLI prints a webhook signing secret that starts with whsec_. You need this value in your API's environment configuration.

  • Copy the whsec_ value from the terminal output.
  • Open the .env file in your payment-dashboard-api project.
  • Replace the STRIPE_WEBHOOK_SECRET value with the signing secret you copied.
  • If your NestJS server is not running, open another terminal, navigate to the payment-dashboard-api folder, and start it by running:
npm run start:dev

Why do I need three terminals?

You now have three processes running at once. The NestJS API server handles your backend logic. The Vite dev server serves your React frontend. The Stripe CLI listener forwards webhook events from Stripe to your local API.

This is a normal setup for local full stack development with third-party integrations.

  • Go to your browser at http://localhost:5173.
  • Click the Make a Test Payment ($20.00) button.

Your browser redirects to a Stripe Checkout page. This is a real Stripe-hosted form, running in test mode.

  • Enter 4242 4242 4242 4242 as the card number.
  • Enter any future expiry date (e.g. 12/30).
  • Enter any three-digit CVC (e.g. 123).
  • Fill in any name and email address.
  • Click Pay.

What is the test card number?

The card number 4242 4242 4242 4242 is Stripe's standard test card for successful payments. No real money is charged. Stripe test mode processes fake transactions so you can verify your integration end-to-end.

After paying, Stripe redirects you back to http://localhost:5173?success=true. Meanwhile, Stripe sends a webhook event to your API through the CLI forwarder.

  • Refresh the page at http://localhost:5173. You should see your transaction in the table with a green COMPLETED badge, a $20.00 amount, and the email you entered.

What just happened end-to-end?

  • React called your NestJS API to create a Checkout Session.
  • Your API called Stripe, saved a PENDING transaction in PostgreSQL, and returned the Checkout URL.
  • The browser redirected to Stripe's hosted Checkout page.
  • After payment, Stripe sent a checkout.session.completed webhook event to your API.
  • Your webhook handler updated the transaction status from PENDING to COMPLETED in the database.
  • React fetched the updated transaction list and displayed it in the table.

This is the exact request lifecycle you would describe in an interview: Client to Controller to Service to Database and back.

Transaction stuck on PENDING?

  • Check the terminal running stripe listen. You should see events like checkout.session.completed with a 200 response. If you see 400 or 500 errors, the webhook secret probably does not match.
  • Confirm that the STRIPE_WEBHOOK_SECRET in your .env file matches the whsec_ value printed by stripe listen. Restart your NestJS server after updating the .env file.
  • Make sure your NestJS server is running. Webhooks fail silently if the server is not accepting connections on port 3000.
  • Help me debug why my Stripe webhook is not updating transaction status.

Secret mission

Add Transaction Filtering and Stripe Refunds

Your dashboard displays transactions, but what happens when a customer asks for a refund? Upgrade your dashboard from a display tool into a management tool. Add status filtering and the ability to process Stripe refunds directly from the UI.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Stripe in test mode, so there are no ongoing costs.

Resources you used:

  • NestJS API project (payment-dashboard-api folder on your Desktop).
  • React frontend project (payment-dashboard-ui folder on your Desktop).
  • PostgreSQL database (payment_dashboard).
  • Prisma migrations in the prisma/migrations directory.
  • Stripe test mode account and API keys.
  • Globally installed NestJS CLI (@nestjs/cli).
  • .env file containing your Stripe test keys and database URL.

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue with the Secret Mission.

  • Your NestJS dev server and React dev server will stop when you close the terminal windows. Start them again anytime with npm run start:dev and npm run dev respectively.
  • PostgreSQL runs as a background service on your machine. It uses minimal resources when idle.
  • Your Stripe account stays in test mode. Test mode is completely free with no expiration.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping all your project files and database intact.

  • Close any terminal windows running npm run start:dev (NestJS) or npm run dev (React).
  • Optionally stop the PostgreSQL service. On Windows, open **Services** and stop **postgresql-x64-17**. On macOS, run brew services stop postgresql.
  • Keep the payment-dashboard-api and payment-dashboard-ui folders on your Desktop. You can resume development anytime by restarting the servers.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete the payment-dashboard-api folder from your Desktop.
  • Delete the payment-dashboard-ui folder from your Desktop.
  • Drop the PostgreSQL database. Open your terminal and run psql -U postgres -c "DROP DATABASE payment_dashboard;".
  • Uninstall the NestJS CLI globally by running npm uninstall -g @nestjs/cli.
  • Your Stripe test account can stay. It has no cost and no live payment data. To delete it, go to **Settings**, then **Account details**, then **Close my account** in the Stripe Dashboard.

Mission accomplished

That's a Wrap!

Nice work! You just built a production-architected full stack fintech application from scratch.

You've learned how to:

  • Architect a NestJS backend with modular architecture (Modules, Controllers, Services), dependency injection, DTOs with validation pipes, and environment configuration for managing secrets across environments.
  • Connect Prisma ORM to PostgreSQL using a schema-first design and versioned migrations to manage your database as code.
  • Integrate Stripe Checkout Sessions for payment processing and handle webhook events with signature verification for asynchronous, event-driven architecture.
  • Secret Mission: Add transaction status filtering with query parameters and a refund flow using the Stripe Refunds API.

Ready to quiz yourself?