Agora
Patterns

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:

app/payment_handlers/payment_failed.ts
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:

app/services/refund_service.ts
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:

app/payment_handlers/payment_refunded.ts
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:

GatewayRefunds
Woovi / OpenPixno — no refund API; its capabilities declares only subscriptions
InfinitePaynorefunds: 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=stripe

One 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 shipper

It asks six questions, each a failure that produces no error anywhere:

CheckCountsWhat a non-zero means
stuck_webhooksevents claimed in the ledger and unfinished for over 15 minnothing is consuming the dispatcher — the durable:work worker is not running
failed_webhooksevents the dispatcher gave up on in the last 24 hhandlers threw and retries ran out, so those events never took effect
unconfirmed_paymentscharges created over 2 h ago and still pendingwhat a webhook endpoint that stopped being reachable looks like from the inside
disputes_dueopen disputes whose evidence window closes within 72 ha chargeback deadline is closing. Past it the dispute is lost by default rather than on the merits
open_disputesevery dispute in warning, open or under_reviewno deadline requireda chargeback is open and the money is already out of the account
rejected_deliveriesdeliveries the endpoint refused in the last 24 husually 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:

start/payments.ts
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.

On this page