Service Renewal Hooks
The eight hooks extending a service and changing its plan: renewals, auto-renewal, upgrades and downgrades.
Overview
A renewal arrives by three separate paths: the customer starts one by hand, auto-renewal charges a stored card, or a paid invoice triggers it. All three meet at the same event hook.
A plan change is not one step. The request is taken, a flow is picked by the payment situation (an invoice was raised, it was scheduled for the term end, or it went straight to the queue), and the change lands only at the end.
Reference
Stopping a manual renewal
Runs where a customer starts a renewal by hand, before the renewal engine is called at all.
source (here manual), plus the add-on discovery, metric and notification flags. This gate sees the manual path alone; auto-renewal and the invoice path do not pass here.Hook::add('gate:service.manual_renew', 10, function ($service, $renew_opts) {
// Renewing a suspended service spends money for nothing; open it first.
if (($service['status'] ?? '') === 'suspended')
return 'A suspended service wants activating first.';
return null;
});Learning that a renewal landed
Runs after the date was extended. All three renewal paths arrive here.
Hook::add('action:service.renewed', 10,
function ($serviceId, $service, $newDuedate, $oldDuedate) {
// Did the date REALLY move? The same one can be written again.
if ($newDuedate === $oldDuedate) return;
Crm::renewed($serviceId, $oldDuedate, $newDuedate);
});Stopping the auto-renewal switch
Runs while a customer switches auto-renewal on or off, with the value not written yet.
Hook::add('gate:service.autorenew_toggle', 10, function ($service, $enable, $uid) {
// Switching it off lets the service lapse quietly: hold it while money is owed.
if (!$enable && Acme::hasDebt($uid))
return 'Auto-renewal stays on while a balance is owed.';
return null;
});Following the auto-renewal switch
Runs after the value was written.
Hook::add('action:service.autorenew_changed', 10, function ($service, $enable) {
// The new state is the SECOND parameter; the field on the record is old.
if (!$enable) Retention::flag((int) ($service['id'] ?? 0), 'autorenew-off');
});Stopping a plan change
Runs before a plan change starts. Both upgrades and downgrades pass here.
true for an upgrade, false for a downgrade. Read the direction here rather than working it out from the product ids.Hook::add('gate:service.upgrade', 10,
function ($service, $old_pid, $product_id, $price_data, $isUp) {
// A downgrade can lose data: stop an account already over the target.
if (!$isUp && Acme::usageAbovePlan($service, $product_id))
return 'Your current usage does not fit the target plan.';
return null;
});Following a plan change request
Runs after the request was taken. The flow value says when the change lands.
invoice_unpaid (an invoice was raised and is unpaid), scheduled (set for the term end), queued (free of charge, straight to the queue). On all three the change has not landed yet.0 where there is none.Hook::add('action:service.plan_change_requested', 10,
function ($service, $old_pid, $new_pid, $flow, $updown_id, $invoice_id) {
// The change has NOT landed; the flow says when it will.
if ($flow === 'invoice_unpaid') Crm::awaitPayment($invoice_id, $updown_id);
});Learning that a plan change landed
Runs after the plan change was truly applied.
service_id, old_service, new_product, type (upgrade or downgrade), needs_recreate, params. A true needs_recreate means the service will be rebuilt on the server.Hook::add('action:service.updowngrade.applied', 10, function ($payload) {
// A rebuild means downtime for a while: tell the customer.
if (!empty($payload['needs_recreate']))
Notify::planRebuild((int) ($payload['service_id'] ?? 0));
});Changing the plans on offer
Runs after the upgrade options shown to the customer were built.
Hook::add('filter:service.upgrade_products', 10, function (&$products) {
// Never show a plan you would refuse at the gate.
$products = array_values(array_filter($products,
fn ($p) => Acme::sellable((int) ($p['id'] ?? 0))));
});Following an add-on renewal
Runs after an add-on gets a longer term. Payment has landed and the new due date is written.
Hook::add('action:service.addon.renewed', 10,
function ($addonId, $addon, $service, $newDuedate, $oldDuedate) {
// Move the entitlement on the outside licence to the new due date.
Acme::extendEntitlement($addonId, $newDuedate);
});Following the repeat count running out
Runs when a product reaches its set number of repeats and the last renewal invoice is made. Nothing renews automatically after this.
Hook::add('action:service.recurring_completed', 10,
function ($service_id, $service, $count, $limit) {
// Catch the final cycle: send the customer an offer to carry on.
Acme::offerContinuation((int) ($service['owner_id'] ?? 0), $service_id);
});Changing the auto-payment answer
Runs before the question "can the renewal job charge this account without the customer present?" is answered. Declare a source the core does not know about here.
owner_id, the account being asked about.Hook::add('filter:service.auto_pay_source_available', 10,
function (&$available, $ctx) {
if ($available) return; // the core already found a source
// Declare the mandate you keep yourself.
$available = Acme::hasMandate((int) ($ctx['owner_id'] ?? 0));
});Pitfalls
The service in the second parameter is a snapshot from before the extension: its date, term and amount are the old ones. The new date is the third parameter. Reading the record and concluding "not renewed" comes from here. The date also never moves backwards, so old and new can be the same.
On the request hook the change has not landed. The flow can say one of three things: an invoice was raised and is unpaid, it was set for the term end, or it went straight to the queue. To act on the new plan, wait for the applied hook.
Auto-renewal hands you the service record from before the change at the gate and at the event alike. The new state is a separate parameter in both. A listener reading the field on the record mistakes a switch-on for a switch-off.
The upgrade gate refuses an option after the customer picked it. Writing the same rule into the list filter keeps it off the screen, so nobody tries in vain. Keep the gate for safety and use the filter for courtesy.
Related Articles
- Service Status Hooks
- Invoice and Payment Hooks
- Service Lifecycle Hooks
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.