# Writing a Product Module

https://dev.wisecp.com/es/writing-a-product-module

A product module provisions what is neither a hosting account nor a domain: a licence, a subscription, a certificate. No server record behind it.

## Overview

A service that is neither hosting nor server nor domain resolves to the Product type: `special` and `software` services, and SSL certificates.

Four of the seven Product modules are SSL products, and SSL has its own base class beneath the generic one.

- **ProductModule**: The generic base: module trait, client area contract, service importer. Not abstract.
- **SslProductModule**: Abstract: certificate dashboard, product fields, validation parsing, seven customer actions.
- **Services::module_type()**: Hosting and server go to Servers, domain to Registrars, everything else to Product.
- **Services::run_module()**: The same single door; an unimplemented verb is skipped.

## Prerequisites

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/module-configuration).
- Certificates extend the SSL base, everything else the generic one.
- Provider API credentials, read from `$this->config`.
- A product of type `special` or `software` bound to the module.

## Structure

The same shape as every module type, minus the server.

```bash
coremio/modules/Product/Acme/
├── Acme.php            extends ProductModule, or SslProductModule for certificates
├── ApiClient.php       your HTTP wrapper
├── config.php          metadata and the settings form
├── logo.png
├── lang/en.php
└── pages/
    ├── configuration.php   the module settings screen in the admin panel
    └── dashboard.php       the management surface; its presence opens the client tab
```

| Difference | Server module | Product module |
| --- | --- | --- |
| Credentials | Server record, base class decrypts | Module configuration, you decrypt |
| Connection test | `test_connect()` on the server form | `controller_test_connection()` |
| Settings screen | From the configuration fields | Your `page_configuration()` + save controller |
| Resource limits | `get_limit()`, base class resolves | From the product module data |
| Client tools | Tool catalogue and shared templates | Your dashboard page + callable actions |
| Client tab | Always, with a dashboard | Opt in: ship `pages/dashboard.php` |

## Walkthrough

### Pick the Base Class

The SSL base is abstract: seven methods to implement, the certificate surface inherited.

```php
namespace WISECP\Modules\Product;

use Exception;
use ProductModule;

// A licence, a subscription, an application tenant.
class Acme extends ProductModule
{
    public function __construct()
    {
        parent::__construct();       // required: it runs initModule('Product')
    }
}
```

```php
namespace WISECP\Modules\Product;

use SslProductModule;

class AcmeSSL extends SslProductModule
{
    // Seven abstract methods must be implemented; the rest of the surface is inherited.
    protected function initApi(): void { /* ... */ }
    public function fetchRemoteStatus(): array { return []; }
    protected function sslProductOptions(): array { return []; }
    protected function apiReissue(string $csr, string $dcv_method, string $approver_email): array|bool { return true; }
    protected function apiResendValidation(string $domain = ''): array|bool { return true; }
    protected function apiRevalidate(string $domain = ''): array|bool { return true; }
    protected function apiChangeValidationMethod(string $domain, string $method, string $approver = 'admin'): array|bool { return true; }
}
```

### Settings Screen

The module owns its settings page. Methods prefixed `controller_` are reachable from it, hyphens turned into underscores.

1. `page_configuration()` builds the screen, usually with the form builder.
2. `controller_save()` writes the posted fields with `save_config()`. Encrypt every secret first.
3. `controller_test_connection()` proves the credentials.

### Product Fields

`product_configuration()` returns the field descriptors the admin fills per product. They become the product's module data and reach provisioning as `$this->options['creation_info']`.

### Lifecycle

Same shape as a server module: read the choices, call the provider, hand back what to store.

1. Build the API client lazily inside the method, not in the constructor.
2. Make `create()` retry safe: the queue re-runs it, so check for an existing account and persist credentials early.

### Client Surface

Nothing is exposed to the customer by default; see [Product Module Client Management](https://dev.wisecp.com/en/product-module-client-management).

## Reference

### The Generic Base

```php
class ProductModule
{
    public bool   $client_area = false;              // true only while rendering in the client area
    public string $area_link   = '';                 // client controller link carrying the service id
    public array  $client_callable_methods = [];     // handle_* names the customer may run
    public array  $client_readonly_methods = [];     // the subset served over GET, no CSRF token

    public function __construct();                   // calls initModule('Product')
    public function get_page($page_file = '', $vars = []): string;
    public function use_controller($param = '');
    public function service_management_page(): string;
    public function has_client_management(): bool;
    public function client_overview_data(): array;
    public function client_quick_actions(int $limit = 8): array;
    protected function import_service(array $data): int;
}
```

- **use_controller($param)**: Dispatches to `controller_{param}`, hyphens to underscores. An absent method returns nothing, so an unknown page fails quietly.
- **get_page($page_file, $vars)**: Loads a template from your `pages/` directory, injecting `$module`; falls back to the shared special product templates.
- **has_client_management()**: True when `pages/dashboard.php` exists or `page_dashboard()` is defined; override to `false` for admin only.
- **import_service(array $data): int**: Creates a service row for an account that already exists at the provider. Returns the id, or 0 when owner or product is missing.

### Lifecycle Signatures

None exists on the base and none is abstract; the core probes each with `method_exists()`, so the signature is the contract.

```php
// Settings screen. Reached through use_controller().
public function page_configuration(): string;
public function controller_save(): array;
public function controller_test_connection(): array;

// Product and service forms. Both save methods take their values BY REFERENCE.
public function product_configuration(array $data = []): array;
public function save_product_configuration(array &$values): void;
public function service_configuration(): array;
public function save_service_configuration(array &$values): void;

// Provisioning. Note the return type is array|bool here, wider than the server type.
public function create(): array|bool;
public function renew(): array|bool;
public function suspend(): array|bool;
public function unsuspend(): array|bool;
public function cancel(): array|bool;
public function upgrade(): array|bool;
public function change_password(string $password): bool;

// Addons, identical in shape to the server type.
public function addon_create(array $addon = []): array|bool;
public function addon_suspend(array $addon = []): array|bool;
public function addon_unsuspend(array $addon = []): array|bool;
public function addon_cancel(array $addon = []): array|bool;
public function addon_upgrade(array $addon, array $new_addon): array|bool;

// Live state and the admin dashboard.
public function fetchRemoteStatus(): array;
public function getDashboardData(): array;

// Metered billing.
public function metrics_usage(): array;
public function metrics_usage_bulk(array $services): array;
public function metric_enable(array $metric): void;
public function metric_disable(array $metric): void;

// Customer actions. The name here is the declared name with handle_ in front.
public function handle_reset_usage(): array;
```

> **upgrade() takes no argument on this type**
> 
> A server module receives the new product as `upgrade(array $new_product = [])`. Product modules declare `upgrade(): array|bool` and read the new state from the service.

### The SSL Contract

```php
abstract protected function initApi(): void;
abstract public function fetchRemoteStatus(): array;
abstract protected function sslProductOptions(): array;
abstract protected function apiReissue(string $csr, string $dcv_method, string $approver_email): array|bool;
abstract protected function apiResendValidation(string $domain = ''): array|bool;
abstract protected function apiRevalidate(string $domain = ''): array|bool;
abstract protected function apiChangeValidationMethod(string $domain, string $method, string $approver = 'admin'): array|bool;
```

```php
// Suspension has no meaning for a certificate, so both are answered for you.
public function suspend(): array|bool;
public function unsuspend(): array|bool;

// Product fields: the certificate dropdown plus the included SAN count.
public function product_configuration(array $data = []): array;
public function save_product_configuration(array &$values): void;

// The seven customer actions, each already owner, CSRF and active-service guarded.
public function handle_reissue(): array;
public function handle_resend_validation(): array;
public function handle_revalidate(): array;
public function handle_change_validation_method(): array;
public function handle_add_san(): array;
public function handle_remove_san(): array;
public function handle_download_certificate(): string;

// Helpers around the certificate state.
public function stagedSans(): array;
public function certificateId(): string;
public static function collectExpiringServices(string $module, int $maxDays = 30): array;
```

- **client_callable_methods**: All seven action names, already filled by the base.
- **client_readonly_methods**: Only `download_certificate`: streamed over GET, so no token and no POST.
- **dcv_methods**: `email`, `http`, `https`, `dns`. Anything else normalises back to e-mail.
- **shared certificate strings**: Merged into `$this->lang` at construction; your file wins on a clash.
- **fetchRemoteStatus() shape**: Reads `status`, `domain`, `ssl_type`, `sans`, `sans_included`, `sans_addon`, `sans_max`, `issued_at`, `expires_at`, `validation_method`, `approver_email`, `dcv_file`, `dcv_dns`, `serial_number`, `signature_algo`, `key_size`, `issuer`, `crt_code` and `ca_code`.

### import_service() Keys

- **owner_id, product_id**: Both required integers; a missing or unknown one returns 0 without writing.
- **cycle**: A key such as `monthly`: resolves period, duration and price, priced in the buyer's currency first.
- **period, period_time, amount, amount_cid**: Explicit overrides: `period` skips the cycle lookup, `amount` skips the price lookup.
- **options**: Merged into the service options. `established` is forced true; the product's module data goes to `creation_info`.
- **name, status, cdate, duedate, renewaldate**: Name defaults to the product name, status to `active`, the three dates to now.

## Example

A licence module: settings save, provisioning call, reading the result back.

```php
public function controller_save(): array
{
    $endpoint = Filter::init("POST/api_endpoint", "hclear");
    if (!$endpoint) throw new Exception($this->lang['err-endpoint-required']);

    $this->save_config([
        'api_endpoint' => $endpoint,

        // Pass-through: any other filter strips what makes a key strong.
        'api_key'      => $this->encode_str(Filter::init("POST/api_key", "password")),
        'mode'         => Filter::init("POST/mode", "letters"),
    ]);

    return ['status' => 'successful'];
}

public function controller_test_connection(): array
{
    $this->initApi();
    $this->api->call('ping');

    return ['status' => 'successful', 'message' => $this->lang['connection-ok']];
}

private function initApi(): void
{
    // Lazy: the instance is built in contexts that never reach the network.
    if (isset($this->api)) return;

    include_once __DIR__ . DS . 'ApiClient.php';
    $this->api = new ApiClient(
        $this->config['settings']['api_endpoint'] ?? '',
        $this->decode_str($this->config['settings']['api_key'] ?? ''),
    );
}
```

```php
public function create(): array|bool
{
    // What the admin configured on the product, with the order-time copy preferred.
    $module_data = ($this->options['creation_info'] ?? []) ?: ($this->product['module_data'] ?? []);

    $plan  = $module_data['plan'] ?? 'starter';
    $seats = (int) ($module_data['seats'] ?? 1);

    // Addons add to the base allowance; requirements are what the buyer typed.
    $seats += (int) ($this->addon_params['extra_seats'] ?? 0);
    $company = $this->requirement_params['company_name'] ?? '';

    $this->initApi();

    // Retry safety: the queue re-runs this method after a failure.
    $existing = $this->options['config']['id'] ?? '';
    if ($existing) return true;

    $result = $this->api->call('licences', [
        'plan'    => $plan,
        'seats'   => $seats,
        'company' => $company,
        'email'   => $this->user['email'] ?? '',
    ], 'POST');

    return [
        'config' => [
            'id'  => $result['licence_id'] ?? '',
            'key' => $this->encode_str($result['licence_key'] ?? ''),
        ],
        'login' => [
            'username' => $result['username'] ?? '',
            'password' => $this->encode_str($result['password'] ?? ''),
        ],
    ];
}
```

```php
// Every later verb starts from what create() returned. The core merged
// 'config' and 'login' into the service options before this ran.
public function cancel(): array|bool
{
    $licenceId = $this->options['config']['id'] ?? '';
    if (!$licenceId) return true;             // nothing was ever provisioned

    $this->initApi();
    $this->api->call('licences/' . $licenceId, [], 'DELETE');

    return true;
}

// Live provider state for the admin dashboard and the client overview.
public function fetchRemoteStatus(): array
{
    $licenceId = $this->options['config']['id'] ?? '';
    if (!$licenceId) return [];

    $this->initApi();
    $remote = $this->api->call('licences/' . $licenceId);

    return [
        'status'     => $remote['state'] ?? 'unknown',
        'seats_used' => (int) ($remote['seats_used'] ?? 0),

        // Format before returning: the client area prints these values as they are.
        'expires_at' => DateManager::format(Config::get("options/date-format"), $remote['expires'] ?? ''),
    ];
}
```

## Pitfalls

> **Do not copy a server module and delete the server parts**
> 
> `upgrade()` has a different signature, and there is no resolved limit helper and no tool catalogue. Start from a Product sandbox archetype.

> **Build the API client lazily, never in the constructor**
> 
> The instance is built on pages that never call the provider.

> **A secret in the module configuration must be encrypted**
> 
> No server record does it for you: encrypt before saving, decrypt before use, and read the posted value with the pass-through filter.

> **Report failure by throwing**
> 
> Setting an error string and returning `false` is a leftover. Throw a translated exception; the queue records it and retries.

> **Format dates and numbers before returning them**
> 
> The client area prints values exactly as given, a raw provider timestamp included.

## Related Articles

- [Product Module Client Management](https://dev.wisecp.com/en/product-module-client-management)
- [Writing a Server Module](https://dev.wisecp.com/en/writing-a-server-module)
- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle)
- [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder)
