# Writing a Currency Module

https://dev.wisecp.com/es/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.

```php
// 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

```text
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

```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

```php
// 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

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

```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;
}
```

```php
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
<?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.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway)
- [Domain Helpers](https://dev.wisecp.com/en/domain-helpers)
- [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task)
- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
