Create your own
Lesson illustration

Managing Failed Payments, Dunning, and Access Recovery Policies

Good to continue from renewal handling. You now have a transactional way to record a paid recurring period exactly once, even when a provider repeats webhooks. The opposite case needs equally careful treatment: a failed renewal is not automatically a cancellation, and it is not necessarily an immediate loss of access.

This lesson separates two responsibilities:

  • The billing provider owns payment collection and retry attempts: its dunning process.
  • Your subscription module owns the local product policy: how long access continues, when plan changes are restricted, and when access is suspended.

By the end, you will have a provider-neutral model and implementation approach for failed-payment and recovery events that works for both individual and organization-owned subscriptions.


Failure to pay is a collection state, not a single terminal event

A recurring charge can fail for many reasons: insufficient funds, a temporary bank issue, an expired card, or a payment method that requires customer action. Providers generally distinguish between temporary collection states, often called past_due, and terminal states such as unpaid or canceled. The exact names differ, so your domain should not expose them directly.

The key rule is:

Do not implement payment retries yourself when the billing provider manages recurring collection.

Your module should record the provider’s current collection status and respond to it. It should not decide that “three failures means cancel” unless that decision is explicitly part of a configured product policy and is consistent with the provider’s authoritative subscription state.

The Overdue System tutorial from Kill Bill provides a useful concrete model: retry payments, warn the customer, block access only after a defined delay, then possibly cancel later.

Overdue System

Read Kill Bill’s “Overdue System” tutorial as a reference model for separating retries, warning states, access blocking, and cancellation. Its XML configuration is product-specific, but its operational distinctions are useful for a provider-neutral module.

Begin in the “Scenario” section and read the intended dunning policy: retries, customer notification, entitlement blocking, cancellation, and restricting plan changes are separate decisions. Then read the “Overdue configuration” section, especially the timed state progression. Focus on the difference between WARNING, where access continues, and BLOCKED, where entitlements are disabled. In “Example of Customers,” compare the bad and good customer outcomes. Read the unresolved case, then note how a successful payment returns the good customer to CLEAR. Finally, in “Use of the APIs,” read from “When a customer attempts to use the service” through the entitlement check discussion. The lesson’s important idea is that subscription lifecycle and overdue status together determine access.

A generic lifecycle looks like this:

A provider subscription lifecycle showing trial and paid subscription paths, successful charges that preserve full access, failed charges that trigger provider dunning, and eventual suspension or termination. Your local module should consume these billing outcomes rather than reproduce the provider’s payment retry engine.

For your implementation, avoid encoding this as one overloaded status field. “Active” can mean that the commercial subscription still exists, while “suspended” may mean that product access is currently denied due to unpaid billing. Those are different dimensions.


Model commercial lifecycle separately from collection and access

A reusable module needs to cope with combinations such as:

  • an active annual subscription with an overdue invoice;
  • a subscription scheduled to cancel at period end that is also delinquent;
  • a subscription that recovered payment but remains non-renewing because the owner had already canceled;
  • an organization subscription whose seats remain assigned even while member access is suspended.

Use orthogonal fields rather than forcing these cases into a giant enum.

export enum SubscriptionLifecycleStatus {
  TRIALING = 'trialing',
  ACTIVE = 'active',
  CANCELED = 'canceled',
  TERMINATED = 'terminated',
}

export enum PaymentCollectionStatus {
  CLEAR = 'clear',
  DELINQUENT = 'delinquent',
  SUSPENDED = 'suspended',
}

export enum ProviderDunningStatus {
  UNKNOWN = 'unknown',
  RETRYING = 'retrying',
  ACTION_REQUIRED = 'action_required',
  EXHAUSTED = 'exhausted',
  RESOLVED = 'resolved',
}

A practical subscription projection could include:

export class SubscriptionEntity {
  id: string;

  lifecycleStatus: SubscriptionLifecycleStatus;
  paymentCollectionStatus: PaymentCollectionStatus;

  providerKey: string;
  providerSubscriptionId: string;
  providerVersion: string | null;

  cancelAtPeriodEnd: boolean;

  currentPeriodStart: Date | null;
  currentPeriodEnd: Date | null;

  delinquencyStartedAt: Date | null;
  graceEndsAt: Date | null;
  accessSuspendedAt: Date | null;

  revision: number;
}

The meanings should remain precise:

FieldMeaningMust not mean
lifecycleStatusWhether the subscription commercially existsWhether the latest payment attempt passed
paymentCollectionStatusYour local payment-trouble projectionThe provider’s exact internal retry state
providerDunningStatusNormalized provider collection informationYour entitlement decision
graceEndsAtThe local policy deadline for continued accessA provider retry date
accessSuspendedAtWhen local entitlement changed to deniedThat the provider canceled the subscription

This distinction prevents a dangerous bug during recovery. If a customer fixes payment, recovery should clear delinquency and restore access. It should not accidentally reactivate a subscription whose owner had previously scheduled cancellation.


Persist the unpaid invoice or collection case

A subscription-level graceEndsAt is convenient for reads, but it is not enough for a reliable history. Persist a record for the unpaid invoice or provider collection case that opened the delinquency.

For most recurring providers, the stable identity is the provider invoice ID. If the provider does not expose invoices, use its documented immutable identifier for the overdue collection cycle.

CREATE TABLE subscription_payment_incidents (
  id uuid PRIMARY KEY,

  subscription_id uuid NOT NULL
    REFERENCES subscriptions(id),

  provider_key varchar NOT NULL,
  provider_invoice_id varchar NOT NULL,

  provider_dunning_status varchar NOT NULL,
  provider_attempt_count integer NOT NULL DEFAULT 0,

  first_failed_at timestamptz NOT NULL,
  last_failed_at timestamptz NOT NULL,
  grace_ends_at timestamptz NOT NULL,

  recovered_at timestamptz NULL,
  recovery_provider_event_id varchar NULL,

  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT subscription_payment_incidents_attempts_nonnegative
    CHECK (provider_attempt_count >= 0),

  CONSTRAINT subscription_payment_incidents_grace_after_failure
    CHECK (grace_ends_at >= first_failed_at),

  CONSTRAINT subscription_payment_incidents_invoice_unique
    UNIQUE (provider_key, provider_invoice_id)
);

The uniqueness constraint does important work. Providers can send:

  • the same failed-payment event more than once;
  • a subscription update and an invoice failure event for the same unpaid invoice;
  • later failure events as the provider retries collection.

All may refer to the same payment incident. The first observed failure establishes the grace deadline. Subsequent retry failures can update the provider status and attempt count, but they must not silently extend the customer’s grace period.

For example, if the policy is seven days, calculate:

Do not calculate it from lastFailedAt. Otherwise every provider retry effectively renews product access.


Make dunning policy explicit and configurable

Your core provider adapter should normalize facts, not embed business policy. The host product may want a two-day grace period for a developer tool, a fourteen-day grace period for an organization product, or no continued access for a high-cost service.

Represent that policy as configuration owned by your subscription module or host application.

export interface DunningAccessPolicy {
  /**
   * Continued product access after the first confirmed unpaid invoice.
   */
  gracePeriodHours: number;

  /**
   * Restrict plan, quantity, and cancellation-reversal commands while
   * payment is unresolved.
   */
  blockSubscriptionChangesDuringDelinquency: boolean;

  /**
   * A provider must still report unresolved collection before local access
   * is suspended at the grace deadline.
   */
  requireUnresolvedProviderDunningForSuspension: boolean;

  /**
   * An exhausted provider collection state may suspend access before the
   * ordinary grace deadline only if the product explicitly opts in.
   */
  suspendImmediatelyWhenProviderExhausted: boolean;
}

A conservative default for a reusable SaaS module is:

const defaultDunningAccessPolicy: DunningAccessPolicy = {
  gracePeriodHours: 7 * 24,
  blockSubscriptionChangesDuringDelinquency: true,
  requireUnresolvedProviderDunningForSuspension: true,
  suspendImmediatelyWhenProviderExhausted: false,
};

This produces a clear contract:

  1. A confirmed failed recurring payment opens delinquency.
  2. The provider continues its own retry and customer-notification process.
  3. Your product may display a payment warning and block commercial changes.
  4. Access continues through the configured local grace period.
  5. When the grace period expires, access is suspended only if collection remains unresolved.
  6. A confirmed recovery clears delinquency and restores access when the commercial subscription is still valid.

The policy should be versioned or copied into the incident/audit record when applied. Otherwise, changing the global grace configuration today can unpredictably change the deadline of an incident that began last week.


Normalize failure and recovery events at the provider boundary

As with successful renewals, provider-specific payload interpretation belongs in the adapter. Your domain service receives normalized events with stable identifiers and explicit collection facts.

export interface PaymentCollectionFailedEvent {
  providerKey: string;
  providerEventId: string;
  providerSubscriptionId: string;
  providerVersion: string | null;

  providerInvoiceId: string;

  /**
   * When the provider first considers this invoice unpaid.
   * It must remain stable across retries for the same invoice.
   */
  firstFailedAt: Date;
  occurredAt: Date;

  dunningStatus: ProviderDunningStatus;
  attemptCount: number;
}

export interface PaymentCollectionRecoveredEvent {
  providerKey: string;
  providerEventId: string;
  providerSubscriptionId: string;
  providerVersion: string | null;

  providerInvoiceId: string;
  recoveredAt: Date;

  /**
   * The provider confirms this collection case no longer requires payment.
   */
  dunningStatus: ProviderDunningStatus.RESOLVED;
}

The adapter may map provider vocabulary differently:

Provider-specific conditionNormalized status
Provider is scheduling or executing retriesRETRYING
Customer must authenticate or replace payment methodACTION_REQUIRED
Provider has stopped retries but invoice remains unpaidEXHAUSTED
Invoice is paid, voided, or otherwise no longer collectibleRESOLVED

Do not emit PaymentCollectionRecoveredEvent merely because the customer updated a credit card in your self-service UI. A new card is an attempt to resolve the issue, not proof of payment. Recovery requires provider confirmation that the specific invoice or collection case has been resolved.

Similarly, a successful payment can have two independent business effects:

  • It may be a successful renewal, which advances the billing period. Your previous handler owns that fact.
  • It may be a recovery, which closes an existing delinquency incident and restores local access.

A provider event can contain enough information for both. Keep the operations coordinated in one transaction when they are triggered by the same authoritative invoice, but do not let recovery itself invent a new billing period.


Apply a failure without resetting grace

When a failure event arrives, lock the subscription and locate the incident by (providerKey, providerInvoiceId). The operation must be idempotent at the business level, not just the webhook-delivery level.

The state transition is:

Current collection statusEventNew collection statusAccess
CLEARFirst unresolved payment failureDELINQUENTAllowed through grace period
DELINQUENTLater retry failure for same invoiceDELINQUENTStill governed by original grace deadline
SUSPENDEDLater retry failureSUSPENDEDDenied
DELINQUENT or SUSPENDEDConfirmed recoveryCLEARRestored if lifecycle is still eligible

A focused TypeORM-style service method can look like this:

async function applyPaymentFailure(
  event: PaymentCollectionFailedEvent,
  policy: DunningAccessPolicy,
): Promise<void> {
  await dataSource.transaction(async function applyInTransaction(manager) {
    const subscriptions = manager.getRepository(SubscriptionEntity);
    const incidents = manager.getRepository(SubscriptionPaymentIncidentEntity);
    const audits = manager.getRepository(SubscriptionTransitionAuditEntity);

    const subscription = await subscriptions.findOneOrFail({
      where: {
        providerKey: event.providerKey,
        providerSubscriptionId: event.providerSubscriptionId,
      },
      lock: { mode: 'pessimistic_write' },
    });

    let incident = await incidents.findOne({
      where: {
        providerKey: event.providerKey,
        providerInvoiceId: event.providerInvoiceId,
      },
    });

    if (!incident) {
      const graceEndsAt = new Date(
        event.firstFailedAt.getTime() +
          policy.gracePeriodHours * 60 * 60 * 1000,
      );

      incident = incidents.create({
        id: randomUUID(),
        subscriptionId: subscription.id,
        providerKey: event.providerKey,
        providerInvoiceId: event.providerInvoiceId,
        providerDunningStatus: event.dunningStatus,
        providerAttemptCount: event.attemptCount,
        firstFailedAt: event.firstFailedAt,
        lastFailedAt: event.occurredAt,
        graceEndsAt,
      });

      await incidents.insert(incident);

      subscription.paymentCollectionStatus =
        PaymentCollectionStatus.DELINQUENT;
      subscription.delinquencyStartedAt = event.firstFailedAt;
      subscription.graceEndsAt = graceEndsAt;
      subscription.revision += 1;

      await subscriptions.save(subscription);

      await audits.insert({
        id: randomUUID(),
        subscriptionId: subscription.id,
        eventType: 'payment_delinquency_opened',
        actorType: 'billing_provider',
        actorId: event.providerKey,
        details: {
          providerEventId: event.providerEventId,
          providerInvoiceId: event.providerInvoiceId,
          graceEndsAt: graceEndsAt.toISOString(),
          dunningStatus: event.dunningStatus,
        },
        occurredAt: event.occurredAt,
      });

      return;
    }

    if (incident.recoveredAt) {
      throw new DomainError('stale_failure_for_recovered_invoice');
    }

    incident.providerDunningStatus = event.dunningStatus;
    incident.providerAttemptCount = Math.max(
      incident.providerAttemptCount,
      event.attemptCount,
    );
    incident.lastFailedAt = event.occurredAt;

    await incidents.save(incident);
  });
}

The first branch opens the incident and computes graceEndsAt once. The second updates provider progress without changing firstFailedAt or graceEndsAt.

In production, handle a unique-constraint conflict on subscription_payment_incidents_invoice_unique similarly to the renewal handler: reload the incident, verify it belongs to the same subscription and invoice, then treat the operation as a concurrent duplicate/update rather than creating a second incident.


Suspend access by policy and time, not by webhook timing

A failure event is not enough to guarantee that the grace deadline will be enforced. Providers may make no further delivery after the original failure, and a worker could be temporarily unavailable when a retry occurs.

Run a scheduled policy evaluator. It should find unresolved incidents whose local grace deadline has passed and decide whether access should be suspended.

The evaluator should not retry a payment or call a provider endpoint that changes billing state. It only applies the local access policy.

async function suspendExpiredGracePeriods(now: Date): Promise<void> {
  const candidates = await incidentRepository.find({
    where: {
      recoveredAt: IsNull(),
      graceEndsAt: LessThanOrEqual(now),
    },
    take: 200,
  });

  for (const candidate of candidates) {
    await dataSource.transaction(async function evaluate(manager) {
      const subscription = await manager
        .getRepository(SubscriptionEntity)
        .findOneOrFail({
          where: { id: candidate.subscriptionId },
          lock: { mode: 'pessimistic_write' },
        });

      const incident = await manager
        .getRepository(SubscriptionPaymentIncidentEntity)
        .findOneOrFail({
          where: { id: candidate.id },
        });

      if (incident.recoveredAt) {
        return;
      }

      if (
        subscription.paymentCollectionStatus ===
        PaymentCollectionStatus.SUSPENDED
      ) {
        return;
      }

      if (!isUnresolvedDunningStatus(incident.providerDunningStatus)) {
        return;
      }

      subscription.paymentCollectionStatus =
        PaymentCollectionStatus.SUSPENDED;
      subscription.accessSuspendedAt = now;
      subscription.revision += 1;

      await manager.save(subscription);

      await manager.insert(SubscriptionTransitionAuditEntity, {
        id: randomUUID(),
        subscriptionId: subscription.id,
        eventType: 'access_suspended_for_nonpayment',
        actorType: 'system_policy',
        actorId: 'dunning-policy',
        details: {
          incidentId: incident.id,
          providerInvoiceId: incident.providerInvoiceId,
          graceEndsAt: incident.graceEndsAt.toISOString(),
        },
        occurredAt: now,
      });
    });
  }
}

In a more cautious implementation, refresh an authoritative provider snapshot before suspending if the stored dunning status is stale or ambiguous. Perform the network call outside the database transaction, then lock and re-check the local records before writing. This avoids holding PostgreSQL locks while waiting on an external provider.

The fuller event-ordering and authoritative-snapshot rules come in Module 4. For now, establish the policy principle: when local data indicates non-payment but the provider’s current state is uncertain, do not guess.


Apply recovery carefully

A recovery event should close the specific payment incident. It must not blindly clear all historical incidents or overwrite the commercial lifecycle.

async function applyPaymentRecovery(
  event: PaymentCollectionRecoveredEvent,
): Promise<void> {
  await dataSource.transaction(async function applyInTransaction(manager) {
    const subscriptions = manager.getRepository(SubscriptionEntity);
    const incidents = manager.getRepository(SubscriptionPaymentIncidentEntity);

    const subscription = await subscriptions.findOneOrFail({
      where: {
        providerKey: event.providerKey,
        providerSubscriptionId: event.providerSubscriptionId,
      },
      lock: { mode: 'pessimistic_write' },
    });

    const incident = await incidents.findOneOrFail({
      where: {
        providerKey: event.providerKey,
        providerInvoiceId: event.providerInvoiceId,
      },
    });

    if (incident.recoveredAt) {
      return;
    }

    incident.providerDunningStatus = ProviderDunningStatus.RESOLVED;
    incident.recoveredAt = event.recoveredAt;
    incident.recoveryProviderEventId = event.providerEventId;

    await incidents.save(incident);

    subscription.paymentCollectionStatus = PaymentCollectionStatus.CLEAR;
    subscription.delinquencyStartedAt = null;
    subscription.graceEndsAt = null;
    subscription.accessSuspendedAt = null;
    subscription.revision += 1;

    await subscriptions.save(subscription);

    await manager.insert(SubscriptionTransitionAuditEntity, {
      id: randomUUID(),
      subscriptionId: subscription.id,
      eventType: 'payment_recovered',
      actorType: 'billing_provider',
      actorId: event.providerKey,
      details: {
        providerEventId: event.providerEventId,
        providerInvoiceId: event.providerInvoiceId,
        restoredFromSuspension:
          subscription.paymentCollectionStatus ===
          PaymentCollectionStatus.SUSPENDED,
      },
      occurredAt: event.recoveredAt,
    });
  });
}

There is one subtle implementation bug in this illustrative code: after assigning CLEAR, the expression checking whether recovery came from suspension is always false. Capture the prior value before mutation.

const wasSuspended =
  subscription.paymentCollectionStatus ===
  PaymentCollectionStatus.SUSPENDED;

// Update incident and subscription fields.

details: {
  providerEventId: event.providerEventId,
  providerInvoiceId: event.providerInvoiceId,
  restoredFromSuspension: wasSuspended,
},

That small detail matters to your audit trail and customer notifications.

Recovery restores payment-based access only. Your entitlement layer, which you will implement in Module 5, should still check other conditions:

  • the commercial lifecycle is ACTIVE or otherwise eligible;
  • the current period has not expired;
  • the subscription is not canceled or terminated;
  • the relevant feature exists on the current plan.

For example, recovering an invoice on a subscription already terminated by the provider should not restore access. Treat that as a provider-state reconciliation issue, not as a successful local reactivation.


Test the temporal and idempotency invariants

Use PostgreSQL integration tests for these paths. Your tests should control the clock or inject a Clock abstraction; relying on new Date() directly makes grace-period tests slow and fragile.

The essential cases are:

ScenarioExpected result
First failed paymentOne open incident; status becomes DELINQUENT; grace deadline is set once
Duplicate failure webhookNo second incident; grace deadline remains unchanged
Provider retry for same invoiceAttempt count and latest dunning status may update; original grace deadline remains unchanged
Grace deadline has not passedScheduled evaluator does not suspend access
Grace deadline passed and dunning unresolvedStatus becomes SUSPENDED once; one suspension audit record
Grace deadline passed but incident recoveredNo suspension
Confirmed recovery during graceIncident closes; collection status returns to CLEAR
Confirmed recovery after suspensionIncident closes; access-related status returns to CLEAR
Card updated but no provider payment successRemains DELINQUENT or SUSPENDED
Stale failure after recoveryRejected or queued for reconciliation; must not reopen delinquency

A particularly valuable invariant test is:

expect(reloadedIncident.graceEndsAt).toEqual(originalGraceEndsAt);

Run it after duplicate and later-retry failure events. If that assertion fails, your customers can unintentionally receive unlimited access as long as the provider continues retrying their card.


Key takeaways

Failed payment, provider dunning, and denied product access are related but distinct states.

  • Let the provider manage payment retries and normalize its dunning information at the adapter boundary.
  • Persist one payment incident per provider-scoped unpaid invoice or collection case.
  • Set the local grace deadline from the first confirmed failure; later retry failures must not extend it.
  • Keep commercial lifecycle, payment collection state, and provider dunning status separate.
  • Use a scheduled evaluator to enforce grace expiry even when no further webhook arrives.
  • Restore payment-based access only after an authoritative provider recovery event, and do not accidentally reverse cancellation or termination.
  • Record every meaningful transition in the audit history.

Next, you will connect these lifecycle rules to access decisions: evaluating entitlements from plan features, subscription state, billing dates, and the grace policy, then enforcing them through a NestJS guard.

Can't find a good explanation? Sign up and we'll make it for you

Sign up