Testing your payment gateway
Unit-test your stateless gateway driver against canned API and webhook payloads without touching the network: prove capability honesty, minor-unit correctness, a fail-closed signing secret, and that you settle through Core's boundary — never in the driver.
On this page
- Why test in isolation
- Step 1: a syntax check with php -l
- Step 2: assert the type and the capabilities
- Step 3: a canned-payload contract harness
- The doubles: a Deposit and its currency config
- Minor units: the zero-decimal trap (pure)
- process() — the checkout-dispatcher contract
- ipn() — verify, store proof, settle exactly once
- Fail closed on a missing or empty signing secret
- refund() — honest by construction
- testConnection() and the sandbox branch
- Integration: the full signed install
- Keep the static safety scanner happy
- Pre-submit checklist
A payment-gateway driver is code Core runs in production against a live processor — it moves real money at checkout, and a wrong branch is not a cosmetic bug: it either takes money and never credits the buyer, or credits a buyer who never paid. You cannot hot-patch it on a running install: the marketplace signs the artifact on approval, and every installed file is hash-checked at load time (see Security & trust model). So the moment to catch a wrong return shape, a forgeable webhook, or a zero-decimal overcharge is before you submit. This article covers how to test a driver in isolation, against canned payloads, so you know it honours the contract every way Core will read it — and, above all, that it settles the way Core requires.
Why test in isolation#
You cannot test a gateway by dropping its folder into a running Core. The registry refuses to load an extension without a signed, entitled gateway_extensions row registered from a real install — three gates that are a distribution concern, deliberately out of the loop when you exercise the class yourself.
That is good news: your driver is a plain, stateless PHP class. In a test you construct it with new Gateway() and call its methods. There is no constructor and no instance state — everything a method needs is passed in as the Deposit (or GatewayCurrency) argument and read per call through the trait helpers (getParam(), isSandbox(), toMinorUnit(), ownConfig()). Nothing depends on the registry, and no payment processor has to be reachable. A focused suite around the class covers exactly what production invokes.
Because AbstractGateway supplies a safe "not supported" default for every method, you can build the driver — and its tests — one method at a time: implement process() and ipn(), test them, declare PaymentCapability::CHARGE; then add refund() and declare REFUND. Every method you have not written yet still returns a well-formed failure.
There is one rule that has no registrar equivalent, and it is the thing your tests exist to protect: a driver never credits balance or marks an invoice paid. In ipn() you verify the money moved, store the proof on $deposit->detail, and call $this->settle($deposit) — nothing else. settle() routes to Core's single boundary (PaymentController::userDataUpdate → PaymentService::finalizeSuccessfulPayment), which is idempotent and row-locked. Your suite proves the driver defers to that seam; it does not — and must not — re-implement crediting.
Step 1: a syntax check with php -l#
Before anything clever, make sure every file parses. Lint the driver and any helper classes in your package:
find your-gateway -name '*.php' -print0 | xargs -0 -n1 php -lphp -l only checks that a file parses — it says nothing about behaviour or the static safety scan the reviewer runs later. Treat a clean lint as the entry ticket, not a pass.
Step 2: assert the type and the capabilities#
Two things Core relies on before it ever calls a payment method: your driver is the right type, and it declares only capabilities it truly implements — and those must match the manifest, because Core reads the manifest to gate the UI without loading your code.
use App\PaymentGateways\GatewayInterface;
use App\PaymentGateways\PaymentCapability;
use Salieno\Payment\Stripe\Gateway;
public function test_driver_is_a_gateway(): void
{
$this->assertInstanceOf(GatewayInterface::class, new Gateway());
}
public function test_capabilities_match_the_manifest(): void
{
$manifest = json_decode(file_get_contents(__DIR__.'/../salieno.json'), true);
$declared = (new Gateway())->capabilities();
sort($manifest['capabilities']);
sort($declared);
$this->assertSame($manifest['capabilities'], $declared); // one source of truth, two files
$this->assertContains(PaymentCapability::CHARGE, $declared); // every gateway must charge
foreach ($declared as $cap) {
$this->assertContains($cap, PaymentCapability::ALL); // no typo'd key survives
}
// A capability you do NOT back must stay hidden — assert it is absent, not shown-and-failing.
$this->assertNotContains(PaymentCapability::RECURRING, $declared); // no off-session auto-billing
$this->assertNotContains(PaymentCapability::HOSTED_FIELDS, $declared); // hosted page, not inline SDK
$this->assertNotContains(PaymentCapability::CRYPTO, $declared); // fiat card processor
}Extending AbstractGateway makes the instanceof check pass for free. The capabilities checks guard a subtler mistake: a typo like 'refunds' instead of 'refund' is silently dropped by Core's PaymentCapability::sanitize(), so the control never appears. Asserting every declared key is in PaymentCapability::ALL catches that, and asserting the manifest array equals capabilities() catches the two drifting apart. The absence assertions matter more here than for a registrar: declaring RECURRING when you have no off-session billing tells Core to hand you automatic renewals you will fail, and declaring REFUND you cannot honour hides the admin's wallet-credit fallback. Declare exactly what you do. See The payment contract for the full capability-to-control map, and pair each capability you declare with a happy-path test of the method behind it.
Step 3: a canned-payload contract harness#
The heart of your suite feeds the driver canned payloads and asserts each method returns the exact shape Core reads. How you fake the network depends on how your driver talks to the processor:
- A REST gateway that calls through the trait's `http()` helper issues every request through Laravel's
Httpfacade, soHttp::fake()intercepts them all — exactly as a registrar driver does. You never touch a real API. - A gateway built on a vendor SDK (like the Stripe and PayPal reference drivers) makes its API calls through the SDK's own client, which
Http::fake()does not see. But the two things you most need to test are still fully offline: the SDK's webhook verification is a local HMAC (no network at all), and the amount/verification arithmetic belongs in small pure helpers you call directly. The live create-order / create-session call is deferred to the integration pass.
Run these inside Core's test suite, extending Core's base TestCase, so the app key, the Http facade and the __() translator are bootstrapped.
The doubles: a Deposit and its currency config#
Two lightweight doubles carry everything the stateless driver reads: a GatewayCurrency holding the decrypted credentials and the mode, and a Deposit carrying the money model. getParam() reads the credential store, so set it as the decrypted array — Core's encrypted:object cast handles encryption at rest:
use App\Models\Deposit;
use App\Models\GatewayCurrency;
use Tests\TestCase;
class StripeContractTest extends TestCase
{
/** A configured sandbox connection profile — the context every helper reads credentials from. */
private function config(array $overrides = []): GatewayCurrency
{
$gc = new GatewayCurrency();
$gc->gateway_parameter = array_merge([
'secret_key' => 'sk_test_123',
'webhook_secret' => 'whsec_test_123',
'mode' => 'sandbox', // -> $this->isSandbox($gc) is true
], $overrides);
return $gc;
}
/** A Deposit carrying the money model the driver charges against. */
private function deposit(string $currency = 'USD', float $amount = 10.00): Deposit
{
$deposit = new Deposit();
$deposit->trx = 'TRX123'; // your idempotency key
$deposit->final_amount = $amount; // the amount to charge, in method_currency
$deposit->method_currency = $currency;
$deposit->success_url = 'https://demo.test/ok';
$deposit->failed_url = 'https://demo.test/no';
return $deposit;
}
}Where the credentials come from at run time.process(),refund()andtestConnection()resolve credentials throughgetParam($ctx, …). When$ctxis aGatewayCurrency(as intestConnection) the read is direct and needs no database. When$ctxis aDeposit,getParam()calls$deposit->gatewayCurrency(), a live lookup — so a fullprocess()happy path needs a saved config row (RefreshDatabase), while the fail-closed branches bail before any charge and can run against the bare doubles above.
Minor units: the zero-decimal trap (pure)#
This is the highest-signal offline test you can write, and the one most drivers get wrong. toMinorUnit() must NOT multiply a zero-decimal currency (JPY, KRW, VND, HUF, …) by 100 — doing so overcharges the buyer 100×. Expose the amount helper on a tiny test subclass and pin both sides:
class TestableStripe extends \Salieno\Payment\Stripe\Gateway
{
public function minor(float $amount, string $currency): int { return $this->toMinorUnit($amount, $currency); }
public function major(int $minor, string $currency): float { return $this->fromMinorUnit($minor, $currency); }
}
public function test_minor_units_respect_zero_decimal_currencies(): void
{
$g = new TestableStripe();
$this->assertSame(1000, $g->minor(10.00, 'USD')); // 10.00 USD -> 1000 cents
$this->assertSame(1000, $g->minor(1000, 'JPY')); // 1000 JPY -> 1000 (NOT 100000)
$this->assertSame(1000.0, $g->major(1000, 'JPY')); // and back again, unscaled
}The reference Stripe driver feeds exactly this into Checkout, with the trap called out inline:
// Integer minor unit — zero-decimal currencies (JPY/KRW/VND/…) must NOT be ×100.
'unit_amount' => $this->toMinorUnit((float) $deposit->final_amount, (string) $deposit->method_currency),A gateway whose API wants a decimal string rather than an integer minor unit has the mirror problem — PayPal rejects "100.00" for a zero-decimal currency. The reference PayPal driver keeps that in a pure formatAmount() helper for the same reason: so you can unit-test it without a network call.
protected function formatAmount(float $amount, string $currency): string
{
$decimals = in_array(strtoupper($currency), self::ZERO_DECIMAL_CURRENCIES, true) ? 0 : 2;
return number_format($amount, $decimals, '.', '');
}process() — the checkout-dispatcher contract#
process(Deposit) starts a payment and returns the dispatcher contract as an array. The simplest and most common shape is a hosted-page redirect. Two things to test: it fails closed to a clean error when it cannot start, and on success it returns the redirect shape and stashes the gateway's own reference so the callback can find this attempt.
The fail-closed branch is offline — with no secret configured the driver never reaches the API:
public function test_process_fails_closed_without_a_secret_key(): void
{
$result = (new Gateway())->process($this->deposit()); // no config row -> no secret
$this->assertTrue($result['error'] ?? false); // ['error'=>true,'message'=>…]
$this->assertIsString($result['message']);
}That mirrors the driver's own guard, which returns the abort envelope rather than throwing:
$secret = $this->secretKey($deposit);
if (! $secret) {
return $this->failCheckout(__('Stripe is not configured (missing secret key).'));
}For the happy path, if your gateway calls its REST API through http(), Http::fake() the create-session response and assert the return array is ['redirect' => true, 'redirect_url' => 'https://…'] and that $deposit->btc_wallet now holds the processor's reference. For an SDK-based gateway the create call reaches the sandbox, so that assertion belongs to the integration pass below — but note the contract you are aiming for either way:
// Remember the session id so the webhook can resolve this exact attempt.
$deposit->btc_wallet = $session->id;
$deposit->save();
return $this->redirectTo($session->url); // ['redirect' => true, 'redirect_url' => …]Note the idempotency key on the create call ('idempotency_key' => $deposit->trx): retrying process() for the same attempt returns the same session, never a second charge. Use $deposit->trx — your stable idempotency key — anywhere the processor accepts one.
ipn() — verify, store proof, settle exactly once#
This is the method that moves money, and the centrepiece of the suite. The SDK's webhook verification runs entirely locally, so you can sign a canned event with a test secret and drive ipn() end to end with no network. Override settle() on a spy subclass so the test asserts the driver routes through the seam without booting Core's full settlement machinery, and override webhookSecret() to inject the same secret you sign with (bypassing the ownConfig() lookup):
class SpyStripe extends \Salieno\Payment\Stripe\Gateway
{
public array $settled = [];
public ?string $secret = 'whsec_test_123';
protected function webhookSecret(): ?string { return $this->secret; }
protected function settle(Deposit $deposit): void { $this->settled[] = $deposit->id; }
}
/** t=<ts>,v1=<hmac> — the exact header Stripe sends, computed locally. */
private function sign(string $payload, string $secret): string
{
$t = time();
return 't='.$t.',v1='.hash_hmac('sha256', $t.'.'.$payload, $secret);
}Because ipn() resolves the deposit (Deposit::find(...)) and calls $deposit->save(), this happy-path test needs the database (RefreshDatabase) and a saved Deposit whose id the event carries in its metadata:
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
public function test_a_signed_webhook_stores_proof_and_settles(): void
{
$deposit = Deposit::factory()->create([
'status' => \App\Constants\Status::PAYMENT_PENDING,
'trx' => 'TRX123',
]);
$payload = json_encode([
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_test_1',
'payment_status' => 'paid',
'metadata' => ['deposit_id' => $deposit->id, 'trx' => 'TRX123'],
]],
]);
$gw = new SpyStripe();
$request = Request::create('/client/ipn/stripe', 'POST', [], [], [], [
'HTTP_STRIPE_SIGNATURE' => $this->sign($payload, 'whsec_test_123'),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response = $gw->ipn($request);
$this->assertSame(200, $response->getStatusCode()); // ipnAck()
$this->assertNotNull($deposit->fresh()->detail); // proof stored on the deposit
$this->assertSame([$deposit->id], $gw->settled); // routed through Core's seam
}The driver only settles when the money is genuinely captured and the deposit is still open — checkout.session.completed also fires for unpaid async sessions, so the reference guards on the payment status:
$paid = in_array($session->payment_status ?? '', ['paid', 'no_payment_required'], true)
|| $event->type === 'checkout.session.async_payment_succeeded';
if ($paid && in_array((int) $deposit->status, [Status::PAYMENT_INITIATE, Status::PAYMENT_PENDING], true)) {
$deposit->detail = $session; // proof of payment (carries payment_intent for refunds)
$deposit->save();
$this->settle($deposit); // Core's idempotent, row-locked settlement boundary
}On "settle exactly once." Exactly-once is Core's guarantee, not yours. A webhook and the buyer's browser-return commonly both arrive for the same payment; both call settle(), and Core credits once because that boundary is idempotent and row-locked. Do not build your own dedup and do not assert on a call count as your idempotency proof — assert that the driver stores the verified proof and hands off to settle(), and never credits balance or marks an invoice paid itself. The PAYMENT_INITIATE/PAYMENT_PENDING status guard above is belt-and-braces, not the mechanism.
Fail closed on a missing or empty signing secret#
An empty signing secret is not "unconfigured but harmless" — it is forgeable. Verifying an HMAC with an empty key means any attacker-crafted body validates, and the driver would settle a deposit that was never paid. The reference checks the secret before it verifies, and rejects when it is missing or empty. Prove both the empty-secret and the missing-signature paths, and prove no settlement happens:
public function test_empty_signing_secret_is_rejected_and_never_settles(): void
{
$gw = new SpyStripe();
$gw->secret = ''; // empty-key HMAC would validate anything
$payload = '{"type":"checkout.session.completed"}';
$request = Request::create('/client/ipn/stripe', 'POST', [], [], [], [
'HTTP_STRIPE_SIGNATURE' => $this->sign($payload, ''),
'CONTENT_TYPE' => 'application/json',
], $payload);
$response = $gw->ipn($request);
$this->assertSame(400, $response->getStatusCode()); // ipnReject()
$this->assertSame([], $gw->settled); // nothing settled
}
public function test_a_webhook_with_no_signature_header_is_rejected(): void
{
$request = Request::create('/client/ipn/stripe', 'POST', [], [], [], [], '{}');
$this->assertSame(400, (new Gateway())->ipn($request)->getStatusCode());
}Both map to the driver's opening guards — note that these run before any deposit lookup, so they need no database:
$sig = $request->header('Stripe-Signature');
if (! $sig) {
return $this->ipnReject('No signature header');
}
// Fail closed: an empty signing secret would validate an attacker-forged event (empty-key HMAC),
// settling any deposit without payment.
$secret = $this->webhookSecret();
if (! $secret) {
return $this->ipnReject('Webhook signing secret is not configured');
}An unverifiable or forged event must never return 200. ipnReject() answers a non-2xx so the processor keeps the event pending rather than treating it as accepted, and ipnAck() is reserved for events you have genuinely handled. Confirm too that a malformed payload or a bad signature decodes to a clean reject, not a fatal — the reference catches UnexpectedValueException and SignatureVerificationException and returns ipnReject() for each.
refund() — honest by construction#
refund(Deposit, float) is only ever called when you declare PaymentCapability::REFUND, and it must be honest: return success => true only when the gateway actually accepted the reversal. The reference returns success solely on a succeeded/pending (Stripe) or COMPLETED/PENDING (PayPal) status, and error() on anything else or any thrown failure. Two things are cheaply unit-testable offline: the amount goes out in the right minor unit, and the driver fails closed when it cannot find the processor's reference on the original payment. The reference id extraction reads only $deposit->detail, so no database is needed:
public function test_refund_needs_a_capture_reference_on_the_original_payment(): void
{
$deposit = $this->deposit();
$deposit->detail = (object) []; // no payment_intent / capture id stored
$result = (new Gateway())->refund($deposit, 5.00);
$this->assertFalse($result['success']);
$this->assertStringContainsString('reference', $result['message']); // actionable, not a stack trace
}The live reversal — success => true with a real refund_id, and a partial amount that respects the zero-decimal rule (toMinorUnit($amount, …)) — is proven against the sandbox in the integration pass, because it is the processor, not your code, that decides whether a refund is accepted.
testConnection() and the sandbox branch#
The admin "Test connection" button calls testConnection(GatewayCurrency). Because it takes a GatewayCurrency directly, the credential-missing branch is a clean offline test, and it must surface a clear, verbatim reason rather than a generic failure:
public function test_connection_refuses_empty_credentials_with_guidance(): void
{
$result = (new Gateway())->testConnection($this->config(['secret_key' => '']));
$this->assertFalse($result['success']);
$this->assertStringContainsString('secret key', $result['message']); // tells the operator what to fix
}And assert the sandbox switch resolves the way the config says, since every off-site call keys off it:
public function test_mode_resolves_the_sandbox_flag(): void
{
$g = new TestableStripe();
$this->assertTrue($g->isSandboxFor($this->config(['mode' => 'sandbox'])));
$this->assertFalse($g->isSandboxFor($this->config(['mode' => 'live'])));
}The valid-credentials path reaches the processor (->balance->retrieve() for Stripe, an access-token fetch for PayPal), so exercise it against the sandbox first — never point a fresh profile at the live API before a green probe.
Integration: the full signed install#
Unit tests prove the contract; one end-to-end pass proves the plumbing. A copied folder will not run — it is unsigned — so go through the real path on a test install:
- Submit the package to marketplace.salieno.com; on approval it is signed (see Publishing your gateway).
- In admin, open Payments → Gateway Extensions and install it. The install verifies the signature against Core's pinned key, registers the driver, and auto-adds a config row under Auto Gateways seeded from your manifest's
credentialslist. - Open that config, fill in the credentials Core rendered from your manifest (see Credentials & the config form), set the mode to sandbox, and click Test connection.
- With a green probe, take a sandbox payment end to end:
process()should redirect to the hosted page, and the buyer's return plus the webhook should both land on/client/ipn/<slug>and settle the deposit exactly once. Confirm the balance moves once, and that a second redelivery of the webhook changes nothing. - Register the webhook endpoint at the processor and configure its signing secret as a global credential — then confirm an out-of-band event (a dashboard refund, an async success) is verified and reconciled.
Two payment-specific behaviours to confirm while you are here. Toggle the gateway's status off in admin (the operator's containment lever for leaked credentials): checkout and the callback must both resolve to NullGateway — a disabled gateway settles nothing. And confirm that a confirmed callback still settles even if you simulate a lapsed licence: on an inbound webhook the entitlement gate is lenient by design (a paid buyer must be credited), while signature verification stays strict.
Do the live exercise against the sandbox first; only then a single real payment on the live API.
Keep the static safety scanner happy#
When you submit, the marketplace runs a static safety scan before a human reviews. It flags dynamic code-execution patterns — the shell and eval family. A gateway driver never needs any of them: it makes API calls through http() or a vendor SDK and parses the response (JSON and the SDK's own decoders, all fine). If the scanner finds a flagged token, expect a rejection.
The gotcha is false positives. The scan matches token patterns, not reachability — a flagged name immediately followed by an open paren can trip it even inside a comment or a string literal. Keep those tokens out of comments and strings; describe them in words rather than writing the name-and-paren. php -l will happily parse code the scanner rejects, so pass both. More on what the reviewer checks is in Security & trust model.
Pre-submit checklist#
Before you package and submit:
php -lis clean on every.phpfile in the package.- The driver is
instanceof GatewayInterface,capabilities()returns only keys inPaymentCapability::ALL, includesCHARGE, and equals the manifest'scapabilitiesarray. - A capability you do not implement is absent from
capabilities()and its control stays hidden — especiallyRECURRINGandREFUND. toMinorUnit()/formatAmount()do not scale zero-decimal currencies (JPY/KRW/VND/HUF/…); proven with a unit test.process()returns a clean abort envelope (['error' => true, …]) when it cannot start, and the redirect shape (['redirect' => true, 'redirect_url' => …]) plus a stashed reference on success.ipn()verifies authenticity, stores proof on$deposit->detail, and settles only through$this->settle($deposit)— the driver never credits balance or marks an invoice paid.- A missing signature header and an empty signing secret both reject with a non-2xx and settle nothing.
refund()returnssuccess => trueonly when the gateway accepted it, and fails closed with an actionable message when the reference is missing.testConnection()surfaces the real credential error verbatim, and the sandbox branch is proven to resolve from the config.- No shell/eval token appears anywhere in the package, comments and strings included.
With those green, your driver behaves the same in every surface Core renders from it, and — most importantly — it never moves money except through the one boundary that is safe to. The remaining steps — signing, entitlement, install — are the marketplace's job, covered in Publishing your gateway.