Writing a Currency Module
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.
// 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/OpenRatesfirst: under a hundred lines, no credentials, the whole contract in one file.ExchangeRateAPIadds an API key and a settings field.
Structure
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
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
<?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
- Select the module in the currency settings screen and click the test button. It calls
exchange_rates()against the local currency with two targets. - Run the sync, then compare
currencies.ratefor one currency against the provider's own site. - Convert something. An inverted rate produces prices that look plausible and are wrong.
Reference
The Contract
// 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
general/currency. Lowercase it if your provider wants that.
[currency_id => 'CODE']; the test button passes a plain list of codes. Never use it as the keys of your result.
['USD' => 32.15, 'EUR' => 35.02], uppercase codes, float values, or false. An empty array counts as failure at the test site.
$from expressed in the target. The stored column is used as amount / rate_from * rate_to, so inverting it distorts every price.
OpenRates when it is missing or uninstalled. Only a failure of OpenRates returns null.
$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
$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);
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.
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;
}
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])
);
}
<?php
return [
'api_key' => '',
'help-link' => 'https://example.com',
];
Pitfalls
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.
The caller prints $instance->error verbatim, and an empty message becomes an operator ticket that says nothing.
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.
The file declares namespace WISECP\Modules\Currency, so an unqualified Modules::Config() resolves inside that namespace and fatals at runtime.
When the configured module cannot be loaded the resolver falls back to OpenRates, which needs no credentials. Rates go stale rather than disappearing.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.