Skip to main content

Dunning Management

Dunning management is the process of handling failed subscription payments through automated retry attempts and customer notifications. The reference app includes a sophisticated dunning system that maximizes subscription retention while providing flexibility in handling various failure scenarios.

What is Dunning?

Dunning refers to the process of communicating with customers about failed payments and automatically retrying charges. The goal is to recover revenue from failed billing attempts while maintaining a positive customer experience.
The term “dunning” comes from the 17th-century word “dun,” meaning to make persistent demands for payment.

DunningTracker Model

The app uses a DunningTracker model to track retry attempts for each failed billing cycle:
model DunningTracker {
  id                Int       @id @default(autoincrement())
  shop              String
  contractId        String
  billingCycleIndex Int
  failureReason     String
  completedAt       DateTime?
  completedReason   String?

  @@unique([shop, contractId, billingCycleIndex, failureReason], name: "uniqueBillingCycleFailure")
  @@index([completedAt])
}

Factory Implementation

import type {DunningTracker, Prisma} from '@prisma/client';
import {Factory} from 'fishery';
import prisma from '~/db.server';

export const dunningTracker = Factory.define<
  Prisma.DunningTrackerCreateInput,
  {},
  DunningTracker
>(({onCreate, sequence}) => {
  onCreate((data) => prisma.dunningTracker.create({data}));

  return {
    shop: `shop-${sequence}.myshopify.com`,
    contractId: `gid://shopify/SubscriptionContract/${sequence}`,
    billingCycleIndex: sequence,
    failureReason: 'CARD_EXPIRED',
  };
});
  • shop: The Shopify shop domain
  • contractId: The subscription contract ID
  • billingCycleIndex: The specific billing cycle that failed
  • failureReason: The error code from the failed billing attempt
  • completedAt: When dunning was completed (successfully or otherwise)
  • completedReason: Why dunning was stopped

Dunning Configuration

Dunning behavior is configured through the Settings metaobject:
const SETTINGS_METAOBJECT_FIELDS: NonNullMetaobjectField[] = [
  {
    key: 'retryAttempts',
    value: 3, // Number of retry attempts
    valueType: MetafieldType.NUMBER_INTEGER,
  },
  {
    key: 'daysBetweenRetryAttempts',
    value: 7, // Days between retries
    valueType: MetafieldType.NUMBER_INTEGER,
  },
  {
    key: 'onFailure',
    value: 'cancel', // Action after final failure: 'cancel' or 'pause'
    valueType: MetafieldType.SINGLE_LINE_TEXT_FIELD,
  },
];

Configuration Options

Number of times to retry a failed billing attempt (typically 2-5).Default: 3 attemptsMore attempts = higher recovery rate but longer delay before giving up

DunningService Implementation

The core dunning logic is handled by the DunningService class:
import type {Settings} from '~/types';

interface DunningServiceArgs {
  shopDomain: string;
  contract: SubscriptionContractWithBillingCycle['subscriptionContract'];
  billingCycle: SubscriptionContractWithBillingCycle['subscriptionBillingCycle'];
  settings: Settings;
  failureReason: string;
}

export class DunningService {
  static BILLING_CYCLE_BILLED_STATUS = 'BILLED';
  static TERMINAL_STATUS = ['EXPIRED', 'CANCELLED'];

  shopDomain: string;
  contract: SubscriptionContractWithBillingCycle['subscriptionContract'];
  billingCycle: SubscriptionContractWithBillingCycle['subscriptionBillingCycle'];
  settings: Settings;
  failureReason: string;

  async run(): Promise<DunningServiceResult> {
    const {shopDomain, contract, billingCycle, failureReason} = this;

    if (this.billingAttemptNotReady) {
      return 'BILLING_ATTEMPT_NOT_READY';
    }

    const dunningTracker = await findOrCreateBy({
      shop: shopDomain,
      contractId: contract.id,
      billingCycleIndex: billingCycle.cycleIndex,
      failureReason: failureReason,
    });

    if (this.billingCycleAlreadyBilled) {
      await markCompleted(dunningTracker);
      return 'BILLING_CYCLE_ALREADY_BILLED';
    }

    if (this.contractInTerminalStatus) {
      await markCompleted(dunningTracker);
      return 'CONTRACT_IN_TERMINAL_STATUS';
    }

    switch (true) {
      case this.finalAttempt:
        await markCompleted(dunningTracker);
        await new FinalAttemptDunningService({...}).run();
        return 'FINAL_ATTEMPT_DUNNING';
      case this.penultimateAttempt:
        await new PenultimateAttemptDunningService({...}).run();
        return 'PENULTIMATE_ATTEMPT_DUNNING';
      default:
        await new RetryDunningService({...}).run();
        return 'RETRY_DUNNING';
    }
  }

  private get finalAttempt(): boolean {
    return this.billingAttemptsCount >= this.settings.retryAttempts;
  }

  private get penultimateAttempt(): boolean {
    return this.billingAttemptsCount === this.settings.retryAttempts - 1;
  }
}

Dunning Workflow

The dunning process follows a multi-stage approach:

1. Billing Attempt Failure

When a billing attempt fails, Shopify sends a webhook to the app:
export class DunningStartJob extends Job<
  Jobs.Parameters<Webhooks.SubscriptionBillingAttemptFailure>
> {
  public queue: string = 'webhooks';

  async perform(): Promise<void> {
    const {shop, payload} = this.parameters;

    const {admin_graphql_api_id: billingAttemptId, error_code: failureReason} =
      payload;

    const dunningService = await buildDunningService({
      shopDomain: shop,
      billingAttemptId,
      failureReason,
    });

    result = await dunningService.run();
    logger.info({result}, 'Completed DunningService');
  }
}

2. Retry Attempts

The service determines which type of retry to execute:
Regular Retry (attempts 1 to n-2):
  • Schedule next billing attempt
  • Send customer notification email
  • Wait configured number of days
Penultimate Attempt (attempt n-1):
  • Schedule next billing attempt
  • Send urgent notification to customer
  • Warn that this is the second-to-last attempt
Final Attempt (attempt n):
  • Schedule final billing attempt
  • Send final warning to customer
  • If this fails, execute on-failure action

3. On Failure Actions

After all retry attempts are exhausted:
switch (settings.onFailure) {
  case 'cancel':
    // Cancel the subscription contract
    await cancelSubscriptionContract(contractId);
    break;
  case 'pause':
    // Pause the subscription for merchant review
    await pauseSubscriptionContract(contractId);
    break;
}
Cancelled contracts cannot be reactivated. Consider using “pause” if you want to give merchants the option to manually recover subscriptions.

Error Types and Handling

Different error types may require different handling strategies:
export const SubscriptionBillingAttemptErrorCode = {
  InsufficientInventory: 'INSUFFICIENT_INVENTORY',
  InventoryAllocationsNotFound: 'INVENTORY_ALLOCATIONS_NOT_FOUND',
  PaymentMethodDeclined: 'PAYMENT_METHOD_DECLINED',
  CardExpired: 'CARD_EXPIRED',
  // ... other error codes
};

Inventory-Specific Dunning

The app includes separate dunning logic for inventory failures:
if (
  errorCode === SubscriptionBillingAttemptErrorCode.InsufficientInventory ||
  errorCode === SubscriptionBillingAttemptErrorCode.InventoryAllocationsNotFound
) {
  const inventoryService = await buildInventoryService({
    shopDomain: shop,
    billingAttemptId,
    failureReason,
  });
  result = await inventoryService.run();
}

Inventory Configuration

{
  key: 'inventoryRetryAttempts',
  value: 5, // More attempts for inventory issues
  valueType: MetafieldType.NUMBER_INTEGER,
},
{
  key: 'inventoryDaysBetweenRetryAttempts',
  value: 1, // Retry more frequently
  valueType: MetafieldType.NUMBER_INTEGER,
},
{
  key: 'inventoryOnFailure',
  value: 'skip', // Skip the cycle instead of cancelling
  valueType: MetafieldType.SINGLE_LINE_TEXT_FIELD,
}
Inventory failures are often temporary, so the app uses more aggressive retry settings with shorter intervals.

Customer Communication

The dunning process includes automated customer emails:
await new RetryDunningService({
  shopDomain,
  subscriptionContract: contract,
  billingAttempt: lastBillingAttempt,
  daysBetweenRetryAttempts: settings.daysBetweenRetryAttempts,
  billingCycleIndex: billingCycle.cycleIndex,
  sendCustomerEmail: true, // Send notification
}).run();

Email Templates

Friendly notification that payment failed with instructions to update payment method.Subject: “Action needed: Update payment method”

Merchant Notifications

Merchants also receive notifications about subscription issues:
merchantEmailTemplateName: MerchantEmailTemplateName.SubscriptionPaymentFailureMerchant
This helps merchants proactively reach out to high-value customers or identify systemic payment issues.

Best Practices

  1. Balance retry attempts: Too few retries leave revenue on the table; too many annoy customers
  2. Optimize timing: 7 days between retries is typical for credit card payment cycles
  3. Segment by value: Consider different dunning strategies for high-value vs. low-value subscriptions
  4. Monitor recovery rates: Track which retry attempt recovers the most subscriptions
  5. Handle inventory separately: Use shorter retry intervals for inventory failures
  6. Customize emails: Make sure dunning emails match your brand voice and provide clear instructions

Analytics and Monitoring

Track these metrics to optimize your dunning process:
  • Recovery rate: Percentage of failed payments successfully recovered
  • Recovery by attempt: Which retry attempt has the highest success rate
  • Time to recovery: Average days until payment is recovered
  • Failure reasons: Most common error codes causing failures
  • Churn rate: Percentage of subscriptions lost to payment failures

Dunning Tracker Queries

// Find active dunning trackers
const activeTrackers = await prisma.dunningTracker.findMany({
  where: {
    shop: shopDomain,
    completedAt: null,
  },
});

// Find trackers for a specific contract
const contractTrackers = await prisma.dunningTracker.findMany({
  where: {
    shop: shopDomain,
    contractId: contractId,
  },
  orderBy: {
    billingCycleIndex: 'desc',
  },
});

// Mark tracker as completed
await prisma.dunningTracker.update({
  where: { id: trackerId },
  data: {
    completedAt: new Date(),
    completedReason: 'BILLING_CYCLE_ALREADY_BILLED',
  },
});

Build docs developers (and LLMs) love