# Writing your own server module

CWP Billing creates, suspends, unsuspends and terminates accounts through **server modules** (CWP, cPanel,
DirectAdmin, Plesk, Proxmox, Virtualizor, SolusVM, Webuzo). You can add a module for any other panel or system
yourself, as an **extension**. The same works for **domain registrars**.

An extension is a folder of plain PHP files in `extensions/`. It runs next to the encoded billing code, and
updates never change it. If you would rather not write it yourself, ask us: we develop missing modules for free.

## The parts of an extension

```
extensions/
  mypanel/                     the extension name: a-z, 0-9 and _ only
    extension.php              tells the billing what the extension adds
    MyPanelModule.php          the module class
```

`extension.php` returns an array. To add a module, list it under `modules` with its code:

```php
<?php
declare(strict_types=1);

require_once __DIR__ . '/MyPanelModule.php';

return [
    'name' => 'My Panel module',
    'modules' => ['mypanel' => \MyCompany\MyPanelModule::class],
];
```

Then enable the extension in `config/config.php`:

```php
'extensions' => ['mypanel'],
```

The module now appears under **Panel type** when you add a server, and under **Module** in the product editor.

Rules:
- The module code (here `mypanel`) is 2 to 32 characters long: a-z, 0-9 and _ only. It is stored with every
  server and product that uses it, so don't change it later.
- A code that a built-in module already uses (`cwp`, `cpanel`, `directadmin`, `plesk`, `proxmox`, `virtualizor`,
  `solusvm`, `webuzo`) is ignored.
- Give the extension folder a name of your own, such as your company name. A folder with the name of an extension
  that ships with the billing would be overwritten by an update.
- Use your own PHP namespace, not `CwpBilling\...`.
- Requires PHP 8.2 or newer, like the billing.

## The module class

The class implements `CwpBilling\Modules\Module`. Every method returns a `CwpBilling\Modules\Result`:
`Result::ok('message', [data])` or `Result::fail('message')`. A module never throws for a remote error. It returns
`Result::fail()` with a message staff can act on, and the billing shows that message and writes it to the module log.

```php
<?php
declare(strict_types=1);

namespace MyCompany;

use CwpBilling\Modules\Http;
use CwpBilling\Modules\Module;
use CwpBilling\Modules\Result;

final class MyPanelModule implements Module
{
    /** Set by the billing: writes each API call to the module log (Services → service → Module log). */
    public ?\Closure $logger = null;

    public static function info(): array
    {
        return [
            'name' => 'My Panel',               // shown under Panel type / Module
            'port' => 8443,                     // default API port of a new server
            'key_help' => 'My Panel → Settings → API: create a key and enter it here',
            // settings of a product that uses this module: key => [label, type, help]
            // type: text, number, checkbox, or package (a list filled by packages() below)
            'product_config' => [
                'package' => ['Plan', 'package', 'The plan on My Panel'],
                'backups' => ['Daily backups', 'checkbox', ''],
            ],
        ];
    }

    public function test(array $server): Result
    {
        $r = $this->call($server, 'GET', '/api/ping', [], 'test');
        return $r->ok ? Result::ok('Connected.') : $r;
    }

    public function create(array $service, array $server): Result
    {
        $cfg = $service['product']['config'];
        $r = $this->call($server, 'POST', '/api/accounts', [
            'domain' => $service['domain'],
            'username' => $service['username'],
            'password' => $service['password'],
            'email' => $service['client']['email'],
            'plan' => (string)($cfg['package'] ?? ''),
            'backups' => !empty($cfg['backups']),
        ], 'create', (int)$service['id']);
        if (!$r->ok) return $r;
        // optional: what the panel assigned (only username and dedicated_ip are taken over)
        return Result::ok('Account created.', ['service_update' => ['dedicated_ip' => (string)($r->data['ip'] ?? '')]]);
    }

    public function suspend(array $service, array $server, string $reason): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/suspend', ['reason' => $reason], 'suspend', (int)$service['id']);
    }

    public function unsuspend(array $service, array $server): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/unsuspend', [], 'unsuspend', (int)$service['id']);
    }

    public function terminate(array $service, array $server): Result
    {
        return $this->call($server, 'DELETE', '/api/accounts/' . rawurlencode($service['username']), [], 'terminate', (int)$service['id']);
    }

    public function changePackage(array $service, array $server, string $package): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/plan', ['plan' => $package], 'change_package', (int)$service['id']);
    }

    public function changePassword(array $service, array $server, string $password): Result
    {
        return $this->call($server, 'POST', '/api/accounts/' . rawurlencode($service['username']) . '/password', ['password' => $password], 'change_password', (int)$service['id']);
    }

    public function packages(array $server): array
    {
        $out = [];
        foreach ((array)($this->call($server, 'GET', '/api/plans', [], 'packages')->data['plans'] ?? []) as $p) $out[(string)$p['id']] = (string)$p['name'];
        return $out;   // [value => label]; empty when the server cannot be reached
    }

    public function loginUrl(array $service, array $server): ?string
    {
        return null;   // or a one-time login URL to the client's panel ("Log in to the control panel")
    }

    private function call(array $server, string $method, string $path, array $body, string $action, ?int $serviceId = null): Result
    {
        $url = 'https://' . $server['hostname'] . ':' . (int)$server['port'] . $path;
        [$code, $resp, $err] = Http::request($method, $url, ['Authorization: Bearer ' . $server['api_key'], 'Content-Type: application/json'],
            $method === 'GET' ? null : json_encode($body), (bool)$server['verify_ssl'], $action === 'create' ? 300 : 60);
        $json = $resp !== null ? json_decode($resp, true) : null;
        if ($resp === null) $res = Result::fail('Connection failed: ' . $err);
        elseif ($code >= 400 || !is_array($json)) $res = Result::fail('My Panel answered HTTP ' . $code . ': ' . mb_substr(strip_tags((string)$resp), 0, 200));
        else $res = Result::ok('OK', $json);
        if ($this->logger && $action !== 'packages') {
            unset($body['password']);   // never log passwords
            ($this->logger)($action, $serviceId, (int)$server['id'], $method . ' ' . $path . ' ' . json_encode($body), $res->ok ? 'OK' : $res->message, $res->ok);
        }
        return $res;
    }
}
```

## What the module receives

`$server` is the servers row, with the API key already decrypted:

| Key | Meaning |
|---|---|
| `id`, `name` | The server in the billing |
| `hostname`, `ip` | Host name (the billing calls the API there) and the shared IP for new accounts |
| `port` | API port |
| `api_key` | The API key as staff entered it (decrypted, only in memory) |
| `verify_ssl` | 1 = check the panel's TLS certificate |

`$service` is the services row, plus:

| Key | Meaning |
|---|---|
| `id`, `domain`, `username`, `status`, `dedicated_ip`, `billing_cycle`, `next_due_date` | The service |
| `password` | The account password (plain, only in memory). The billing sets a random one before `create()` |
| `client` | The client row: `email`, `firstname`, `lastname`, `companyname`, `country` … |
| `product` | The product row; `product['config']` holds the module settings from `info()['product_config']` |
| `options` | The client's configurable options (Catalogue → Configurable options): option code => value of the chosen choice (quantity: the number) |

Configurable options are how clients choose things at order time, such as the operating system, disk size or
number of IPs. Give the option a **code** and each choice a **value** in the admin area, then read
`$service['options']['code']` in your module.

## How the billing calls the module

- **Create** runs when an order is paid, accepted, or created by hand (product setting **Create the account**). After
  `Result::ok()` the service becomes Active and the client gets the welcome e-mail.
- **Suspend** and **Unsuspend** are called by the overdue automation, by staff, and when an overdue invoice is paid.
- **Terminate** runs after the termination days, on a cancellation at the end of the period, or by staff.
- **Change package** runs on an upgrade or downgrade to a product with another plan; **Change password** runs when
  staff change the password on the service page.
- When the panel runs on the billing's own server, or **Run account actions from cron** is ticked on the server,
  create, suspend, unsuspend, change package and terminate run from the cron job. Your module works the same way in
  both cases.
- Calls take up to 300 seconds for create and terminate (the web request continues even when the browser gives up),
  60 seconds otherwise. Use the timeouts of `Http::request()` as in the example.

`CwpBilling\Modules\Http::request($method, $url, $headers, $body, $verifySsl, $timeoutSeconds)` returns
`[$httpCode, $responseBody, $error]`; `$responseBody` is null when the connection failed. You can also use PHP's
curl directly.

## Testing

1. **Servers → Add server**, choose your panel type, enter the host name, port and API key, then **Test connection**
   (calls `test()`).
2. Create a product with your module, pick the plan (from `packages()`), and order it as a test client, or add a
   service by hand and use **Create account** on the service page.
3. Every call and its answer is in the service's **Module log**. Errors from PHP itself are in
   `storage/logs/php-YYYY-MM-DD.log`.

If the module does not show up under Panel type, check the PHP log: the billing logs an extension module that it
skips (wrong code, class not found, or the class does not implement `CwpBilling\Modules\Module`).

## Domain registrars

Registrars work the same way: list the class under `registrars` in `extension.php`, for example
`'registrars' => ['myregistrar' => \MyCompany\MyRegistrar::class]`. The class implements
`CwpBilling\Registrars\Registrar` (`info()`, `__construct(array $settings)`, `check()`, `register()`, `transfer()`, `renew()`,
`setNameservers()`, `epp()`, `lock()`, `sync()`).

`info()` returns `['name' => …, 'settings' => [key => [label, text|password|checkbox, help]]]`. Staff fill in these
settings under **Domains → Registrars**, and the billing passes them to the constructor. A registrar that also has
`costPrices(array $tlds): array` can be used for Domain pricing → Import prices.

## More an extension can do

Besides modules, an extension can add admin pages and routes, a panel on the client's service page, daily jobs,
console commands, its own database tables and its own settings. See `extensions/README.md`.
