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:
- Do not charge — the stock check happens before payment, deliberately.
- Void — if the gateway supports it and capture has not happened.
- 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
- Simulate a failed payment. Confirm the order is still
PENDING_PAYMENTand the payment row isFAILED. - Read the message the customer sees. Rewrite it worse —
Error: payment declined— and decide what that costs the shop. - Retry and succeed. Confirm two payment rows exist against one order, with different references.
- Place an unpaid order and run the sweep. Confirm it is not cancelled.
- 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. - Check
slot_bookingsand confirm the delivery place was freed too. - Make
setStatusthrow for one order in a batch of three. Confirm the other two are still swept. - Refund a paid order as the shop. Then try again and confirm 404.
- Try to refund as the customer. Confirm 403.
- Set
PAYMENT_WINDOW_MINUTESto 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