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 endsWith 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:
- Trial period ends
- Customer has a valid payment method
- 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 immediatelyDowngrading
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
| Behavior | Description | Use Case |
|---|---|---|
create_prorations | Calculate credit/charge based on time remaining | Default for upgrades/downgrades |
none | No proration, apply new price immediately | Free trials, special promotions |
always_invoice | Always create an invoice for proration | Enterprise 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 periodawait billing.subscriptions.changePlan('sub_123', { newPlanId: 'plan_basic', applyAt: 'period_end'});Complete Example
// Upgrade with immediate prorationconst 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 subscriptionawait 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 resumesCancellation
Immediate Cancellation
await billing.subscriptions.cancel('sub_123', { atPeriodEnd: false});
// Access ends immediately// No refund by defaultCancel at Period End
await billing.subscriptions.cancel('sub_123', { atPeriodEnd: true});
// Access continues until currentPeriodEnd// No more charges// Status changes to 'canceled' at period endReactivating Cancelled Subscription
// Only works if atPeriodEnd was true and period hasn't endedawait 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 failbilling.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 hourcron.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
| Option | Description | Example |
|---|---|---|
gracePeriodDays | Days before canceling due to failed payment | 7 |
retryIntervals | Days between payment retries | [1, 3, 5] |
trialConversionDays | Days before trial end to attempt conversion | 0 |
processPayment | Function to process payment with provider | See above |
getDefaultPaymentMethod | Function to get customer’s default payment method | See above |
onEvent | Optional callback for lifecycle events | See above |
Lifecycle Events
The service emits events for all operations:
Renewal Events:
subscription.renewed: Successfully renewedsubscription.renewal_failed: Renewal payment failedsubscription.entered_grace_period: Entered grace period
Trial Conversion Events:
subscription.trial_converted: Trial converted to paidsubscription.trial_conversion_failed: Trial conversion failed
Retry Events:
subscription.retry_scheduled: Payment retry scheduledsubscription.retry_succeeded: Retry succeededsubscription.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 jobconst lifecycleQueue = new Queue('subscription-lifecycle', { connection });
await lifecycleQueue.add( 'process-all', {}, { repeat: { pattern: '0 * * * *', // Every hour }, });
// Process jobconst 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 daysBalanced (Standard):
gracePeriodDays: 7,retryIntervals: [1, 3, 5], // 3 retries over 9 daysLenient (Enterprise):
gracePeriodDays: 30,retryIntervals: [3, 7, 14, 21], // 4 retries over 45 daysBest Practices
- Always use
atPeriodEnd: truefor cancellations unless explicitly requested - Send proactive notifications for trial ending, payment failing
- Implement grace periods to reduce involuntary churn
- Track cancellation reasons to improve your product
- Allow reactivation within a reasonable window
- Preview changes before applying plan updates
- Automate lifecycle operations using SubscriptionLifecycleService
- Monitor lifecycle events and respond appropriately
- Configure retry strategies based on your business model
- Test thoroughly with different scenarios before going live