orvacon

dunningkit

Recover failed payments with a scheduled, backed-off retry policy — a stateless dunning state machine.

@orvacon/dunningkit recovers a failed payment with a backed-off retry schedule. It is stateless: it performs no charge and keeps no store of its own — your cron re-runs the charge and persists the state; dunningkit owns only the policy (when to retry, when to give up).

bun add @orvacon/dunningkit
import { dunningkit, hours, days } from "@orvacon/dunningkit";

const dunning = dunningkit({ schedule: [hours(1), days(1), days(3)] }); // three retries

// a charge failed — open a cycle, persist the state:
let state = dunning.open(outcome, now); // your orva.authorize() result goes straight in

// your cron, for each due record:
if (dunning.due(state, now)) {
  const retry = await orva.authorize(charge);
  state = dunning.advance(state, retry, now);
  if (state.status !== "scheduled") await stopDunning(paymentId, state);
}

open / advance fold a charge outcome into the next DunningStatescheduledrecovered (a retry succeeded) · exhausted (retryable failures ran the schedule out) · abandoned (a non-retryable error, gave up at once).

Dunning is not the core's auto-retry. isRetryableError governs immediate auto-retry — re-sending the same request now — and only gateway_error qualifies: a declined charge re-sent at once just declines again, risking a double charge. Dunning is the opposite, a delayed re-attempt days later, when the customer may have topped up. So dunningkit retries declined and gateway_error by default, while merchant-side errors (invalid_request, auth_error, conflict) are abandoned at once. Override with shouldRetry.