Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Payment Gateways

Community payment gateway modules for the billing system. Each gateway is a self-contained folder that drops into a panel's modules/payment_gateways/ directory and adds a new checkout option (Stripe, Razorpay, PayPal, and anything you build).

This repo is open to contributions. Fork it, add your gateway as a new folder, and open a pull request. Merged gateways become available to every operator running the platform.


Quick Start

# 1. Fork this repo on GitHub, then clone your fork
git clone https://github.com/<you>/payment-gateways.git
cd payment-gateways

# 2. Copy the template to your gateway's slug
cp -r _example acme_pay

# 3. Build it out
#    - edit acme_pay/manifest.json   (slug, class FQN, currencies)
#    - edit acme_pay/src/*.php        (rename class + namespace, implement methods)
#    - edit acme_pay/frontend/*.js    (browser SDK bridge)

# 4. Open a PR back to this repo
git checkout -b gateway/acme_pay
git add acme_pay
git commit -m "feat: add Acme Pay gateway"
git push origin gateway/acme_pay

Then open a pull request. See Contributing for the review checklist.


Repo Layout

payment-gateways/            # this repo == a panel's modules/payment_gateways/ dir
    _example/                # bare template - copy this to start a new gateway
    _stripe/                 # reference: SDK + client-secret + signed webhook
    _razorpay/               # reference: hosted modal + returned-signature capture
    _paypal/                 # reference: pure-HTTP (no SDK) + API webhook verify
    acme_pay/                # ← your PR adds a folder like this
    README.md                # this file

Folders prefixed with _ are not auto-discovered — they are templates and reference implementations to study and copy, not live gateways. Your own gateway folder must NOT start with an underscore.

Stripe, Razorpay, and PayPal also ship built into the platform core (app/PaymentGateways/Drivers/); the _stripe/_razorpay/_paypal folders here are module-format mirrors of those drivers, provided purely as worked examples.

A single gateway folder:

<your_slug>/
    manifest.json            # Required - module metadata
    src/
        YourGateway.php      # Required - PHP gateway class
    frontend/
        your_gateway.js      # Required - browser-side SDK bridge
    README.md                # Recommended - credentials + webhook setup

The folder name (slug) must be lowercase, contain only letters, digits, and underscores, and match the slug field in manifest.json.


Installing & Testing Against a Panel

To test your gateway you need a running install of the platform. Clone this repo into its modules/payment_gateways/ directory (or symlink your single folder there):

# inside a panel install
git clone https://github.com/<you>/payment-gateways.git modules/payment_gateways

Then:

  1. In the admin panel, open Billing → Payment Gateways and click Re-discover Modules. Your gateway appears in the list.
  2. Click Configure, enter sandbox/test credentials, set Mode to Sandbox, enable.
  3. Open a test user account, add funds, complete a checkout with your gateway.
  4. Send a test webhook with your provider's testing tool or curl:
curl -X POST https://your-panel.test/billing/webhook/your_slug \
     -H "Content-Type: application/json" \
     -H "X-Example-Signature: $(echo -n '{"type":"payment.succeeded"}' | openssl dgst -sha256 -hmac 'your_secret' | awk '{print $2}')" \
     -d '{"type":"payment.succeeded","id":"evt_test","data":{"transaction_id":"...","payment_id":"..."}}'
  1. If you have a full panel checkout, run the discovery test suite:
vendor/bin/phpunit tests/Feature/Billing/ModuleDiscoveryTest.php

Sandbox vs Live

Switch endpoints on $this->mode ('test' or 'live'), never a config field:

$baseUrl = $this->mode === 'live'
    ? 'https://api.yourprovider.com'
    : 'https://sandbox.yourprovider.com';

Gateway Contract

Manifest Schema

manifest.json must be valid JSON:

Field Type Required Description
slug string yes Unique gateway identifier, lowercase, underscores only
name string yes Human-readable display name
version string yes SemVer string, e.g. "1.0.0"
author string yes Author or organisation name
gateway_class string yes Fully-qualified PHP class name
autoload string yes Path to PHP file, relative to manifest directory
frontend_js string yes Path to JS file, relative to manifest directory
supports array yes ISO 4217 currency codes your gateway accepts
supports_refunds bool no true if refund() is implemented (default false)
{
    "slug": "acme_pay",
    "name": "Acme Pay",
    "version": "1.0.0",
    "author": "Acme Corp",
    "gateway_class": "Modules\\PaymentGateways\\AcmePay\\AcmePayGateway",
    "autoload": "src/AcmePayGateway.php",
    "frontend_js": "frontend/acme_pay.js",
    "supports": ["USD", "EUR", "GBP"],
    "supports_refunds": true
}

The manager require_onces the autoload file before instantiating your class. That single require_once is the only thing the loader does — it does not run Composer, scan src/, or load any vendor/ directory. A class with no external dependencies needs nothing more.


Composer Dependencies

If your gateway needs third-party packages (a provider SDK, etc.), ship a composer.json and a vendored vendor/ directory inside your module folder, then bootstrap your own autoloader from the top of your autoload entry file. The platform never loads it for you.

acme_pay/
    composer.json
    vendor/              # commit this, or have your CI build it before the PR
        autoload.php
    src/AcmePayGateway.php

At the very top of src/AcmePayGateway.php:

<?php

namespace Modules\PaymentGateways\AcmePay;

// Load this module's own Composer dependencies (provider SDK, etc.).
// The platform require_once's this file but does NOT load module vendors for you.
$vendor = __DIR__ . '/../vendor/autoload.php';
if (is_file($vendor)) {
    require_once $vendor;
}

use App\PaymentGateways\AbstractPaymentGateway;
// ... rest of your class

Rules and caveats

  • Never bundle a package the platform core already ships. PHP cannot hold two versions of the same class — whichever loaded first wins, so your code may silently run against the wrong version. Guzzle, Symfony components, Monolog, the Laravel HTTP client, and the Stripe /Razorpay SDKs are already present; use those instead of vendoring your own copy. Only vendor packages that are unique to your provider.
  • Keep the entry file's top-level code to the autoload require only. The loader runs that require_once on every request, so do no other work at file scope.
  • Commit vendor/ (or build it in CI before opening the PR). There is no composer install step on the panel — the folder is used as-is.
  • Need a conflicting version of a shared package? Isolate it with a prefixer such as php-scoper. This is heavy; only do it when a clash is unavoidable.

PHP Class Contract

Your class must extend App\PaymentGateways\AbstractPaymentGateway and define public const SLUG matching the manifest slug.

Do not define a constructor. The base class constructor receives a App\Models\Billing\PaymentGatewayConfig $row and populates:

  • $this->config — decrypted credentials array (from $row->credentials)
  • $this->mode'test' or 'live' (from $row->mode)
  • $this->row — the raw model row

Required methods

slug(): string — return self::SLUG. Used for registration keying.

name(): string — human-readable gateway name shown in admin and on checkout.

configSchema(): array — field definitions for the admin credentials form:

[
    'key'       => 'api_key',     // array key in $this->config
    'type'      => 'text',        // 'text' | 'password' | 'select' | 'checkbox'
    'label'     => 'API Key',     // shown in admin form
    'required'  => true,          // validated before saving
    'encrypted' => true,          // stored encrypted at rest (use for secrets)
]

The base class validateConfig(array $config): array iterates this schema and checks required fields automatically. Override only for cross-field validation.

createIntent(Transaction $tx): IntentResult — called when checkout begins. Create a payment intent on your provider's API and return the result.

  • $tx->amount_payable — amount in minor units (e.g. cents)
  • $tx->currency->code — ISO 4217 currency code
  • $tx->id — embed in provider metadata so webhooks can resolve it
new IntentResult(
    pgIntentId: string,        // provider's intent/session ID
    clientSecret: ?string,     // browser token (null if unused)
    frontendExtra: array,      // extra keys merged into frontendPayload result
);

frontendPayload(Transaction $tx, IntentResult $intent): array — object passed to your JS mount(). Include only publishable/safe values, never API secrets.

captureAndVerify(Transaction $tx, array $clientPayload): CaptureResult — called server-side after the browser flow. $clientPayload is whatever your JS passed to onSuccess({...}).

Security: $clientPayload comes from the browser and is attacker-controllable. Never treat it as proof of payment. Independently confirm with the provider — verify a returned signature (Razorpay HMACs order_id|payment_id) or re-fetch/capture via the provider API (Stripe, PayPal) — and populate paidAmountMinor/status/paidCurrency from the provider's response, not from $clientPayload or $tx. On a declined or unverifiable payment, return CaptureResult(status: 'failed', ...) rather than throwing. Return 'requires_action' when the payment needs 3-D Secure / SCA. Gate credential access with requireConfig(...) so an unconfigured gateway surfaces a clear GatewayException instead of a PHP fatal.

new CaptureResult(
    pgPaymentId: string,       // provider's payment reference ID
    status: string,            // 'succeeded' | 'failed' | 'requires_action'
    rawResponse: array,        // full API response for audit logging
    paidAmountMinor: int,      // amount confirmed paid, minor units
    paidCurrency: string,      // ISO 4217 currency code
    pgFeeMinor: ?int,          // provider processing fee, minor units (null if unknown)
);

verifyWebhookSignature(Request $request): bool — validate the HMAC/signature on an incoming webhook. Return false to reject with a 401; parseWebhookEvent() is not called when this returns false.

$sig      = $request->header('X-Your-Signature');
$secret   = $this->config['webhook_secret'] ?? null;
$expected = hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($expected, $sig);

Always use hash_equals() for constant-time comparison. Read the raw body with $request->getContent() before any JSON decode — some providers require exact raw bytes.

parseWebhookEvent(Request $request): WebhookEvent — parse the verified body:

new WebhookEvent(
    pgEventId: string,         // provider's unique event ID (used for idempotency)
    eventType: string,         // provider-native event type string
    paymentStatus: string,     // 'succeeded' | 'failed' | 'pending' | 'refunded'
    transactionId: ?string,    // local transaction UUID from metadata (null if missing)
    pgPaymentId: ?string,      // provider's payment reference ID
    rawPayload: array,         // decoded webhook body for audit logging
);

Map your provider's event type strings to the standard paymentStatus values with a match expression (see _example/src/ExampleGateway.php::parseWebhookEvent()).

Optional methods

supportsRefunds(): bool — return true if you implement refund(). The admin panel checks this before showing the refund button. Set together with manifest.supports_refunds.

refund(Transaction $tx, int $amountMinor, string $reason): RefundResult — the base class throws NotSupportedException by default. Override to implement. Partial refunds work by passing $amountMinor less than the captured amount — validate it does not exceed:

new RefundResult(
    pgRefundId: string,            // provider's refund reference ID
    status: string,                // 'succeeded' | 'failed' | 'pending'
    refundedAmountMinor: int,      // amount refunded, minor units
    rawResponse: array,            // full API response for audit logging
    pgRefundFeeMinor: ?int,        // provider refund fee, minor units (null if unknown)
);

Return status: 'pending' for async refunds confirmed later via webhook.

Helper methods (from the base class)

  • $this->minorToMajor(int $minor, string $currency): float
  • $this->majorToMinor(float $major, string $currency): int
  • $this->requireConfig(string $key): mixed — fetch a credential or throw GatewayException
  • $this->logEvent(string $level, string $msg, array $ctx = []): void — writes to the billing_gateways log channel with your slug prepended

JavaScript SDK Contract

Register your gateway under window.billingGateways:

window.billingGateways = window.billingGateways || {};
window.billingGateways['your_slug'] = {
    async mount(payload, onSuccess, onError) {
        // render your widget into <div id="billing-gateway-mount">
    }
};
Parameter Type Description
payload object Return value of frontendPayload() from the server
onSuccess function Call with a plain object when the user authorises payment
onError function Call with a human-readable error string on failure

The checkout page provides <div id="billing-gateway-mount"> inside the payment form. Render your provider's widget there. On completion:

onSuccess({ payment_id: 'PROVIDER_PAYMENT_ID', /* any keys captureAndVerify needs */ });

The keys you pass become the $clientPayload array in captureAndVerify(). Design both sides together. On any failure (declined, network, cancel):

onError('Human-readable message shown to the user');

Load a hosted provider SDK dynamically inside mount(), wrapped in try-catch:

const script = document.createElement('script');
script.src   = 'https://sdk.yourprovider.com/v1/checkout.js';
document.head.appendChild(script);
await new Promise((resolve, reject) => {
    script.onload  = resolve;
    script.onerror = () => reject(new Error('Failed to load payment SDK'));
});

Webhook Signature Patterns

HMAC-SHA256 (most common)

public function verifyWebhookSignature(Request $request): bool
{
    $sig    = $request->header('X-Your-Signature');
    $secret = $this->config['webhook_secret'] ?? null;
    if (!$sig || !$secret) {
        return false;
    }
    $expected = hash_hmac('sha256', $request->getContent(), $secret);
    return hash_equals($expected, $sig);
}

Timestamp + HMAC (replay prevention) — validate the timestamp is within an acceptable window (e.g. 5 minutes) before verifying the HMAC:

public function verifyWebhookSignature(Request $request): bool
{
    $header = $request->header('X-Your-Signature') ?? '';
    $parts  = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v]   = explode('=', $part, 2) + [1 => ''];
        $parts[$k] = $v;
    }
    $timestamp = (int) ($parts['t'] ?? 0);
    if (abs(time() - $timestamp) > 300) {
        return false; // replay window exceeded
    }
    $secret   = $this->config['webhook_secret'] ?? null;
    $payload  = $timestamp . '.' . $request->getContent();
    $expected = hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}

RSA signature verification — for providers using asymmetric signatures:

$pubKey    = $this->config['webhook_public_key'];
$signature = base64_decode($request->header('X-Signature'));
$result    = openssl_verify($request->getContent(), $signature, $pubKey, OPENSSL_ALGO_SHA256);
return $result === 1;

Reference Implementations

Three complete, module-format reference gateways ship in this repo. Each demonstrates a different real-world integration shape — read whichever is closest to your provider, then copy _example/ (the bare template) to start your own:

Folder Provider Pattern it demonstrates
_stripe/ Stripe Vendor SDK, client-secret, signed-payload webhook, processing-fee capture
_razorpay/ Razorpay Hosted checkout modal, returned-signature verification (no client secret)
_paypal/ PayPal Pure HTTP (no SDK), OAuth, major-unit amounts, API-based webhook verification

These mirror the drivers built into the platform core (app/PaymentGateways/Drivers/). Patterns they all follow that are easy to miss:

  • Never trust the browser in captureAndVerify() — confirm server-side, read amounts from the provider response.
  • Guard every credential access with requireConfig(...) — clear error over PHP fatal.
  • requires_action is a real status, not an error — return it for 3-D Secure / SCA.
  • Intent id != payment idpgIntentId is stored as pg_intent_id; the captured pgPaymentId is stored as pg_payment_id and is what refund() operates on.
  • Mode comes from $this->mode, not a config field.
  • Webhooks dedupe on pgEventId — return the provider's stable event id.

Contributing

  1. Fork this repo and create a branch: gateway/<your_slug>.
  2. Copy _example/ to <your_slug>/ and build it out.
  3. Write a README.md in your folder covering the credentials required and webhook setup steps.
  4. Test end-to-end against a panel install in sandbox mode (see Installing & Testing).
  5. Open a PR. One gateway per PR.

PRs are reviewed for:

  • Correct implementation of all required interface methods
  • Secure webhook signature verification (constant-time comparison, raw-body read)
  • No leaking of API secrets to the frontend (frontendPayload() is publishable-only)
  • requireConfig() guarding every credential access
  • Sandbox/test mode support via $this->mode
  • At least one supported currency confirmed working end-to-end

Reporting issues

Open a GitHub issue for bugs in a gateway or the contract. For security-sensitive reports (a gateway leaking secrets, a signature bypass), do not open a public issue — email the maintainers instead.

License

See LICENSE.

About

This repository serves as a template for building a third-party payment gateway integration that drops into the billing system.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages