js

How to Build a Reliable Payment System with NestJS, Stripe, and PostgreSQL

Learn how to create a secure, production-ready payment system using NestJS, Stripe, and PostgreSQL with real-world best practices.

How to Build a Reliable Payment System with NestJS, Stripe, and PostgreSQL

I’ve been thinking about money lately. Not in a philosophical way, but in a very practical one. Specifically, how we move it around on the internet. Every time I buy something online, I wonder: what’s happening behind that “Pay Now” button? How does the money get from my bank account to the seller, securely and reliably, without anything going wrong? This curiosity led me down a path of building a system to handle it myself. Today, I want to share what I learned about creating a payment system that’s ready for real customers, using modern tools.

Let’s start with a simple truth: handling payments is more than just charging a card. It’s about creating a reliable conversation between your app, the payment processor, and your database. If this conversation breaks down, you might charge someone twice, or not at all. Neither is good. So, how do we build something that can handle this pressure?

First, we need the right tools. I chose NestJS for the backend because it provides a clear structure. Stripe handles the complex payment regulations. PostgreSQL stores every transaction permanently. This combination gives us a solid foundation. But tools alone aren’t enough. We need a plan.

The core of our system will be a single source of truth: our database. Stripe will tell us about events, but we must record them in our own tables. This is crucial for generating reports, handling customer service queries, and knowing exactly what happened and when. Let’s design a table to track a payment’s entire life.

// payment.entity.ts
@Entity('payments')
export class Payment {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  stripePaymentIntentId: string;

  @Column()
  userId: string;

  @Column({ type: 'int' }) // Amount in cents
  amount: number;

  @Column()
  currency: string;

  @Column()
  status: string; // e.g., 'succeeded', 'failed'

  @Column({ unique: true })
  idempotencyKey: string; // Prevents duplicate charges

  @CreateDateColumn()
  createdAt: Date;
}

This table tracks the essentials. Notice the idempotencyKey. This is a secret weapon. Imagine a customer’s internet connection drops right as they click “Pay.” Their browser might retry the request. Without an idempotency key, that could create two charges. The key ensures that the same request, sent twice, only results in one action.

Now, let’s connect to Stripe. We never want to hardcode secret keys in our code. We use environment variables. In our main app module, we set up a service to talk to Stripe.

// stripe.service.ts
import { Injectable } from '@nestjs/common';
import Stripe from 'stripe';

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

  constructor() {
    this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
      apiVersion: '2023-10-16',
    });
  }

  async createPaymentIntent(amount: number, currency: string) {
    return this.stripe.paymentIntents.create({
      amount,
      currency,
      automatic_payment_methods: { enabled: true },
    });
  }
}

This service creates a Payment Intent. Think of this as Stripe’s way of reserving an amount to charge. It doesn’t take money yet. It just gets everything ready. We get back a client_secret which our frontend can use to complete the payment securely.

But here’s a question: what happens after the payment is made on the frontend? How do we know if it succeeded? This is where webhooks come in. Stripe sends an HTTP POST request to a special URL in our app to tell us the final result. We must listen for it.

// webhook.controller.ts
@Controller('webhook')
export class WebhookController {
  @Post()
  async handleWebhook(@Req() request, @Res() response) {
    const sig = request.headers['stripe-signature'];
    let event;

    try {
      event = this.stripe.webhooks.constructEvent(
        request.rawBody,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      response.status(400).send(`Webhook Error: ${err.message}`);
      return;
    }

    // Handle the event
    switch (event.type) {
      case 'payment_intent.succeeded':
        const paymentIntent = event.data.object;
        await this.updatePaymentStatus(paymentIntent.id, 'succeeded');
        break;
      case 'payment_intent.payment_failed':
        await this.updatePaymentStatus(paymentIntent.id, 'failed');
        break;
    }

    response.json({ received: true });
  }
}

This code does two vital things. First, it verifies the request is truly from Stripe using a secret signature. Never skip this step! Second, it updates our database record based on the event. Our app’s internal state now matches reality.

What about refunds? A customer might receive a faulty product. We need a clean way to return their money and record why.

// payment.service.ts
async processRefund(paymentId: string, amount: number, reason: string) {
  // 1. Find the original payment in OUR database
  const payment = await this.paymentRepository.findOne({
    where: { id: paymentId }
  });

  if (!payment) {
    throw new Error('Payment not found');
  }

  // 2. Check if we've already refunded too much
  if (payment.refundedAmount + amount > payment.amount) {
    throw new Error('Refund amount exceeds original charge');
  }

  // 3. Call Stripe to actually refund the money
  const refund = await this.stripeService.refundPayment(
    payment.stripePaymentIntentId,
    amount
  );

  // 4. Update OUR record
  payment.refundedAmount += amount;
  payment.status = amount === payment.amount ? 'refunded' : 'partially_refunded';
  await this.paymentRepository.save(payment);

  // 5. Create a refund record for our audit trail
  await this.createRefundRecord(payment, refund.id, amount, reason);

  return refund;
}

This process keeps us in control. We check our rules first, then tell Stripe to act, and finally update our own books. Every step is logged. Can you see how having our own database record makes this simple?

Testing this whole flow is critical. You don’t want to discover bugs with real money. Stripe provides test card numbers and a way to simulate webhooks. We can write automated tests that mimic a customer’s journey.

// payment.e2e-spec.ts
it('should create, confirm, and record a payment', async () => {
  // 1. Simulate a frontend request to create a Payment Intent
  const createResponse = await request(app.getHttpServer())
    .post('/payments/intent')
    .send({ amount: 2000, currency: 'usd' });

  expect(createResponse.body.clientSecret).toBeDefined();

  // 2. Simulate Stripe sending a success webhook
  const mockWebhookEvent = createMockEvent('payment_intent.succeeded', {
    id: createResponse.body.paymentIntentId
  });

  const webhookResponse = await request(app.getHttpServer())
    .post('/webhook')
    .set('stripe-signature', generateTestSignature(mockWebhookEvent))
    .send(mockWebhookEvent);

  expect(webhookResponse.status).toBe(200);

  // 3. Check our database updated correctly
  const dbPayment = await paymentRepository.findOne({
    where: { stripePaymentIntentId: createResponse.body.paymentIntentId }
  });
  expect(dbPayment.status).toBe('succeeded');
});

This test runs without touching a real bank account. It builds confidence that all the pieces connect as expected.

Building this changed how I see online commerce. It’s a dance of precision, where every step must be recorded and every failure must have a clear path to resolution. The goal is to make something so reliable that users never have to think about it. The “magic” of a one-click purchase is actually the result of careful, deliberate engineering.

I hope walking through this process gives you a clearer picture of what happens behind the scenes. It’s a fascinating challenge that blends security, user experience, and data integrity. What part of the payment flow do you find most interesting? Is it the security, the user experience, or the data tracking?

If you found this exploration helpful, please share it with others who might be building the next big thing. Have you built a payment system before? What was your biggest challenge? Let me know in the comments below


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: nestjs, stripe, payment system, postgresql, backend development



Similar Posts
Blog Image
Build Real-Time Collaborative Text Editor: Socket.io, Operational Transform, Redis Complete Tutorial

Learn to build a real-time collaborative text editor using Socket.io, Operational Transform, and Redis. Master conflict resolution, user presence, and scaling for production deployment.

Blog Image
Build Production-Ready GraphQL APIs with NestJS TypeORM Redis Caching Performance Guide

Learn to build scalable GraphQL APIs with NestJS, TypeORM, and Redis caching. Includes authentication, real-time subscriptions, and production deployment tips.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Applications in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe, scalable web apps. Step-by-step guide to seamless database operations. Start building today!

Blog Image
Build Multi-Tenant SaaS with NestJS, Prisma & Row-Level Security: Complete Developer Guide

Learn to build scalable multi-tenant SaaS apps with NestJS, Prisma & PostgreSQL RLS. Complete guide with authentication, isolation & deployment tips.

Blog Image
How to Build Scalable Event-Driven Microservices with NestJS, RabbitMQ, and Redis: Complete Guide

Learn to build scalable event-driven microservices with NestJS, RabbitMQ, and Redis. Master message queuing, caching, CQRS patterns, and production deployment strategies.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack applications. Build powerful database-driven apps with seamless TypeScript support.