Writing a Product Module

3 views Markdown

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 and 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.

layout
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
DifferenceServer moduleProduct module
CredentialsServer record, base class decryptsModule configuration, you decrypt
Connection testtest_connect() on the server formcontroller_test_connection()
Settings screenFrom the configuration fieldsYour page_configuration() + save controller
Resource limitsget_limit(), base class resolvesFrom the product module data
Client toolsTool catalogue and shared templatesYour dashboard page + callable actions
Client tabAlways, with a dashboardOpt in: ship pages/dashboard.php

Walkthrough

Pick the Base Class

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

the two shapes
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')
    }
}
certificate shape
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.

Reference

The Generic Base

exact signatures
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.

exact signatures
// 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

you implement
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;
what you inherit
// 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.

settings
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'] ?? ''),
    );
}
provisioning
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'] ?? ''),
        ],
    ];
}
reading it back
// 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.

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.