Skip to content

Subscription Lifecycle

Subscription Lifecycle Guide

This guide covers managing subscriptions from creation to cancellation.

Lifecycle Overview

┌──────────────────────────────────────────────────────────────┐
│ SUBSCRIPTION LIFECYCLE │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌──────────┐ ┌────────┐ │
│ │ Created │───────▶│ Trialing │───────▶│ Active │ │
│ └─────────┘ └──────────┘ └────────┘ │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ ┌──────────┐ │
│ │ │ │ Past Due │ │
│ │ │ └──────────┘ │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌──────────┐ ┌────────┐ │
│ └────────────▶│ Cancelled│◀───────│ Unpaid │ │
│ └──────────┘ └────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘

Creating Subscriptions

Basic Creation

const subscription = await billing.subscriptions.create({
customerId: 'cus_123',
planId: 'price_pro_monthly',
paymentMethodId: 'pm_card_visa'
});

With Trial Period

const subscription = await billing.subscriptions.create({
customerId: 'cus_123',
planId: 'price_pro_monthly',
trialDays: 14 // 14-day trial
});
// Subscription starts in 'trialing' status
// No payment required until trial ends

With Promo Code

const subscription = await billing.subscriptions.create({
customerId: 'cus_123',
planId: 'price_pro_monthly',
promoCodeId: 'LAUNCH50' // 50% off first month
});

Trial Management

Checking Trial Status

const subscription = await billing.subscriptions.get('sub_123');
if (subscription.status === 'trialing') {
const daysLeft = Math.ceil(
(subscription.trialEnd - Date.now()) / (1000 * 60 * 60 * 24)
);
console.log(`Trial ends in ${daysLeft} days`);
}

Trial Ending Notification

billing.on('subscription.trial_ending', async (event) => {
const { customerId, trialEnd } = event.data;
// Send reminder email 3 days before trial ends
await sendEmail(customerId, 'trial_ending', {
trialEndDate: trialEnd,
action: 'add_payment_method'
});
});

Converting Trial to Paid

Trial converts automatically when:

  1. Trial period ends
  2. Customer has a valid payment method
  3. Payment succeeds

Plan Changes

Upgrading

const subscription = await billing.subscriptions.update('sub_123', {
planId: 'price_enterprise_monthly',
prorationBehavior: 'create_prorations'
});
// Customer is charged the prorated difference immediately
// New features are available immediately

Downgrading

const subscription = await billing.subscriptions.update('sub_123', {
planId: 'price_basic_monthly'
});

Using changePlan for Plan Changes

The changePlan() method provides more control over plan changes with automatic proration calculation:

const result = await billing.subscriptions.changePlan('sub_123', {
newPlanId: 'price_enterprise_monthly',
prorationBehavior: 'create_prorations'
});
console.log('Prorated credit:', result.proration?.creditAmount);
console.log('Prorated charge:', result.proration?.chargeAmount);
console.log('Effective date:', result.proration?.effectiveDate);

Proration Behavior Options

BehaviorDescriptionUse Case
create_prorationsCalculate credit/charge based on time remainingDefault for upgrades/downgrades
noneNo proration, apply new price immediatelyFree trials, special promotions
always_invoiceAlways create an invoice for prorationEnterprise customers, accounting requirements

Apply Timing Options

// Apply change immediately (default)
await billing.subscriptions.changePlan('sub_123', {
newPlanId: 'plan_pro',
applyAt: 'immediately'
});
// Apply change at end of current billing period
await billing.subscriptions.changePlan('sub_123', {
newPlanId: 'plan_basic',
applyAt: 'period_end'
});

Complete Example

// Upgrade with immediate proration
const upgradeResult = await billing.subscriptions.changePlan('sub_123', {
newPlanId: 'plan_enterprise',
newPriceId: 'price_enterprise_monthly', // Optional: specify exact price
prorationBehavior: 'create_prorations',
applyAt: 'immediately'
});
if (upgradeResult.proration) {
// Charge customer the difference
if (upgradeResult.proration.chargeAmount > 0) {
await billing.payments.process({
customerId: upgradeResult.subscription.customerId,
amount: upgradeResult.proration.chargeAmount,
currency: 'USD',
subscriptionId: upgradeResult.subscription.id,
metadata: {
reason: 'plan_upgrade_proration'
}
});
}
// Apply credit if downgrading
if (upgradeResult.proration.creditAmount > 0) {
console.log(`Credit of ${upgradeResult.proration.creditAmount} applied to account`);
}
}

Pausing & Resuming

Pausing

// Pause subscription
await billing.subscriptions.pause('sub_123');

When paused:

  • Billing stops
  • Access continues (or stops, based on your implementation)
  • Status changes to ‘paused’

Resuming

await billing.subscriptions.resume('sub_123');
// Status returns to 'active'
// Billing resumes

Cancellation

Immediate Cancellation

await billing.subscriptions.cancel('sub_123', {
atPeriodEnd: false
});
// Access ends immediately
// No refund by default

Cancel at Period End

await billing.subscriptions.cancel('sub_123', {
atPeriodEnd: true
});
// Access continues until currentPeriodEnd
// No more charges
// Status changes to 'canceled' at period end

Reactivating Cancelled Subscription

// Only works if atPeriodEnd was true and period hasn't ended
await billing.subscriptions.update('sub_123', {
cancelAtPeriodEnd: false
});

Collecting Feedback

billing.on('subscription.canceled', async (event) => {
// Store cancellation reason
await storeCancellationData({
subscriptionId: event.data.id,
customerId: event.data.customerId,
canceledAt: event.data.canceledAt,
reason: event.data.metadata?.cancellationReason
});
// Send feedback survey
await sendSurvey(event.data.customerId, 'cancellation');
});

Handling Failed Payments

Past Due Status

When a payment fails, the subscription enters ‘past_due’ status:

billing.on('payment.failed', async (event) => {
const { subscriptionId, customerId, failureReason } = event.data;
// Notify customer
await sendEmail(customerId, 'payment_failed', {
reason: failureReason,
updatePaymentUrl: `${APP_URL}/billing/payment-method`
});
// Internal tracking
await recordPaymentFailure(subscriptionId);
});

Retry Logic

Configure automatic retries:

const billing = createQZPayBilling({
paymentAdapter: stripeAdapter,
storage: drizzleStorage,
config: {
paymentRetry: {
attempts: 4,
schedule: [1, 3, 5, 7] // Days after initial failure
}
}
});

Dunning Management

// After all retries fail
billing.on('subscription.unpaid', async (event) => {
// Final warning
await sendEmail(event.data.customerId, 'subscription_suspended', {
reactivateUrl: `${APP_URL}/billing/reactivate`
});
// Optionally revoke access
await revokeAccess(event.data.customerId);
});

Feature Access Control

Based on Status

function hasAccess(subscription: Subscription): boolean {
const activeStatuses = ['active', 'trialing', 'past_due'];
// Allow access during grace period
if (subscription.status === 'canceled') {
return subscription.currentPeriodEnd > new Date();
}
return activeStatuses.includes(subscription.status);
}

With Entitlements

const hasFeature = await billing.entitlements.check(
customerId,
'advanced_analytics'
);
if (!hasFeature) {
throw new Error('Upgrade to access this feature');
}

Automated Lifecycle Management

For production applications, you should automate subscription lifecycle operations using the SubscriptionLifecycleService.

What is SubscriptionLifecycleService?

The lifecycle service orchestrates the complete lifecycle of subscriptions, including:

  • Automatic Renewals: Charge and renew active subscriptions when their period ends
  • Trial Conversions: Convert trial subscriptions to paid automatically
  • Payment Retries: Retry failed payments according to a configured schedule
  • Automatic Cancellations: Cancel subscriptions after grace period expires

Setup

import { createSubscriptionLifecycle } from '@qazuor/qzpay-core';
const lifecycle = createSubscriptionLifecycle(billing, storage, {
gracePeriodDays: 7,
retryIntervals: [1, 3, 5], // Retry after 1, 3, and 5 days
trialConversionDays: 0,
processPayment: async (input) => {
// Process payment with your provider
const result = await stripe.paymentIntents.create({
amount: input.amount,
currency: input.currency,
customer: providerCustomerId,
payment_method: input.paymentMethodId,
confirm: true,
});
return {
success: result.status === 'succeeded',
paymentId: result.id,
};
},
getDefaultPaymentMethod: async (customerId) => {
const pm = await storage.paymentMethods.findDefaultByCustomerId(customerId);
return pm ? { id: pm.id, providerPaymentMethodId: pm.stripePaymentMethodId } : null;
},
onEvent: async (event) => {
// Handle lifecycle events
console.log(`[Lifecycle] ${event.type}`, event);
switch (event.type) {
case 'subscription.renewal_failed':
await sendEmail(event.customerId, 'payment_failed');
break;
case 'subscription.trial_converted':
await sendEmail(event.customerId, 'trial_converted');
break;
case 'subscription.canceled_nonpayment':
await sendEmail(event.customerId, 'subscription_canceled');
break;
}
},
});

Running in a Cron Job

Process all lifecycle operations on a regular schedule:

import cron from 'node-cron';
// Run every hour
cron.schedule('0 * * * *', async () => {
console.log('Processing subscription lifecycle...');
const results = await lifecycle.processAll();
console.log({
renewals: results.renewals.succeeded,
conversions: results.trialConversions.succeeded,
retries: results.retries.succeeded,
cancellations: results.cancellations.processed,
});
});

Configuration Options

OptionDescriptionExample
gracePeriodDaysDays before canceling due to failed payment7
retryIntervalsDays between payment retries[1, 3, 5]
trialConversionDaysDays before trial end to attempt conversion0
processPaymentFunction to process payment with providerSee above
getDefaultPaymentMethodFunction to get customer’s default payment methodSee above
onEventOptional callback for lifecycle eventsSee above

Lifecycle Events

The service emits events for all operations:

Renewal Events:

  • subscription.renewed: Successfully renewed
  • subscription.renewal_failed: Renewal payment failed
  • subscription.entered_grace_period: Entered grace period

Trial Conversion Events:

  • subscription.trial_converted: Trial converted to paid
  • subscription.trial_conversion_failed: Trial conversion failed

Retry Events:

  • subscription.retry_scheduled: Payment retry scheduled
  • subscription.retry_succeeded: Retry succeeded
  • subscription.retry_failed: Retry failed

Cancellation Events:

  • subscription.canceled_nonpayment: Canceled due to non-payment

With Background Jobs

Use a queue system for more robust processing:

import { Queue, Worker } from 'bullmq';
const connection = { host: 'localhost', port: 6379 };
// Schedule job
const lifecycleQueue = new Queue('subscription-lifecycle', { connection });
await lifecycleQueue.add(
'process-all',
{},
{
repeat: {
pattern: '0 * * * *', // Every hour
},
}
);
// Process job
const worker = new Worker(
'subscription-lifecycle',
async (job) => {
const results = await lifecycle.processAll();
return results;
},
{ connection }
);
worker.on('completed', (job, result) => {
console.log(`Lifecycle processing completed`, result);
});

Retry Strategies

Configure retry intervals based on your business needs:

Aggressive (High-value customers):

gracePeriodDays: 14,
retryIntervals: [1, 2, 3, 5, 7, 10], // 6 retries over 28 days

Balanced (Standard):

gracePeriodDays: 7,
retryIntervals: [1, 3, 5], // 3 retries over 9 days

Lenient (Enterprise):

gracePeriodDays: 30,
retryIntervals: [3, 7, 14, 21], // 4 retries over 45 days

Best Practices

  1. Always use atPeriodEnd: true for cancellations unless explicitly requested
  2. Send proactive notifications for trial ending, payment failing
  3. Implement grace periods to reduce involuntary churn
  4. Track cancellation reasons to improve your product
  5. Allow reactivation within a reasonable window
  6. Preview changes before applying plan updates
  7. Automate lifecycle operations using SubscriptionLifecycleService
  8. Monitor lifecycle events and respond appropriately
  9. Configure retry strategies based on your business model
  10. Test thoroughly with different scenarios before going live