Development of Subscription for Regular Deliveries in E-Commerce
Subscription changes store economics: customer no longer decides to buy each time — they decided once. For store this is predictable cash flow and lower repeat sales cost. Technically one of most complex e-commerce tasks: recurring payments, schedule management, payment failure handling, pause and cancellation management.
System Architecture
Subscription system consists of three independent layers:
1. Subscription core — plans, subscriptions, periods storage 2. Billing engine — recurring payments, retry logic, dunning 3. Fulfillment — order generation and shipment management
Separation is critical: billing failure shouldn't block already-paid subscriptions, plan change shouldn't break current billing cycles.
Data Schema
CREATE TABLE subscription_plans (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
interval_type VARCHAR(20) NOT NULL, -- day | week | month | year
interval_count INTEGER NOT NULL DEFAULT 1,
trial_days INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE subscription_plan_items (
id BIGSERIAL PRIMARY KEY,
plan_id BIGINT NOT NULL REFERENCES subscription_plans(id),
variant_id BIGINT NOT NULL REFERENCES product_variants(id),
qty INTEGER NOT NULL DEFAULT 1,
discount NUMERIC(5,2) NOT NULL DEFAULT 0 -- subscriber discount %
);
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
plan_id BIGINT NOT NULL REFERENCES subscription_plans(id),
status VARCHAR(50) NOT NULL DEFAULT 'active',
-- trial | active | paused | past_due | cancelled | expired
payment_method_id VARCHAR(255) NOT NULL, -- token from payment system
current_period_start TIMESTAMP NOT NULL,
current_period_end TIMESTAMP NOT NULL,
trial_ends_at TIMESTAMP,
paused_at TIMESTAMP,
resumes_at TIMESTAMP,
cancelled_at TIMESTAMP,
cancel_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE subscription_billing_attempts (
id BIGSERIAL PRIMARY KEY,
subscription_id BIGINT NOT NULL REFERENCES subscriptions(id),
amount NUMERIC(12,2) NOT NULL,
status VARCHAR(50) NOT NULL, -- success | failed | pending
payment_id VARCHAR(255),
failure_code VARCHAR(100),
failure_message TEXT,
attempted_at TIMESTAMP NOT NULL DEFAULT NOW(),
next_retry_at TIMESTAMP
);
Recurring Payments
Key question — which payment gateway supports card tokenization and server charges without user involvement:
| Gateway | Recurring | Features |
|---|---|---|
| Stripe | PaymentIntents + SetupIntents |
Best API, SCA-ready |
| YuKassa | recurring_object |
Relevant for RF |
| CloudPayments | token-payments |
Suits CIS |
| Robokassa | Recurring via Recurring |
Basic |
Example with CloudPayments — typical for Belarus/Russia:
class RecurringBillingService
{
public function chargeSubscription(Subscription $subscription): BillingAttempt
{
$amount = $this->calculateAmount($subscription);
$attempt = BillingAttempt::create([
'subscription_id' => $subscription->id,
'amount' => $amount,
'status' => 'pending',
]);
try {
$result = $this->cloudpayments->chargeToken([
'Token' => $subscription->payment_method_id,
'Amount' => $amount,
'Currency' => 'RUB',
'AccountId' => $subscription->user_id,
'Description' => "Subscription #{$subscription->id}",
]);
$attempt->update(['status' => 'success', 'payment_id' => $result->TransactionId]);
$this->advancePeriod($subscription);
} catch (PaymentDeclinedException $e) {
$attempt->update([
'status' => 'failed',
'failure_code' => $e->getCode(),
'failure_message' => $e->getMessage(),
'next_retry_at' => $this->calculateRetryTime($attempt),
]);
$this->handleFailedPayment($subscription, $attempt);
}
return $attempt;
}
}
Dunning — Failed Payment Handling
Dunning is retry process on charge failure. Standard scheme:
Day 0: Charge failed → status past_due, attempt 1
Day 3: Retry attempt 2 + "Payment problem" email
Day 7: Retry attempt 3 + email with update card button
Day 14: Retry attempt 4 + SMS
Day 21: Subscription automatically cancelled
Each step — job in queue:
// Scheduled on failed charge
RetrySubscriptionPayment::dispatch($subscription)
->delay(now()->addDays(3));
// Scheduled on second failure
SendDunningEmail::dispatch($subscription, 'update_card')
->delay(now()->addDays(7));
Important: on failure service still ships current period — subscriber shouldn't suffer for technical issue. Subscription moves to past_due but order created.
Pause Management
Pause — competitive advantage over full cancellation. Subscriber specifies resume date:
class SubscriptionManager
{
public function pause(Subscription $subscription, Carbon $resumeAt): void
{
if ($resumeAt->lte(now())) {
throw new InvalidResumeDateException();
}
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
'resumes_at' => $resumeAt,
]);
// Cancel next charge
$subscription->pendingBillingJobs()->delete();
// Schedule resume
ResumeSubscription::dispatch($subscription)->delay($resumeAt);
}
}
Max pause duration — configurable (usually 3 months). Pauses mid-period: unused time doesn't expire — next period starts from resumes_at.
Subscription Customization
Subscriber must self-manage:
- Change composition (add/remove items)
- Change volume (×1, ×2, etc.)
- Change interval (bi-weekly → monthly)
- Skip one period
- Change delivery address
- Update card
Mid-cycle plan change requires proration and possible credit or charge:
// Upgrade mid-cycle: charge for difference
$unusedDays = now()->diffInDays($subscription->current_period_end);
$totalDays = $subscription->current_period_start->diffInDays($subscription->current_period_end);
$prorationFactor = $unusedDays / $totalDays;
$chargeNow = ($newPlan->price - $currentPlan->price) * $prorationFactor;
Order Generation
On shipment day, real order created:
class SubscriptionOrderFactory
{
public function createFromSubscription(Subscription $subscription): Order
{
return DB::transaction(function () use ($subscription) {
$order = Order::create([
'user_id' => $subscription->user_id,
'source' => 'subscription',
'subscription_id' => $subscription->id,
'delivery_address_id' => $subscription->delivery_address_id,
]);
foreach ($subscription->plan->items as $planItem) {
$price = $planItem->variant->price
* (1 - $planItem->discount / 100);
$order->items()->create([
'variant_id' => $planItem->variant_id,
'qty' => $planItem->qty,
'unit_price' => $price,
]);
$this->stockService->reserve($planItem->variant_id, $planItem->qty);
}
return $order;
});
}
}
Subscriber Personal Cabinet
Minimal UI for subscription management:
- Delivery history with order view
- Next shipment date
- Buttons: "Skip Next", "Pause", "Cancel"
- Composition and volume management
- Payment history with receipt download
Implementation Timeline
- Basic subscriptions + recurring billing (one gateway): 7–10 days
- Dunning + retry logic + notifications: 3–4 days
- Pause management, skips, plan upgrade: 3–4 days
- Subscriber personal cabinet: 3–5 days
- Analytics (MRR, churn, LTV): +2–3 days
Full system: 3–5 weeks depending on payment gateway and pricing complexity.







