# Service Renewal Hooks

https://dev.wisecp.com/es/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

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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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.

$isUpbool`true` 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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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**.

## Related Articles

- [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks)
- Invoice and Payment Hooks
- [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks)
