Skip to main content
Back to Home

What Zelsis checks · Payments

Payment bugs Zelsis catches before they charge a customer

A payment bug reaches customers as a wrong charge, a refund request and a bad review. Zelsis reads checkout and webhook code for the mistakes behind those.

Checked on every pull request

  • Webhooks that grant credits or orders twice when the provider retries the delivery
  • Pay buttons that stay clickable while the charge is pending
  • Amounts rounded to whole units, or multiplied by 100 without rounding, before they reach Stripe
  • Prices, discounts or quantities taken from the request instead of the server
  • checkout.session.completed fulfilled without checking payment_status
  • Payment calls with no idempotency key
  • Stock sold twice because two orders read it at the same time

Caught in real code

Our test suite scans these examples on every release.

Webhook that pays out twice

SAAS3-02

Stripe and Polar retry deliveries. A handler that adds credits without recording the event id grants them again on every retry.

app/api/stripe/webhook/route.ts
Flagged
export async function POST(req: Request) {
  const evt = stripe.webhooks.constructEvent(await req.text(), sig, secret);
  if (evt.type === 'checkout.session.completed') {
    await prisma.user.update({
      where: { id: evt.data.object.metadata.userId },
      data: { credits: { increment: 100 } },
    });
  }
  return new Response('ok');
}
Passes
export async function POST(req: Request) {
  const evt = stripe.webhooks.constructEvent(await req.text(), sig, secret);
  if (evt.type === 'checkout.session.completed') {
    await prisma.$transaction(async (tx) => {
      if (await tx.processedWebhook.findUnique({ where: { id: evt.id } })) return;
      await tx.processedWebhook.create({ data: { id: evt.id } });
      await tx.user.update({
        where: { id: evt.data.object.metadata.userId },
        data: { credits: { increment: 100 } },
      });
    });
  }
  return new Response('ok');
}

Charge rounded to whole dollars

BUG-70

The total is rounded before it is turned into cents, so a $19.99 order is charged $20.00.

lib/stripe/charge.ts
Flagged
import { stripe } from './client';
export async function chargeOrder(order: { total: number; customerId: string }) {
  return stripe.paymentIntents.create({
    amount: Math.round(order.total) * 100,
    currency: 'usd',
    customer: order.customerId,
  });
}
Passes
import { stripe } from './client';
export async function chargeOrder(order: { total: number; customerId: string }) {
  return stripe.paymentIntents.create({
    amount: Math.round(order.total * 100),
    currency: 'usd',
    customer: order.customerId,
  });
}