The first payment of a subscription is the easy one. The customer is at checkout, and if the bank wants a 3D Secure check, the customer is there to pass it. Every payment after that happens with nobody watching. The code has to find the renewal, build an order, charge a card saved weeks ago, and cope when the bank says no.
We build Rentals & Bookings, a rental plugin for WooCommerce, and a rental extension for Magento 2, plus a Subscriptions & Memberships add-on for both. This post walks through how renewals work in the released code: the WooCommerce add-on 1.0.9 with Rentals & Bookings Regular 1.0.81, and the Magento 2 module 1.0.3. The two versions were built separately and differ in places. We'll say where.
Finding what's due
WooCommerce runs a daily WP-cron event, sisubscriptions_process_renewals. It selects active subscriptions whose next_payment_date has passed and queues one Action Scheduler job for each. A subscription that already has a renewal job waiting or running is skipped, so it is never queued twice.
Magento keeps a schedule table, sisub_schedule. Each row is one thing to do for one subscription and one billing cycle: a renewal, a retry or a final dunning action, with a due date and a status. A cron job in the module's own sisubscriptions group runs every five minutes and claims due rows with a single UPDATE:
// SalesIgniter\Subscriptions\Model\ResourceModel\Schedule::claimDue() (trimmed)
$sql = "UPDATE {$table} SET status = ?, claimed_by = ?, claimed_at = ? WHERE status = ? AND due_at <= ?";
$bind = [ScheduleModel::STATUS_CLAIMED, $token, $nowUtc, ScheduleModel::STATUS_PENDING, $nowUtc];
// ... optional filter on the action
$sql .= ' ORDER BY due_at ASC, schedule_id ASC LIMIT ' . max(1, $limit);
$connection->query($sql, $bind);
The token is the host name, the process id and four random bytes. Only the process whose UPDATE matched a row owns it. The dispatcher publishes each claimed id to the sisubscriptions.renewal topic on Magento's MySQL message queue, and the sisubscriptionsRenewal consumer does the work. If publishing fails, the dispatcher processes the row itself so nothing stays claimed. A setting, "Process renewals inside cron", skips the queue. "Renewals per dispatcher run" defaults to 50.
Workers can die halfway. Each dispatcher run first puts back rows that have been claimed for more than two hours, but only rows with no order yet. A row that placed an order never runs again.
Building the renewal order
Both platforms make a real order for each renewal, so it shows up in reports and refunds like any other.
On WooCommerce, the renewal copies the lines of the subscription's first order at the price paid then. If that order was a free trial, it bills the recurring amount stored on the subscription instead of the trial's $0. A coupon from the first order carries over only when it is marked "Also discounts subscription renewals".
On Magento, the renewal is a quote built for the customer and submitted as an order. The "Renewal price" setting is Locked by default (the price at signup) or Current (today's catalog price for the customer's group).
The off-session charge, gateway by gateway
Every payment plugin has its own way to charge a saved card.
WooCommerce: one service in Regular
The charge goes through OffSessionPayment in Rentals & Bookings Regular, the same service that charges booking deposits. Most gateway plugins expose their renewal method only to the official WooCommerce Subscriptions plugin, so each branch calls that method directly:
// SibookingRegular\Model\OffSessionPayment::chargeThroughGateway() (trimmed)
if ( $gateway_id === 'stripe' ) {
add_filter( 'wc_stripe_generate_create_intent_request', [ $this, 'injectStripeIntentRequest' ], 10, 3 );
try {
return $this->chargeStripe( $order, $gateway );
} finally {
remove_filter( 'wc_stripe_generate_create_intent_request', [ $this, 'injectStripeIntentRequest' ], 10 );
}
}
if ( strpos( $gateway_id, 'braintree_' ) === 0 ) {
return $this->chargeBraintree( $order, $gateway );
}
if ( $gateway_id === 'woocommerce_payments' ) {
return $this->chargeWooPayments( $order, $gateway );
}
if ( $gateway_id === 'anet' ) {
return $this->chargeAuthorizeNet( $order, $gateway );
}
if ( in_array( $gateway_id, [ 'ppcp-gateway', 'ppcp-credit-card-gateway' ], true ) ) {
return $this->chargePayPal( $order );
}
// unknown gateway: only when it declares WC-Subscriptions style support itself
if ( ! $gateway->supports( 'subscriptions' ) && ! $gateway->supports( 'subscription_cancellation' ) ) {
return $this->result( false, 'This payment method cannot be charged automatically.', true );
}
// ... then process_payment() for that gateway
Stripe gets process_subscription_payment(), with a filter that puts the saved customer and payment method into the PaymentIntent request. Braintree goes through its plugin's process_renewal_payment(). Authorize.net charges the stored card through its tokenization class. PayPal Payments gets its own renewal handler. WooPayments charges the saved token as a merchant-initiated payment, and a decline or an authentication request falls back to a payment link for the customer.
Each branch returns the same small array: success, error and manual. Every charge is logged under WooCommerce ā Status ā Logs.
Magento: a strategy per payment method
Magento has a Vault for saved cards, but not every gateway uses it. The official Stripe module, for one, keeps the card on the Stripe customer. Our shared module, SalesIgniter_Common, defines a StrategyInterface with readiness(), configureQuote(), afterOrderPlaced(), isAsync() and isManual(). It ships a generic Vault strategy, an offline strategy and a free one for $0 renewals. The gateway strategies are optional modules that register only where their gateway is installed: SalesIgniter_CommonPayStripe, CommonPayBraintree, CommonPayAdyen, CommonPayMollie, and CommonPayTokenBase for the ParadoxLabs Authorize.Net CIM and CyberSource modules.
The Subscriptions module adds a small sub-module per gateway, mostly for the first order. Braintree sends that order as recurring_first, Adyen uses the "Subscription" processing model, and Mollie asks for a mandate with sequenceType=first. The Stripe one refuses to save a product that is also a Stripe Billing subscription, because "the customer would be billed twice".
On Stripe, the charge comes first and the order second:
// SalesIgniter\Common\SubModules\Stripe\Model\StripeGateway::chargeOffSession()
return $client->paymentIntents->create([
'amount' => $this->helper->convertMagentoAmountToStripeAmount($amount, strtolower($currency)),
'currency' => strtolower($currency),
'customer' => $customerId,
'payment_method' => $paymentMethodId,
'off_session' => true,
'confirm' => true,
'description' => $description,
'metadata' => $metadata,
], ['idempotency_key' => $idempotencyKey]);
off_session with confirm is how Stripe documents charging a saved payment method later. A subscription sends sisub-<id>-<cycle>-<quote id> as its idempotency key. When the charge succeeds, the Stripe module's own "order from charge" path records it, so invoices, refunds and webhooks behave as for any Stripe order. If the charge went through but the order failed, the PaymentIntent id is already saved on the subscription, and the next attempt reuses that charge.
Mollie is asynchronous. The order is placed and the row waits in awaiting_payment until Mollie confirms. A payment still unconfirmed after 48 hours counts as a failed attempt.
Manual payment methods
Bank transfer, cheque and cash on delivery can't be charged without the customer. Both platforms place the renewal order anyway and move to the next cycle.
On WooCommerce, such a gateway returns manual. The renewal order goes On hold with the note "Renewal order - manual payment required." and the next payment date moves forward. On Magento, check or money order, bank transfer, cash on delivery and purchase order use the offline strategy. The order is placed pending, and the Renewals Log says it is waiting for a manual payment.
When the bank says no
WooCommerce treats every failure the same way. The renewal order is marked Failed, the customer gets a payment-failed email with an "Update Payment Method" button, and a retry runs 24 hours later. Each retry builds a fresh renewal order. On the third failure within 30 days, the subscription goes On hold and no more retries are scheduled. My Account then shows an "Update Payment" link. Nothing in 1.0.9 reactivates the subscription by itself: staff set it back to Active in the order's Subscription Management box.
Magento sorts each decline into a class first. Stripe's mapper, for example, files insufficient_funds and do_not_honor as soft, expired_card and stolen_card as hard, authentication_required as action required, and an expired API key as config. Then the dunning policy decides:
// SalesIgniter\Subscriptions\Model\Renewal\DunningPolicy::decide() (trimmed)
if ($decline->isRevoked()) {
return new DunningDecision(null, Config::FINAL_CANCEL, 'hard_decline', true, false, null);
}
switch ($decline->getClass()) {
case Decline::HARD:
return new DunningDecision(null, null, 'hard_decline', true, false, $failedAtUtc->modify('+' . $grace . ' days'), $final);
case Decline::ACTION_REQUIRED:
return new DunningDecision(null, null, 'action_required', true, true, $failedAtUtc->modify('+' . $grace . ' days'), $final);
case Decline::CONFIG:
case Decline::SOFT:
default:
// ...
if ($failuresSoFar < count($offsets)) {
$retryAt = $failedAtUtc->modify('+' . $offsets[$failuresSoFar] . ' days');
return new DunningDecision($retryAt, null, $state, true, false, null);
}
return new DunningDecision(null, $final, 'exhausted', true, false, null);
}
A soft decline is retried. "Retry after (days)" defaults to 1,3,5,7, and each offset counts from the failure before it, so the retries land on days 1, 4, 9 and 16. A hard decline is never retried, since retrying a stolen card only annoys the bank. The customer is asked for a new card, and the final action waits for the grace period (7 days by default). An action-required decline means the issuer wants 3D Secure, which an off-session charge can't do, so the email with a payment link goes out at once. A config decline, such as a payment module that isn't enabled, follows the soft schedule and also raises an admin notice. A revoked mandate cancels the subscription at once.
The final action, "When every retry has failed", is On hold by default. The other choices are to cancel, or to hold and cancel after a number of days (14 by default). Every failure email carries a signed recovery link. Paying through it settles the unpaid cycle, makes the new card the subscription's card and ends the dunning.
The statuses follow a small state machine. A failed charge moves Active to Payment failed (past_due). The final action moves that to On hold. A paid renewal from either one goes back to Active.
Charging once per cycle
Cron runs overlap, and staff press "Charge now" while the cron job is running. Each platform guards against a second charge.
WooCommerce takes a MySQL named lock per subscription before anything else:
// Sisubscriptions\Includes\SubscriptionCron (trimmed)
$name = 'sisub_renew_' . substr( md5( DB_NAME . '|' . $wpdb->prefix ), 0, 12 ) . '_' . (int) $subscription_id;
$got = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $name ) );
With a timeout of 0, a second process gets its answer at once and stops with "This subscription is being renewed right now. Nothing more was charged." The lock holds across PHP processes and is freed if the process dies. Inside the lock, the code reads the subscription again and checks that it is still due. If the first run already moved the next payment date, the second stops with "Not due yet ⦠Nothing was charged." That covers the "Charge now" button on the Manual Renewal Processing screen too.
Magento stacks four checks:
- The claim. One process owns a row.
- The cycle. The row's cycle must be the next unpaid one, and a cycle that already has a renewal order is skipped.
- A unique key on subscription, type and cycle in the order link table. If two orders still race to one cycle, the second link fails and the admin gets a "possible double charge" notice.
- The gateway key. A repeated Stripe call with the same idempotency key returns the first result.
"Renew now" in the admin, in My Account (if the store allows it) and through the admin GraphQL mutation sisubRenewNow all claim the same pending row. A second click gets "This renewal is already being processed."
Rental subscriptions
A rental subscription has a physical unit attached. It must stay reserved while the member pays, and a renewal must not reserve it twice.
On WooCommerce, a subscription rental has a start date but no end, so checkout reserves the booking as far ahead as the "Reserve stock up to" setting says, two years by default. Each renewal order line carries the booking dates moved forward one billing period. After a successful charge, the original booking's end moves to the new start plus that setting. The reservation keeps rolling two years ahead.
Magento's rental bridge holds the reservation on a rolling horizon of 730 days. The first order holds it to start plus the horizon, and each renewal moves it to the new paid-through date plus the horizon. Cancelling or expiring shortens it to the paid-through date plus the product's turnover time. The renewal quote is flagged is_reserved, so the rental extension writes no second reservation and doesn't refuse the line because the member's own hold uses the stock. A rental subscription also can't be paused while the item is out: "A rental subscription can be paused only after the item is returned."
The last trap is price. A rental product's catalog price is usually $0, because rental prices come from dates. When a Magento member changes billing period, say from monthly to quarterly, 1.0.3 prices the new period from the rental extension's own quote for one period of the new plan, then applies the plan's price rule:
// SalesIgniter\Subscriptions\Model\Subscription\PlanChangePrice::unitPrice() (trimmed)
$source = $this->sourceFor($product);
if ($source !== null) {
if ($plan->getPriceMode() === 'fixed') {
return $plan->recurringPrice(0.0);
}
$base = $source->basePrice($s, $item, $product, $plan);
if ($base === null || $base <= 0) {
throw new LocalizedException(__(
'%1 cannot be priced %2 at the moment, so the billing period was not changed.',
// ... item name, plan label
));
}
return $plan->recurringPrice($base);
}
If that period can't be priced, the change is refused, so a period is never billed at $0. WooCommerce 1.0.9 has no plan change in My Account (members can pause, resume, cancel or change their payment method), so the case doesn't come up there.
If you're building this yourself
Keep each upcoming charge as a row with a status, and claim it with one UPDATE. Crashes and overlapping runs then become one problem with one answer. Key every charge by subscription and cycle, in your own tables and at the gateway. Sort declines before you retry: an empty account and a stolen card need different answers. And for rentals, ask the rental engine for the price, never the catalog.
The WooCommerce add-on has its own Subscriptions & Memberships page for WooCommerce, with the settings in setting up subscriptions and recurring billing. The Magento module is on the Magento 2 Subscriptions & Memberships page, a guide to managing Magento subscriptions, and the GraphQL reference for subscriptions.
















