Service Cancellation Hooks

1 views Markdown

The seven hooks running where a customer says "I no longer want this": the request, the approval, taking it back, and the cancellation itself.

Overview

Cancelling has two layers, and mixing them up puts your code in the wrong place. The upper layer is the request: a customer asks, an operator approves, or the customer takes it back. The lower layer is the work: the service is truly closed.

The request layer keeps a record and talks to the customer. The work layer runs from a scheduled task and reaches the provider. A service can also be cancelled with no request at all: an unpaid invoice does that on its own.

Reference

Stopping a cancellation request

gateservice.cancellation_request
ClientServices the customer asks

Runs before the customer's cancellation request is created. Stopping it means no request is recorded.

Parameters 3
$servicearrayThe customer's own service. Domains do not pass this flow.
$urgencystringThe timing: now, or period-ending at the end of the term. Two very different things: one closes the service today, the other waits out a paid term.
$reasonKeystringThe reason key: not-needed, too-expensive, switching, missing-features, other.
Return 1
stringA non-empty string stops the request; it reaches the customer as the error.
Listener
Hook::add('gate:service.cancellation_request', 10,
    function ($service, $urgency, $reasonKey) {
        // A service under contract does not close before its term ends.
        if ($urgency === 'now' && Acme::underContract($service))
            return 'No immediate cancellation while the contract runs.';

        return null;
    });

Following a cancellation request

actionservice.cancellation_requested
ClientServices it hands you a record id

Runs after the request was recorded. The service is still running at this point.

Parameters 5
$servicearrayThe service record.
$urgencystringThe timing: now, or period-ending at the end of the term. Two very different things: one closes the service today, the other waits out a paid term.
$reasonKeystringThe reason key: not-needed, too-expensive, switching, missing-features, other.
$reasonstringThe stored reason text: the label in the customer's language plus any free note. On other only the free text arrives.
$eventIdintThe id of the created request record. The approve and revoke hooks come back to the same one.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.cancellation_requested', 10,
    function ($service, $urgency, $reasonKey, $reason, $eventId) {
        // The service is STILL running: this is an intent, not a closing.
        Retention::opened($eventId, $reasonKey, $urgency);
    });

Stopping a cancellation approval

gateservice.cancellation_accept
AdminServices the operator approves

Runs before the operator approves a cancellation request.

Parameters 2
$service_idintThe id of the service to be cancelled. An id arrives, not the service record.
$requestarrayThe request record: id, owner_id and the decoded data (timing, reason).
Return 1
stringA non-empty string stops the approval; it reaches the operator as the error.
Listener
Hook::add('gate:service.cancellation_accept', 10, function ($service_id, $request) {
    // Cancelling with an unpaid invoice open loses the money owed.
    if (Acme::hasUnpaid($service_id)) return 'Settle the unpaid invoice first.';

    return null;
});

Following a cancellation approval

actionservice.cancellation.accepted
AdminServices approved

Runs after the operator approved the request.

Parameters 3
$serviceIdintThe service id.
$cancellationTypestringThe kind: now, or at the end of the term. Where the term end was chosen the service does not close today.
$requestDataarrayThe request data: the reason and who approved it.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.cancellation.accepted', 10,
    function ($serviceId, $cancellationType, $requestData) {
        // On a term-end cancellation nothing closes today; hold your counter.
        if ($cancellationType === 'now') Capacity::freed($serviceId);
    });

Following a request taken back

actionservice.cancellation_revoked
ClientServices the customer changed their mind

Runs where the customer took their cancellation request back.

Parameters 3
$servicearrayThe service record.
$eventarrayThe request record as it was before deletion, holding the timing, the reason key, the reason text and any note. The record is gone, so read it here.
$uidintThe account id of the service owner.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.cancellation_revoked', 10,
    function ($service, $event, $uid) {
        // Taking it back is a retention win: close the record.
        Retention::closed((int) ($event['id'] ?? 0), 'revoked');
    });

Stopping the cancellation work

gateservice.cancel
cronjobs/ServiceCancel a veto still cancels

Runs before a service or add-on is truly cancelled. This gate sits below the request layer.

Parameters 4
$target_typestringservice or addon.
$target_idintThe id of what is being cancelled.
$servicearrayThe live row: status, type, module, duedate.
$user_idintThe service owner.
Return 1
stringA non-empty string vetoes the cancel — and the job still turns into a cancel signal, with your reason recorded. Same behaviour as the suspend gate: a veto is not "nothing happens".
Listener
Hook::add('gate:service.cancel', 10,
    function ($target_type, $target_id, $service, $user_id) {
        // With data still moving you may want the cancel held.
        if (Acme::migrationRunning($target_id)) return 'A migration is running.';

        return null;
    });

Following an add-on cancellation request

actionservice.addon.cancellation_requested
ClientServices at period end

Runs when a customer asks for an add-on to end at the close of its period. The request is recorded but the add-on is still live and keeps working until the due date.

Parameters 5
$servicearrayThe service the add-on belongs to.
$addonarrayThe add-on being cancelled. At this moment the row is still active; the cancellation lands on the due date.
$reasonstringWhat the customer wrote. The field is optional, so it can arrive empty. It is cut at 300 characters.
$eventIdintThe id of the cancellation request record.
$uidintThe account that owns the add-on. A sub-user may have done the clicking; that is a different id.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.addon.cancellation_requested', 10,
    function ($service, $addon, $reason, $eventId, $uid) {
        // Start the win-back offer: the add-on still runs, there is time.
        Acme::offerRetention($uid, $addon['addon_name'] ?? '', $reason);
    });

Following a cancellation being taken back

actionservice.addon.cancellation_revoked
ClientServices changed their mind

Runs when a customer takes back a cancellation request. This is where you stop the win-back flow you started.

Parameters 4
$servicearrayThe service the add-on belongs to.
$addonarrayThe add-on whose cancellation was taken back.
$eventarrayThe deleted request record, reason and approval included. By the time the hook runs that row is gone from the database; this is the last copy of it.
$uidintThe account that owns the service.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.addon.cancellation_revoked', 10,
    function ($service, $addon, $event, $uid) {
        Acme::stopRetention($uid, $addon['addon_name'] ?? '');
    });

Following a scheduled downgrade being cancelled

actionservice.scheduled_downgrade_cancelled
ClientServices changed their mind

Runs when a downgrade set for the due date is called off. The service carries on with the package it has.

Parameters 3
$servicearrayThe service record. The package did not change: the downgrade was planned, never applied.
$scheduledarrayThe cancelled plan, as it was before the update. Its status field still holds the old value.
$uidintThe account that owns the service.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.scheduled_downgrade_cancelled', 10,
    function ($service, $scheduled, $uid) {
        // Take the downgrade back out of your capacity forecast.
        Acme::releaseForecast((int) ($service['id'] ?? 0));
    });

Pitfalls

A request and a cancellation are not the same

The request hooks run where a customer asks; the service is still up. The real closing happens on the cancel hooks below. Freeing capacity, deleting data or raising a final invoice from the request hook leaves damage you cannot undo where the customer takes it back.

A term-end cancellation closes nothing today

The kind on the approval hook is either now or the end of the term. On the second the service runs to the end of the paid period. A listener assuming "cancelled" without reading it counts a service the customer is still using as closed.

A cancel veto does not prevent the cancel

Returning text at the cancel gate does not stop the work; the signal still turns into a cancel and only your reason is recorded. The same trap as the suspend gate. To truly prevent it, act on the request layer.

Not every cancellation comes from a request

An unpaid invoice takes a service to cancellation with no request. A system listening only to the request hooks never sees those. To see every cancellation, listen to the status hook on the layer below.

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.