# Payment Gateways

**Portal:** Manage · **Nav:** Setting (pinned sidebar footer) → **Payment Gateways** — one tab in the Setting hub, beside Roles and Devices. It is deliberately NOT in the `payments` strip (Payment History / Links / Unreconciled): how the platform is *allowed* to collect money is configuration; what it actually collected is sales data. · **Permission:** `view-integrations` (read) / `manage-integrations` (write) — deliberately the *integrations* pair, like AI Providers and Zoom, **not** the sales pair, because this page holds API keys rather than sales data.

## What it does

Stores the credentials this system uses to collect money — **encrypted in the database, never in `.env`**. There are no payment keys in the environment file at all: `config/services.php` carries only a pointer comment. The keys an operator can see and rotate on this page are the keys the app actually uses.

One row per provider (`payment_gateways`), each holding **that provider's own key set** as an encrypted JSON blob — Stripe needs `secret` + `webhook_secret`, PayEx needs `secret` + `base_url`, a future provider needs whatever it needs. **Adding a gateway never means adding columns.**

## How it works

- **Catalogue-driven UI.** `PaymentGateway::PROVIDERS` declares each provider's label, blurb, capability flags and **field spec** (`label`, `secret`, `hint`, `required`). One generic `GatewayCard.vue` and one dynamic Form Request render every provider — so a new gateway needs no new UI code.
- **Secrets are write-only.** `credentials` is `encrypted:array` and listed in `$hidden`, so it can never reach an Inertia prop. The page reads `credentialHints()`, which is per-field rather than blanket: a **secret** field comes back as *is it set* + *last four* and nothing else, while a **non-secret** field (a base URL, a merchant id) comes back with its real value and renders as an ordinary editable input — a setting you can see but never read is one you cannot correct. Nothing marked `secret: true` ever leaves the server. A configured secret renders as `••••••••abcd` with a **Replace** button; submitting a blank value keeps the stored one, and the literal `__clear__` removes it. The input is a masked text field (not `type=password`) with `data-lpignore`/`data-1p-ignore`, so password managers stop offering to save API keys.
- **Always save, then verify.** A key can be well-formed and still be refused (a restricted key missing a permission), so the save never blocks on the probe: the row is written, then a smallest-possible authenticated call runs and its outcome is stamped on `verified_at`. A failed probe reads as a **status on the card**, not as a save error. `Test connection` re-runs it on demand.
⚠️ **The probe is a capability, not a guarantee.** The reachability check `isConfigured()` is declared on `HostedLinkGateway`, not on the base `PaymentGateway` contract — which declares `checkout()` and nothing else. So `probe()` returns **null** for any driver that does not implement it, and null means *"not testable"*, never *"verified"*: `verified_at` is left alone rather than stamped on faith, `Test connection` answers *"… has no connection test — its credentials are checked when it first takes a payment"*, and the **Payments** check on System Health parks that gateway at **Warning** for as long as it stays enabled. A second gateway that only opens one-shot checkouts therefore needs its own smallest-possible authenticated call promoted onto the base contract, or it will read as half-broken forever.
- **Enabling is guarded.** A gateway cannot be enabled while a required credential is missing — enabling an unusable gateway would take checkouts to a dead end.
- **A provider can be catalogued but NOT available.** `PROVIDERS[...]['available'] => false` lists the provider with a `Lock` icon, an `unavailable_note` explaining why, and **no fields to fill in**. It is refused at every layer, not just hidden in the UI: `PaymentGateway::isUsable()`, `GatewayManager::assertAvailable()` (called from `driver()` and `probeDriver()`), a **403** on the credential write, and the provider's own webhook/callback (`isUsable()` → **503**). Listed rather than removed on purpose — a provider that is simply absent reads as a missing feature, one shown with a reason reads as a decision.
  - **PayEx / EzBeli is currently `available: false`.** Its driver has never run a real transaction and its callback signature scheme was taken from documentation rather than verified against a live callback — so a forged callback could in principle grant a membership. Flip it to `true` after **one sandbox transaction** proves the scheme; `PayExCallbackTest`'s live-behaviour cases skip themselves until then and come back automatically.
- **Exactly one default.** `is_default` is what new checkouts use when a payment link names no provider; promoting one demotes the rest in the same transaction.
- `defaultConfig()` — what new checkouts resolve to: the usable gateway flagged `is_default`, **else the first usable one**. A single-gateway install therefore works before anyone ticks Default, and because the shortlist is filtered through `isUsable()` first, a leftover row for an unavailable provider can never inherit the slot.
- `flush()` — `driver()` memoises per request, so the credential save calls `flush()` before probing; without it the verify step would test the keys the request *started* with rather than the ones it just wrote. `probeDriver()` is deliberately never memoised, and deliberately requires the keys but **not** `is_enabled` — otherwise an operator could never verify a key before switching the gateway on, which is exactly the order they need to work in.
- **The card teaches, not just collects.** Each provider's catalogue entry carries a `setup` block (`intro` + numbered `steps`, each with an optional external `link`), rendered by `GatewayCard` as a collapsible *"Where do I find these keys?"* walkthrough — **collapsed by default**, on a blank card and a configured one alike. The card's job is the form; a wall of steps standing above it reads as work to do rather than as a reference to open when stuck. It lives in the catalogue rather than the component for the same reason the field spec does: **a new provider ships its own guide with no new UI code.** Stripe's steps cover creating a named **standard** Secret key, the webhook endpoint, its signing secret, saving + testing, and adopting pre-existing dashboard links. A restricted key scoped to just this driver's three resources (Checkout Sessions=Write, Payment Links=Write, PaymentIntents=Read) also works and is tighter — but the guide deliberately does **not** offer it as a choice: the operator asked for one path, and a scoped key has to be widened by hand every time a new Stripe call is added here. `PaymentGatewayBindingTest` pins the structure.

A step may also carry **`code`** — an array of literal values the operator must type at the provider verbatim, rendered as mono chips below the prose rather than buried inside it. Stripe's webhook step uses it for the three event names, and that is the whole point: a value that has to be copied character-for-character should never have to be picked out of a sentence.
- **The webhook URL is shown, not remembered.** An **available** card prints the URL to register at the provider (`/webhooks/stripe`) with a copy button, plus — for Stripe — the exact three event types to subscribe. ⚠️ An unavailable card prints nothing but its lock note: the webhook block, the fields, the toggles and both buttons all live inside the `v-else`, so `/webhooks/payex` exists in `GatewayManager::webhookUrl()` and rides in the catalogue payload but is never shown.

### The registry (why this is genuinely multi-gateway)

[`App\Services\Payment\GatewayManager`](/app/Services/Payment/GatewayManager.php) resolves a slug to a driver built from the stored row:

- `driver(?string $provider)` — the named provider, or the default. **An unknown slug throws** rather than falling back, so a typo can never quietly route real money to a different merchant account.
- `slug(?string $provider)` — what a purchase records as its provider, taken from the driver that will actually collect (never a config guess).
- `catalogue()` — the settings page payload; contains no credential values by construction.

Drivers are built through a static `fromConfig(PaymentGateway $config)` factory, so credentials arrive by injection and no driver reads config.

**Adding a provider** = a driver class implementing [`PaymentGateway`](/src/Payment/Gateways/PaymentGateway.php) (+ optionally [`HostedLinkGateway`](/src/Payment/Gateways/HostedLinkGateway.php)), one `DRIVERS` entry and one `PROVIDERS` catalogue entry — plus three things outside the registry that are easy to miss because nothing fails loudly without them: a **`webhookUrl()` match arm** (an unlisted slug falls to `default => null` and the card silently omits the whole webhook block), the **callback route + controller** that URL points at (`routes/main.php`), and a **`PurchaseHistory::PROVIDERS` entry** so the ledger can name the provider it just recorded. Still no migration.

### Capabilities are interfaces, not stubs

⚠️ …but the flag the UI gates on is **not** read from the driver. `supports_hosted_links` is a hand-written catalogue field surfaced as `$gateway->supports_hosted_links`, and nothing reconciles it against what the class actually implements. Write the flag and forget the interface, and the operator gets a *Create at gateway* button that dies at the last step with *"This gateway does not support hosted payment links."*; implement the interface and forget the flag, and the feature is simply invisible. Whichever one you write, write the other in the same commit — the binding test asserts the interface on the classes, not the flag against it.

`HostedLinkGateway` is an **optional** capability for providers that host durable reusable links of their own (Stripe Payment Links). Stripe implements it; PayEx does not — and is therefore not forced to stub methods it will never support. Callers check `instanceof HostedLinkGateway` before offering the feature, and the Payments tab hides link creation when the default gateway lacks it.

## Config

| Key | Where |
|---|---|
| Stripe `secret`, `webhook_secret` | This page |
| PayEx `secret`, `base_url` | Nowhere yet — catalogued but `available: false`, so the card shows the reason and no fields; a write posted anyway is **403** |
| Which gateway is default | This page (`is_default`) |
| Whether a gateway may collect | This page (`is_enabled`) |

⚠️ **A fresh deploy starts with no gateway configured and cannot take money.** That silence is exactly what the **Payments** check on [System Health](/docs/modules_handbook/manage/system-health/readMe.md) breaks: it reports **Down** when no gateway is both enabled and fully configured, and **Warning** when one is live but was never verified.

## Related files

**Backend**
- `src/Payment/PaymentGateway.php` — the model + `PROVIDERS` catalogue (⚠️ not to be confused with `Src\Payment\Gateways\PaymentGateway`, the driver *contract*)
- `src/Payment/Repositories/PaymentGatewayRepository.php` (+ facade) — `save()` (write-only merge) and `markVerified()`
- `app/Services/Payment/GatewayManager.php` — the driver registry
- `src/Payment/Gateways/{PaymentGateway,HostedLinkGateway,PaymentContext,StripeGateway,PayExGateway}.php`
- `app/Http/Controllers/Manage/Payment/PaymentGatewaysController.php`
- `app/Http/Requests/Manage/Payment/Gateways/UpdateRequest.php`
- `src/Common/Services/SystemHealthService.php` — the `payments()` check

**Frontend**
- `resources/js/Pages/Manage/Payment/Gateways/Index.vue` + `Partials/GatewayCard.vue`
- `resources/js/Components/SettingTabs.vue` — the Gateways sub-tab

**Migrations**
- `database/migrations/2026_07_29_100001_create_payment_gateways_table.php`
- `database/migrations/2026_07_29_100002_generalise_payment_provider_columns.php` (`stripe_*` → `provider_*`; `stripe_webhook_events` → `payment_webhook_events` with a `provider` column)

**Routes** — `manage.payment.gateways.{index,update,test}` (`routes/web.php`)

`tests/Concerns/ConfiguresPaymentGateways.php` is the helper every money test uses — and configuring Stripe there means the **real** driver is what the registry hands out, so the trait first pins the SDK to `tests/Support/FakeStripeHttpClient` via `ApiRequestor::setHttpClient()`: an in-process client no test can reach the network through. ⚠️ The Stripe SDK holds that client **statically**, so the trait tears it down in `beforeApplicationDestroyed()` — a fake installed and left behind leaks into every later test in the process, including ones that never asked for a gateway at all.

See also: [Payment Items](/docs/modules_handbook/manage/payments/payment-links/readMe.md) · [Payments ledger](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md).
