Implementation of Subscription Payment Model on Website
Subscription model is one of most predictable income sources for online services. But technical implementation is more complex than one-time payments: need to manage subscription lifecycle, handle failures on retries, ensure grace period and correctly work with upgrades/downgrades.
Data Model
plans (
id, name, billing_period: monthly | yearly | weekly,
price, currency,
trial_days, features (jsonb),
is_active
)
subscriptions (
id, user_id, plan_id,
status: trialing | active | past_due | canceled | expired,
current_period_start, current_period_end,
trial_ends_at,
cancel_at_period_end (boolean),
canceled_at,
payment_method_id,
external_subscription_id (id in payment system)
)
subscription_invoices (
id, subscription_id, amount, currency,
status: draft | open | paid | failed | void,
attempt_count, next_attempt_at,
paid_at, payment_id
)
Choosing Payment Provider
Stripe Billing — best option for international SaaS. Built-in subscription management, Smart Retries, Customer Portal out of box. Stripe manages retry logic on failed charges itself.
YooKassa + own logic — for Russian market. YooKassa supports auto-payments via saved cards (recurring payments). Subscription management logic needs to be built yourself.
CloudPayments — good option for Russia, has built-in subscriptions with Webhooks.
Subscription Lifecycle
Registration
↓
Trial (7/14/30 days) → Auto-convert to Active
↓
Active → charge at period end → new period
↓ ↓
Cancel Payment Failed
↓ ↓
Cancel at Past Due (grace period 3-7 days)
period end ↓
↓ Retry (1, 3, 7 days)
Expired ↓
Expired (after N failed attempts)
Automatic Charges
When using Stripe: subscription created once, Stripe manages renewals itself. When using YooKassa — need own scheduler:
// Scheduled Job: hourly
$dueSubscriptions = Subscription::where('status', 'active')
->where('current_period_end', '<=', now())
->get();
foreach ($dueSubscriptions as $subscription) {
dispatch(new RenewSubscriptionJob($subscription));
}
RenewSubscriptionJob attempts charge via saved payment method. On success — updates current_period_end. On failure — moves to past_due and schedules retries.
Grace Period and Smart Retries
After first failed charge, subscription moves to past_due — user retains access but receives notifications. Retry attempts:
- +1 day: first retry
- +3 days: second retry with email reminder
- +7 days: final attempt, warning about disabling
- +10 days: status
expired, access revoked
Stripe Dunning Management does this automatically with ML-based retry timing.
Plan Upgrade and Downgrade
Changing plan is non-trivial task. Amount recalculation is done proportionally to period remainder:
- Upgrade (to more expensive plan): immediately, charge difference for remaining period
- Downgrade (to cheaper plan): takes effect next period, credit applied
$unusedDays = $subscription->daysRemainingInPeriod();
$creditAmount = $unusedDays * ($currentPlan->dailyPrice());
$chargeAmount = $unusedDays * ($newPlan->dailyPrice()) - $creditAmount;
Customer Portal and Subscription Management
User should be able to:
- View current plan and next charge date
- Change plan (with proportion calculation)
- Update payment method (new card entry via hosted fields)
- Cancel subscription with explanation (exit survey)
- Download invoices
Stripe provides ready Customer Portal — hosted page for management. For custom UI on YooKassa need to build your own.
Preventing Churn
Technical solutions to reduce subscription cancellations:
- Email 3 and 7 days before charge with amount reminder
- Notification of card expiration 30 days prior
- Ability to pause subscription (instead of cancel)
- Offer on cancel: 20% discount next month
Subscription Analytics
Key metrics: MRR (Monthly Recurring Revenue), Churn Rate, LTV, Trial-to-Paid Conversion, Average Revenue Per User. For calculation need specialized queries on historical subscription data — not enough to just sum payments.
Development timeframe: 4–6 weeks for complete system with lifecycle management, retry logic, customer portal and basic analytics.







