# Writing a Payment Gateway

https://dev.wisecp.com/es/writing-a-payment-gateway

A Payment module turns a provider into a checkout option. It shows the pay screen, charges the card or forwards the client, and returns an array the core settles.

## Overview

A gateway module extends `PaymentGatewayModule` and lives in `coremio/modules/Payment/{Name}/{Name}.php`. 164 ship, four the sandbox samples below.

The payment line has four legs; the module owns two.

```text
checkout snapshot   core writes a `checkouts` row holding the exact figures the client saw
      |
pay surface         MODULE  payment_screen() decides what the client meets
      |
capture / callback  MODULE  capture($params) charges, or callback() receives the provider's return
      |
settle              core    settle_checkout() books the payment onto the invoices
```

**A module never marks an invoice paid.** It returns `['status' => 'successful', ...]` and the core books it. Doing it yourself double-books: a retried callback runs the code twice.

## Prerequisites

- The module skeleton: directory, class file, `config.php`, `lang/`. See [Module Anatomy](https://dev.wisecp.com/en/module-anatomy).
- Four `Sample*` gateways: working modules, one per archetype.
- Provider credentials for a test account, and a public host if the provider posts a callback.
- Instances come from `Modules::getInstance('Payment', 'Acme')`, never `new Acme()`.

## Structure

```text
Acme.php        the class: extends PaymentGatewayModule
config.php      returns ['meta' => [...], 'settings' => [...]]
lang/en.php     returns a flat key => string map, read as $this->lang['key']
lang/tr.php
logo.png        shown in the admin module list and on the checkout method row
```

Pick the archetype first; it decides which methods you declare. The core finds them with `method_exists`.

| Archetype | You declare | Client experience | Sandbox to copy |
| --- | --- | --- | --- |
| Merchant card form | `capture()` plus `standard_card = true` | Card fields inside the checkout page, charged by you | `SampleMerchant` |
| Redirect / hosted page | `area()` and `callback()` | A forwarding pane, then the provider's own page | `SampleThirdParty` |
| Card vault | `capture()`, `card_setup_result()`, meta `card-storage-supported` | Saved cards and off session auto pay | `SampleTokenized` |
| Recurring agreement | `payment_screen()` override, `callback()`, `cancel_subscription()` | Pay once or subscribe, then the provider charges renewals | `SampleSubscription` |

PayTR overrides `payment_screen()` for an iframe. Iyzico redirects with `area()`. Stripe vaults cards; PayPal pairs a one time charge with an agreement.

## Walkthrough

### The Module

The base constructor fills `$this->config`, `$this->lang`, `$this->dir`, `$this->url`, callback links and capability settings; call `parent::__construct()`.

```php
class Acme extends PaymentGatewayModule
{
    public function __construct()
    {
        parent::__construct();

        // $this->links['callback'] is now the public return address for this
        // module, signed with get_auth_token(); hand it to the provider.
    }
}
```

### Capabilities

Capability settings come from `config.php` meta, read by the base constructor.

```php
<?php
return [
    'meta' => [
        'name'                   => 'Acme Pay',
        'version'                => '1.0',
        'icon_type'              => 'font',
        'icon'                   => 'bi bi-credit-card',
        'card-storage-supported' => false,  // can vault a card token
        'auto-payment-supported' => false,  // can charge off session (auto pay)
        'standard-card-form'     => false,  // renders the shared card entry form
        'embedded-pane'          => true,   // pane may embed inside the checkout page
        'subscription-mode'      => 'amount',  // items | amount | fixed, see below
        'subscription-anchor'    => false,  // may start an agreement on a future date
    ],
    'settings' => [
        'commission_rate'      => 0,
        'force_convert_to'     => 0,
        'min_amount'           => 0,   // accepted amount range; 0 leaves the bound off
        'max_amount'           => 0,
        'amount_limit_cid'     => 0,   // currency the two bounds are written in
        'accepted_countries'   => [],
        'unaccepted_countries' => [],
    ],
];
```

### Settings Form

Declare `config_fields()`; the settings screen builds itself. `controller_settings()` saves every declared key into `config['settings']`, plus the shared fields: status, commission rate, forced currency, amount range, country lists.

```php
public function config_fields()
{
    return [
        'merchant_id' => [
            'name'        => $this->lang['merchant-id'] ?? 'Merchant ID',
            'description' => $this->lang['merchant-id-desc'] ?? '',
            'type'        => 'text',
            'value'       => $this->config['settings']['merchant_id'] ?? '',
            'placeholder' => 'acme_12345',
        ],
        'secret_key' => [
            'name'  => $this->lang['secret-key'] ?? 'Secret Key',
            'type'  => 'password',
            'value' => $this->config['settings']['secret_key'] ?? '',
        ],
    ];
}
```

### Pay Surface

For a redirect gateway declare `area($params)` and return markup. `pre_area()` assembles `$params`; `payment_screen()` reports `['mode' => 'html', ...]`. `embedded-pane` true puts it in the checkout page, false on the dedicated pay page.

### The Return

Declare `callback()`. The dispatcher routes `payments/{Module}/{auth_token}/callback` to it and passes what the provider sent, resolving nothing. Identify the checkout, verify the signature, describe the outcome; the dispatcher calls `settle_checkout()`.

> **The callback leg has no session**
> 
> Carry the checkout id yourself, usually as a query parameter on the return address from `LinkGenerator::wQS()`. Read the owner from it.

### Charge a Card

Declare `capture($params)`. `pre_capture()` takes the browser's card post and validates the CSRF token and the card fields. It also confirms the checkout belongs to the acting account. Then it resolves any installment plan and stashes the card metadata, never the CVC. Return a status array; success settles immediately.

## Reference

### The Base Class

```php
// The checkout in play
public function set_checkout($checkout): void;
public function get_checkout($id = 0, $status = '', $type = '', $uid = 0);
public function save_checkout($id = 0, $fields = []): bool;
public function checkout_total(): float;      // authoritative amount to charge
public function checkout_currency(): int;     // currency id, NOT the ISO code
public function getItems(): array;

// Currency
public function cid_convert_code($id = 0);    // 4 => "USD"
public function currency($id = 0);            // idempotent: id or code in, row out

// Money and fees
public function commission_fee_calculator($amount): float;
public function get_commission_rate();
public function installment_plans(array $bin, float $base, int $cid): array;
public function installment_plan_total(float $base, float $rate): float;

// Cards
public function get_stored_card($id = 0, $user_id = 0): array;
public function checkSaveCard(): bool;
public function checkAutoPay(): bool;
public function card_setup_result(): array;
public function generate_card_identification_checkout($pmethod = ''): int;

// Subscriptions
public function checkout_subscribable(): array;
public function set_subscribed_items($arg = []): void;
public function set_subscribed_sources(array $sources = []): void;

// Plumbing
public function get_auth_token(): string;
public function define_function($name = '', $function_name = ''): void;
public function controller_settings($extraFields = []): array;
public function isEnabled(): bool;
public function save_custom_data($data, $checkout_id = 0): void;
public function get_custom_data($checkout_id = 0);

// The three the core calls, overridable
public function payment_screen(): array;
public function pre_area(): string|false;
public function pre_capture(): string;

// The settlement, called for you. Never call it from module code.
public static function settle_checkout(PaymentGatewayModule $module, array $checkout, array $result): array;
```

- **checkout_total()**: The only correct amount to send a provider. It already carries tax, commission fee and any installment surcharge.
- **checkout_currency()**: The internal currency *id*. Providers want the ISO code, so pass it through `cid_convert_code()`.
- **get_auth_token()**: The signature segment inside the callback address. A callback whose token does not match is rejected, so build the address from `$this->links`.
- **define_function()**: Publishes one extra endpoint at `payments/{Module}/function/{name}` for a multi step pane. Use underscores; a hyphen or dot is folded into one.
- **settle_checkout()**: Idempotent by design: it re-reads the row, short circuits when the checkout is already paid and de-duplicates by transaction id. A twin callback is harmless.

### What You Declare

The core probes these with `method_exists`. Only `card_setup_result()` exists on the base, defaulting to `['status' => 'unsupported']`: an override, the rest additions.

```php
public function capture($params = []);                 // card charge, returns the status array
public function area($params = []);                    // redirect gateway, returns markup
public function callback();                            // provider return, returns the status array
public function bin_check($number);                    // local BIN table, returns card metadata
public function config_fields();                       // admin settings fields
public function refundInvoice($invoice = []);          // refund, returns bool
public function card_setup_result(): array;            // OVERRIDE: 3-D return of a vaulting run

// The sandboxes declare one parameter, but the core calls this with TWO: the
// charge base is passed so a provider whose plans depend on the amount can ask.
public function installment_rates($bin = [], $base = 0);   // [count => deferred-interest percent]

// Subscriptions
public function cancel_subscription($params = []): bool;
public function get_subscription($params = []): array|false;
public function change_subscription_fee($params = [], $value = 0, $currency = 0): bool;
public function remove_subscription_item($params = []): bool;   // 'items' mode: drop one line
```

### $params Keys

Both receive a superset of the V3 contract. `area()` gets six keys: `checkout_id`, `amount`, `currency`, `currency_id`, `clientInfo`, `items`. The rest below, including the `id` mirror, is capture only.

- **checkout_id**: The checkout row id. Also mirrored as `id` for V3 modules.
- **amount**: Float. The full figure to charge, grown by the installment surcharge when a plan was resolved.
- **currency**: The ISO **code** as a string, for example `"TRY"`. Casting it to int is the most common porting bug.
- **currency_id**: The int currency id, for modules that price or convert.
- **clientInfo**: The payer's name, e-mail and address, from the checkout's frozen user data.
- **items**: The line rows. Their totals are **net**; they do not add up to `amount`.
- **data**: The checkout data blob, including the frozen `user_data`. Capture only.
- **type**: Card schema or type, from your own `bin_check()` or the stored card row. Capture only.
- **installment**: The plan count the server approved, not what the browser asked for. Zero means a single charge. Capture only.
- **num, holder_name, expiry_m, expiry_y, cvc**: A new card, already validated. Present only when no stored card was picked.
- **card_storage**: **Absent unless a stored card is being charged.** The decrypted vault row, including `token` and `ln4`.
- **save_card, auto_pay**: Booleans, both forced off when the payer is a sub user on someone else's account.

### What You Return

| Method | Key | Meaning |
| --- | --- | --- |
| `capture()` | `status` | Settled: `successful`, `success`, `paid`, `pending`, `papproval`. Handed back to the browser: `redirect`, `3d`, `output`. Anything else is `error` |
| `redirect` | Where to send the browser, for 3-D or a step up challenge. Read only under `redirect` or `3d` |  |
| `message` | A `label => value` map on success, a sentence on failure |  |
| `card` | The tokenized card package, handed to the vault when the client asked to save it |  |
| `output` | Raw markup to print full page, for a bank form that auto submits. Read only under `output` or `3d` |  |
| `callback()` | `status` | `successful`, `pending` or `error` |
| `message` | Put the provider reference under `Transaction ID`; settle de-duplicates on it |  |
| `paid` | `['amount' => float, 'currency' => int]`, the figure truly charged when it differs |  |
| `callback_message` | Echoed verbatim instead of redirecting, for a provider wanting an acknowledgement |  |
| `payment_screen()` | `mode` | `card`, `html`, `redirect`, `choices`, `legacy`, `none` or `error` |
| `html` | Server produced markup, printed unescaped |  |
| `redirect` | Target for the single forwarding button |  |
| `choices` | Rows of `['label' => string, 'url' => string, 'image' => string]`, as PayPal offers one time payment beside an agreement. `note` prints above them; an `error` mode carries `message` |  |

The base `payment_screen()` derives the mode from the methods you declared. Override it only when the provider needs its own markup.

### Commission

Commission is a header field, never a line item. The operator sets `commission_rate` per gateway; the core prices it during recalculation. `checkout_total()` contains the fee, `commission_fee_calculator()` is display only.

```php
$base = $this->checkout_total();
$fee  = $this->commission_fee_calculator($base);   // rate from config['settings']['commission_rate']
$note = Money::formatter_symbol($fee, $this->checkout_currency());
```

### Amount Range

Three shared settings decide whether the gateway is offered at all, stored under `config['settings']`.

- **min_amount**: Float. Smallest payable total; `0` leaves the lower bound off.
- **max_amount**: Float. Largest payable total; `0` leaves the upper bound off. A maximum below the minimum is refused at save time.
- **amount_limit_cid**: Currency id the bounds are written in. Empty falls back to the system currency.

`Checkout::payment_methods($ucid, ['amount' => $total])` converts both bounds into the paying currency and drops a gateway whose range excludes the total. Checkout, invoice, bulk payment and Add Funds pass through it.

The figure compared is the total *before* this gateway's commission and installment charge. Do not re-check the range inside `capture()`: the total already carries the fee.

Dropping the field from the settings form (`'unusedFields' => ['amount_limits']`) posts neither key, and the stored range survives. A page without the total reads the bounds off the list entry.

## Example

A complete redirect gateway. The pane builds the address the provider calls back; the callback finds that checkout.

```php
class Acme extends PaymentGatewayModule
{
    public function area($params = [])
    {
        $cid = (int) ($params['currency_id'] ?? 0);

        // The return address the provider will call. The auth token inside
        // $this->links['callback'] is what makes the dispatcher accept it, and
        // custom_id is how the callback finds this checkout without a session.
        $return = LinkGenerator::wQS($this->links['callback'], [
            'custom_id' => (int) ($params['checkout_id'] ?? $this->checkout_id),
        ]);

        $session = $this->open_provider_session([
            'merchant'    => $this->config['settings']['merchant_id'] ?? '',
            'amount'      => $params['amount'] ?? 0,
            'currency'    => $params['currency'] ?? '',   // ISO code, already converted
            'reference'   => 'chk_' . (int) $this->checkout_id,
            'return_url'  => $return,
        ]);

        if (($session['url'] ?? '') === '')
            return '';   // an empty return makes payment_screen() report mode "error"

        $label  = htmlspecialchars((string) ($this->lang['pay-button'] ?? 'Continue'), ENT_QUOTES);
        $target = htmlspecialchars($session['url'], ENT_QUOTES);
        $amount = htmlspecialchars(Money::formatter_symbol((float) ($params['amount'] ?? 0), $cid), ENT_QUOTES);

        return '<div class="d-grid">'
            . '<span class="price-chip num-tabular mb-2">' . $amount . '</span>'
            . '<a class="btn btn-primary btn-lg" href="' . $target . '">' . $label . '</a>'
            . '</div>';
    }
}
```

```php
public function callback()
{
    $checkout_id = (int) Filter::init("REQUEST/custom_id", "numbers");
    $checkout    = $checkout_id ? $this->get_checkout($checkout_id) : false;

    if (!$checkout)
        return ['status' => "error", 'message' => "checkout-not-found"];

    // set_checkout() populates $this->checkout, $this->checkout_id and the
    // client info, so checkout_total() and checkout_currency() answer below.
    $this->set_checkout($checkout);

    $reference = (string) Filter::init("REQUEST/reference", "letters_numbers");
    $signature = (string) Filter::init("REQUEST/signature", "letters_numbers");

    // Verify BEFORE trusting anything: the callback address is public.
    if (!$this->signature_matches($reference, $signature))
        return ['status' => "error", 'checkout_id' => $checkout_id, 'message' => $this->pay_lang("error-verification")];

    $remote = $this->fetch_provider_charge($reference);

    if (($remote['state'] ?? '') !== 'captured')
        return [
            'status'      => "error",
            'checkout_id' => $checkout_id,
            'message'     => (string) ($remote['reason'] ?? ($this->lang['error-declined'] ?? 'Declined.')),
        ];

    return [
        'status'      => "successful",
        'checkout_id' => $checkout_id,
        'message'     => ['Transaction ID' => $reference],

        // Report what was ACTUALLY taken when it differs from the snapshot,
        // for example after an installment plan chosen on the provider page.
        'paid'        => [
            'amount'   => (float) ($remote['amount'] ?? $this->checkout_total()),
            'currency' => $this->checkout_currency(),
        ],
    ];
}
```

```php
// coremio/classes/PaymentGatewayModule.php, settle_checkout()
$result = $module->callback();

// 1. already paid?          -> short circuit, no second booking
// 2. status not successful  -> log, send the client to links['failed']
// 3. deferred order?        -> build the order and the invoice from the blueprint
// 4. charged more than the snapshot? -> book the installment surcharge first
// 5. book each invoice:
foreach (\Checkout::invoice_ids($fresh) as $invoiceId) {
    $due = round((float) Invoices::balance($invoiceId), 2);
    if ($due <= 0.005) continue;

    Invoices::add_payment($invoiceId, [
        'amount'         => $due,
        'currency'       => (int) (Invoices::get($invoiceId, ['select' => "currency"])["currency"] ?? 0),
        'pmethod'        => $module->name,
        'transaction_id' => $tx,                 // from message['Transaction ID']
        'description'    => $module->lang["name"] ?? $module->name,
    ]);
}
```

A full payment flips the invoice to paid. The linked order activates, the service is provisioned, the income row written, the notification sent.

## Pitfalls

> **params['currency'] is an ISO code, not a number**
> 
> `(int) $params['currency']` yields nonsense such as `4` where the provider expected `USD`. For the id, read `currency_id`.

> **Line items do not add up to the total**
> 
> Item totals are net; there is no `data.tax` key and no `amount_including_discount`, both V3. For a line breakdown, reconcile the remainder against `checkout_total()` with a "tax and fees" row.

> **An empty card_storage array reads as "a stored card is present"**
> 
> Modules gate that branch with `is_array($params['card_storage'] ?? null)`, so the key is absent for a new card. Never default it to `[]`.

> **A server to server callback wants an acknowledgement, not a redirect**
> 
> Return `callback_message` and the dispatcher echoes it. Redirect a machine caller and the provider treats the notification as failed, then retries.

> **The CVC is never persisted**
> 
> It is passed to `capture()` and forgotten. Writing it into checkout data or the vault is a compliance failure. The stored card keeps only the provider's token and display metadata.

> **Report failure through the return array**
> 
> The contract is `['status' => 'error', 'message' => ...]`, which the settlement logs and shows. `$this->error` plus false is a legacy fallback, read only when the array is empty.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Writing a Currency Module](https://dev.wisecp.com/en/writing-a-currency-module)
- [Writing a Fraud Module](https://dev.wisecp.com/en/writing-a-fraud-module)
- [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout)
- [Domain Helpers](https://dev.wisecp.com/en/domain-helpers)
