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
Retry Attempts
Days Between Retries
On Failure Action
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
How many days to wait between retry attempts. Default : 7 daysGives customers time to update payment methods or add funds
What to do after all retry attempts fail. Options :
cancel: Cancel the subscription
pause: Pause the subscription for merchant review
Default : cancel
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
First Failure
Penultimate Attempt
Final Attempt
Cancellation
Friendly notification that payment failed with instructions to update payment method. Subject: “Action needed: Update payment method”
More urgent tone, warning that subscription will be cancelled soon. Subject: “Urgent: Your subscription payment failed”
Final warning before cancellation with clear call-to-action. Subject: “Final notice: Subscription will be cancelled”
Confirmation that subscription was cancelled due to payment failure. Subject: “Your subscription has been cancelled”
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
Balance retry attempts : Too few retries leave revenue on the table; too many annoy customers
Optimize timing : 7 days between retries is typical for credit card payment cycles
Segment by value : Consider different dunning strategies for high-value vs. low-value subscriptions
Monitor recovery rates : Track which retry attempt recovers the most subscriptions
Handle inventory separately : Use shorter retry intervals for inventory failures
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' ,
},
});