Domain Helpers

6 views Markdown

The business rules live in helpers. The panel, the client area, the API and the scheduler all call the same rule. That is how they agree on what a service, an order or an amount means.

Overview

A model answers what is stored. A helper answers what should happen. What suspending a service does, what an order becomes when it is paid, how an amount converts between currencies. Because four surfaces need those answers, the rule lives in one place and each surface calls it.

They are static classes over WDB. A module, a hook listener or a cron handler reaches them the same way a controller does. Doing the work yourself with queries produces something that is right today and diverges the first time the product's own rule changes.

Reference

Services A customer's provisioned service: its row, its add-ons, its status transitions and the module action each transition carries.
Money Currencies, conversion, display and tax. Every stored amount belongs to a currency, and this is what turns it into the one being served.
Orders The order document: creation, its items, and the status it takes from the services underneath it.
Products The catalogue: products, categories, add-ons, prices, servers and domain extensions, translated for the active language.
User The client record, the free-form information attached to it, and the activity entries other surfaces show.

Services

Services
public static function get(int $id = 0, string $select = '', $noCache = false): array;
// -> the users_products row: id, owner_id, order_id, invoice_id, subscription_id, type,
//    type_id, product_id, name, period, period_time, total_amount, amount, amount_cid, qty,
//    status, status_msg, suspended_reason, pmethod, auto_pay, cdate, duedate, suspend_date,
//    cancel_date, terminated_date, server_terminated, renewaldate, process_exemption_date,
//    module, options, metrics, notes, unread
//    'options' and 'metrics' arrive DECODED as arrays; a column that is NULL stays null.
//    'module' is always present and falls back to "none" - which is why a missing service
//    is still a non-empty array. $select narrows the columns, $noCache skips the memo.

public static function add(array $data = []): int;                      // the new id, 0 on failure
public static function set(int $id = 0, array $data = []): bool;
public static function delete(int $id = 0, bool $cancelModule = false): bool;
public static function preload(array $ids = []): void;                  // one query, fills get()'s memo

public static function statuses(): array;
// -> status key => ['title' => label, <group> => true]. The keys are waiting, inprocess,
//    active, completed, suspended, expired, cancelled; the group flag is what code should
//    branch on, since 'completed' groups as active and 'expired' groups as cancelled.

public static function addons(string|int|null $service_id = 0): array;  // users_products_addons rows,
                                                                        // plus a computed 'rak' sort column
public static function get_addon(int $id = 0, string $select = ''): array;
public static function requirements(int $service_id = 0): array;

public static function change_status(int $serviceId, string $status, array $options = []): bool;
// $options, all optional:
//   apply_on_module  false | true | 'sync' | 'queue'   run the module action inline, or queue it
//   module_completed bool    the module already did its part; do not hold the service at inprocess
//   force_status     bool    write the status verbatim, skipping that same guard
//   reason           string  stored as suspended_reason for suspended and cancelled
//   user_id          int     who is doing this, for the history entry
//   notify           bool    send the customer the status notification

public static function run_module(array|int $service, string $action, array $params = []): mixed;
// -> null when the module has no such method, false when it refused, otherwise the module's
//    own return value. Throws when the instance cannot be built, or when the module threw.

public static function add_history(int $user_id = 0, int $service_id = 0, string $name = '', array $data = []): int;

Money

Money
public static function getUCID(): int;      // the currency id being served to this visitor
public static function selected(): int;    // readable alias of getUCID()

public static function exChange($amount, $cid1 = 0, $cid2 = 0);
// -> the converted amount. Two answers are not conversions:
//    $cid1 === $cid2  -> $amount comes back untouched, no rate is read
//    it cannot convert -> 0, which is what an unknown currency, a rate of 0, and an
//                        $amount that is not above zero all produce

public static function formatter($amount = 0, $cid = 0, $symbol = false, $exchange = false, $info = false): array|string;
// -> "99.90" · with $symbol: "$99.90" · with $info an array instead of a string:
//    ['currency_id' => 4, 'currency_code' => 'USD', 'prefix' => '$', 'suffix' => '',
//     'symbol' => '$', 'amount' => '99.90']
//    $exchange converts first: true uses the served currency, an id or code uses that one.

public static function formatter_symbol($amount = 0, $currency = 0, $exchange = false, $info = false): string;
// The third argument is $exchange here, NOT $symbol - the symbol is always on.

public static function deformatter($amount = '', $currency = 0): float;   // "1.234,56" -> 1234.56

public static function Currency($identified = 0, $isActive = false): array;
// -> the currencies row: id, status, local, hidden, country, countries, code, name, prefix,
//    suffix, rate, format, modules. [] when there is no such currency. $identified takes an
//    id OR a code ("USD"); $isActive makes an inactive currency answer [] as well.

public static function getCurrencies(int $default = 0): array;
public static function currency_code(int $id = 0): string;
public static function getSymbol($currency = 0);
// -> ['position' => 'LEFT'|'RIGHT', 'prefix' => ..., 'suffix' => ..., 'symbol' => ...]

public static function get_tax_amount($amount = 0, $rate = 0);            // tax ON TOP of $amount
public static function get_inclusive_tax_amount($amount = 0, $rate = 0);  // tax already INSIDE $amount
public static function get_discount_amount($amount = 0, $rate = 0);       // $amount * $rate / 100

Orders

Orders
public static function get(int $id, $select = ''): array;
// -> the orders row: id, user_id, invoice_id, affiliate_id, ordernum, cdate, tax_type,
//    taxes, amount, currency, pmethod, status, ip, notes, discounts, items, details
//    taxes, discounts, items and details arrive DECODED as arrays. [] when there is none.
public static function get_order_by_number(int $num, string $select = ''): array;

public static function create(array $data = []): int;
// Only these keys are read; anything else in $data is dropped rather than stored.
//   user_id       int     REQUIRED
//   amount        float   REQUIRED - may be 0.0, may not be absent
//   currency      int     REQUIRED - a currency id
//   status        string  'waiting'
//   pmethod       string  'none'
//   tax_type      string  'exclusive'
//   taxes         array   encoded to JSON
//   discounts     array   encoded to JSON
//   items         array   encoded to JSON
//   details       array   encoded to JSON
//   invoice_id    int     0
//   affiliate_id  int     0
//   notes         string  null
//   ip            string  the requester's address
// Throws when a required key is missing. The order number is generated for you, and a
// listener may veto the whole call.

public static function set(int $id, array $data): bool;
public static function delete(int $id, bool $deleteServices = true): bool;
public static function services(int $order_id = 0): array;
// -> the order's services, narrowed to id, name, type, status, amount, module, period, period_time

public static function statuses(): array;          // waiting, inprocess, active, cancelled
public static function payment_statuses(): array;  // incomplete, complete, unknown
public static function change_status(int $order_id, string $status, bool $updateServices = false, string|bool $applyOnModule = false): bool;
public static function recalculateStatus(int $order_id, bool $force = false): ?string;
public static function generateOrderNumber(): int;
public static function add_history($user_id = 0, $order_id = 0, $name = '', $data = []): int;

Products

Products
public static function get(string|int|null $id = 0, string $lang = '', string $select = ''): array;
// -> the products row plus the translated 'title' and its alias 'name'. An empty $lang means
//    the active one. 'options' and 'module_data' arrive DECODED as arrays. [] when there is
//    no such product. Memoised per (id, lang) for the whole process, so a row edited in
//    between is not re-read.
public static function set($id = 0, $set = []): bool;

public static function types(): array;
// -> type key => ['title' => ..., 'description' => ..., 'icon' => ...]. The list is not fixed:
//    a module extends it through the register:product_types hook.
public static function groups(): array;
public static function cycles(): array;
public static function cycle($duration = '', $period = ''): string;      // (1, 'month') -> "monthly"

public static function get_price($type, $owner, $owner_id, $lang = 'none'): array;
public static function get_price_by_criteria(string $owner, int $owner_id, string $cycle = '', string|int $currency = '', int $status = 1, string $type = ''): array;
// $owner is a literal that differs per table and is NOT guessable: a product price is
// stored under 'products' (plural), a domain extension under 'tld', an add-on under
// 'addon'. The wrong form matches nothing and reports no error.

public static function addon($id = 0, $lang = '', $select = ''): array;
public static function requirement($id = 0, $lang = '', $select = '');
public static function get_category($id, $lang = '', $select = ''): array;
public static function get_server($id = 0): array;     // the servers row, 'password' already decrypted
public static function get_tld($definition = 'com', $select = '');

User

User
public static function getData($id = 0, $fields = '*', $fetch = 'object', $noCache = false): object|array;
// $fields is accepted and NOT used: the whole users row is always read, and the memo is
// keyed by id alone. $fetch picks the shape - 'object' (the default) gives a stdClass,
// anything else gives an array. Reading one column therefore has to say so:
//   $groupId = (int) (User::getData($id, '', 'array')['group_id'] ?? 0);

public static function setData($id = 0, $data = []): int|bool;
public static function create($data = []);
public static function delete(int $id, array $options = []): bool;

public static function getInfo($owner_id = 0, $names = [], $noCache = false): array;
// -> the names you asked for as name => value, with null for one that was never stored.
public static function AddInfo($owner_id = 0, $values = []);       // upsert, name => value
public static function deleteInfo($owner_id = 0, $name = ''): int|bool;

public static function addAction($id = 0, $reason = '', $detail = '', $data = [], $target_id = 0);
// $id         the client the entry belongs to
// $reason     a free-form grouping label: 'alteration', 'addition', 'delete', 'module-log'
// $detail     the TRANSLATION KEY from locale actions.php - the sentence is rendered from it
// $data       the placeholders that sentence uses ({service_name} and so on); also stored as JSON
// $target_id  the record the entry is about, when that is not the client itself

public static function addNote(int $owner_id, string $content, bool $pinned, int $adminId, string $adminName): array|false;
public static function getPrivileges($id = 0, $resultType = 'array'): array|string;
public static function parseDealership(string $data): array;

What "Not Found" Looks Like

Call Answer for a record that does not exist The guard to write
Services::get($id) ['module' => 'none'] - non-empty, so it passes a truthiness test if (!($service['id'] ?? 0))
Services::get_addon($id) [] if (!$addon)
Orders::get($id) [] if (!$order)
Products::get($id) [] if (!$product)
Money::Currency($id) [] if (!$currency)
User::getData($id) an empty stdClass, or [] with 'array' if (!($user->id ?? 0))

Example

using the rules instead of rewriting them
// The service. This is the one getter whose "not found" answer is still truthy, so the
// guard reads the identifier rather than the array.
$service = Services::get($id);
if (!($service['id'] ?? 0)) throw new Exception('Service not found');

// The amount belongs to the currency stored beside it, and the visitor may be served
// another one. Convert first, render second.
$served    = Money::getUCID();
$amount    = Money::exChange((float) $service['amount'], (int) $service['amount_cid'], $served);
$displayed = Money::formatter_symbol($amount, $served);

// Perform the action through the helper, so the history, the module and the notification
// all happen the way the panel would have done them.
Services::change_status((int) $service['id'], 'suspended', [
    'apply_on_module' => 'queue',
    'reason'          => 'Payment overdue',
    'user_id'         => $adminId,
    'notify'          => true,
]);

// Record it on the client's activity. The third argument is the key in locale actions.php;
// the fourth fills that sentence's placeholders.
User::addAction((int) $service['owner_id'], 'alteration', 'acme-service-suspended', [
    'service_id'   => (int) $service['id'],
    'service_name' => $service['name'] ?? '',
    'amount'       => $displayed,
], (int) $service['id']);
the side that reads it back
// The entry keeps the key, the sentence rendered in the installation's own language, and
// the placeholders as JSON. A key with no translation shows up here as the key itself.
$stmt = WDB::select('reason, detail, locale_detail, data, ctime')
    ->from('users_actions')
    ->where('owner_id', '=', (int) $service['owner_id'])
    ->where('detail', '=', 'acme-service-suspended')
    ->order_by('id DESC')
    ->limit(1);

$entry = $stmt->build() ? $stmt->getAssoc() : [];
$vars  = $entry ? Utility::jdecode($entry['data'] ?? '', true) : [];

// And the status the helper actually wrote, read without the memo the first call filled.
$after = Services::get((int) $service['id'], '', true)['status'] ?? '';

Pitfalls

Writing a row is not the same as performing the action

Creating or suspending a service through the helper also does the things around it. The history, the related records, the module action, the events other code listens for. An insert or an update of your own does none of that. It leaves an installation that looks right and behaves oddly later.

A conversion that cannot be done answers zero, not the input

Every stored amount belongs to a currency, and the visitor may be served another. Convert before you compare, sum or display. Put the served currency in the key of anything you cache. When the conversion cannot be done, exChange() returns 0. An unknown currency, a rate of zero and an amount that is not above zero all land there. A credit or refund passed through it as a negative number becomes zero without a word.

Two getters that answer something other than what they seem to

Services::get() always carries a module key, so a missing service is a truthy array and if (!$service) never fires. User::getData() ignores its column list entirely and hands back a stdClass unless you ask for an array. Casting its result while expecting one column silently yields the wrong number. On Money::formatter_symbol() the third argument is $exchange, not $symbol. Its fourth argument makes the underlying call return an array against a declared string return type.

An activity entry needs its translation key

The sentence comes from the third argument, not from anything you pass as text. The second is only a grouping label. A key that exists in one language file and not the others shows up as the bare key. Everyone else sees it that way in the client's history. Add it to every language file when you add the call.

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.