Service Renewal Hooks

1 views Markdown

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

gateservice.manual_renew
ClientServices the manual path only

Runs where a customer starts a renewal by hand, before the renewal engine is called at all.

Parameters 2
$servicearrayThe service being renewed.
$renew_optsarrayThe options going to the engine: 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.
Return 1
stringA non-empty string stops the action; the text is thrown as the error and shown to the customer.
Listener
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

actionservice.renewed
Services::process_renewal() the record holds the OLD date

Runs after the date was extended. All three renewal paths arrive here.

Parameters 4
$serviceIdintThe id of the renewed service.
$servicearrayA snapshot from before the extension. The date, term and amount are still the old ones; do not read the new date from here.
$newDuedatestringThe new date that was written. It never moves backwards: the later of the candidate and the current one is kept.
$oldDuedatestringThe date before the extension. The same as the one on the record, passed separately for an easy comparison.
Return 1
voidThe return is ignored.
Listener
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

gateservice.autorenew_toggle
ClientServices the state being asked for

Runs while a customer switches auto-renewal on or off, with the value not written yet.

Parameters 3
$servicearrayThe service record. Its auto-renewal field still holds the old value.
$enableboolThe new state being asked for.
$uidintThe service owner. A sub-user may be the one acting; that id does not arrive here.
Return 1
stringA non-empty string stops the action; the text is thrown as the error and shown to the customer.
Listener
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

actionservice.autorenew_changed
ClientServices the record holds the OLD value

Runs after the value was written.

Parameters 2
$servicearrayThe service record, read before the change. Its auto-renewal field still holds the old value; the new state is the second parameter.
$enableboolThe new state that was written.
Return 1
voidThe return is ignored.
Listener
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

gateservice.upgrade
Services up and down

Runs before a plan change starts. Both upgrades and downgrades pass here.

Parameters 5
$servicearrayThe current service row.
$old_pidintThe id of the current product.
$product_idintThe id of the target product.
$price_dataarrayThe resolved target price row.
$isUpbooltrue for an upgrade, false for a downgrade. Read the direction here rather than working it out from the product ids.
Return 1
stringA non-empty string stops the action; the text is thrown as the error and shown to the customer.
Listener
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

actionservice.plan_change_requested
ClientServices three separate flows

Runs after the request was taken. The flow value says when the change lands.

Parameters 6
$servicearrayThe service record, still on the old plan.
$old_pidintThe id of the old product.
$new_pidintThe id of the wanted product.
$flowstringThe resulting flow: 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.
$updown_idintThe id of the created plan-change record.
$invoice_idintThe id of the raised invoice, or 0 where there is none.
Return 1
voidThe return is ignored.
Listener
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

actionservice.updowngrade.applied
Services one array parameter

Runs after the plan change was truly applied.

Parameters 1
$payloadarrayA single array: 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.
Return 1
voidThe return is ignored.
Listener
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

filterservice.upgrade_products
Hook::runRefs by reference

Runs after the upgrade options shown to the customer were built.

Parameters 1
$productsarrayrefThe products on offer. Dropping one hides it on the screen; using this rather than the gate keeps the customer from seeing an option they cannot take.
Return 1
voidThe value changes by reference; the return is not read.
Listener
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

actionservice.addon.renewed
handle_extend_addon_paid after payment

Runs after an add-on gets a longer term. Payment has landed and the new due date is written.

Parameters 5
$addonIdintThe id of the add-on record.
$addonarrayThe add-on row as it was before the extension. Its due date and amount hold the old values; take the new ones from the separate parameters.
$servicearrayThe parent service record.
$newDuedatestringThe new due date that was written.
$oldDuedatestringThe due date before the extension.
Return 1
voidThe return is ignored.
Listener
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

actionservice.recurring_completed
generate_renewal final cycle

Runs when a product reaches its set number of repeats and the last renewal invoice is made. Nothing renews automatically after this.

Parameters 4
$service_idintThe id of the service that ran out.
$servicearrayThe service record.
$countintHow many renewals were made for this service.
$limitintThe repeat limit set on the product.
Return 1
voidThe return is ignored. The invoice already exists and cannot be stopped here.
Listener
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

filterservice.auto_pay_source_available
ClientAutoRenewGuard passed by link

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.

Parameters 2
$availableboolby linkWhat the core decided. You may write over it.
$ctxarrayContext: owner_id, the account being asked about.
Return 1
voidThe return is ignored; you change the value in place. Saying yes grants permission to try. If your source cannot actually charge, the renewal fails and the service is suspended.
Listener
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

On the renewal event the record holds the OLD date

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.

A plan change request is not the change

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.

On the switch hooks the record shows the old value

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.

Drop a plan you would refuse

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.