Skip to content
ExponaBLOG
NESTJS · DAY 062 / 100

Payment integration in NestJS: best practices explained

Lessons from integrating Stripe and regional gateways.

Expona Admin11 min read

Opening

Payment failures in production are expensive—they crush revenue, trigger support requests, and erode customer trust. After integrating Stripe, PayPal, and regional gateways across e-commerce and SaaS platforms, we've learned that payment integration in NestJS: best practices explained comes down to four pillars: abstraction, webhook-first architecture, strict state machines, and automated reconciliation. This article walks through patterns we've refined through hundreds of thousands of transactions.

One Gateway Interface — A PaymentGateway Abstraction

Early in a project, you're tempted to sprinkle Stripe SDK calls throughout your checkout service. Six months later, your CEO says "we need to support Alipay in China," and you're hunting down 40 files. A gateway abstraction solves this by defining a contract that every provider implements.

Create a core interface that all payment providers must fulfill:

typescript
export interface PaymentGateway {
  authorize(input: AuthorizeInput): Promise<AuthorizeResult>;
  capture(paymentId: string, amount: number): Promise<CaptureResult>;
  refund(paymentId: string, amount: number): Promise<RefundResult>;
  getTransaction(paymentId: string): Promise<Transaction>;
}

export interface AuthorizeInput {
  amount: number;
  currency: string;
  customerId: string;
  orderId: string;
  metadata?: Record<string, string>;
}

export interface AuthorizeResult {
  paymentId: string;
  status: 'authorized' | 'declined' | 'error';
  errorMessage?: string;
  authCode?: string;
}

export interface CaptureResult {
  paymentId: string;
  status: 'captured' | 'failed';
  errorMessage?: string;
}

export interface RefundResult {
  paymentId: string;
  refundId: string;
  status: 'refunded' | 'failed';
  amount: number;
}

Now implement providers as separate services. Here's a Stripe implementation:

typescript
@Injectable()
export class StripePaymentGateway implements PaymentGateway {
  private stripe: Stripe;

  constructor(configService: ConfigService) {
    this.stripe = new Stripe(configService.get('STRIPE_SECRET_KEY'));
  }

  async authorize(input: AuthorizeInput): Promise<AuthorizeResult> {
    try {
      const paymentIntent = await this.stripe.paymentIntents.create({
        amount: Math.round(input.amount * 100),
        currency: input.currency.toLowerCase(),
        customer: input.customerId,
        metadata: {
          orderId: input.orderId,
          ...input.metadata,
        },
      });

      return {
        paymentId: paymentIntent.id,
        status: paymentIntent.status === 'requires_capture' ? 'authorized' : 'declined',
        authCode: paymentIntent.id,
      };
    } catch (error) {
      return {
        paymentId: '',
        status: 'error',
        errorMessage: error.message,
      };
    }
  }

  async capture(paymentId: string, amount: number): Promise<CaptureResult> {
    try {
      await this.stripe.paymentIntents.confirm(paymentId, {
        amount_to_capture: Math.round(amount * 100),
      });
      return { paymentId, status: 'captured' };
    } catch (error) {
      return { paymentId, status: 'failed', errorMessage: error.message };
    }
  }

  async refund(paymentId: string, amount: number): Promise<RefundResult> {
    try {
      const refund = await this.stripe.refunds.create({
        payment_intent: paymentId,
        amount: Math.round(amount * 100),
      });
      return {
        paymentId,
        refundId: refund.id,
        status: 'refunded',
        amount: refund.amount / 100,
      };
    } catch (error) {
      return {
        paymentId,
        refundId: '',
        status: 'failed',
        amount: 0,
      };
    }
  }

  async getTransaction(paymentId: string): Promise<Transaction> {
    const pi = await this.stripe.paymentIntents.retrieve(paymentId);
    return {
      paymentId: pi.id,
      status: pi.status as any,
      amount: pi.amount / 100,
      currency: pi.currency,
    };
  }
}

Inject the gateway into your checkout service by provider type. A factory function selects the right implementation based on region or merchant config:

typescript
@Injectable()
export class PaymentGatewayFactory {
  constructor(
    private stripe: StripePaymentGateway,
    private alipay: AlipayPaymentGateway,
  ) {}

  getGateway(region: string): PaymentGateway {
    switch (region) {
      case 'US':
      case 'EU':
        return this.stripe;
      case 'CN':
        return this.alipay;
      default:
        return this.stripe;
    }
  }
}

@Injectable()
export class CheckoutService {
  constructor(private gatewayFactory: PaymentGatewayFactory) {}

  async authorizePayment(
    order: Order,
    region: string,
  ): Promise<AuthorizeResult> {
    const gateway = this.gatewayFactory.getGateway(region);
    return gateway.authorize({
      amount: order.total,
      currency: order.currency,
      customerId: order.customerId,
      orderId: order.id,
    });
  }
}

With this pattern, adding a new provider is a one-file addition. Checkout logic never changes.

Webhooks Are the Truth — Not Browser Redirects

Here's a scenario we've seen cost money: a customer completes Stripe payment, sees a success page, closes the browser window before the redirect happens. You never receive the callback, so you mark the order as pending forever. Meanwhile, the customer's credit card was charged.

Rule: webhooks are the source of truth for payment state. Redirect pages are UX hints only.

Set up a webhook controller that validates signatures and updates your order state:

typescript
@Controller('webhooks/payment')
export class PaymentWebhookController {
  private stripe: Stripe;

  constructor(
    configService: ConfigService,
    private orderService: OrderService,
    private logger: Logger,
  ) {
    this.stripe = new Stripe(configService.get('STRIPE_SECRET_KEY'));
  }

  @Post('stripe')
  async handleStripeWebhook(@Req() req: Request) {
    const sig = req.headers['stripe-signature'] as string;
    const secret = process.env.STRIPE_WEBHOOK_SECRET;

    let event: Stripe.Event;
    try {
      event = this.stripe.webhooks.constructEvent(
        req.rawBody,
        sig,
        secret,
      );
    } catch (err) {
      this.logger.warn(`Webhook signature verification failed: ${err.message}`);
      return { received: false };
    }

    switch (event.type) {
      case 'payment_intent.succeeded':
        await this.handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
        break;

      case 'payment_intent.payment_failed':
        await this.handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
        break;

      case 'charge.refunded':
        await this.handleRefunded(event.data.object as Stripe.Charge);
        break;

      default:
        this.logger.debug(`Unhandled webhook type: ${event.type}`);
    }

    return { received: true };
  }

  private async handlePaymentSucceeded(pi: Stripe.PaymentIntent) {
    const orderId = pi.metadata.orderId;
    const order = await this.orderService.findById(orderId);

    if (!order) {
      this.logger.error(`Webhook received for unknown order: ${orderId}`);
      return;
    }

    await this.orderService.updatePaymentState(orderId, {
      status: 'captured',
      paymentId: pi.id,
      capturedAt: new Date(),
      amount: pi.amount / 100,
    });

    await this.orderService.markForFulfillment(orderId);
  }

  private async handlePaymentFailed(pi: Stripe.PaymentIntent) {
    const orderId = pi.metadata.orderId;
    await this.orderService.updatePaymentState(orderId, {
      status: 'declined',
      errorMessage: pi.last_payment_error?.message || 'Payment failed',
      failedAt: new Date(),
    });
  }

  private async handleRefunded(charge: Stripe.Charge) {
    const paymentId = charge.payment_intent as string;
    const order = await this.orderService.findByPaymentId(paymentId);

    if (!order) return;

    await this.orderService.updatePaymentState(order.id, {
      status: 'refunded',
      refundedAt: new Date(),
      refundAmount: charge.amount_refunded / 100,
    });
  }
}

Key points:

  • Validate the signature with your webhook secret. Never trust an unsigned message claiming to be from Stripe.
  • Be idempotent. Webhooks can fire multiple times for the same event. Use a unique event ID to deduplicate.
  • Store the raw webhook for audits. Log the full payload so you can replay logic if needed.
  • Use a queue for slow operations. Don't make Stripe wait 30 seconds while you send emails. Acknowledge receipt immediately, then process async.

State Machine for Payments — Reject Invalid Transitions

A payment has a lifecycle. Permitting any state to flow to any other state creates chaos. A customer refund shouldn't be possible if the payment is still pending. A capture shouldn't happen twice.

Define valid transitions explicitly:

typescript
export enum PaymentStatus {
  PENDING = 'pending',
  AUTHORIZED = 'authorized',
  CAPTURED = 'captured',
  DECLINED = 'declined',
  REFUNDED = 'refunded',
  CANCELLED = 'cancelled',
}

type Transition = [PaymentStatus, PaymentStatus];

const VALID_TRANSITIONS: Set<string> = new Set([
  [PaymentStatus.PENDING, PaymentStatus.AUTHORIZED].join('→'),
  [PaymentStatus.PENDING, PaymentStatus.DECLINED].join('→'),
  [PaymentStatus.PENDING, PaymentStatus.CANCELLED].join('→'),
  [PaymentStatus.AUTHORIZED, PaymentStatus.CAPTURED].join('→'),
  [PaymentStatus.AUTHORIZED, PaymentStatus.DECLINED].join('→'),
  [PaymentStatus.AUTHORIZED, PaymentStatus.CANCELLED].join('→'),
  [PaymentStatus.CAPTURED, PaymentStatus.REFUNDED].join('→'),
  [PaymentStatus.CAPTURED, PaymentStatus.CANCELLED].join('→'),
]);

@Injectable()
export class PaymentStateMachine {
  canTransition(from: PaymentStatus, to: PaymentStatus): boolean {
    const key = [from, to].join('→');
    return VALID_TRANSITIONS.has(key);
  }

  assertTransition(from: PaymentStatus, to: PaymentStatus): void {
    if (!this.canTransition(from, to)) {
      throw new InvalidPaymentTransitionError(
        `Cannot transition from ${from} to ${to}`,
      );
    }
  }
}

Enforce this in your order service:

typescript
@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order) private orders: Repository<Order>,
    private stateMachine: PaymentStateMachine,
  ) {}

  async updatePaymentState(
    orderId: string,
    update: Partial<PaymentRecord>,
  ): Promise<Order> {
    const order = await this.orders.findOneOrFail({ where: { id: orderId } });
    const currentStatus = order.paymentStatus as PaymentStatus;
    const newStatus = update.status as PaymentStatus;

    this.stateMachine.assertTransition(currentStatus, newStatus);

    order.paymentStatus = newStatus;
    order.paymentId = update.paymentId || order.paymentId;
    order.capturedAt = update.capturedAt || order.capturedAt;
    order.refundedAt = update.refundedAt || order.refundedAt;

    return this.orders.save(order);
  }
}

This guard prevents logic bugs and makes your code auditable. When an incident happens, you can trace exactly which transitions occurred and in what order.

Reconcile — A Daily Job Compares Provider Settlements

Your database says Stripe charged $10,000 yesterday. Stripe's settlement report says they charged $9,950. Who's right? A reconciliation job finds these gaps before they spiral into angry finance calls.

Build a scheduled task that compares your records with the provider's settlement data:

typescript
@Injectable()
export class PaymentReconciliationService {
  constructor(
    @InjectRepository(Order) private orders: Repository<Order>,
    private stripe: Stripe,
    private logger: Logger,
  ) {}

  @Cron('0 2 * * *') // Run daily at 2 AM
  async reconcileDailySettlements() {
    const yesterday = subDays(new Date(), 1);
    const startOfDay = startOfDay(yesterday);
    const endOfDay = endOfDay(yesterday);

    // Fetch your local records
    const localTransactions = await this.orders.find({
      where: {
        paymentStatus: PaymentStatus.CAPTURED,
        capturedAt: Between(startOfDay, endOfDay),
      },
    });

    // Fetch provider settlements
    const stripePayout = await this.stripe.payouts.list({
      created: {
        gte: Math.floor(startOfDay.getTime() / 1000),
        lte: Math.floor(endOfDay.getTime() / 1000),
      },
    });

    // Compare
    const localSum = localTransactions.reduce(
      (sum, order) => sum + order.paymentAmount,
      0,
    );
    const stripeSum = stripePayout.data.reduce(
      (sum, payout) => sum + payout.amount / 100,
      0,
    );

    if (Math.abs(localSum - stripeSum) > 0.01) {
      this.logger.error(
        `Reconciliation mismatch for ${yesterday.toISOString()}: ` +
        `Local=${localSum}, Stripe=${stripeSum}`,
      );

      // Alert ops, create a ticket, etc.
      await this.alertOps({
        date: yesterday,
        localAmount: localSum,
        providerAmount: stripeSum,
        difference: localSum - stripeSum,
      });
    } else {
      this.logger.log(`Reconciliation passed for ${yesterday.toISOString()}`);
    }
  }

  private async alertOps(mismatch: any) {
    // Send to Slack, PagerDuty, or your ops tool
  }
}

Also keep a reconciliation log table to track each run:

typescript
@Entity()
export class PaymentReconciliationLog {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  reconciliationDate: Date;

  @Column('numeric')
  localAmount: number;

  @Column('numeric')
  providerAmount: number;

  @Column('numeric')
  difference: number;

  @Column({ nullable: true })
  discrepancyNotes: string;

  @Column()
  createdAt: Date;
}

This catches bugs, gateway errors, and fraud early. It's also required for SoC 2 audits.

Common Mistakes

  • Trusting the redirect response. Implement webhook handling first, then add a redirect flow. The redirect is only for UI polish.
  • Not handling duplicate webhooks. Build an idempotency key into every payment operation, or store processed event IDs in Redis with TTL.
  • Capturing synchronously in the checkout endpoint. Authorize in the endpoint (for UX), capture async from the webhook. This keeps checkout fast and reliable.
  • Skipping the state machine. It feels like overhead at first, but it prevents refund-after-cancel bugs that cost real money in production.
  • Ignoring small reconciliation gaps. A $0.50 difference today is a data corruption pattern tomorrow. Investigate all gaps.
  • Not versioning your gateway interface. When Stripe changes their API, you want to upgrade gradually, not rewrite checkout. Keep a compatibility shim.

Key Takeaways

  • Use a PaymentGateway interface so adding a new provider (Alipay, PayPal, local gateways) requires only a new service file, never touching checkout logic.
  • Webhooks are your source of truth. Browser redirects can be faked or interrupted. Always verify state from the provider's callback, never from client signals.
  • Implement a state machine that explicitly defines which transitions are valid (e.g., pending → authorized → captured → refunded) and rejects all others.
  • Run daily reconciliation jobs that compare your settlement records with the provider's reports. Alert ops on any gap, even small ones.
  • Make webhook handlers idempotent by deduplicating on event ID. Process async via a queue so Stripe isn't waiting for your database writes.
  • Keep a detailed audit trail of every payment event: timestamp, status, amount, provider response. This is non-negotiable for disputes and compliance.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles