RizTech Academy logo
RizTech Academy
PaymentsLesson 4 of 430 min

Failed payments, retries and refunds

Payments fail. Not occasionally — routinely. A few percent of card attempts are declined on any given day for reasons that have nothing to do with your code, and how the shop handles that is worth as much as the happy path.

A decline is usually boring

Daily limit reached. The bank's fraud model did not like a grocery shop at 11pm. The OTP was mistyped. The UPI app timed out. Insufficient balance two days before payday.

None of these mean "this customer cannot pay". Most mean "not with that method, not this minute".

Which is why the order stays put:

data: { status: "FAILED", idempotencyKey: event.eventId },
// The order stays PENDING_PAYMENT so the customer can try again.

The basket, the address and the delivery slot are all still valid. Cancelling the order on a failed payment throws all of that away, and the customer has to start again from an empty cart — which is where most of them stop.

The message matters more than the code

setError(
  "The payment did not go through. Nothing has been charged — you can try again.",
);

Three clauses, all deliberate.

"Did not go through", not "declined". You rarely know why, and gateways deliberately do not tell you in detail — a message that says "insufficient funds" to somebody standing at a counter is a message that should not have been shown.

"Nothing has been charged." This is the sentence people are looking for. The fear after a failed payment is that the money left anyway, and a customer who is not told this will ring the bank instead of trying again.

"You can try again." Say that the door is still open, or they will assume it is not.

Compare with what most applications show: Payment failed (code: E4021). That tells the customer nothing they can act on and sends them to support.

Retrying opens a new attempt

const existing = await this.prisma.payment.findFirst({
  where: { orderId: order.id, status: "PENDING" },
  orderBy: { createdAt: "desc" },
});

if (existing) return { …existing… };

The reuse check looks for a PENDING payment. A failed one is not pending, so retrying creates a new row with a new reference.

That is right, and it matters for two reasons. The gateway's reference is consumed by the attempt, so reusing it would confuse both sides. And the history of attempts is worth keeping: three failures then a success is a story the shop may want to see when the same customer rings about something else.

One order, many payment rows, at most one of them succeeding.

The order that is never paid for

async expireStale(now = new Date()): Promise<{ cancelled: string[] }> {
  const cutoff = new Date(now.getTime() - PAYMENT_WINDOW_MINUTES * 60 * 1000);

  const stale = await this.prisma.order.findMany({
    where: { status: "PENDING_PAYMENT", createdAt: { lt: cutoff } },
    select: { orderNumber: true },
  });
  …
}

This is the cost of creating the order before taking the money, and it is not optional.

A PENDING_PAYMENT order holds stock. Somebody who opens the payment page and closes the tab has quietly removed the last bag of atta from the shop's shelf — forever, unless something puts it back. Do that ten times on a Saturday and the shop is showing "out of stock" on items sitting in front of it.

for (const order of stale) {
  try {
    await this.orders.setStatus(order.orderNumber, "CANCELLED", {
      note: "Not paid within the payment window",
    });
    cancelled.push(order.orderNumber);
  } catch (error) {
    this.logger.error(`Could not expire ${order.orderNumber}`, error);
  }
}

Through setStatus, so the stock release, the slot release and the audit entry all come for free. A second copy of that logic here is a second copy that will drift.

The try inside the loop so one problem order does not stop the sweep. A batch job that dies on its third item and leaves the rest is worse than one that skips and logs.

now is a parameter, so the whole thing is testable at any moment without mocking the clock — the same habit as slotIsBookable in module 12.

Fifteen minutes

PAYMENT_WINDOW_MINUTES = 15 is a judgement, not a fact. Long enough for somebody to find their card, fail once, and try a different method. Short enough that stock is not held all afternoon.

Ticketing sites use five. Furniture shops use days. Pick a number, write down why, and revisit it when the shop complains about one direction or the other.

Running it

Here it is an admin endpoint, so it can be run and watched. In production it is a scheduled job — @nestjs/schedule with a cron expression, or an external scheduler hitting the endpoint. Either is fine; what matters is that something runs it, and that somebody notices if it stops.

An expiry sweep that silently stopped three weeks ago is a shop slowly running out of everything.

Refunds

const payment = await this.prisma.payment.findFirst({
  where: { order: { orderNumber }, status: "SUCCEEDED" },
  orderBy: { createdAt: "desc" },
});

if (!payment) throw AppException.notFound("No successful payment to refund.");

Only a SUCCEEDED payment can be refunded, and finding none is a 404 rather than a silent success. Refunding twice would be a real loss of real money, and the status is what prevents it:

await this.prisma.payment.update({
  where: { id: payment.id },
  data: { status: "REFUNDED", idempotencyKey: reference },
});

After that, the status: "SUCCEEDED" filter finds nothing and a second attempt gets the 404.

REFUNDED, not deleted. The money moved twice and both movements are part of the record. A payments table you can delete rows from is a payments table nobody can reconcile against a bank statement.

Refunds are admin-only

@Post(":orderNumber/refund")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN")

Obviously — but worth saying why it is obvious. A customer-triggered refund is a customer-triggered transfer of money out of the shop's account. Even with every check in place, that is a decision for a person.

Void beats refund

From the first lesson: cancelling an authorisation before capture costs nothing; refunding a capture costs a fee and takes days to reach the customer.

So the order of preference when something goes wrong is:

  1. Do not charge — the stock check happens before payment, deliberately.
  2. Void — if the gateway supports it and capture has not happened.
  3. Refund — when the money has genuinely moved.

The Kirana Store's checkout claims stock before a payment is opened, which is why option 1 covers almost every case. That ordering was chosen in module 12 for a different reason, and this is the second payoff.

What still has to be built for real money

Being honest about the gap, because it is where people get stuck.

A raw webhook log. Every event received, stored before it is processed. Three weeks later, in a dispute, that table is the only thing that can answer what happened.

Reconciliation. Every gateway provides a daily settlement report. Somebody — or something — must compare it against your payments table. Discrepancies are normal and finding them a month later is not.

Partial refunds. Refunding one item out of five is a different shape: a refund becomes a row of its own rather than a status on the payment.

Chargebacks. The customer disputes the charge with their bank. Money is taken back whatever you think, and you have a window to supply evidence.

Reporting the tax. GST on delivery charges, invoices, and what the accountant needs. Not glamorous, and not optional.

None of those change the shape built here. Each one adds a table beside it — which is the sign that the shape is right.

Check your work

Why a failed payment leaves the order pending: declines are usually dull, the basket and slot are still valid, and cancelling loses a nearly-made sale.

The three clauses of a good failure message: what happened, that nothing was charged, and that they can try again.

Why retrying opens a new payment row: the gateway reference is consumed, and the attempt history is worth keeping.

Why unpaid orders must be swept: a PENDING_PAYMENT order holds stock, and abandoned tabs would empty the shop.

Why the sweep goes through setStatus: stock release, slot release and the audit entry are already there.

Why the try is inside the loop: one bad order must not stop the batch.

Why now is a parameter: testability without mocking the clock.

Why a refund needs a SUCCEEDED payment: it is what stops a second refund, and refunding twice loses real money.

Why REFUNDED rather than deleting: the money moved twice, and both movements are the record.

Why voiding beats refunding: a void is free and instant; a refund costs a fee and days.

Practice

  1. Simulate a failed payment. Confirm the order is still PENDING_PAYMENT and the payment row is FAILED.
  2. Read the message the customer sees. Rewrite it worse — Error: payment declined — and decide what that costs the shop.
  3. Retry and succeed. Confirm two payment rows exist against one order, with different references.
  4. Place an unpaid order and run the sweep. Confirm it is not cancelled.
  5. Age it with UPDATE orders SET "createdAt" = now() - interval '30 minutes' and run the sweep again. Confirm it is cancelled and the stock came back.
  6. Check slot_bookings and confirm the delivery place was freed too.
  7. Make setStatus throw for one order in a batch of three. Confirm the other two are still swept.
  8. Refund a paid order as the shop. Then try again and confirm 404.
  9. Try to refund as the customer. Confirm 403.
  10. Set PAYMENT_WINDOW_MINUTES to 1 and watch an order expire while you are looking at the payment page. Decide whether the page should warn them.

Next: testing the whole stack — enough to change the code without fear, and no more.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship