Recovering
When a payment does not go through — dunning a failed subscription charge, refunding where the gateway supports it, and reconciling the billing tables after an outage.
Three flows that all start from something going wrong, and all end at the same place: your tables telling the truth again.
Dunning — a subscription charge failed
payment.failed arrives with a reason. Escalate rather than suspending on the first failure — cards
expire, banks decline transiently, and a customer suspended by a temporary decline is a support
ticket:
import { inject } from '@adonisjs/core'
import type { WebhookEventFor } from '@adonis-agora/payments'
@inject()
export default class PaymentFailedHandler {
static readonly eventType = 'payment.failed'
constructor(private access: AccessService, private mail: MailService) {}
async handle(event: WebhookEventFor<'payment.failed'>): Promise<void> {
// `PaymentWebhookData` — `externalReference` and `reason` are both on it, typed.
const data = event.data
const subscription = await Subscription.findBy('id', stripPrefix(data.externalReference))
if (!subscription) return
const attempts = subscription.failedAttempts + 1
await subscription.merge({ failedAttempts: attempts, status: 'past_due' }).save()
if (attempts >= 3) {
await this.access.suspend(subscription.id)
await this.mail.send(new SubscriptionSuspended(subscription, data.reason))
} else {
await this.mail.send(new PaymentFailed(subscription, attempts))
}
}
}`reason` is usually not there
data.reason is not part of the normalized payment payload — PaymentWebhookData is gatewayId,
amount, currency, and optionally customerId, subscriptionId, externalReference and
metadata. Only the Adyen driver fills a reason in, from the notification's own field. Everywhere
else it is undefined, and the decline code is on event.raw under the gateway's own name. Write
the email so it reads correctly without one.
Reset the counter on recovery
Without a reset in the payment.succeeded handler, a customer who fails once in March and once in
July is one decline away from suspension forever.
await subscription.merge({ failedAttempts: 0, status: 'active' }).save()Refunds
Not every gateway has them. Check the capability before you render the button, not after the user clicks it:
const payments = getPayments()
const driver = payments.driver(payment.provider)
payments.assertCapability(driver, 'refunds') // throws on Woovi/OpenPix
const refund = await driver.refund(payment.gatewayId, partialAmountCents)refund(gatewayId) refunds in full; passing an amount refunds partially, in cents.
The refund confirms by webhook like everything else — revoke on payment.refunded, not on the
API response:
static readonly eventType = 'payment.refunded'
async handle(event: WebhookEventFor<'payment.refunded'>) {
const ref = event.data.externalReference
if (!ref) return
const updated = await Order.query()
.where('id', ref)
.andWhere('status', 'paid') // the inner guard, again
.update({ status: 'refunded' })
if (updated[0] === 0) return
await this.grants.revokeFor(ref)
}Sixteen of the eighteen drivers declare refunds: true. Two do not, and they are the two the
capability check exists for:
| Gateway | Refunds |
|---|---|
| Woovi / OpenPix | no — no refund API; its capabilities declares only subscriptions |
| InfinitePay | no — refunds: false, along with invoices and subscriptions |
For Woovi, a refund is an out-of-band Pix transfer you make yourself; record it in your own tables and revoke access there, because no webhook is coming. InfinitePay is the same shape — the driver is a payment link and nothing more.
The other sixteen — Stripe, Adyen, Square, Mollie, PayPal, Razorpay, Paddle, Lemon Squeezy, Polar,
Dodo, Asaas, AbacatePay, Efí, Pagar.me, PagBank and Mercado Pago — all support refund(), in full or
partially.
Reconciling after an outage
Webhooks are reliable, not infallible. After a deploy that dropped events, a gateway incident, or a
change someone made by hand in a dashboard, payments:sync reconciles the billing tables with the
gateway — in both directions, and over every page of the listing rather than the first:
node ace payments:sync --all
node ace payments:sync --customer=cus_123 --provider=stripeOne of --customer or --all is required — with neither, the command prints the fix and exits
without touching anything. --all pages the customer mappings written by
ensureCustomer({ store }) / store.saveCustomer(); if none exist it says so rather than reporting
a healthy zero.
Sync goes through `listInvoices`, so it needs the `invoices` capability
Reconcile is invoice-shaped: it pages a customer's gateway invoices, asks the gateway's payment
resource what each one actually says, and writes that back — a local row reading paid while the
gateway says refunded is corrected, not skipped. (Invoice['status'] has no refunded or
disputed member, which is why the listing only enumerates and findPayment decides.)
Seven drivers declare invoices: false — Adyen, Efí, InfinitePay, Mercado Pago, Mollie, PagBank and
PayPal — and Woovi declares no invoices key at all. On any of them the command fails fast with the
gateway named, before a request goes out. There is no charge-by-charge reconcile in this library;
for those gateways, replay from the gateway's own dashboard.
Sync repairs billing tables, not your business logic
It upserts into billing_payments. It does not run your handlers, so credits are not granted and
subscriptions are not activated by it. Two things it leaves alone on purpose: a local disputed row
(the gateway usually still reports a charged-back payment as received, and reconciling that to
paid re-counts money the bank pulled back) and a paid_at that is already recorded. After a large
reconcile, diff against your own tables — or replay the events from the gateway dashboard so the
normal path runs and the handlers fire.
payments:health — the six checks
One command worth running on a schedule to notice the gap before a customer does:
node ace payments:health
node ace payments:health --json # for a metrics shipperIt asks six questions, each a failure that produces no error anywhere:
| Check | Counts | What a non-zero means |
|---|---|---|
stuck_webhooks | events claimed in the ledger and unfinished for over 15 min | nothing is consuming the dispatcher — the durable:work worker is not running |
failed_webhooks | events the dispatcher gave up on in the last 24 h | handlers threw and retries ran out, so those events never took effect |
unconfirmed_payments | charges created over 2 h ago and still pending | what a webhook endpoint that stopped being reachable looks like from the inside |
disputes_due | open disputes whose evidence window closes within 72 h | a chargeback deadline is closing. Past it the dispute is lost by default rather than on the merits |
open_disputes | every dispute in warning, open or under_review — no deadline required | a chargeback is open and the money is already out of the account |
rejected_deliveries | deliveries the endpoint refused in the last 24 h | usually a rotated webhook secret that never reached the deployment |
Four of them have a threshold flag: --stuck-after and --unconfirmed-after in minutes, --window
(the failure window) and --dispute-window in hours. open_disputes has none by design — an open
chargeback is money already gone, so there is no horizon at which it stops mattering — and
rejected_deliveries uses a fixed 24 h from the command. See
CLI.
open_disputes is the one that catches what disputes_due structurally cannot. That check filters
on evidence_due_by, which only a gateway that publishes a deadline can fill — so on most installs
it reports zero forever while a chargeback sits open. rejected_deliveries is the only visibility a
refused delivery has anywhere: it is answered 400 before an event exists, so it never becomes a
ledger row.
disputes_due is the odd one out and the reason to run this on a cron rather than when something
looks wrong: nothing is broken, and the money goes anyway if nobody answers in time. The report
prints the disputes by name — provider, dispute id, payment id, deadline — because a count names
nobody to email. Windows already past stay counted; they are still open and still unanswered. See
Disputes.
It exits non-zero when any check is non-zero, so a cron entry or a container healthcheck can page on it without parsing the output. To read the failures themselves, page the ledger — the handler's error message survives nowhere else:
const failures = await store.listWebhookEvents({ status: 'failed', size: 20 })See Health checks.
A failed event is claimable again, so once the underlying cause is fixed, replaying it from the
gateway dashboard re-runs it properly. See Idempotency.
The signal that catches a broken endpoint
The hardest failure to notice is the one where nothing errors: your webhook URL stops being reachable, charges keep being created, and no confirmation ever arrives. Neither table looks wrong on its own.
Alert on the ratio:
import { onDiagnostic } from '@adonis-agora/diagnostics'
onDiagnostic('payments', 'charge.created', () => metrics.increment('payments.charge_created'))
onDiagnostic('payments', 'payment.succeeded', () => metrics.increment('payments.confirmed'))A charge.created rate that holds while payment.succeeded falls off a cliff is a broken endpoint,
and nothing else in the system will tell you. See Production.
Marketplace splits
Share a charge across recipients — Asaas splits by percent or fixed amount, Woovi subaccounts keyed by Pix key — and the integer arithmetic that keeps a computed split summing to the total.
Disputes
A chargeback is the only event that takes revenue back after it settled, and it runs on a clock. The three moments, why actionableUntil is the field that matters, how to reach the stored dispute and submit evidence where the gateway allows it — and why the decision to fight or refund stays in your code.