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.
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'), nevernew Acme().
Structure
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().
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
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.
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().
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
// 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;
cid_convert_code().
$this->links.
payments/{Module}/function/{name} for a multi step pane. Use underscores; a hyphen or dot is folded into one.
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.
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.
id for V3 modules.
"TRY". Casting it to int is the most common porting bug.
amount.
user_data. Capture only.
bin_check() or the stored card row. Capture only.
token and ln4.
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.
$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'].
0 leaves the lower bound off.
0 leaves the upper bound off. A maximum below the minimum is refused at save time.
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.
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>';
}
}
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(),
],
];
}
// 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
(int) $params['currency'] yields nonsense such as 4 where the provider expected USD. For the id, read currency_id.
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.
Modules gate that branch with is_array($params['card_storage'] ?? null), so the key is absent for a new card. Never default it to [].
Return callback_message and the dispatcher echoes it. Redirect a machine caller and the provider treats the notification as failed, then retries.
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.
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
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.