Skip to content

Phase 12: Native Subscriptions

Implement a complete recurring billing engine: subscription creation, renewal processing, dunning, upgrade/downgrade with proration, and a self-service customer portal.

  • src/Models/Subscription.php
  • src/Models/Renewal.php
  • src/Repositories/SubscriptionRepository.php
  • src/Repositories/RenewalRepository.php
  • src/Services/Subscriptions/RenewalProcessor.php
  • src/Services/Subscriptions/DunningManager.php
  • src/Services/Subscriptions/BillingScheduler.php
  • src/Services/Subscriptions/GatewayBridge.php
  • src/Services/Subscriptions/Proration.php
$sub->status // active | trial | on-hold | pending-cancel | cancelled | expired
$sub->billing_period // daily | weekly | monthly | yearly
$sub->next_payment // datetime string
$sub->recurring_total // float
$sub->currency // ISO 4217
$sub->is_active() // true for active|trial
$sub->status_label() // human-readable string
BillingScheduler schedules WP-Cron → wplm_process_renewals
RenewalProcessor::process_due()
for each subscription where next_payment <= now AND status = active
GatewayBridge::charge($sub, $amount)
↓ success
RenewalRepository::record(status='completed')
BillingScheduler::advance_next_payment($sub)
LicenseService::extend_expiry($license_id, $period)
do_action('wplm_subscription_renewed', $sub)
↓ failure
DunningManager::on_failure($sub, $renewal)
$result = apply_filters('wplm_gateway_charge', null, $subscription, $amount);

If no gateway has hooked wplm_gateway_charge, the filter returns null and GatewayBridge::charge() returns WP_Error('no_gateway', ...). This is intentional — WPLM ships without a bundled payment gateway. Merchants wire their own:

add_filter('wplm_gateway_charge', function($result, $sub, $amount) {
return MyGateway::charge($sub->customer_id, $amount, $sub->currency);
}, 10, 3);

Configurable retry schedule (stored in settings, default: 3/7/14 days after initial failure).

On each failed renewal:

  1. Increment failure_count on the subscription.
  2. Record a Renewal row with status='failed'.
  3. If failure_count < max_retries: schedule next retry via BillingScheduler.
  4. If failure_count >= max_retries: set subscription status = 'cancelled' and fire do_action('wplm_subscription_payment_failed_final', $sub).
  5. On each failure, fire do_action('wplm_subscription_payment_failed', $sub, $renewal) — default handler sends the “Payment Failed” email to the customer.

Proration::calculate_credit(Subscription $old, Subscription $new): float

Computes the unused portion of the current billing cycle as a credit toward the new plan:

credit = (remaining_days / total_days) × old_recurring_total
charge = new_recurring_total - credit

Used by the upgrade/downgrade endpoint: POST /wplm/v1/subscriptions/{id}/change-plan.