Writing a Currency Module

7 views Markdown

A Currency module is a rate source. It answers one question: what is one unit of my currency worth in these others? The scheduled sync writes the answer onto the currency table.

Overview

Currency is the smallest module type, and the only one with no base class. The contract is duck typed: the core resolves your class, checks for a method by name, and calls it. Seven modules ship, including the free default OpenRates and the platform's own WAtlas.

Without a base class the contract is only visible at the call sites. There are three, and they agree on one required method.

every place the core calls a Currency module
// 1. coremio/helpers/Money.php: the only rate path, used by the sync task
$instance = self::currency_module();               // configured module, else OpenRates
if (!$instance) return "Currency module not available.";
$rates = $instance->exchange_rates($from, $to);
if (!$rates) return $instance->error;              // the module's own message reaches the operator

// 2. coremio/operations/AdminFinancialCurrencies.php: settings save, then the test button
$instance = Modules::getInstance("Currency", $module);
if ($instance && method_exists($instance, "save_config")) $instance->save_config($module_data[$module]);
// ...
if (!method_exists($instance, "exchange_rates")) throw new Exception("Module does not implement exchange_rates().");
$instance->config = array_replace_recursive($instance->config, $module_data[$module]);   // unsaved form values
$rates = $instance->exchange_rates($localCode, $targets);

// 3. coremio/api/Resources/Admin/Concerns/FinancialCurrencies.php: the same two, over the API

exchange_rates() is required, and save_config() as soon as your module has a setting. page_settings() draws it. $config and $error are read directly by the caller, so they must exist and be public.

Prerequisites

  • A rate provider that returns a base currency and a set of quotes. Watch its free tier: the sync runs on a schedule.
  • The system's own local currency must be configured; every rate is expressed against it.
  • Instances come from Modules::getInstance('Currency', 'Acme').
  • Read coremio/modules/Currency/OpenRates first: under a hundred lines, no credentials, the whole contract in one file. ExchangeRateAPI adds an API key and a settings field.

Structure

coremio/modules/Currency/Acme/
Acme.php        namespace WISECP\Modules\Currency; class Acme  (no extends)
config.php      a FLAT array, not the meta/settings split other module types use
lang/en.php
lang/tr.php
The config file is flat here

Server, Payment and Registrar modules return ['meta' => [...], 'settings' => [...]]. A Currency module returns the settings themselves and reads them as $this->config['api_key']. The nested shape gives you a key that is always empty.

The class extends nothing, so it declares its own state. Those four properties are the interface the callers touch.

Walkthrough

Declare the Class and Its State

Acme.php
<?php
namespace WISECP\Modules\Currency;

class Acme
{
    public string  $name   = "Acme";
    public ?array  $config = null;
    public ?array  $lang   = null;
    public ?string $error  = null;      // the caller reads this when you return false

    public function __construct()
    {
        // Global classes need the leading backslash inside this namespace,
        // otherwise PHP looks for WISECP\Modules\Currency\Modules.
        $this->config = \Modules::Config("Currency", $this->name);
        $this->lang   = \Modules::Lang("Currency", $this->name);
    }
}

Fetch the Rates

Ask the provider for everything it has against $from and return an uppercase code map. Do not filter down to $to.

Draw the Setting and Persist It

page_settings() returns raw markup for the currency settings screen. Name every input module_data[{ModuleName}][{key}]: that is the array the save operation hands to your save_config(). The test button posts the same array and overlays it on $this->config in memory. A key can be tested before it is saved.

Verify Before You Trust It

  1. Select the module in the currency settings screen and click the test button. It calls exchange_rates() against the local currency with two targets.
  2. Run the sync, then compare currencies.rate for one currency against the provider's own site.
  3. Convert something. An inverted rate produces prices that look plausible and are wrong.

Reference

The Contract

what the call sites require
// REQUIRED
public function exchange_rates(string $from = '', array $to = []): array|false;

// REQUIRED once the module has any setting
public function save_config(array $data = []): bool;

// OPTIONAL: markup for the currency settings screen
public function page_settings(): string;

// Public state the callers read directly
public string  $name;
public ?array  $config;    // overwritten in memory by the test button
public ?array  $lang;
public ?string $error;     // surfaced to the operator when exchange_rates() returns false
$from The uppercase ISO code of the system's local currency, resolved from general/currency. Lowercase it if your provider wants that.
$to Advisory, and its shape differs by caller. The sync passes [currency_id => 'CODE']; the test button passes a plain list of codes. Never use it as the keys of your result.
return value ['USD' => 32.15, 'EUR' => 35.02], uppercase codes, float values, or false. An empty array counts as failure at the test site.
rate direction One unit of $from expressed in the target. The stored column is used as amount / rate_from * rate_to, so inverting it distorts every price.
Money::currency_module() Resolves the configured module and falls back to OpenRates when it is missing or uninstalled. Only a failure of OpenRates returns null.
Money::get_exchange_rates() The single path to a provider. On success an array. On failure it hands back your $error verbatim. The caller receives a string (or null when you set none) and never false, which is why every caller tests with is_array().

The Daily Call Cap

Before reaching your module the helper counts calls for the day in coremio/storage/currency-overload-limit.php. At 48 calls it returns the string "Currency API exceeds over limit." without touching the provider, which protects a free tier from a misconfigured schedule.

What the Sync Does With Your Answer

coremio/cronjobs/CurrencySync.php
$rates = Money::get_exchange_rates($localCode, $targets);

if (!is_array($rates))
    return ['success' => false, 'result' => ['error' => is_string($rates) ? $rates : 'invalid-response']];

// Extension point: inject a currency the provider cannot quote, or override one.
Hook::runRefs('filter:money.exchange_rates_fetch', $rates, $localCode, $targets);

// Uppercase code => currency id, so a provider that answered with everything
// still updates only the currencies this installation actually has.
$codeToId = [];
foreach ($targets as $cid => $code) $codeToId[strtoupper($code)] = (int) $cid;

foreach ($rates as $code => $rate) {
    $codeUp = strtoupper((string) $code);
    $rate   = (float) $rate;

    if (!isset($codeToId[$codeUp])) continue;          // not a currency here: ignored
    if ($rate <= 0 || $rate > 999999999999.99999999) { $skipped++; continue; }   // sanity guard

    WDB::update('currencies', ['rate' => $rate])->where('id', '=', $codeToId[$codeUp])->save();
}

Hook::run('action:money.exchange_rates_updated', $changes, $localCode);
filter:money.exchange_rates_fetch Runs on the fetched map before anything is written. Inject a currency the provider cannot quote here.
action:money.exchange_rates_updated Runs after persistence with only the rates that actually changed.
filter:money.exchange_rate Runs inside every conversion, not at sync time. Reserve it for policy such as a margin, never for fetching.

Both surfaces have API twins: POST /financial/currency-modules/test for the live provider call, POST /financial/currencies/sync to dispatch the task inline.

Example

A complete module with one credential. The field name connects the three: what page_settings() prints is what save_config() receives is what exchange_rates() reads.

Acme.php
public function exchange_rates(string $from = '', array $to = []): array|false
{
    $apiKey = (string) ($this->config["api_key"] ?? '');

    if ($apiKey === '') {
        $this->error = "Acme api_key is not configured.";
        return false;
    }

    $url = "https://api.example.com/v1/" . urlencode($apiKey) . "/latest/" . urlencode(strtoupper($from));

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 15);

    $response = curl_exec($ch);
    if ($response === false) $this->error = curl_error($ch) ?: "Currency provider request failed.";
    curl_close($ch);

    // Log BEFORE the early returns: an operator debugging a bad key needs to
    // see the request that produced the failure, not only the failure.
    \Modules::save_log("Currency", $this->name, "exchange", [
        'api_url' => $url,
        'from'    => $from,
        'to'      => $to,
    ], $response, $this->error);

    if ($this->error) return false;

    $decoded = \Utility::jdecode(trim((string) $response), true);

    if (!is_array($decoded) || ($decoded["result"] ?? '') !== 'success') {
        $this->error = (string) ($decoded["error-type"] ?? 'Acme returned an error.');
        return false;
    }

    // Uppercase keys, float values. $to is NOT used as a filter: the sync
    // keeps what it recognises and ignores the rest.
    $result = [];
    foreach ((array) ($decoded["conversion_rates"] ?? []) as $code => $rate)
        $result[strtoupper((string) $code)] = (float) $rate;

    return $result;
}
Acme.php, the setting and its round trip
public function page_settings(): string
{
    $apiKey = htmlspecialchars((string) ($this->config["api_key"] ?? ''), ENT_QUOTES);

    // The input NAME is the contract with the save operation:
    // module_data[Acme][api_key] arrives as $data['api_key'] in save_config().
    return '<div class="row mb-0 align-items-center">'
        . '<label for="acme-apikey" class="col-sm-3 col-form-label fw-semibold">API Key</label>'
        . '<div class="col-sm-9">'
        . '<input type="text" class="form-control" id="acme-apikey"'
        . ' name="module_data[Acme][api_key]" value="' . $apiKey . '" placeholder="api_key">'
        . '</div></div>';
}

public function save_config(array $data = []): bool
{
    // Merge, do not replace: a partial post must not wipe the other keys.
    $merged = array_replace_recursive($this->config ?: [], $data);

    // file_write() invalidates the opcode cache for a .php target, which is why
    // the saved value is readable on the very next request.
    return (bool) \FileManager::file_write(
        __DIR__ . DS . "config.php",
        \Utility::array_export($merged, ['pwith' => true])
    );
}
config.php, the file save_config writes
<?php
return [
    'api_key'   => '',
    'help-link' => 'https://example.com',
];

Pitfalls

Do not filter your answer down to $to

Its keys are currency ids from the sync and plain integers from the test button. Filtering on it gives the two callers different results. Return everything and let the core match by code.

A failure must set $error and return false

The caller prints $instance->error verbatim, and an empty message becomes an operator ticket that says nothing.

Forty-eight calls a day, counted before your code runs

The counter is incremented per call, not per successful call, so a test loop burns the same budget as the scheduled run. Check the cap before you suspect the provider.

Use the leading backslash for global classes

The file declares namespace WISECP\Modules\Currency, so an unqualified Modules::Config() resolves inside that namespace and fatals at runtime.

A missing module does not break pricing

When the configured module cannot be loaded the resolver falls back to OpenRates, which needs no credentials. Rates go stale rather than disappearing.

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.