Writing a Payment Gateway

7 views Markdown

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.

the line
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.
  • 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

coremio/modules/Payment/Acme/
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.

ArchetypeYou declareClient experienceSandbox to copy
Merchant card formcapture() plus standard_card = trueCard fields inside the checkout page, charged by youSampleMerchant
Redirect / hosted pagearea() and callback()A forwarding pane, then the provider's own pageSampleThirdParty
Card vaultcapture(), card_setup_result(), meta card-storage-supportedSaved cards and off session auto paySampleTokenized
Recurring agreementpayment_screen() override, callback(), cancel_subscription()Pay once or subscribe, then the provider charges renewalsSampleSubscription

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().

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

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

config_fields()
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

PaymentGatewayModule signatures
// 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.

optional module methods
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

MethodKeyMeaning
capture()statusSettled: successful, success, paid, pending, papproval. Handed back to the browser: redirect, 3d, output. Anything else is error
redirectWhere to send the browser, for 3-D or a step up challenge. Read only under redirect or 3d
messageA label => value map on success, a sentence on failure
cardThe tokenized card package, handed to the vault when the client asked to save it
outputRaw markup to print full page, for a bank form that auto submits. Read only under output or 3d
callback()statussuccessful, pending or error
messagePut the provider reference under Transaction ID; settle de-duplicates on it
paid['amount' => float, 'currency' => int], the figure truly charged when it differs
callback_messageEchoed verbatim instead of redirecting, for a provider wanting an acknowledgement
payment_screen()modecard, html, redirect, choices, legacy, none or error
htmlServer produced markup, printed unescaped
redirectTarget for the single forwarding button
choicesRows 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.

fee, for display
$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.

Acme.php, the pane
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>';
    }
}
Acme.php, callback()
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(),
        ],
    ];
}
settle_checkout(), in the base
// 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.

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.