Skip to content

omnisense/omnitrade

Omnitrade: one contract for payment providers and commerce platforms - purchase, authorize, capture, refund, webhooks, platform orders - and its Symfony bundle (Omnibus's sibling for trade).

Floor
Family
Version
1.x
Verified for real
Its README does not say
Licence
MIT
composer require omnisense/omnitrade:1.x-dev

Omnitrade is one PHP contract for payment providers and commerce platforms. A shop asks every provider the same questions - take this payment, reserve it, capture it, give money back, where does this payment stand, what is this webhook telling me, what is in this shop's catalogue, how many are left - and gets the same answers back: Transaction, Refund, Notification, PlatformOrder, Product, Stock. Each provider is a package of its own (omnitrade/stripe, omnitrade/paypal, omnitrade/shopify...) that turns those questions into the service's API calls.

$gateway = $registry->get('card');

$transaction = $gateway->purchase($payment);   // PAID, or PENDING with the page to send the buyer to
$gateway->fetch($transaction->reference);      // where it stands now
$gateway->refund($transaction->reference, Money::of(500, 'EUR'), idempotencyKey: 'refund-42');
$gateway->notify($request->getContent(), $request->headers->all());   // a webhook, checked and read

$page = $gateway->fetchProducts(updatedSince: $lastSync);   // the platform's catalogue, page by page
$gateway->fetchProduct('https://cave.example/products/margaux-2019');   // by id or by its page
$gateway->fetchInventory();                                  // stock levels, where the platform counts them

$gateway->affiliateLink('https://www.amazon.fr/dp/B0C1234567');   // the product's address with the site's tag

$account = $gateway->createAccount('FR', 'host@example.org');   // a connected account (Stripe Connect, Express)
$gateway->accountLink($account->reference, $return, $refresh);  // where its holder completes it
$gateway->subscribe($payment, 'month');                         // a subscription, on the provider's page

The design is Omnibus's, itself Payum's: a provider's factory fills a configuration with its name, its options, its API client and one action per request it answers; the gateway it builds runs the action that supports each request; a registry holds the shop's gateways by name. A provider that does not do something (no authorizations at Shopify) has no action for it: supports() says so beforehand, and asking anyway throws RequestNotSupportedException before any call. What every provider answers is kept whole in each model's $raw: normalization loses nothing.

The core needs no framework: it requires symfony/http-client-contracts and nothing else. Each provider requires symfony/http-client, a library that stands alone. A Symfony bundle is included for applications that want the wiring done (Symfony).

Published as omnisense/omnitrade, formerly glitchr/omnitrade: the package replaces the old name, so a composer.json that still requires glitchr/omnitrade resolves to this package and never installs both. omnisense/ is the vendor of every family's core; the providers keep the omnitrade/ vendor, the namespace is Omnitrade\ (Omnitrade\<Provider>\ for a provider) and the GitHub repositories keep their names.

When to use it

  • A shop that takes payments through a hosted page (Stripe Checkout, PayPal's approval page, a Shopify invoice, a WooCommerce checkout) and wants to change provider, or offer two, without rewriting its checkout, its webhook endpoint and its order screens.
  • A site that copies the catalogue of a platform it does not own (Shopify, WooCommerce, Stripe Products) into its own tables, or reads any shop's product page, and writes affiliate links.
  • A platform that takes payments for others (a gift fund, a marketplace) through Stripe Connect, or sells subscriptions through Stripe Billing.

It is not a payment service of its own (no account, no money held, no card data: the buyer pays on the provider's page) and it stores nothing: keeping the transaction's reference, the webhook's event id and the catalogue's copy is the application's job. It does not take card numbers: no provider here has a server-side card request.

Providers

Six packages. The capabilities are what each gateway's supports() answers with its required options set (probed on 2026-10-25 in the family's harness image, PHP 8.4, on a MockHttpClient: supports() makes no call).

Payments:

Package Factory name Purchase Authorize, capture, void Refund Fetch Payment methods Notify
omnitrade/stripe stripe yes (Checkout) - yes yes yes (configured list) yes
omnitrade/paypal paypal yes (Orders v2) yes yes yes yes (fixed list) yes (verified by PayPal)
omnitrade/shopify shopify yes (draft order invoice) - - yes - yes
omnitrade/woocommerce woocommerce yes (unpaid order) - yes yes yes (the shop's gateways) yes
omnitrade/web web - - - - - -
omnitrade/amazon amazon - - - - - -

Platform orders, catalogue, affiliate links, connected accounts, subscriptions:

Package Fetch order Fetch products Fetch product Inventory Affiliate link Connected accounts Subscriptions
omnitrade/stripe - yes (Products and Prices) yes (by id) - - yes (Express) yes (Checkout, portal)
omnitrade/paypal - - - - - - -
omnitrade/shopify yes yes yes (id, handle, page) yes - - -
omnitrade/woocommerce yes yes yes (id, slug, page) yes - - -
omnitrade/web - - yes (any page's markup) - yes (rules by host) - -
omnitrade/amazon - with client_id and client_secret (a search) with client_id and client_secret - yes (partner tag) - -

"Connected accounts" are CreateAccount, AccountLink and FetchAccount; "Subscriptions" are Subscribe, FetchSubscription, CancelSubscription and SubscriptionPortal. Each provider's own manual lists its options, credentials and behaviour.

How this manual is organised

Propose a change docs/index.md

Installation

composer require omnisense/omnitrade omnitrade/paypal            # one provider
composer require omnitrade/stripe omnitrade/shopify omnitrade/woocommerce omnitrade/web omnitrade/amazon

Requirements

  • PHP 8.2 or later.
  • omnisense/omnitrade requires symfony/http-client-contracts (^3.0) and nothing else.
  • Each provider requires omnisense/omnitrade and symfony/http-client (^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework. Besides:
Package Also requires
omnitrade/stripe ext-bcmath, omnipay/stripe (^3.2) and omnipay/common (^3.0), nyholm/psr7, php-http/discovery, psr/http-client, psr/http-factory
omnitrade/paypal -
omnitrade/shopify psr/log
omnitrade/woocommerce -
omnitrade/web ext-dom, ext-libxml
omnitrade/amazon -

Omnitrade needs no framework. It runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).

omnitrade/stripe stands on Omnipay (omnipay/stripe), a library with no framework either; Omnipay's own requirements come with it, symfony/http-foundation among them - used as a library for its request object: no kernel, no container.

Plain PHP

<?php // bare.php

require __DIR__.'/vendor/autoload.php';

use Omnitrade\Model\Customer;
use Omnitrade\Model\Line;
use Omnitrade\Model\Money;
use Omnitrade\Model\Payment;
use Omnitrade\PayPal\PayPalGatewayFactory;
use Omnitrade\Registry;
use Omnitrade\Request\Authorize;
use Omnitrade\Request\FetchProducts;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

// The HTTP client the providers call with: in an application, HttpClient::create(). Here, one that
// answers as PayPal's sandbox does (its documentation's example order), so that the script runs as
// it is, without an account.
$http = new MockHttpClient(static fn (string $method, string $url) => new MockResponse(json_encode(match (parse_url($url, PHP_URL_PATH)) {
    '/v1/oauth2/token' => ['access_token' => 'paypal_test_not_a_real_token', 'expires_in' => 32400],
    '/v2/checkout/orders' => ['id' => '5O190127TN364715T', 'status' => 'PAYER_ACTION_REQUIRED', 'links' => [
        ['rel' => 'payer-action', 'href' => 'https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T'],
    ]],
})));

$registry = new Registry([new PayPalGatewayFactory($http)], [
    'paypal' => ['factory' => 'paypal', 'options' => ['client_id' => 'paypal_test_not_a_real_client_id', 'secret' => 'paypal_test_not_a_real_secret', 'sandbox' => true]],
]);
$gateway = $registry->get('paypal');

var_dump($gateway->supports(Authorize::class), $gateway->supports(FetchProducts::class));   // what it does, said beforehand

$transaction = $gateway->purchase(new Payment(
    amount: Money::of(1250, 'EUR'),                       // minor units
    reference: 'ORDER-1042',
    description: 'Commande n° 1042',
    customer: new Customer(email: 'camille@example.org', name: 'Camille Durand', country: 'FR'),
    lines: [new Line('Dix heures', Money::of(1250, 'EUR'))],
    returnUrl: 'https://shop.example/retour',
    cancelUrl: 'https://shop.example/panier',
    idempotencyKey: 'attempt-1',
));

echo $transaction->provider, ' ', $transaction->reference, ' ', $transaction->status->value, "\n";
if ($transaction->isRedirect()) {
    echo 'send the buyer to ', $transaction->redirectUrl, "\n";   // then capture() when they are back
}
$ php bare.php
bool(true)
bool(false)
paypal 5O190127TN364715T pending
send the buyer to https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T

(first run on 2026-10-05 in an empty directory, after composer require omnisense/omnitrade omnitrade/paypal; run again on 2026-10-25 in the family's harness image, PHP 8.4, with the same output. Every provider asks for an account's keys, so the script answers in PayPal's place, with the order id of PayPal's own documentation. With HttpClient::create() and a sandbox application's client id and secret, the same lines create a real sandbox order.)

That is all there is to it:

  • a factory per provider package (StripeGatewayFactory, PayPalGatewayFactory, ShopifyGatewayFactory, WooCommerceGatewayFactory, WebGatewayFactory, AmazonGatewayFactory), which takes the HTTP client to call with - the application's, a MockHttpClient in a test; with none given it makes its own (HttpClient::create());
  • the registry, built by hand from the factories and the gateways' options, by name: get(), and create($name, $overrides) for keys typed in a back office;
  • the gateways it gives - purchase(), authorize(), capture(), refund(), void(), fetch(), notify(), fetchProducts(), fetchProduct(), fetchInventory(), affiliateLink()... - the same for every provider; supports() says beforehand what one does.

Building a gateway calls nothing: a token (PayPal's, Amazon's) is asked for on the first call that needs it, and a missing required option is reported by create() - or by Registry::get() the first time the gateway is asked for - as an InvalidConfigException naming it (The "paypal" gateway needs: secret.).

No class of a framework is loaded on the way - a test of this package checks it in a process of its own (Tests/BareTest.php), and so does docker compose run --rm omnitrade bare (harness).

After the buyer is back

$paid = $gateway->capture($transaction->reference, idempotencyKey: 'attempt-1');   // PayPal: the money is taken now
$paid->isPaid();

$gateway->fetch($transaction->reference);                                          // where it stands now
$gateway->refund($transaction->reference, Money::of(500, 'EUR'), idempotencyKey: 'refund-42');

What each provider does on the way back differs (Stripe takes the money on its page, PayPal on capture(), Shopify and WooCommerce bring nobody back): payments.

A webhook

use Omnitrade\Exception\InvalidNotificationException;

try {
    $notification = $gateway->notify(file_get_contents('php://input'), getallheaders());   // checked against its signature, then read
} catch (InvalidNotificationException) {
    http_response_code(400);
    exit;
}

More in webhooks.

A product page, no key

use Omnitrade\Web\WebGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

$web = (new WebGatewayFactory(HttpClient::create()))->create(['affiliate' => ['amazon.fr' => ['tag' => 'mysite-21']]]);

$product = $web->fetchProduct('https://shop.example/products/margaux-2019');   // from its JSON-LD, microdata or OpenGraph
$product?->title; $product?->price(); $product?->media;
$web->affiliateLink('https://www.amazon.fr/dp/B0C1234567');                    // the address with the site's tag

The catalogue of a platform (Shopify, WooCommerce, Stripe), page by page: catalogue. Connected accounts: connect. Subscriptions: subscriptions.

In a framework

  • Symfony: Omnitrade\Bridge\Symfony\OmnitradeBundle registers the factories on the application's http_client, builds the registry from config/packages/omnitrade.yaml and makes each gateway injectable by its name: see Symfony. Its components (symfony/config, symfony/dependency-injection, symfony/http-kernel) are not required by this package: a Symfony application has them.
  • Laravel: there is no bridge for Omnitrade; a service provider of the application builds the registry once: Laravel.
  • Any other: build the Registry once, where the framework builds its services (a service provider, a container definition), as the script above does.

What each exception means and when it is thrown: Errors.

Propose a change docs/installation.md

Symfony

Omnitrade runs without a framework (installation); in a Symfony application its bundle does the wiring. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel (6.4, 7 or 8) - are not required by omnisense/omnitrade: the application has them, and nothing of them is loaded outside Symfony.

Registering the bundle

There is no Flex recipe: register Omnitrade\Bridge\Symfony\OmnitradeBundle by hand.

// config/bundles.php
return [
    // ...
    Omnitrade\Bridge\Symfony\OmnitradeBundle::class => ['all' => true],
];

Configuration

# config/packages/omnitrade.yaml
omnitrade:
    gateways:                      # by name: a factory and its options
        card:   { factory: stripe, options: { api_key: '%env(STRIPE_API_KEY)%', webhook_secret: '%env(STRIPE_WEBHOOK_SECRET)%' } }
        paypal: { factory: paypal, options: { client_id: '%env(PAYPAL_CLIENT_ID)%', secret: '%env(PAYPAL_SECRET)%', sandbox: true } }
# .env.local, or bin/console secrets:set
STRIPE_API_KEY=...
STRIPE_WEBHOOK_SECRET=...

The whole tree, as the bundle's configuration dumps it (YamlReferenceDumper on the bundle's extension, run in the harness image on 2026-10-25; bin/console config:dump-reference omnitrade prints the same in an application):

omnitrade:

    # The shop's providers, by name: a factory (stripe, paypal, shopify, woocommerce, web, amazon...) and its options.
    gateways:

        # Prototype
        name:
            factory:              ~ # Required
            options:              []
Key Type Default
omnitrade.gateways map, keyed by the gateway's name {} the shop's providers
omnitrade.gateways.<name>.factory string, required, not empty - a factory's name: stripe, paypal, shopify, woocommerce, web, amazon, or an application's own factory's getName()
omnitrade.gateways.<name>.options any value (a map) [] handed as they are to the factory's create(): the provider's options (Configuration)

The options are not validated when the container compiles - the tree accepts any value - but when the gateway is built: a required option missing or empty is an InvalidConfigException naming it, the first time the gateway is asked for.

Services

Every omnitrade/* provider package installed has its factory registered as a service, on the application's http_client service (nullOnInvalid(): with no HTTP client service, the factory makes its own). The bundle knows the six providers of the table and registers those whose class exists. What the container holds, with card and paypal configured:

Service id Class Autowiring
Omnitrade\Registry Registry Registry $registry every configured gateway by name: get(), has(), names(), options(), all(), factories(), create() with other options. Public.
omnitrade.gateway.<name> GatewayInterface GatewayInterface $<name> one gateway, built by Registry::get('<name>') the first time it is injected
Omnitrade\Stripe\StripeGatewayFactory, Omnitrade\PayPal\PayPalGatewayFactory, Omnitrade\Shopify\ShopifyGatewayFactory, Omnitrade\WooCommerce\WooCommerceGatewayFactory, Omnitrade\Web\WebGatewayFactory, Omnitrade\Amazon\AmazonGatewayFactory the provider's factory by its class tagged omnitrade.gateway_factory
use Omnitrade\GatewayInterface;

final class CheckoutController
{
    public function __construct(private readonly GatewayInterface $card)   // the "card" gateway
    {
    }
}

The argument's name selects the gateway: GatewayInterface $card is omnitrade.gateway.card (Symfony's named autowiring aliases, registerAliasForArgument(); the container holds the alias Omnitrade\GatewayInterface $card). A gateway name that is not a valid PHP variable name (my-card) is still reachable by its service id or by the registry.

Nothing is built when the container compiles: a gateway is built the first time it is asked for, and an option left empty only shows then (InvalidConfigException). Keys typed in a back office rather than set in the environment: $registry->create('card', ['api_key' => $stored]), a gateway built afresh with those options over the configured ones and not kept by the registry.

An application's own provider

A class of the application that implements Omnitrade\GatewayFactoryInterface (usually by extending Omnitrade\GatewayFactory, Writing a provider) is autoconfigured: tagged omnitrade.gateway_factory, it joins the registry, and its getName() can be named as a factory:

omnitrade:
    gateways:
        bank:
            factory: bank_transfer     # App\Payment\BankTransferGatewayFactory::getName()

Tests/Bridge/OmnitradeBundleTest.php checks it: two gateways of an application's own factory, built from the configuration and injected by their names.

A webhook controller

use Omnitrade\Exception\InvalidNotificationException;
use Omnitrade\GatewayInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class PaymentWebhookController extends AbstractController
{
    #[Route('/payment/webhook', methods: ['POST'])]
    public function __invoke(Request $request, GatewayInterface $card): Response
    {
        try {
            $notification = $card->notify($request->getContent(), $request->headers->all());
        } catch (InvalidNotificationException) {
            return new Response(status: 400);
        }
        // $notification: which payment, what became of it ($reference, $status, $transaction)

        return new Response(status: 204);
    }
}

$request->headers->all() gives each header as a list of values, lower-cased; notify() reads headers whatever their case and takes the first value of a list (webhooks).

The bundle registers no command, event, Twig function or route: the gateways are services, used from the application's controllers, message handlers and commands.

Propose a change docs/symfony.md

Laravel

omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnitrade provider (it wires omniauth, omnimap and omnishield among others, not Omnitrade). None is needed: the registry is plain PHP, and a service provider of the application builds it once, from a config file, with a symfony/http-client client (every provider requires that package; Laravel's own HTTP client is Guzzle's, which the providers do not take).

The service provider below is written by hand in the application; it is not shipped by any package.

// config/omnitrade.php
return [
    'gateways' => [
        'card' => ['factory' => 'stripe', 'options' => [
            'api_key' => env('STRIPE_API_KEY'),
            'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
        ]],
        'paypal' => ['factory' => 'paypal', 'options' => [
            'client_id' => env('PAYPAL_CLIENT_ID'),
            'secret' => env('PAYPAL_SECRET'),
            'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
            'sandbox' => (bool) env('PAYPAL_SANDBOX', true),
        ]],
    ],
];
// app/Providers/OmnitradeServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omnitrade\GatewayInterface;
use Omnitrade\PayPal\PayPalGatewayFactory;
use Omnitrade\Registry;
use Omnitrade\Stripe\StripeGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

final class OmnitradeServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function (): Registry {
            $http = HttpClient::create();

            return new Registry([
                new StripeGatewayFactory($http),
                new PayPalGatewayFactory($http),
            ], config('omnitrade.gateways'));
        });

        // One gateway as the default GatewayInterface, when the application has one provider.
        $this->app->bind(GatewayInterface::class, fn ($app) => $app->make(Registry::class)->get('card'));
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the registry where it is needed:

use Illuminate\Http\Request;
use Omnitrade\Exception\InvalidNotificationException;
use Omnitrade\Registry;

final class PaymentWebhookController
{
    public function __invoke(Request $request, Registry $registry)
    {
        try {
            $notification = $registry->get('card')->notify($request->getContent(), $request->headers->all());
        } catch (InvalidNotificationException) {
            return response('', 400);
        }
        // $notification->reference, ->status, ->transaction

        return response('', 204);
    }
}

A webhook route takes a POST from the provider without Laravel's CSRF token: exclude its path from the CSRF middleware.

List in the provider every factory the application uses: unlike the Symfony bundle, a hand-built registry knows only the factories it is given. Configuration caching (php artisan config:cache) keeps working: the options are plain arrays read through config(). This page was written from the registry's API; it was not run in a Laravel application.

Propose a change docs/laravel.md

Concepts

Gateway

Omnitrade\GatewayInterface is one provider, configured - a payment service (Stripe, PayPal) or a commerce platform (Shopify, WooCommerce). Every gateway answers the same methods; each typed method is a shortcut for execute() with its request.

Method Returns Request
getName(): string the factory's name (stripe, paypal), also the provider of every model it returns -
getTitle(): string the provider's display name (Stripe, PayPal) -
supports(string $request): bool whether one of the gateway's actions answers that request class -
execute(Request $request): Request the same request, answered any
purchase(Payment $payment): Transaction the payment: PAID when taken at once, PENDING with a redirectUrl when the buyer pays on a page Purchase
authorize(Payment $payment): Transaction a reservation, captured or voided later Authorize
capture(string $reference, ?Money $amount = null, ?string $idempotencyKey = null): Transaction the authorization taken, all of it or $amount Capture
refund(string $reference, ?Money $amount = null, ?string $idempotencyKey = null, ?string $reason = null): Refund money given back, all of it or $amount Refund
void(string $reference): Transaction the authorization let go VoidAuthorization
fetch(string $reference): Transaction the transaction as the provider has it now FetchTransaction
paymentMethods(?Money $for = null, ?string $country = null): array PaymentMethod[] GetPaymentMethods
notify(string $body, array $headers = []): Notification a webhook, checked against its signature, read as one event Notify
fetchOrder(string $reference): PlatformOrder a platform's own order FetchOrder
fetchProduct(Reference|string $reference): ?Product one product, by id or page address; null when there is none FetchProduct
fetchProducts(?string $cursor = null, ?\DateTimeInterface $updatedSince = null, ?string $query = null, int $limit = 50): ProductPage a page of the catalogue FetchProducts
fetchInventory(array $references = []): array list<Stock>, of these variants or of all FetchInventory
affiliateLink(Reference|string $reference, ?string $tag = null): ?string the address with the site's affiliate tag; null when there is no programme for it AffiliateLink
createAccount(string $country, ?string $email = null, string $type = Account::EXPRESS, array $metadata = []): Account a connected account CreateAccount
accountLink(string $reference, string $returnUrl, string $refreshUrl): string the provider's onboarding page AccountLink
fetchAccount(string $reference): Account the account as it stands FetchAccount
subscribe(Payment $payment, string $interval = 'month', int $intervalCount = 1, ?string $price = null): Transaction a subscription started on the provider's page Subscribe
fetchSubscription(string $reference): Subscription the subscription as it stands FetchSubscription
cancelSubscription(string $reference, bool $atPeriodEnd = true): Subscription the subscription stopped, at the period's end by default CancelSubscription
subscriptionPortal(string $customer, string $returnUrl, ?string $locale = null): string the provider's page where a subscriber manages it SubscriptionPortal

Some request fields have no argument in the shortcut: createAccount() sends no businessType, the default capabilities (['transfers']) and no idempotency key, subscribe() no trialDays nor customer, accountLink() the onboarding type. Build the request and pass it to execute() for those:

use Omnitrade\Request\CreateAccount;
use Omnitrade\Request\Subscribe;

$account = $gateway->execute(new CreateAccount('FR', 'lea@example.org', capabilities: ['transfers', 'card_payments'], idempotencyKey: 'host-42'))->getAccount();
$transaction = $gateway->execute(new Subscribe($payment, 'month', trialDays: 14, customer: 'cus_Q1x'))->getTransaction();

Omnitrade\Gateway is the one implementation: a name, a title and a list of actions. execute() hands the request to the first action whose supports() accepts it; when none does it throws RequestNotSupportedException (The "shopify" gateway does not support Authorize.) before anything is sent; when the action returns without setting a result it throws a ProviderException (... left the request unanswered.). supports() probes the actions with an instance of the request class built without its constructor, so it costs no call.

Factory and Config

Omnitrade\GatewayFactoryInterface builds a provider's gateway from options:

Method
getName(): string the name gateways are configured with: stripe, paypal, shopify, woocommerce, web, amazon
create(array $options = []): GatewayInterface a gateway, its options validated

Every provider extends Omnitrade\GatewayFactory, which takes an optional Symfony\Contracts\HttpClient\HttpClientInterface in its constructor (none: the provider calls HttpClient::create() itself) and does the work in create():

  1. createConfig($options) - a Config (an ArrayObject) holding the options given, then the provider's populateConfig() fills each key the options did not set with its default;
  2. the keys listed in omnitrade.required_options are checked: one missing, '' or [] is an InvalidConfigException listing every missing key;
  3. omnitrade.api - the provider's API client, usually a closure of the Config - is built;
  4. every key starting with omnitrade.action. that is (or whose closure returns) an ActionInterface becomes an action, in the order of the keys; a closure returning null registers no action, which is how a provider makes an operation depend on an option (Amazon's reads, which need credentials). Actions implementing ApiAwareInterface receive the API client;
  5. a Gateway is returned, named by omnitrade.factory_name and titled by omnitrade.factory_title.

getName() builds a Config with no options and reads its omnitrade.factory_name.

The reserved keys a factory sets:

Key
omnitrade.factory_name the gateway's name
omnitrade.factory_title the gateway's title
omnitrade.required_options list of option names that must be filled
omnitrade.api the API client, or a Closure(Config): object that builds it
omnitrade.action.<name> an ActionInterface, or a Closure(Config): ?ActionInterface

Because the options are set before the defaults, a key such as omnitrade.action.notify given in the options replaces the provider's own action. Config::defaults($defaults) sets only keys not set yet, Config::get($key, $default = null) reads one, Config::validateNotEmpty($keys) is step 2 (its message names the gateway: The "paypal" gateway needs: secret.).

Registry

Omnitrade\Registry holds the shop's gateways by name:

$registry = new Registry(
    [new StripeGatewayFactory($http), new PayPalGatewayFactory($http)],   // iterable of factories, keyed by getName()
    [
        'card' => ['factory' => 'stripe', 'options' => ['api_key' => '...']],
        'paypal' => ['factory' => 'paypal', 'options' => ['client_id' => '...', 'secret' => '...']],
    ],
);
Method
get(string $name): GatewayInterface the gateway, built on the first call and kept
has(string $name): bool whether a gateway of that name is configured
options(string $name): array the options it is configured with ([] for an unknown name)
create(string $name, array $overrides = []): GatewayInterface a gateway built afresh, $overrides over its configured options (array_replace), not kept
names(): list<string> the configured gateways' names
all(): array<string, GatewayInterface> every configured gateway, built
factories(): list<string> the factories' names: what factory: may name

An unknown name is an InvalidConfigException naming the configured ones (No "card" gateway; configured: paypal, shop.), a factory not given to the registry one naming the installed ones (No "stripe" factory for the "other" gateway; installed: paypal, shopify.).

Requests and actions

A request (Omnitrade\Request\Request) is a question put to a gateway: setResult($result) answers it, getResult() reads the answer, isAnswered() says whether an action set one (a null result counts: fetchProduct() of an unknown product is answered null). Each request has a typed getter for its result.

Request Constructor Result, getter
Purchase Payment $payment ?Transaction, getTransaction()
Authorize Payment $payment ?Transaction, getTransaction()
Capture string $reference, ?Money $amount = null, ?string $idempotencyKey = null ?Transaction, getTransaction()
VoidAuthorization string $reference ?Transaction (CANCELLED), getTransaction()
Refund string $reference, ?Money $amount = null, ?string $idempotencyKey = null, ?string $reason = null ?Refund, getRefund()
FetchTransaction string $reference ?Transaction, getTransaction()
GetPaymentMethods ?Money $for = null, ?string $country = null PaymentMethod[], getMethods() ([] when unanswered)
Notify string $body, array $headers = [] ?Notification, getNotification(); header($name) reads a header whatever its case, the first value of a list
FetchOrder string $reference ?PlatformOrder, getOrder()
FetchProduct Reference|string $reference (made a Reference with Reference::of()) ?Product, getProduct()
FetchProducts ?string $cursor = null, ?\DateTimeInterface $updatedSince = null, ?string $query = null, int $limit = 50 ProductPage, getPage() (an empty page when unanswered)
FetchInventory array $references = [] list<Stock>, getStocks()
AffiliateLink Reference|string $reference, ?string $tag = null ?string, getUrl()
CreateAccount string $country, ?string $email = null, string $type = Account::EXPRESS, ?string $businessType = null, array $metadata = [], array $capabilities = ['transfers'], ?string $idempotencyKey = null ?Account, getAccount()
AccountLink string $reference, string $returnUrl, string $refreshUrl, string $type = AccountLink::ONBOARDING ?string, getUrl()
FetchAccount string $reference ?Account, getAccount()
Subscribe Payment $payment, string $interval = 'month', int $intervalCount = 1, ?string $price = null, ?int $trialDays = null, ?string $customer = null ?Transaction, getTransaction()
FetchSubscription string $reference ?Subscription, getSubscription()
CancelSubscription string $reference, bool $atPeriodEnd = true ?Subscription, getSubscription()
SubscriptionPortal string $customer, string $returnUrl, ?string $locale = null ?string, getUrl()

Constants: AccountLink::ONBOARDING (onboarding), AccountLink::UPDATE (update); Subscribe::INTERVALS (day, week, month, year). Subscribe refuses another interval, or an $intervalCount under 1, with an \InvalidArgumentException.

An action (Omnitrade\Action\ActionInterface) answers the requests it supports: supports(Request $request): bool and execute(Request $request): void, which sets the result or throws a ProviderException. An action that talks to the service implements ApiAwareInterface (setApi(object $api): void); ApiAwareTrait implements it, checking that the API is an instance of the class named in $this->apiClass (else an InvalidConfigException, ... needs a <class>, got <class>.). How to write one: Writing a provider.

Money

Omnitrade\Model\Money is an amount in a currency's minor units: Money::of(1250, 'EUR') is 12,50 EUR. Every amount of every model is a Money.

Member
int $amount minor units (cents)
string $currency ISO 4217, upper-cased by the constructor (eur becomes EUR)
Money::of(int $amount, string $currency) the same as new Money()
equals(Money $other): bool same amount, same currency
decimal(): string in major units, as a decimal API expects (12.50)
Money::fromDecimal(string|float $decimal, string $currency) from a provider's decimal, rounded to the minor unit
Money::minorDigits(string $currency): int 0 for BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, MGA, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF; 3 for BHD, IQD, JOD, KWD, LYD, OMR, TND; 2 for any other
__toString() 12.50 EUR
echo Money::of(1250, 'eur'), ' | ', Money::of(1250, 'JPY'), ' | ', Money::of(1250, 'KWD');   // 12.50 EUR | 1250 JPY | 1.250 KWD
Money::fromDecimal('12.50', 'EUR')->amount;    // 1250
Money::fromDecimal('1250', 'JPY')->amount;     // 1250
Money::fromDecimal(0.1 + 0.2, 'EUR')->amount;  // 30

(run on 2026-10-25 in the harness image)

Payment, Customer, Line

Omnitrade\Model\Payment is what to charge, and everything a provider may want with it. The amount is the truth; the lines are for the buyer's eyes (a hosted page), and a provider that cannot take them as given charges the amount as one line.

Field Type Default
amount Money required what is charged
reference string required yours - the order's -, sent to the provider as the merchant reference
description ?string null the single line's label when the lines are not listed
customer ?Customer null who pays
lines list<Line> [] what is paid for
shipping ?Money null posting, on top of the lines; inside amount
discount ?Money null money off, taken from the lines; inside amount
returnUrl ?string null where the buyer comes back after a hosted page, paid or not
cancelUrl ?string null where the buyer goes when they give up
idempotencyKey ?string null the same purchase asked twice happens once, at providers that honour one: give it per attempt, and keep it with the attempt
method ?string null a provider's payment method to insist on (card, sepa_debit), else its choice
notice ?string null a mention to print on the page and the receipt (why there is no VAT)
metadata array<string, scalar> [] kept by providers that keep metadata, given back on notify()
locale ?string null BCP 47, for the provider's page
destination ?string null a connected account the money goes to (acct_...): connected accounts
applicationFee ?Money null what the platform keeps of a payment sent to destination; inside amount

The constructor throws an \InvalidArgumentException for an applicationFee without a destination (An application fee is kept on a payment sent to a destination account.) or one in another currency, negative, or larger than the amount. linesAddUp(): bool says whether there are lines and the lines' totals plus shipping minus discount equal the amount, all in its currency: then a provider may list them. Which fields each provider reads: Configuration.

Omnitrade\Model\Customer - all optional: email, name, reference (the provider's own id for them, Stripe's cus_...), phone, street (list<string>), postalCode, city, country (ISO 3166-1 alpha-2).

Omnitrade\Model\Line - label (string), unitAmount (Money), quantity (int, 1), sku, imageUrl, reference (the provider's own id for it: a Shopify variant gid, a WooCommerce product id), physical (bool, false: goods to post, as opposed to a service or a download). total(): Money is unitAmount times quantity.

Transaction and Status

Omnitrade\Model\Transaction is a payment as the provider knows it.

Field Type
provider string the gateway's name
reference string the provider's id: a Checkout session, a PayPal order, a Shopify draft order, a WooCommerce order
status Status where it stands
amount ?Money what it is for
redirectUrl ?string the page the buyer must be sent to (with PENDING)
message ?string the provider's word on it: a refusal's reason, the provider's own status
providerCode ?string the provider's code for it
method ?string the payment method, as the provider names it (card, paypal)
refunded ?Money what was given back so far
metadata array<string, mixed> the provider's metadata, or what the provider package keeps there (PayPal's capture id)
createdAt ?\DateTimeImmutable
raw array the provider's whole answer

isRedirect() is true when there is a redirectUrl and the status is PENDING; isPaid(), isPending(), isRefused() test the status.

Omnitrade\Model\Status, a string-backed enum:

Case Value isFinal() isSettled()
PENDING pending no no started, not settled: the buyer is on the provider's page, a transfer is on its way
AUTHORIZED authorized no no the money is reserved; capture() takes it
PAID paid yes yes
REFUSED refused yes no
CANCELLED cancelled yes no
EXPIRED expired yes no
PARTIALLY_REFUNDED partially_refunded yes yes
REFUNDED refunded yes yes

isFinal() is "nothing more will happen to it", isSettled() "the money came in (and may since have gone back)". isFinal() is true for PARTIALLY_REFUNDED although a further refund can still follow: a status of the payment, not of its refunds.

Refund

Omnitrade\Model\Refund: provider, reference (the provider's refund id, Stripe's re_...), amount (Money), status (Status: REFUNDED once done, PENDING while on its way, REFUSED when the provider failed it), transactionReference (the transaction refunded), message, raw.

PaymentMethod

Omnitrade\Model\PaymentMethod: code (as the provider names it: card, paypal, sepa_debit, klarna), label, kind (one of the constants), currencies (list<string>, empty for any), raw. Kinds: PaymentMethod::CARD (card), WALLET (wallet), BANK (bank), BUY_NOW_PAY_LATER (bnpl), OTHER (other, the default).

Notification

Omnitrade\Model\Notification is what a provider is telling the shop, checked: one event about one transaction, product, account or subscription.

Field Type
provider string
event string the provider's event type: checkout.session.completed, PAYMENT.CAPTURE.COMPLETED, orders/paid, order.updated
reference ?string what it concerns, by its id there: the transaction (or platform order), the product, the account, the subscription
status ?Status what it means for the transaction, when it means anything (null: a hint to ignore, or to read from raw)
transaction ?Transaction the transaction as the event carries it, when it does
id ?string the provider's id for the event, to handle it once
raw array the event as received (Shopify and WooCommerce: the topic, headers read and the payload)
product ?Product a catalogue event's product (null for a deletion)
stocks list<Stock> a stock event's levels
account ?Account a connected account's event: the account as it stands
subscription ?Subscription a subscription's event: the subscription as it stands

isAccount() and isSubscription() test account and subscription; isCatalogue() is true when there is a product or stocks, or when the event's name starts with product or inventory.

PlatformOrder

Omnitrade\Model\PlatformOrder is a commerce platform's order (Shopify, WooCommerce): provider, reference (the platform's id), number (the number people see, #1042), status (Status), total (Money), customer (?Customer), lines (list<Line>), financialStatus and fulfillmentStatus (the platform's own words), createdAt, raw.

The catalogue

All final readonly, under Omnitrade\Model (a summary also in catalogue):

Product - a product as a platform has it:

Field Type
provider string
reference string the platform's id (a Shopify gid, a WooCommerce id, a Stripe prod_..., an ASIN, a page's canonical address)
title string
description ?string HTML, as the platform holds it (Amazon's features as an escaped list, a web page's text escaped with <br> between its lines)
handle ?string its handle or slug there
brand ?string Shopify's vendor, WooCommerce's brand, a brand metadata
url ?string the product page, when there is one
status string Product::ACTIVE (active), DRAFT (draft), ARCHIVED (archived)
tags list<string>
categories list<string> categories, product types, collections, by name
attributes array<string, string|list<string>> key/value: metafields, metadata, WooCommerce attributes
options list<Option> what the variants differ by
variants list<ProductVariant> at least one
media list<Media>
merchant ?Merchant who sells it, when it is not the shop itself
updatedAt ?\DateTimeImmutable
raw array

isActive(), variant(string $reference): ?ProductVariant, price(): ?Money (the lowest of the variants' first offers).

ProductVariant - one thing one can put in a cart: reference, title (null for a product's only variant), sku, barcode, offers (list<Offer>, the shop's own first), options (array<string, string>, its value of each option by the option's name), stock (?Stock), image (?Media), attributes, weight (grams), raw. offer() is the first offer, price() its price.

Offer - a price at which a variant can be bought, and from whom: price (Money), compareAt (the struck-through price), available (bool, true), url (where to buy it), merchant (null: the shop itself), reference (the platform's id for the price: a Stripe price), taxIncluded (?bool, as the platform says).

Stock - how many of a variant are left: reference (the variant, or the product when it has no variants), quantity (?int), tracked (bool, true), sku, location (where it is held, when the platform counts by location), item (the platform's id for the counted item: a Shopify inventory item). A platform that does not track it says tracked: false and quantity: null. available() is true when not tracked, when the quantity is unknown, or above 0.

Media - url, alt, type (Media::IMAGE image or Media::VIDEO video), width, height, reference. Option - name, values (list<string>). Merchant - name, reference, url, country (ISO 3166-1 alpha-2). ProductPage - products (list<Product>), next (the cursor of the next page, null on the last); hasMore().

Reference - which product is meant: id or url, at least one (else an \InvalidArgumentException). Reference::of($idOrUrl) trims the string and makes it a url when it starts with http:// or https://, an id otherwise; isUrl(), path() (the URL's path without the query), slug() (its last segment, URL-decoded), __toString() (the id, else the URL).

Reference::of('  https://cave.example/products/margaux-2019?variant=7 ');
// id null, url 'https://cave.example/products/margaux-2019?variant=7', path '/products/margaux-2019', slug 'margaux-2019'
Reference::of('gid://shopify/Product/42');   // id 'gid://shopify/Product/42', isUrl() false

Connected accounts and subscriptions

Account - somebody else's account at the provider, opened through the platform: provider, reference (acct_...), type (Account::EXPRESS express, STANDARD standard, CUSTOM custom), country, email, detailsSubmitted, chargesEnabled, payoutsEnabled, requirements (list<string>, what the provider still wants: external_account), defaultCurrency, metadata, raw. isReady() is chargesEnabled && payoutsEnabled. Connected accounts.

Subscription - provider, reference (sub_...), status (Subscription::ACTIVE active, TRIALING trialing, PAST_DUE past_due, UNPAID unpaid, CANCELLED cancelled, INCOMPLETE incomplete, PAUSED paused), customer (the provider's customer, what the portal is opened for), amount, interval (day, week, month, year), intervalCount, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd, cancelledAt, price (the provider's price or product), metadata, raw. isActive() (active or trialing), isCancelled(). Subscriptions.

Invariants

  • Money in minor units, currencies ISO 4217 upper-case, countries ISO 3166-1 alpha-2.
  • Every model a gateway returns carries the gateway's name as provider and a non-empty reference.
  • ProductPage::$next is null on the last page, never an empty string.
  • raw keeps what the provider answered: anything the models leave out is there.

The conformance suite checks these on every provider (testing).

Propose a change docs/concepts.md

Payments

Every provider here takes the money on a page of its own: the gateway answers purchase() with a Transaction PENDING and the page's address (redirectUrl), the application sends the buyer there, and the provider says later - on the return page, through fetch(), or by webhook - what became of it. None takes a card number on the server.

A complete payment, from the order to the refund

The script runs as it is (composer require omnitrade/stripe): it answers in Stripe's place, with answers shaped as Stripe's API gives them. With HttpClient::create() in place of the MockHttpClient and a test key, the same lines open a real Checkout session.

<?php // payments.php: a Checkout payment from the order to the refund, on answers shaped as Stripe gives them

require __DIR__.'/vendor/autoload.php';

use Omnitrade\Model\Customer;
use Omnitrade\Model\Line;
use Omnitrade\Model\Money;
use Omnitrade\Model\Payment;
use Omnitrade\Stripe\StripeGatewayFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

// In an application: HttpClient::create(), and a test key. Here, Stripe's answers written by hand.
$session = ['id' => 'cs_test_a1', 'object' => 'checkout.session', 'amount_total' => 2750, 'currency' => 'eur', 'payment_method_types' => ['card'], 'created' => 1761400000, 'metadata' => ['order' => '1042']];
$sent = [];
$http = new MockHttpClient(static function (string $method, string $url, array $options) use ($session, &$sent): MockResponse {
    $call = $method.' '.parse_url($url, PHP_URL_PATH);
    $sent[] = [$call, $options['body'] ?? ''];
    $answer = match ($call) {
        'POST /v1/checkout/sessions' => $session + ['status' => 'open', 'payment_status' => 'unpaid', 'url' => 'https://checkout.stripe.com/c/pay/cs_test_a1'],
        'GET /v1/checkout/sessions/cs_test_a1' => $session + ['status' => 'complete', 'payment_status' => 'paid', 'payment_intent' => 'pi_test_a1'],
        'GET /v1/payment_intents/pi_test_a1' => ['id' => 'pi_test_a1', 'object' => 'payment_intent', 'currency' => 'eur', 'latest_charge' => 'ch_test_a1'],
        'POST /v1/charges/ch_test_a1/refund' => ['id' => 're_test_a1', 'object' => 'refund', 'status' => 'succeeded', 'amount' => 500, 'currency' => 'eur'],
    };

    return new MockResponse(json_encode($answer), ['response_headers' => ['content-type' => 'application/json']]);
});

$stripe = (new StripeGatewayFactory($http))->create(['api_key' => 'sk_test_not_a_real_key', 'webhook_secret' => 'whsec_not_a_real_secret']);

// 1. The order: two bottles and the postage, as lines adding up to the amount - Checkout lists them.
$transaction = $stripe->purchase(new Payment(
    amount: Money::of(2750, 'EUR'),
    reference: 'ORDER-1042',
    description: 'Commande 1042',
    customer: new Customer(email: 'camille@example.org'),
    lines: [new Line('Margaux 2019', Money::of(1200, 'EUR'), 2), new Line('Colissimo', Money::of(350, 'EUR'))],
    returnUrl: 'https://shop.example/paid',
    idempotencyKey: 'ORDER-1042-attempt-1',
    metadata: ['order' => '1042'],
    locale: 'fr-FR',
));
echo $transaction->status->value, ' ', $transaction->reference, ' ', $transaction->redirectUrl, "\n";

// 2. The webhook Stripe sends once the buyer has paid, signed with the endpoint's secret.
$body = json_encode(['id' => 'evt_a1', 'type' => 'checkout.session.completed', 'data' => ['object' => $session + ['status' => 'complete', 'payment_status' => 'paid']]]);
$time = time();
$signature = sprintf('t=%d,v1=%s', $time, hash_hmac('sha256', $time.'.'.$body, 'whsec_not_a_real_secret'));
$notification = $stripe->notify($body, ['Stripe-Signature' => $signature]);
echo $notification->event, ' ', $notification->reference, ' ', $notification->status->value, ' ', $notification->transaction->amount, "\n";

// 3. Where it stands, then 5,00 EUR given back.
echo $stripe->fetch('cs_test_a1')->status->value, "\n";
$refund = $stripe->refund('cs_test_a1', Money::of(500, 'EUR'), idempotencyKey: 'ORDER-1042-refund-1');
echo $refund->reference, ' ', $refund->amount, ' ', $refund->status->value, "\n\n";

foreach ($sent as [$call, $form]) {
    echo $call, "\n";
}
parse_str($sent[0][1], $form);
echo 'success_url: ', $form['success_url'], "\n";
foreach ($form['line_items'] as $line) {
    echo 'line: ', $line['quantity'], ' x ', $line['price_data']['product_data']['name'], ' ', $line['price_data']['unit_amount'], "\n";
}
$ php payments.php
pending cs_test_a1 https://checkout.stripe.com/c/pay/cs_test_a1
checkout.session.completed cs_test_a1 paid 27.50 EUR
paid
re_test_a1 5.00 EUR refunded

POST /v1/checkout/sessions
GET /v1/checkout/sessions/cs_test_a1
GET /v1/checkout/sessions/cs_test_a1
GET /v1/payment_intents/pi_test_a1
POST /v1/charges/ch_test_a1/refund
success_url: https://shop.example/paid?session={CHECKOUT_SESSION_ID}
line: 2 x Margaux 2019 1200
line: 1 x Colissimo 350

(run on 2026-10-25 in the harness image, PHP 8.4, from the workspace's checkouts)

The refund reads the session, then its payment intent, to find the charge it refunds: three calls. Stripe's Checkout takes the money on its page, so there is no capture() here.

omnitrade/stripe sends Payment::$shipping as the Checkout session's shipping rate, on top of the lines; a payment with a discount is one line of its amount there (Checkout takes a discount only as a coupon made beforehand). The other providers send both as such.

Purchase

$transaction = $gateway->purchase(new Payment(Money::of(2750, 'EUR'), 'ORDER-1042', returnUrl: 'https://shop.example/paid'));
if ($transaction->isRedirect()) {
    // keep $transaction->reference with the order, then:
    header('Location: '.$transaction->redirectUrl);
}
Provider What purchase() makes Answer Back to the shop
Stripe a Checkout session, mode=payment PENDING, the session's url returnUrl?session={CHECKOUT_SESSION_ID}; cancelUrl, else returnUrl?cancel=1
PayPal an Orders v2 order, intent CAPTURE PENDING, its payer-action (or approve) link returnUrl?token=<order id>, then capture()
Shopify a draft order PENDING, its invoiceUrl nobody comes back: fetch() or the orders/paid webhook
WooCommerce an order, status: pending, set_paid: false PENDING, its payment_url nobody comes back: fetch() or the order.updated webhook

Stripe and PayPal need returnUrl: without it they throw a ProviderException before any call. The lines are listed when Payment::linesAddUp() (at Stripe, with no discount, above); otherwise the amount is one line named by the description, else the reference. Which other fields each provider sends: configuration.

Authorize, capture, void

Only PayPal answers authorize() (supports(Authorize::class) is false elsewhere).

$transaction = $paypal->authorize($payment);          // an order with intent AUTHORIZE: PENDING, PayPal's page
// the buyer approves it and comes back to returnUrl?token=<order id>
$paid = $paypal->capture($transaction->reference);     // PAID: authorized, then captured
$paid = $paypal->capture($transaction->reference, Money::of(1000, 'EUR'));   // or part of it (final_capture)

capture() reads the order first: an order already captured (or refunded) is answered as it is, so the return page and the webhook may both call it. An order the buyer has not approved is a ProviderException (The order is PAYER_ACTION_REQUIRED: the buyer has not approved it.). For an order made with intent AUTHORIZE, capture() asks PayPal for the authorization (POST /v2/checkout/orders/{id}/authorize) when the order holds none yet, then captures it; fetch() and void() ask for it too, so an approved order reads AUTHORIZED and can be voided.

void($reference) takes the order's id or an authorization's id and lets the authorization go; the answer is the order read again (CANCELLED once PayPal voided it).

Refund

$refund = $gateway->refund($reference);                                   // all of it
$refund = $gateway->refund($reference, Money::of(500, 'EUR'), idempotencyKey: 'ORDER-1042-refund-1', reason: 'One bottle broken');
$refund->status;   // REFUNDED, or PENDING while the provider processes it
Provider $reference Call Notes
Stripe a session (cs_...), a payment intent (pi_...) or a charge (ch_...) omnipay's refund, POST /v1/charges/{charge}/refund; without an amount POST /v1/refunds with the charge (all of it) the session and its intent are read to find the charge; reason is not sent; Idempotency-Key
PayPal an order (its first capture) or a capture id POST /v2/payments/captures/{id}/refund reason as note_to_payer (255 characters); PayPal-Request-Id: <capture>-refund-<key>
WooCommerce an order id POST /orders/{id}/refunds, api_refund: true through the shop's payment gateway; no amount: the order's total; the refund is always REFUNDED
Shopify - not supported Shopify's admin refunds its orders

Fetch

fetch($reference) reads the transaction as the provider has it now: the return page and a webhook that did not arrive both rely on it.

Provider Read Status
Stripe the Checkout session payment_status paid or no_payment_required: PAID; status expired: EXPIRED; else PENDING with the session's url; refunded (the payment intent's latest charge, expanded): REFUNDED or PARTIALLY_REFUNDED
PayPal the order COMPLETED with a capture: PAID, or the capture's REFUNDED, PARTIALLY_REFUNDED, DECLINED/FAILED (REFUSED), PENDING; COMPLETED with an authorization only: AUTHORIZED, or its VOIDED (CANCELLED), EXPIRED, DENIED (REFUSED); VOIDED: CANCELLED; else PENDING
Shopify the draft order and the order it became completed and paid: PAID (refunded: REFUNDED, PARTIALLY_REFUNDED; voided: CANCELLED); deleted: CANCELLED; else PENDING with the invoice URL
WooCommerce the order processing, completed: PAID (REFUNDED or PARTIALLY_REFUNDED by its refunds); refunded: REFUNDED; cancelled: CANCELLED; failed: REFUSED; else (pending, on-hold, checkout-draft) PENDING with its payment_url

Payment methods

paymentMethods(?Money $for, ?string $country) lists what the provider can take. Stripe narrows it by the amount's currency (its euro-only methods), PayPal its Pay Later by the country and the currency; WooCommerce lists the gateways the shop enabled whatever the amount, the shop choosing.

Provider List
Stripe the payment_methods option when set (card, sepa_debit, klarna... labelled from a table of eleven known codes), else card, apple_pay, google_pay, link; sepa_debit, bancontact, ideal, eps left out of an amount not in euros
PayPal paypal (wallet), card (guest checkout), and paylater (buy now, pay later) in AU, FR, DE, IT, ES, GB, US in their currency
WooCommerce the shop's payment gateways enabled (GET /payment_gateways), their kind guessed from their id (stripe/card: card, paypal/apple/google: wallet, bacs/sepa/bank: bank, klarna/afterpay: buy now pay later)

Idempotency

Payment::$idempotencyKey, and the $idempotencyKey argument of capture() and refund(), make the same operation asked twice happen once, where the provider honours it. Give one per attempt and keep it with the attempt.

Provider How it is sent
Stripe Idempotency-Key on the session's creation and on the refund
PayPal PayPal-Request-Id: <reference>-<key> (purchase, authorize), <order>-capture-<key>, <order>-authorize, <capture>-refund-<key>; the purchase's invoice_id is <reference>-<key> too
Shopify, WooCommerce not sent

Propose a change docs/usage-payments.md

Webhooks

notify(string $body, array $headers = []) reads what a provider sends to the shop's webhook endpoint: it checks the signature first, then reads the event as one Notification. Hand it the raw body (re-encoding JSON breaks the signature) and the request's headers; a header is found whatever its case, and a list of values gives its first one, so PHP's getallheaders(), Symfony's $request->headers->all() and Laravel's $request->headers->all() all do.

use Omnitrade\Exception\InvalidNotificationException;

try {
    $notification = $gateway->notify(file_get_contents('php://input'), getallheaders());
} catch (InvalidNotificationException) {
    http_response_code(400);   // not the provider's, or too old: ignore it
    exit;
}

if (null !== $notification->id && $events->seen($notification->id)) {
    http_response_code(204);   // delivered twice: handled once
    exit;
}

match (true) {
    $notification->isCatalogue() => $catalogue->update($notification),            // $product, $stocks
    $notification->isAccount() => $accounts->update($notification->account),
    $notification->isSubscription() => $subscriptions->update($notification->subscription),
    null !== $notification->status => $orders->settle($notification->reference, $notification->status),
    default => null,                                                                // an event to ignore, or to read from $raw
};
http_response_code(204);

A webhook can come before the buyer is back, after, twice, or never: settle the order from whichever comes first (the return page's fetch() or the webhook), and keep the event's id.

$notification->status is what the event means for the transaction, when it means anything, and $notification->reference the transaction it concerns, by the provider's id - the reference purchase() answered (for Shopify, the draft order). The payments example signs and reads a Stripe event, offline.

What each provider checks

Provider Signature Without the secret
Stripe Stripe-Signature: t=<timestamp>,v1=<hmac>: HMAC-SHA256 of <timestamp>.<body> with webhook_secret, any v1 matching; the timestamp within 300 seconds InvalidNotificationException (No webhook secret: ...)
PayPal PayPal checks it itself: the PAYPAL-TRANSMISSION-ID, -TIME, -SIG, PAYPAL-CERT-URL and PAYPAL-AUTH-ALGO headers and the event are posted to POST /v1/notifications/verify-webhook-signature with webhook_id; anything but verification_status: SUCCESS is refused InvalidNotificationException (No webhook_id: ...)
Shopify X-Shopify-Hmac-Sha256: base64 of the HMAC-SHA256 of the body with webhook_secret; and X-Shopify-Shop-Domain, when sent, must be the configured shop InvalidNotificationException
WooCommerce X-WC-Webhook-Signature: base64 of the HMAC-SHA256 of the body with webhook_secret InvalidNotificationException

A signature that holds on a body that is not JSON (or not the provider's event) is a ProviderException, not an InvalidNotificationException. Stripe's signature is the only one with a timestamp; Shopify and WooCommerce resend a delivery with the same id (X-Shopify-Webhook-Id, X-WC-Webhook-Delivery-ID), kept in Notification::$id.

The events each provider reads

Provider Event status Also
Stripe checkout.session.completed, checkout.session.async_payment_succeeded PAID when the session's payment_status is paid, else PENDING transaction: the session
Stripe checkout.session.expired EXPIRED transaction
Stripe checkout.session.async_payment_failed REFUSED transaction
Stripe account.updated - account
Stripe customer.subscription.* - subscription
Stripe product.created, product.updated; price.*; product.deleted - product (read with its prices: one or two calls); null for a deletion
PayPal CHECKOUT.ORDER.APPROVED PENDING (the money comes with capture()) transaction: the order
PayPal CHECKOUT.ORDER.COMPLETED, PAYMENT.CAPTURE.COMPLETED PAID transaction for an order event
PayPal PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.DECLINED REFUSED
PayPal PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.REVERSED REFUNDED
PayPal PAYMENT.AUTHORIZATION.CREATED AUTHORIZED
PayPal PAYMENT.AUTHORIZATION.VOIDED, CHECKOUT.ORDER.VOIDED CANCELLED
Shopify orders/paid PAID reference: the draft order the sale began with, else the order's gid
Shopify orders/cancelled CANCELLED
Shopify refunds/create REFUNDED (a partial refund too) reference: the refund's gid
Shopify products/create, products/update; products/delete - product; null for a deletion
Shopify inventory_levels/update - stocks: one level at one location, by inventory item
WooCommerce order.created, order.updated the order's status, as fetch() reads it transaction: the order
WooCommerce order.deleted -
WooCommerce product.created, product.updated, product.restored; product.deleted - product (variations read along); null for a deletion

Any other event is handed back with no status, its body in $raw. For a PayPal capture event, reference is the order's id from the capture's supplementary_data.related_ids.order_id. For a Shopify order, the merchant's reference (the Payment::$reference) travels as the note attribute omnitrade_reference and is given back in $raw['merchant_reference']. Each provider's manual lists the events to subscribe to, and the catalogue's in catalogue.

The web and Amazon providers receive no webhooks: supports(Notify::class) is false.

Propose a change docs/usage-webhooks.md

Catalogue

The same gateways that take payments read a platform's catalogue: a shop kept in Shopify, WooCommerce or Stripe's own Products is read through one contract, and a site copies it into its own tables (omnibase/marketplace's marketplace:catalogue:sync) or shows it as it is.

$gateway = $registry->get('shopify');

$page = $gateway->fetchProducts();                                   // the first page
while ($page->hasMore()) {
    $page = $gateway->fetchProducts($page->next);                    // the next one, by its cursor
}
$gateway->fetchProducts(updatedSince: new \DateTimeImmutable('-1 day'));   // what changed
$gateway->fetchProducts(query: 'margaux');                           // the platform's own search

$product = $gateway->fetchProduct('gid://shopify/Product/42');       // by its id there
$product = $gateway->fetchProduct('https://cave.example/products/margaux-2019');   // or by its page

$stocks = $gateway->fetchInventory(['gid://shopify/ProductVariant/7']);

A complete example

A catalogue copied page by page, then two affiliate links. The script runs as it is (composer require omnitrade/woocommerce omnitrade/web omnitrade/amazon): it answers in the shop's place, with answers shaped as WooCommerce's REST API v3 gives them - two pages of one product each, the second a variable product with two variations.

<?php // catalogue.php: a WooCommerce catalogue copied page by page, on answers shaped as REST v3 gives them

require __DIR__.'/vendor/autoload.php';

use Omnitrade\Amazon\AmazonGatewayFactory;
use Omnitrade\Web\WebGatewayFactory;
use Omnitrade\WooCommerce\WooCommerceGatewayFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$products = [
    1 => [['id' => 501, 'name' => 'Riesling Grand Cru', 'slug' => 'riesling-grand-cru', 'type' => 'simple', 'status' => 'publish', 'permalink' => 'https://shop.example/product/riesling-grand-cru/',
        'regular_price' => '24.00', 'sale_price' => '19.50', 'on_sale' => true, 'sku' => 'RIE-GC', 'manage_stock' => true, 'stock_quantity' => 36, 'stock_status' => 'instock', 'weight' => '1.3',
        'categories' => [['name' => 'Blancs']], 'tags' => [], 'attributes' => [['name' => 'Brand', 'slug' => 'pa_brand', 'variation' => false, 'options' => ['Domaine Weinbach']]], 'images' => [['id' => 9, 'src' => 'https://shop.example/riesling.jpg', 'alt' => 'Riesling']], 'date_modified_gmt' => '2026-10-20T08:00:00']],
    2 => [['id' => 602, 'name' => 'Pinot Noir', 'slug' => 'pinot-noir', 'type' => 'variable', 'status' => 'publish', 'permalink' => 'https://shop.example/product/pinot-noir/',
        'categories' => [['name' => 'Rouges']], 'tags' => [['name' => 'bio']], 'attributes' => [['name' => 'Millésime', 'variation' => true, 'options' => ['2019', '2020']]], 'images' => [], 'date_modified_gmt' => '2026-10-21T08:00:00']],
];
$variations = [
    ['id' => 603, 'status' => 'publish', 'regular_price' => '18.00', 'sku' => 'PN-19', 'manage_stock' => true, 'stock_quantity' => 0, 'stock_status' => 'outofstock', 'attributes' => [['name' => 'Millésime', 'option' => '2019']]],
    ['id' => 604, 'status' => 'publish', 'regular_price' => '16.00', 'sku' => 'PN-20', 'manage_stock' => false, 'stock_quantity' => null, 'stock_status' => 'instock', 'attributes' => [['name' => 'Millésime', 'option' => '2020']]],
];
$http = new MockHttpClient(static function (string $method, string $url, array $options) use ($products, $variations): MockResponse {
    parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
    [$body, $pages] = match (parse_url($url, PHP_URL_PATH)) {
        '/wp-json/wc/v3/products' => [$products[(int) ($query['page'] ?? 1)] ?? [], '2'],   // two pages of one product
        '/wp-json/wc/v3/products/602/variations' => [$variations, '1'],
    };

    return new MockResponse(json_encode($body), ['response_headers' => ['content-type' => 'application/json', 'x-wp-totalpages' => $pages]]);
});

$woo = (new WooCommerceGatewayFactory($http))->create(['url' => 'https://shop.example', 'consumer_key' => 'ck_not_a_real_key', 'consumer_secret' => 'cs_not_a_real_secret', 'currency' => 'EUR']);

$cursor = null;
do {
    $page = $woo->fetchProducts($cursor, updatedSince: new DateTimeImmutable('2026-10-01'), limit: 1);
    foreach ($page->products as $product) {
        printf("%s %s [%s] brand=%s categories=%s\n", $product->reference, $product->title, $product->status, $product->brand ?? '-', implode(',', $product->categories));
        foreach ($product->variants as $variant) {
            $offer = $variant->offer();
            printf("  %-4s %-6s %-8s %s%s stock=%s %s\n", $variant->reference, $variant->sku, $variant->title ?? '-', $offer?->price, $offer?->compareAt ? ' (was '.$offer->compareAt.')' : '', $variant->stock?->quantity ?? 'not counted', $offer?->available ? 'available' : 'unavailable');
        }
        echo '  lowest price: ', $product->price(), "\n";
    }
    $cursor = $page->next;
} while ($page->hasMore());

// Affiliate links: no call is made for either.
$web = (new WebGatewayFactory($http))->create(['affiliate' => ['fnac.com' => ['awc' => '1234_5678']]]);
var_dump($web->affiliateLink('https://www.fnac.com/a1234/livre?utm_source=news&ref=x'));
var_dump($web->affiliateLink('https://www.example.org/p/1'));
$amazon = (new AmazonGatewayFactory($http))->create(['partner_tag' => 'mysite-21']);
var_dump($amazon->affiliateLink('https://www.amazon.de/Some-Book/dp/B0C1234567/ref=sr_1_3?psc=1'));
$ php catalogue.php
501 Riesling Grand Cru [active] brand=Domaine Weinbach categories=Blancs
  501  RIE-GC -        19.50 EUR (was 24.00 EUR) stock=36 available
  lowest price: 19.50 EUR
602 Pinot Noir [active] brand=- categories=Rouges
  603  PN-19  2019     18.00 EUR stock=0 unavailable
  604  PN-20  2020     16.00 EUR stock=not counted available
  lowest price: 16.00 EUR
string(46) "https://www.fnac.com/a1234/livre?awc=1234_5678"
NULL
string(49) "https://www.amazon.de/dp/B0C1234567?tag=mysite-21"

(run on 2026-10-25 in the harness image, PHP 8.4)

What it shows: a sale price is the offer's price and the regular one its compareAt; a "Brand" attribute is the brand; a variable product's variations are its variants, titled by their values, the one out of stock not available; a variation that does not manage its stock has a Stock with a null quantity (not counted); Product::price() is the lowest variant's. The cursor of a WooCommerce page is its number ("2"); the loop ends when hasMore() is false. Neither affiliate link makes a call: omnitrade/web adds the parameters configured for the host and drops utm_source and ref, and gives null for a host it has no rule for; omnitrade/amazon keeps the Amazon store of the address (www.amazon.de) and writes /dp/<ASIN>?tag=<partner tag>.

Models

All final readonly, under Omnitrade\Model:

Model What it holds
Product provider, reference (the platform's id), title, description (HTML), handle, brand (Shopify's vendor, WooCommerce's brand), url, status (active, draft, archived), tags, categories, attributes (key/value), options, variants, media, merchant, updatedAt, raw
ProductVariant reference, title (null for an only variant), sku, barcode, offers, options (its value of each option), stock, image, attributes, weight (grams), raw
Offer price (Money), compareAt, available, url, merchant (null: the shop itself), reference (a Stripe price), taxIncluded
Stock reference (the variant), quantity (null: not counted), tracked, sku, location, item (a Shopify inventory item)
Media url, alt, type (image, video), width, height, reference
Option name, values: what the variants differ by ("Millésime: 2018, 2019")
Merchant name, reference, url, country: who sells, when it is not the shop
Reference id or url: Reference::of() tells them apart; slug() is the page's last segment
ProductPage products, next (the cursor of the next page, null on the last)

ProductVariant::price() is its first offer's price (the shop's own); Product::price() the lowest of its variants'. What the platform answered is kept in $raw.

Requests

Request Result Shortcut
FetchProducts(?cursor, ?updatedSince, ?query, limit = 50) ProductPage fetchProducts()
FetchProduct(Reference|string) ?Product fetchProduct()
FetchInventory(list<string> $references = []) list<Stock> fetchInventory()
AffiliateLink(Reference|string, ?tag) ?string (a URL; null: no programme for it) affiliateLink()

A provider says what it reads with supports(): Stripe has no stock, so supports(FetchInventory::class) is false there and the stock stays the site's.

Provider FetchProducts FetchProduct (id / URL) FetchInventory
omnitrade/shopify Admin GraphQL products (endCursor), updated_at:> and search filters gid, numeric id, handle / handle from /products/<handle>, id from /admin/products/<id> nodes(ids:) on the variants, or every productVariants
omnitrade/woocommerce REST v3 /products (page number, modified_after, search), variations id (a variation's gives its product), slug / slug, ?p=<id> stock_quantity of products and variations
omnitrade/web not supported none / any product page: JSON-LD Product or ProductGroup, microdata, OpenGraph not supported
omnitrade/amazon (with client_id and client_secret) Creators API searchItems (a query is required, else a ProviderException query_required; 1 to 10 items; no paging) ASIN / an Amazon page naming one (getItems) not supported
omnitrade/stripe Products (starting_after; search on name~ for a query; updatedSince filtered among each page) with their active Prices prod_… / none not supported

A cursor is a position, not a search: hand the same updatedSince and query with it to the next FetchProducts. A provider that filters a page after reading it (Stripe's updatedSince) may give a short or empty page that still has a next one: page on while hasMore().

Each provider's manual says what it maps how: the Catalogue section of omnitrade/stripe, omnitrade/shopify and omnitrade/woocommerce, the Creators API section of omnitrade/amazon, the page-reading section of omnitrade/web.

limit is capped by each provider: 100 at Stripe and WooCommerce, 250 at Shopify, 10 at Amazon (at least 1 everywhere). Amazon ignores cursor and updatedSince and gives no next page.

Webhooks

A product or stock event goes through notify() like a payment's: the Notification then carries $product (null for a deletion; $reference is the product's id) or $stocks, and isCatalogue() is true.

$notification = $gateway->notify($request->getContent(), $request->headers->all());
if ($notification->isCatalogue()) {
    // update the copy: $notification->product, $notification->stocks
}

affiliateLink() gives the address that sends a buyer to a product with the site's tag, or null when the provider has no partner programme for it:

$registry->get('amazon')->affiliateLink('https://www.amazon.fr/dp/B0C1234567/ref=x');   // https://www.amazon.fr/dp/B0C1234567?tag=mysite-21
$registry->get('web')->affiliateLink('https://www.fnac.com/a1');                        // the parameters configured for fnac.com, or null

omnitrade/amazon needs only the partner tag for it (no API call); omnitrade/web adds the query parameters its affiliate option maps to the page's host, and drops the tracking parameters the address came with.

Propose a change docs/catalogue.md

Connected accounts

A platform that takes payments for others - the host of a gift fund, a seller of a marketplace - never keeps that money: the provider opens an account for each of them (Stripe Connect), the buyer pays the platform's page, and the provider passes the payment on, less the platform's fee.

$stripe = $registry->get('stripe');

// 1. An Express account for the host, once.
$account = $stripe->createAccount('FR', 'lea@example.org', metadata: ['holder' => (string) $user->getId()]);
// keep $account->reference ("acct_...")

// 2. The provider's page where they give their identity and bank account.
return new RedirectResponse($stripe->accountLink($account->reference, $returnUrl, $refreshUrl));

// 3. Back on $returnUrl (or on the account.updated webhook): is it ready?
$account = $stripe->fetchAccount($reference);
$account->isReady();          // chargesEnabled && payoutsEnabled
$account->requirements;       // what is still missing: ["external_account"]

// 4. A payment sent to it: a destination charge, the platform's fee kept.
$stripe->purchase(new Payment(
    Money::of(5000, 'EUR'), 'GIFT-7', 'Participation',
    returnUrl: $return,
    destination: $account->reference,
    applicationFee: Money::of(150, 'EUR'),
));

Models and requests

Request Result Shortcut
CreateAccount(country, ?email, type = express, ?businessType, metadata, capabilities = [transfers], ?idempotencyKey) Account createAccount()
AccountLink(reference, returnUrl, refreshUrl, type = onboarding) the page's URL (single use, a few minutes) accountLink()
FetchAccount(reference) Account fetchAccount()

Model\Account: provider, reference, type, country, email, detailsSubmitted, chargesEnabled, payoutsEnabled, requirements, defaultCurrency, metadata, raw; isReady().

Model\Payment takes two more arguments: destination (the connected account) and applicationFee (a Money in the payment's currency, within its amount; refused without a destination). A provider without connected accounts ignores neither: it does not support CreateAccount, so nothing can name a destination there.

Webhooks

notify() hands an account's event back with Notification::$account (the account as it stands) and isAccount() true; $reference is the account's id. With Stripe, forward the Connect events to the same endpoint (stripe listen --forward-connect-to …, or a Connect webhook endpoint in the dashboard).

Provider CreateAccount AccountLink FetchAccount destination / applicationFee
omnitrade/stripe POST /v1/accounts (Express) POST /v1/account_links GET /v1/accounts/{id} payment_intent_data[transfer_data][destination], [application_fee_amount] on the Checkout session
others not supported

What the shortcuts leave out

createAccount() sends no business type, the default capabilities (['transfers']) and no idempotency key; accountLink() asks for the onboarding page. The requests take the rest:

use Omnitrade\Model\Account;
use Omnitrade\Request\AccountLink;
use Omnitrade\Request\CreateAccount;

$account = $stripe->execute(new CreateAccount(
    country: 'FR',
    email: 'lea@example.org',
    type: Account::EXPRESS,
    businessType: 'individual',                     // or "company"; null: asked during onboarding
    metadata: ['holder' => '42'],
    capabilities: ['transfers', 'card_payments'],   // where Stripe requires both for destination charges
    idempotencyKey: 'host-42',                      // the same account asked twice is opened once
))->getAccount();

$url = $stripe->execute(new AccountLink($account->reference, $returnUrl, $refreshUrl, AccountLink::UPDATE))->getUrl();   // the holder changes their details

An account link is valid a few minutes and used once: $refreshUrl is where the holder lands when it ran out - make a new one there. Back on $returnUrl, ask fetchAccount() what is still missing: coming back does not mean the onboarding is complete.

Propose a change docs/connect.md

Subscriptions

A payment that renews: the buyer subscribes on the provider's page, the provider charges every period and tells of each step by webhook.

$transaction = $gateway->subscribe(new Payment(
    Money::of(1900, 'EUR'), 'PLAN-D-42', 'Formule D',
    new Customer(email: 'lea@example.org'),
    returnUrl: $return, metadata: ['order' => 'PLAN-D-42'],
), 'month');
return new RedirectResponse($transaction->redirectUrl);

$subscription = $gateway->fetchSubscription('sub_...');
$subscription->isActive();            // active or trialing
$subscription->currentPeriodEnd;      // what was paid for lasts until then

$gateway->cancelSubscription('sub_...');          // at the end of the paid period
$gateway->cancelSubscription('sub_...', false);   // at once
$gateway->subscriptionPortal($subscription->customer, $returnUrl, 'fr');   // card, invoices, cancellation: the provider's page
Request Result Shortcut
Subscribe(Payment, interval = month, intervalCount = 1, ?price, ?trialDays, ?customer) Transaction (PENDING, redirectUrl) subscribe()
FetchSubscription(reference) Subscription fetchSubscription()
CancelSubscription(reference, atPeriodEnd = true) Subscription cancelSubscription()
SubscriptionPortal(customer, returnUrl, ?locale) the page's URL subscriptionPortal()

The payment's amount is what each period costs, its description what is subscribed to; price names a price kept at the provider instead. Its metadata follows the subscription: every later event carries it.

Model\Subscription: provider, reference, status (active, trialing, past_due, unpaid, cancelled, incomplete, paused), customer, amount, interval, intervalCount, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd, cancelledAt, price, metadata, raw.

Webhooks

  • the first payment is the session's: Notification::$transaction PAID, its raw['subscription'] the subscription's id, raw['customer'] the customer;
  • the subscription's life (customer.subscription.created, .updated, .deleted with Stripe): Notification::$subscription, isSubscription() true, $reference its id. A renewal moves currentPeriodEnd; a payment that fails makes it past_due; its end, cancelled.

Only omnitrade/stripe answers these requests (Checkout in subscription mode, the Billing customer portal).

What the shortcut leaves out

subscribe() takes the payment, the interval, the count and a provider's price. A trial and an existing customer go through the request:

use Omnitrade\Request\Subscribe;

$transaction = $gateway->execute(new Subscribe(
    payment: $payment,
    interval: 'month',          // day, week, month or year; anything else is an \InvalidArgumentException
    intervalCount: 3,           // every three months; under 1 is an \InvalidArgumentException
    price: null,                // a Stripe price ("price_...") in place of the payment's amount
    trialDays: 14,
    customer: 'cus_Q1x',        // the buyer is already Stripe's customer: no customer_email is sent
))->getTransaction();

Propose a change docs/subscriptions.md

Configuration reference

The registry

new Registry(iterable $factories, array $config): the factories installed, and the gateways by name. Each gateway is a factory's name and its options:

[
    'card' => ['factory' => 'stripe', 'options' => ['api_key' => '...', 'webhook_secret' => '...']],
    'woo' => ['factory' => 'woocommerce', 'options' => ['url' => '...', 'consumer_key' => '...', 'consumer_secret' => '...']],
]
Key Type Required
<name> string - the gateway's name: Registry::get('<name>'), GatewayInterface $<name> in Symfony
<name>.factory string yes a factory's getName()
<name>.options map no ([]) handed to the factory's create()

Several gateways may use one factory (two Stripe accounts, a live and a sandbox PayPal). In Symfony the same tree sits under omnitrade.gateways (Symfony).

Options are read when the gateway is built, not before: a required option missing, '' or [] is an InvalidConfigException naming every missing one. An option no code reads is kept in the Config and ignored. Every key of the Config can be given as an option, the reserved ones included: omnitrade.action.<name> replaces a provider's action (Concepts).

omnitrade/stripe

Option Type Default Required
api_key string - yes a secret key (sk_live_..., sk_test_...) or a restricted key with what the gateway calls
webhook_secret ?string null for notify() the endpoint's signing secret (whsec_...); without it notify() refuses every event
adaptive_pricing bool false no true: Stripe may offer the buyer their own currency on Checkout (adaptive_pricing[enabled])
payment_methods list of strings [] no the Checkout methods to insist on (['card', 'sepa_debit'], sent as payment_method_types) and what paymentMethods() lists; []: Stripe's choice per account, country and currency
active_only bool false no true: fetchProducts() lists the active products only

Plain REST calls (catalogue, Connect, subscriptions) have a 30-second timeout; omnipay's calls (Checkout, refunds) use the HTTP client's.

omnitrade/paypal

Option Type Default Required
client_id string - yes the REST app's client id
secret string - yes the REST app's secret
sandbox bool false no true: https://api-m.sandbox.paypal.com with sandbox credentials; else https://api-m.paypal.com
webhook_id ?string null for notify() the webhook's id in the developer dashboard, sent to PayPal's verification
timeout int 15 no seconds, for every call

omnitrade/shopify

Option Type Default Required
shop_domain string - yes example.myshopify.com
admin_token string - yes a custom app's Admin API access token (shpat_...)
webhook_secret ?string null for notify() the app's API secret, which signs its webhooks
api_version string 2026-07 no the Admin API version in https://<shop>/admin/api/<version>/graphql.json
timeout int 15 no seconds
draft_order_tags list of strings [] no tags put on the draft orders purchase() creates
currency ?string null no the currency the catalogue's prices are read in; null: the shop's (shop { currencyCode }). A label: nothing is converted
retry_delay float 1 no seconds before retrying a 429 or a 5xx, doubled at each attempt (3 attempts); a 429's Retry-After prevails, capped at 60

omnitrade/woocommerce

Option Type Default Required
url string - yes the shop's address (https://shop.example); the API is <url>/wp-json/wc/v3/
consumer_key string - yes a REST API key (ck_...), HTTP basic authentication
consumer_secret string - yes its secret (cs_...)
webhook_secret ?string null for notify() the secret set on the shop's webhooks
timeout int 15 no seconds
currency string EUR no the store's currency, for the catalogue's prices (bare decimals in the API); orders carry their own
weight_unit string kg no the store's weight unit: kg, g, lbs, oz; weights are given in grams
product_status string any no what fetchProducts() lists: any (drafts, pending and private ones too), or publish

omnitrade/web

Option Type Default Required
user_agent string Mozilla/5.0 (compatible; Omnitrade/1.0; +https://github.com/glitchr-omnisense/omnitrade-web) no what the shop sees
timeout int 10 no seconds
max_bytes int 3000000 no a longer page is cut there
allow_private bool false no true: addresses of the local network may be read (an intranet, tests)
currency string EUR no of a price found without one
affiliate map host => map of query parameters [] no for affiliateLink(): { 'amazon.fr': { tag: 'mysite-21' } }; a host matches itself and its subdomains

omnitrade/amazon

Option Type Default Required
client_id ?string null for the reads the Creators API credentials; without both, fetchProduct() and fetchProducts() are not supported
client_secret ?string null for the reads
partner_tag ?string null for links the Associates tag (mysite-21), in every request and every link; without it affiliateLink() answers null
marketplace string www.amazon.fr no the store read, sent in the body and as x-marketplace; the host of links to a bare ASIN
credential_version string 3.2 no the token endpoint: 3.1 https://api.amazon.com/auth/o2/token, 3.2 https://api.amazon.co.uk/auth/o2/token, 3.3 https://api.amazon.co.jp/auth/o2/token; another value without token_url is an InvalidConfigException when the gateway is built
token_url ?string null no another token endpoint, in place of credential_version's
api_url string https://creatorsapi.amazon/catalog/v1 no
timeout int 10 no seconds

No option is required: a gateway with only partner_tag writes affiliate links.

What each provider reads of a payment

Payment carries more than any one provider sends. What each purchase() sends (authorize() the same at PayPal), from the code:

Field Stripe PayPal Shopify WooCommerce
amount yes (unit_amount of the single line) amount.value the single line's price, presentmentCurrencyCode the single item's total, currency
reference client_reference_id custom_id (127), PayPal-Request-Id custom attribute omnitrade_reference meta omnitrade_reference
description the single line's name description (127) the draft order's note, the single line's title the single item's name
customer email (customer_email) email (payment_source.paypal.email_address) email billing: email, name (first word / the rest), phone, street (two lines), postal code, city, country
lines name, image, unit amount, quantity name (127), quantity, unit amount, sku, physical (category PHYSICAL_GOODS / DIGITAL_GOODS) a variant gid in reference names the variant, else a custom line (title, physical as requiresShipping, sku) name, quantity, total; a numeric reference as product_id
shipping shipping_options[0][shipping_rate_data], a fixed amount (when the lines are listed) breakdown.shipping shippingLine a flat_rate shipping line
discount not sent as such: the amount is one line breakdown.discount appliedDiscount, fixed amount a negative fee line
returnUrl required, success_url required, experience_context.return_url not used not used
cancelUrl cancel_url (else returnUrl?cancel=1) experience_context.cancel_url not used not used
idempotencyKey Idempotency-Key PayPal-Request-Id, invoice_id not used not used
method payment_method_types not used not used not used
notice custom_text.submit.message, the payment intent's description not used not used customer_note
metadata metadata (strings) not used custom attributes meta_data
locale its first two letters experience_context.locale (_ made -) not used not used
destination, applicationFee a destination charge (connect) not used not used not used

Lines are sent only when Payment::linesAddUp(); otherwise the amount is one line. PayPal also sends user_action: PAY_NOW and shipping_preference: NO_SHIPPING.

Propose a change docs/configuration.md

Errors

Every exception Omnitrade throws implements Omnitrade\Exception\OmnitradeException (an interface extending \Throwable):

Exception Extends When
InvalidConfigException \LogicException a gateway not configured, a factory not installed, a required option missing or empty, an API client of the wrong class, Amazon's unknown credential_version
RequestNotSupportedException \LogicException the provider does not do that (no authorizations at Shopify): supports() says so beforehand; thrown before any call
ProviderException \RuntimeException the provider refused, or could not be reached: any HTTP error status, a transport failure, an answer that cannot be read, a request the provider package refuses before calling (a Checkout session without returnUrl)
InvalidNotificationException ProviderException a webhook whose signature does not hold, that is unsigned or too old, or that cannot be checked for want of a secret

The first two are mistakes of the application (\LogicException): fix the configuration or ask supports() first. The last two happen at run time: catch them where the application talks to the provider.

use Omnitrade\Exception\InvalidNotificationException;
use Omnitrade\Exception\ProviderException;
use Omnitrade\Exception\RequestNotSupportedException;
use Omnitrade\Request\Refund;

if (!$gateway->supports(Refund::class)) {
    return 'Refund it from the platform\'s own admin.';
}
try {
    $refund = $gateway->refund($reference, Money::of(500, 'EUR'), idempotencyKey: $attempt);
} catch (ProviderException $e) {
    $logger->warning('Refund refused', ['provider' => $e->provider, 'code' => $e->providerCode, 'message' => $e->getMessage()]);
}

ProviderException

new ProviderException(string $provider, string $message, ?string $providerCode = null, ?\Throwable $previous = null)
Member
string $provider the gateway's name (stripe, paypal)
?string $providerCode the provider's own error code, when it gave one
getMessage() [<provider>] <message>: the provider's own message when it gave one
getPrevious() a copy of the HTTP client's exception, for a transport failure (no secret in it)

There is no exception per status: a 401 is a ProviderException like a 500, its message and providerCode saying which. What each provider puts there:

Provider message providerCode
Stripe error.message error.code (resource_missing)
PayPal details[0].description, else message, else error_description name (RESOURCE_NOT_FOUND), else details[0].issue, else error
Shopify Shopify returned HTTP 401., the GraphQL errors or a mutation's userErrors (field: message; ...) none; ShopifyApiException::$errors keeps Shopify's error list
WooCommerce message code (woocommerce_rest_shop_order_invalid_id)
Web The page answered HTTP 404., Not a web address, ... is on the local network. the HTTP status, invalid_url, private_address
Amazon errors[0].message (or Message) errors[0].code, else the HTTP status; no_credentials, query_required

A body that is not JSON (an HTML error page from a proxy) is ... answered HTTP <status> with a body that is not JSON.; a transport failure (DNS, connection, timeout) is ... request failed: <the HTTP client's message>, a copy of that exception as previous, the secrets masked. Omnitrade\Shopify\Api\ShopifyApiException is Shopify's ProviderException, with the errors Shopify gave in $errors.

Gateway::execute() also throws a ProviderException when an action returns without answering (<action class> left the request unanswered.): a provider package's bug.

No secret in a failure

What a provider throws keeps the request's secrets out, for a log that writes the whole chain, arguments included (zend.exception_ignore_args off). The HTTP client's message quotes the URL called: in the ProviderException's message its key parameters, a password in its user part and every secret of the request (a key, a password, a bearer or basic credential) are masked as *** (Omnitrade\Http\Redaction::text()). The previous exception is a copy of the client's (Redaction::copy(): the same class and code, that message, its own previous copied the same way, without the response or request a client's exception holds), and no trace of the chain keeps its arguments, where the options, headers and body stood (Redaction::withoutArguments()). A GatewayFactory::create() that fails (a missing option) throws without arguments in its trace either: they held the options, credentials included.

Real messages

From a script run on 2026-10-25 in the harness image, on a registry with a PayPal gateway, one missing its secret, a Shopify gateway, one naming a factory not installed, and PayPal answering HTTP 404:

get("card")
  InvalidConfigException: No "card" gateway; configured: paypal, broken, shop, other.
get("other")
  InvalidConfigException: No "stripe" factory for the "other" gateway; installed: paypal, shopify.
get("broken")
  InvalidConfigException: The "paypal" gateway needs: secret.
shop->authorize()
  RequestNotSupportedException: The "shopify" gateway does not support Authorize.
paypal->fetch()
  ProviderException: [paypal] Specified resource ID does not exist. (provider paypal, providerCode 'RESOURCE_NOT_FOUND')
paypal->notify()
  InvalidNotificationException: [paypal] No webhook_id: the notification cannot be checked. (provider paypal, providerCode NULL)
new Payment(fee, no destination)
  InvalidArgumentException: An application fee is kept on a payment sent to a destination account.

Not found

fetchProduct() of a product the provider does not have answers null, not an exception (Stripe's resource_missing, WooCommerce's invalid_id, Shopify's empty product, an Amazon getItems with no item, a page with no product markup). fetchOrder() of an unknown Shopify order is a ProviderException (Shopify knows no order ...); fetch() of a deleted Shopify draft order is a CANCELLED transaction. Elsewhere an unknown reference is the provider's 404, a ProviderException.

The models' own checks

Models and requests check their arguments with PHP's \InvalidArgumentException, which is not an OmnitradeException: it is thrown by the constructor, before any gateway is involved.

Thrown by When
Payment an applicationFee without a destination; one in another currency, negative or larger than the amount
Reference (and FetchProduct, AffiliateLink, which build one) neither an id nor a URL
Subscribe an interval other than day, week, month, year; an interval count under 1

Propose a change docs/errors.md

Testing

Code that uses Omnitrade depends on GatewayInterface (or on the Registry), never on a provider's classes: a test hands it a gateway that answers what the test needs. Two ways, both offline:

  • a provider of the test's own: a small GatewayFactory whose actions answer fixed transactions. The family has no ready-made fake gateway: a factory of a few lines does it, and supports(), execute() and the exceptions behave as with any provider;
  • a real provider on recorded answers: the provider's factory built on Symfony's MockHttpClient, which answers each request as the service would. The provider's own mapping runs, so the test also proves the application reads what that provider gives.

An application's test

CheckoutTest.php tests a Checkout service of the application both ways:

<?php

namespace App\Tests;

use Omnitrade\Action\ActionInterface;
use Omnitrade\Config;
use Omnitrade\GatewayFactory;
use Omnitrade\GatewayInterface;
use Omnitrade\Model\Money;
use Omnitrade\Model\Payment;
use Omnitrade\Model\Status;
use Omnitrade\Model\Transaction;
use Omnitrade\PayPal\PayPalGatewayFactory;
use Omnitrade\Registry;
use Omnitrade\Request\FetchTransaction;
use Omnitrade\Request\Purchase;
use Omnitrade\Request\Request;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

/** The application's code under test: where to send the buyer, or null when it is paid already. */
final class Checkout
{
    public function __construct(private readonly GatewayInterface $gateway)
    {
    }

    public function start(string $order, int $cents): ?string
    {
        $transaction = $this->gateway->purchase(new Payment(Money::of($cents, 'EUR'), $order, returnUrl: 'https://shop.example/back', idempotencyKey: $order.'-1'));

        return $transaction->isRedirect() ? $transaction->redirectUrl : null;
    }
}

/** A provider for the tests only: every purchase is a page to go to, every fetch is paid. */
final class FixedGatewayFactory extends GatewayFactory
{
    protected function populateConfig(Config $config): void
    {
        $config->defaults([
            'omnitrade.factory_name' => 'fixed',
            'omnitrade.factory_title' => 'Fixed',
            'omnitrade.action.purchase' => new class implements ActionInterface {
                public function supports(Request $request): bool { return $request instanceof Purchase; }
                public function execute(Request $request): void
                {
                    $request->setResult(new Transaction('fixed', 'tx_'.$request->payment->reference, Status::PENDING, $request->payment->amount, 'https://pay.example/tx_'.$request->payment->reference));
                }
            },
            'omnitrade.action.fetch' => new class implements ActionInterface {
                public function supports(Request $request): bool { return $request instanceof FetchTransaction; }
                public function execute(Request $request): void { $request->setResult(new Transaction('fixed', $request->reference, Status::PAID)); }
            },
        ]);
    }
}

final class CheckoutTest extends TestCase
{
    public function testTheBuyerIsSentToTheProvidersPage(): void
    {
        $checkout = new Checkout((new FixedGatewayFactory())->create());

        self::assertSame('https://pay.example/tx_ORDER-7', $checkout->start('ORDER-7', 1250));
    }

    public function testWithPayPalsAnswersRecorded(): void
    {
        $sent = [];
        $http = new MockHttpClient(static function (string $method, string $url, array $options) use (&$sent): MockResponse {
            $sent[] = $method.' '.parse_url($url, \PHP_URL_PATH);

            return new MockResponse(json_encode(match (parse_url($url, \PHP_URL_PATH)) {
                '/v1/oauth2/token' => ['access_token' => 'A21AA_test', 'expires_in' => 32400],
                '/v2/checkout/orders' => ['id' => '5O190127TN364715T', 'status' => 'PAYER_ACTION_REQUIRED', 'links' => [['rel' => 'payer-action', 'href' => 'https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T']]],
            }));
        });
        $registry = new Registry([new PayPalGatewayFactory($http)], ['paypal' => ['factory' => 'paypal', 'options' => ['client_id' => 'id', 'secret' => 'secret', 'sandbox' => true]]]);

        $url = (new Checkout($registry->get('paypal')))->start('ORDER-7', 1250);

        self::assertSame('https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T', $url);
        self::assertSame(['POST /v1/oauth2/token', 'POST /v2/checkout/orders'], $sent);
    }
}
$ vendor/bin/phpunit --testdox tests/CheckoutTest.php
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.26

Time: 00:00.057, Memory: 8.00 MB

Checkout (App\Tests\Checkout)
 ✔ The buyer is sent to the providers page
 ✔ With pay pals answers recorded

OK (2 tests, 3 assertions)

(run on 2026-10-25 in the harness image, with phpunit/phpunit 11.5 and symfony/http-client; MockHttpClient is part of symfony/http-client, which every provider requires.)

What the recorded answers must look like is each provider's: the provider's own tests (Tests/*GatewayTest.php and Tests/*ContractTest.php in each provider's repository) hold answers shaped as each service documents them, and are a good place to copy from. The paths a provider calls are in its manual.

  • A webhook is tested the same way: sign the body as the provider does (Stripe's t=<time>,v1=<hmac>, Shopify's and WooCommerce's base64 HMAC, Api::sign() in omnitrade/woocommerce and Hmac::sign() in omnitrade/shopify compute it) and hand it to notify(); the payments example signs a Stripe event. PayPal's are verified by PayPal: record the answer of POST /v1/notifications/verify-webhook-signature.
  • In a Symfony application, replace the http_client service in the test environment with a MockHttpClient (framework.http_client.mock_response_factory), or configure a gateway on an application factory of the test's own: the bundle autoconfigures it (Symfony).
  • Registry::create($name, $overrides) builds a gateway with other options for one test (a sandbox flag, a webhook secret) without touching the configured one.

The conformance suite

Omnitrade\Testing\GatewayContractTests is the family's contract as PHPUnit tests, offline. It is a trait: a provider's test case uses it, gives its factory, fake options and its service's recorded answers, and gets twelve tests. Every provider of the family runs it (Tests/<Provider>ContractTest.php); a provider of one's own should too (Writing a provider). It requires phpunit/phpunit and symfony/http-client in the provider's require-dev.

No test reaches the network: every request goes to the suite's MockHttpClient. A request with no recorded answer is answered HTTP 404 and named in the failure message (no recorded answer for: ...).

Hooks

Method Must or may Default
factory(HttpClientInterface $http): GatewayFactoryInterface abstract - the provider's factory, built on the HTTP client the suite hands it
options(): array abstract - options that build the gateway with everything it offers turned on (a webhook secret, optional credentials): fake values
recordedAnswers(): array may [] the service's answers by "METHOD /path" (the query left out): a JSON body as an array (HTTP 200), a MockResponse, or a callable (string $method, string $url, array $options): MockResponse for an endpoint whose answer depends on the request (GraphQL)
requiredOptions(): array may the factory's omnitrade.required_options the options that must be given
payment(): Payment may 12,50 EUR, ORDER-1042, a customer, one line, return and cancel URLs, idempotency key attempt-1 what purchase(), authorize() and subscribe() are asked with
transactionReference(): string may contract-transaction the transaction (or platform order) fetch(), capture(), refund(), void() and fetchOrder() name
refundReference(): string may transactionReference() the payment refund() gives money back on (a captured one, where an authorization alone has nothing to refund)
productReference(): string may contract-product the product fetchProduct() and affiliateLink() name: an id there or a page's address
catalogueQuery(): ?string may null the search fetchProducts() is asked with (Amazon needs one)
signedNotification(): ?array needed when notify() is supported null a webhook as the provider signs it: [body, headers]
tamperedNotification(): array may the signed body with one more character the same webhook tampered with (PayPal's: a signature PayPal's recorded verification refuses)

What each test checks

Test Checks
testTheFactoryNamesTheGatewayItBuilds the factory's name is not empty and is the gateway's; the title is not empty; a Registry built from {factory, options} lists the factory and builds the gateway once
testAMissingRequiredOptionIsAConfigurationError each required option left out, then set to '', is an InvalidConfigException (the first naming it); with none required, the gateway builds with no options
testWhatItDoesNotSupportIsRefusedAsNotSupported every request supports() denies throws RequestNotSupportedException naming the gateway, and no HTTP request is made
testWhatItSupportsFailsAsAProviderErrorWhenTheProviderDoes every request it grants, when the service answers HTTP 401, 403, 404, 429 (Retry-After: 0), 500, 503 with an HTML page, HTTP 200 with an HTML page, or cannot be reached, answers or throws a ProviderException naming the provider - never a TypeError, a JsonException or the HTTP client's exception; fetchProduct() on a 404 is not a product
testAPurchaseIsATransactionInMinorUnits purchase() and authorize(), when supported, give a transaction naming the provider with a reference, of the payment's amount (when it gives one), PENDING, AUTHORIZED or PAID, isRedirect() when PENDING with a page
testAFetchedTransactionKeepsItsInvariants fetch(): the provider's name, a reference, money in ISO currencies and not negative, no more refunded than paid, a redirect to an http(s) page
testCaptureAndVoidAnswerTransactions capture() gives a transaction no longer AUTHORIZED, CANCELLED or EXPIRED; void() a CANCELLED one; neither supported: neither is authorize()
testARefundIsRecordedInMinorUnits refund() of 5,00 EUR (key refund-1, reason Overcharged) records 500 EUR cents with the provider's refund id, and a refund's status
testPaymentMethodsAreDescribed paymentMethods(12,50 EUR, 'FR'): a list of PaymentMethod with a code, a label, a kind among the constants, ISO currencies
testASignedNotificationIsReadAndATamperedOneRefused the signed webhook is read as one event naming the provider (its transaction and product keeping their invariants); the tampered one and the unsigned one (no headers) are InvalidNotificationExceptions naming the provider
testAPlatformOrderKeepsItsInvariants fetchOrder(): the provider's name, a reference, a total in an ISO currency, not negative
testTheCatalogueIsPagedAndItsProductsKeepTheirInvariants fetchProducts(limit 10) gives products naming the provider with a reference and a title, and a next that is null or not empty, matching hasMore(); fetchProduct() finds the product named; neither supported: neither is fetchInventory()

Each test of an operation the provider does not support asserts only that it is not supported. Connected accounts and subscriptions are covered by the "supported or not" and error tests only: their answers are checked by the provider's own tests (Stripe's Tests/StripeConnectTest.php).

What a failure looks like - a provider that catches the HTTP client's HttpExceptionInterface only, so that a transport failure escapes:

1) Acme\Pay\Tests\AcmePayContractTest::testWhatItSupportsFailsAsAProviderErrorWhenTheProviderDoes
the provider's failures are ProviderExceptions
Failed asserting that two arrays are identical.
--- Expected
+++ Actual
@@ @@
-Array &0 []
+Array &0 [
+    0 => 'Omnitrade\Request\Purchase on unreachable: Symfony\Component\HttpClient\Exception\TransportException ("Could not resolve host") instead of a ProviderException',
+    1 => 'Omnitrade\Request\FetchTransaction on unreachable: Symfony\Component\HttpClient\Exception\TransportException ("Could not resolve host") instead of a ProviderException',
+]

(run on 2026-10-25 on the provider of Writing a provider, its catch narrowed on purpose.)

Running the family's tests

  • In the workspace (~/Sites/omnitrade: the core and the six providers as path repositories, one phpunit.xml.dist with a suite per package): vendor/bin/phpunit, 145 tests on 2026-10-25.
  • In the Docker harness: docker compose run --rm omnitrade test, every package's tests in one run, the providers from GitHub or from the checkouts beside the core.
  • In one provider's repository, after composer install: vendor/bin/phpunit.

Propose a change docs/testing.md

The Docker harness

docker/ runs this package with every omnitrade/* provider installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNITRADE_PLUGINS=../.. is set in docker/.env

  • and a console that exercises them with the test keys in docker/.env.
cd docker && cp .env.dist .env       # your providers' test keys, when you have them
docker compose run --rm omnitrade gateways
Command Arguments and options
gateways - the providers installed and configured from .env, and what each does (the payment and catalogue requests it supports)
methods <gateway> --amount, --currency (EUR), --country paymentMethods()
purchase <gateway> --amount (minor units; default the lines' total, else 1990), --currency (EUR), --reference (OMNITRADE-<date>), --method, --line "label=unit[xquantity][@reference]" (repeatable) a payment: its page to open, or its state
authorize <gateway> as purchase an authorization to capture later
capture <gateway> <reference> --amount, --currency an authorization captured, all or part
void <gateway> <reference> - an authorization voided
fetch <gateway> <reference> - where a transaction stands
refund <gateway> <reference> --amount, --currency, --reason a refund, whole or partial; its idempotency key is refund-<date>
notify <gateway> -H "Name: value" (repeatable), the body on stdin a webhook checked against its signature and read
order <gateway> <reference> - a platform's order
catalogue <gateway> --query, --since (a date, or -1 day), --limit (10), --cursor, --product <id or page>, --inventory products, variants, prices and stock as tables; the next page's --cursor printed under each page
link <gateway> <reference> --tag an affiliate link
account <gateway> [<reference>] --create <country>, --email, --link a connected account opened, read, or its onboarding page (back to HARNESS_RETURN_URL, refreshed at HARNESS_CANCEL_URL)
subscribe <gateway> as purchase, plus --interval (month), --price a subscription: its page to open
subscription <gateway> <reference> --cancel, --now a subscription read, or stopped (at the period's end, or at once with --now)
bare --recorded, --json plain PHP: the registry built by hand, a product page read, a payment asked, what PHP loaded
test PHPUnit's arguments every package's tests
composer ..., sh, php ... run as they are in the harness's directory

Results are printed whole, as JSON (a transaction's page to open first, as Open: <url>); a configuration error exits with code 2, a provider's error with code 1, each with its message.

docker compose run --rm omnitrade purchase stripe --amount 1990      # prints the Checkout page to open
docker compose run --rm omnitrade fetch stripe cs_test_...
docker compose run --rm omnitrade notify stripe -H 'Stripe-Signature: t=...,v1=...' < event.json
docker compose run --rm omnitrade catalogue shopify --since='-1 day' --limit=20

Bare: no bundle, no container

The console above is a symfony/console application over a registry built by hand; bare is less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing else. It builds the Registry from the provider packages installed and asks each gateway that can be built what it does. With --recorded, on the answers kept in docker/harness/recorded/ and without a call, it reads a product from its page's own markup through omnitrade/web and asks PayPal for a payment of 12.50 EUR - an order, and the page to send the buyer to. Without --recorded the gateways are those of .env, the page read is the one at HARNESS_PRODUCT_URL when set, and nothing is bought. Then it lists what PHP loaded and exits 1 if a class of a framework is among it (Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a bridge, Doctrine, Twig):

$ docker compose run --rm omnitrade bare --recorded
Omnitrade in bare PHP: the registry built by hand, no bundle, no container.

  web          product affiliate
  paypal       purchase authorize capture refund fetch methods notify

omnitrade/web, the page https://boutique.example/p/poussette-yoyo kept in recorded/:
  Poussette Yoyo³ & nacelle, Stokke, sold by Boutique SAS
  449.90 EUR, barcode 8716134000123, 2 pictures - read from its json-ld

omnitrade/paypal, 12.50 EUR for ORDER-1042, from the answers kept in recorded/:
  order 5O190127TN364715T, pending - the buyer goes to https://www.sandbox.paypal.com/checkoutnow?token=5O190127TN364715T

Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a bridge, Doctrine, Twig): none

(the same output on 2026-10-25, the script run in the harness image on the workspace's checkouts, OMNITRADE_AUTOLOAD naming their autoloader, with no network)

The answers in recorded/ were not taken from PayPal nor from a shop: PayPal's are written in the shape its Orders v2 documentation gives, with that documentation's example ids and a token that is manifestly not one; the page is omnitrade/web's own fixture, of a shop that does not exist. bare --recorded --json prints the same whole - every class loaded, every file since the autoloader - without a call: Tests/BareTest.php runs it in a process of its own and checks the list.

omnitrade/stripe stands on Omnipay, which requires symfony/http-foundation for its own request object: installed with it, loaded only when a Stripe payment is made - a library there, with no kernel and no container.

The image is php:8.4-cli-alpine with Composer and bcmath; the harness's packages live in the harness volume of the omnitrade-harness project.

Configuration: docker/.env

docker/.env (git-ignored; copy .env.dist) holds the test keys. A gateway is configured once every key it needs is set; the others are listed by gateways as "needs ...".

Gateway Needs Other keys
stripe STRIPE_API_KEY STRIPE_WEBHOOK_SECRET, STRIPE_ADAPTIVE_PRICING (1: on)
paypal PAYPAL_CLIENT_ID, PAYPAL_SECRET PAYPAL_WEBHOOK_ID, PAYPAL_SANDBOX (0: live; default sandbox)
shopify SHOPIFY_SHOP_DOMAIN, SHOPIFY_ADMIN_TOKEN SHOPIFY_WEBHOOK_SECRET, SHOPIFY_API_VERSION, SHOPIFY_CURRENCY
woocommerce WOOCOMMERCE_URL, WOOCOMMERCE_CONSUMER_KEY, WOOCOMMERCE_CONSUMER_SECRET WOOCOMMERCE_WEBHOOK_SECRET, WOOCOMMERCE_CURRENCY, WOOCOMMERCE_WEIGHT_UNIT
web nothing WEB_AFFILIATE: rules as amazon.fr:tag=mysite-21,fnac.com:awc=1_2
amazon AMAZON_PARTNER_TAG AMAZON_CLIENT_ID, AMAZON_CLIENT_SECRET, AMAZON_MARKETPLACE (www.amazon.fr), AMAZON_CREDENTIAL_VERSION (3.2)

And for the commands: HARNESS_RETURN_URL, HARNESS_CANCEL_URL (where a hosted page sends the buyer back), HARNESS_CUSTOMER_EMAIL, HARNESS_CUSTOMER_NAME (the payment's customer), HARNESS_PRODUCT_URL (the page bare reads), OMNITRADE_PLUGINS (../..: the providers from the checkouts beside the core).

How it is put together

docker/compose.yml (project omnitrade-harness) has one service, omnitrade, image omnisense/omnitrade-harness, which mounts the core at /omnitrade/core, the providers' directory (OMNITRADE_PLUGINS, else the empty docker/plugins/) at /omnitrade/plugins, read only, and the harness volume at /harness. At each start the entrypoint runs docker/harness/setup.php, which writes /harness/composer.json and /harness/phpunit.xml: the core as a path repository, each provider from its checkout under /omnitrade/plugins/<slug> when there is one, else from https://github.com/glitchr-omnisense/omnitrade-<slug>.git (branch 1.x), plus symfony/console, symfony/http-client, symfony/var-dumper and, for the tests, PHPUnit and the bundle's Symfony components. It runs composer install the first time and composer update when that file changed, then the command. The first run needs the network; the volume keeps the packages and Composer's cache between runs.

The .env read by the console comes from env_file (required: false): without it every provider is listed as not configured, and bare --recorded and test still run.

Propose a change docs/harness.md

Writing a provider

A provider is a package of its own (omnitrade/<service>, namespace Omnitrade\<Service>\) that requires omnisense/omnitrade and symfony/http-client. It holds:

  • a factory extending Omnitrade\GatewayFactory, taking an optional HttpClientInterface in its constructor and filling the Config in populateConfig(): omnitrade.factory_name (the name factory: names in the configuration), omnitrade.factory_title, omnitrade.required_options, the defaults of its other options, omnitrade.api (its API client, a closure of the Config) and one omnitrade.action.<name> per thing it does;
  • its actions (Omnitrade\Action\ActionInterface, ApiAwareInterface for those that talk to the service): each one supports one request and answers it with the family's models.

What it does not do, it has no action for: supports() then says no and the gateway throws RequestNotSupportedException. An action that depends on an option (Amazon's reads, which need credentials) is left out by its closure returning null.

Errors: the family's rule

What happens What the provider throws
an option missing or empty InvalidConfigException (the factory does it from omnitrade.required_options)
a request it has no action for RequestNotSupportedException, before any HTTP request
the service answers HTTP 4xx or 5xx (401, 403, 404, 429, 500, 503...) ProviderException, with the service's own error code in providerCode when it gives one
the service cannot be reached (DNS, connection, timeout) ProviderException, the HTTP client's exception as previous
the service answers what cannot be read (an HTML page in place of JSON) ProviderException
a webhook whose signature does not hold, or that is not signed InvalidNotificationException (a ProviderException)
fetchProduct() of a product the service does not have null, not an exception

Never a TypeError, a JsonException, an HTTP client's exception (catch Symfony\Contracts\HttpClient\Exception\ExceptionInterface, which a transport failure also implements, not only HttpExceptionInterface) nor the exception of a library underneath (omnipay's Omnipay\Common\Http\Exception). The family has no exception per status: a 401 is a ProviderException like a 500, its message and providerCode saying which.

Rate limits: a provider may retry a 429 or a 5xx a few times (Shopify: 3 attempts), honouring the answer's Retry-After when there is one, and throws a ProviderException once it gives up. The wait is an option (Shopify's retry_delay) so that a test does not serve it.

Pagination: fetchProducts() answers a ProductPage whose next is the cursor of the next page, null on the last one, never an empty string.

Money is in minor units (Money::of(1250, 'EUR') is 12,50 EUR): a service speaking decimals is read with Money::fromDecimal() and written with Money::decimal().

Conformance suite

Omnitrade\Testing\GatewayContractTests is the family's contract as PHPUnit tests, offline. Every provider's Tests/<Service>ContractTest.php uses it:

use Omnitrade\Testing\GatewayContractTests;

final class PayPalContractTest extends TestCase
{
    use GatewayContractTests;

    protected function factory(HttpClientInterface $http): GatewayFactoryInterface
    {
        return new PayPalGatewayFactory($http);
    }

    protected function options(): array
    {
        return ['client_id' => 'cid', 'secret' => 'sec', 'webhook_id' => 'WH-1'];
    }

    protected function recordedAnswers(): array
    {
        return [
            'POST /v1/oauth2/token' => ['access_token' => 'A21AA', 'expires_in' => 32400],
            'GET /v2/checkout/orders/5O190127TN364715T' => [...],          // a JSON body, HTTP 200
            'POST /v2/checkout/orders' => fn (string $method, string $url, array $options) => new MockResponse(...),
        ];
    }

    protected function signedNotification(): ?array
    {
        return [$body, ['PAYPAL-TRANSMISSION-SIG' => ...]];
    }
}

The provider gives its factory (built on the HttpClientInterface the suite hands it: a MockHttpClient), fake options turning everything on (a webhook secret, optional credentials), and the answers its service would give, by "METHOD /path". A request with no recorded answer is answered HTTP 404, and the failure names it. Optional hooks: transactionReference(), refundReference() (a captured payment, where an authorization alone has nothing to refund), productReference(), catalogueQuery(), tamperedNotification() (when tampering is not a change of the body: PayPal verifies signatures on its side), payment(), requiredOptions().

What it checks:

  • the factory's name is the built gateway's; a Registry builds it from {factory, options};
  • each required option missing or empty is an InvalidConfigException (no required option: it builds with none);
  • every request supports() denies throws RequestNotSupportedException, before any HTTP request;
  • every request it grants, when the service answers HTTP 401, 403, 404, 429, 500, 503 (an HTML page), HTTP 200 with an HTML page, or cannot be reached, answers or throws a ProviderException naming the provider - nothing else; fetchProduct() on a 404 answers null when it answers;
  • on the recorded answers, for each operation supported: purchase()/authorize() give a transaction of the payment's amount in minor units (PENDING, AUTHORIZED or PAID; a redirect to an http(s) page), fetch() a transaction, capture() one no longer authorized, void() a CANCELLED one, refund() of 5,00 EUR a refund of 500 EUR cents with the provider's id, paymentMethods() described methods (a kind among the constants, ISO currencies), fetchOrder() an order with its total, fetchProducts() a page of products with a valid cursor, fetchProduct() the product; every model carries the gateway's name as its provider and a non-empty reference;
  • notify(): the signed webhook is read as one event; the tampered one and the unsigned one are InvalidNotificationExceptions.

Connected accounts and subscriptions (Stripe) are covered by the "supported or not" and error checks only; their answers are tested by the provider's own tests.

Every hook, and what each of the twelve tests checks: Testing.

Run it with the provider's other tests: in the workspace (~/Sites/omnitrade, path repositories and one phpunit.xml.dist), or through the Docker harness (docker compose run --rm omnitrade test).

A complete provider

A payment service of one's own - here "Acme Pay", a hosted page at api.acme-pay.example with a bearer key - in one file for the reading (a package would put each class in its own file under Omnitrade\AcmePay\, or the application's namespace for a provider of its own):

<?php

namespace Acme\Pay;

use Omnitrade\Action\ActionInterface;
use Omnitrade\Action\ApiAwareInterface;
use Omnitrade\Action\ApiAwareTrait;
use Omnitrade\Config;
use Omnitrade\Exception\ProviderException;
use Omnitrade\GatewayFactory;
use Omnitrade\Model\Money;
use Omnitrade\Model\Status;
use Omnitrade\Model\Transaction;
use Omnitrade\Request\FetchTransaction;
use Omnitrade\Request\Purchase;
use Omnitrade\Request\Request;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/** A provider of one's own: a hosted payment page at api.acme-pay.example, bearer key. */
final class AcmePayGatewayFactory extends GatewayFactory
{
    protected function populateConfig(Config $config): void
    {
        $config->defaults([
            'omnitrade.factory_name' => 'acme_pay',
            'omnitrade.factory_title' => 'Acme Pay',
            'omnitrade.required_options' => ['api_key'],
            'sandbox' => false,
            'omnitrade.api' => fn (Config $c) => new Api($this->http ?? HttpClient::create(), (string) $c['api_key'], (bool) $c['sandbox']),
            'omnitrade.action.purchase' => new PurchaseAction(),
            'omnitrade.action.fetch' => new FetchAction(),
        ]);
    }
}

final class Api
{
    public function __construct(private readonly HttpClientInterface $http, private readonly string $key, private readonly bool $sandbox)
    {
    }

    /** @return array<string, mixed> */
    public function call(string $method, string $path, ?array $json = null): array
    {
        try {
            $response = $this->http->request($method, ($this->sandbox ? 'https://sandbox.acme-pay.example' : 'https://api.acme-pay.example').$path, ['auth_bearer' => $this->key, 'json' => $json, 'timeout' => 15]);
            $status = $response->getStatusCode();
            $data = json_decode($response->getContent(false), true);
        } catch (ExceptionInterface $e) {   // a transport failure too, not only an HTTP status
            throw new ProviderException('acme_pay', 'Acme Pay request failed: '.$e->getMessage(), null, $e);
        }
        if (!\is_array($data)) {
            throw new ProviderException('acme_pay', \sprintf('Acme Pay answered HTTP %d with a body that is not JSON.', $status));
        }
        if ($status >= 400) {
            throw new ProviderException('acme_pay', (string) ($data['message'] ?? 'HTTP '.$status), isset($data['error']) ? (string) $data['error'] : null);
        }

        return $data;
    }

    /** @param array<string, mixed> $payment */
    public static function transaction(array $payment): Transaction
    {
        $status = match ($payment['state'] ?? null) {
            'succeeded' => Status::PAID,
            'failed' => Status::REFUSED,
            default => Status::PENDING,
        };

        return new Transaction('acme_pay', (string) $payment['id'], $status, Money::of((int) $payment['amount'], (string) $payment['currency']),
            redirectUrl: Status::PENDING === $status ? ($payment['checkout_url'] ?? null) : null, raw: $payment);
    }
}

final class PurchaseAction implements ActionInterface, ApiAwareInterface
{
    /** @use ApiAwareTrait<Api> */
    use ApiAwareTrait;

    public function __construct()
    {
        $this->apiClass = Api::class;
    }

    public function supports(Request $request): bool
    {
        return $request instanceof Purchase;
    }

    public function execute(Request $request): void
    {
        \assert($request instanceof Purchase);
        $payment = $request->payment;
        $answer = $this->api->call('POST', '/v1/payments', ['amount' => $payment->amount->amount, 'currency' => $payment->amount->currency, 'reference' => $payment->reference, 'return_url' => $payment->returnUrl]);
        $request->setResult(Api::transaction($answer));
    }
}

final class FetchAction implements ActionInterface, ApiAwareInterface
{
    /** @use ApiAwareTrait<Api> */
    use ApiAwareTrait;

    public function __construct()
    {
        $this->apiClass = Api::class;
    }

    public function supports(Request $request): bool
    {
        return $request instanceof FetchTransaction;
    }

    public function execute(Request $request): void
    {
        \assert($request instanceof FetchTransaction);
        $request->setResult(Api::transaction($this->api->call('GET', '/v1/payments/'.rawurlencode($request->reference))));
    }
}

Its conformance test, on two recorded answers - the payment created, then read once paid:

<?php

namespace Acme\Pay\Tests;

use Acme\Pay\AcmePayGatewayFactory;
use Omnitrade\GatewayFactoryInterface;
use Omnitrade\Testing\GatewayContractTests;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\HttpClient\HttpClientInterface;


final class AcmePayContractTest extends TestCase
{
    use GatewayContractTests;

    protected function factory(HttpClientInterface $http): GatewayFactoryInterface
    {
        return new AcmePayGatewayFactory($http);
    }

    protected function options(): array
    {
        return ['api_key' => 'test_key', 'sandbox' => true];
    }

    protected function transactionReference(): string
    {
        return 'pay_1';
    }

    protected function recordedAnswers(): array
    {
        $payment = ['id' => 'pay_1', 'amount' => 1250, 'currency' => 'EUR'];

        return [
            'POST /v1/payments' => $payment + ['state' => 'pending', 'checkout_url' => 'https://sandbox.acme-pay.example/pay/pay_1'],
            'GET /v1/payments/pay_1' => $payment + ['state' => 'succeeded'],
        ];
    }
}
$ vendor/bin/phpunit --testdox tests/AcmePayContractTest.php
Acme Pay Contract (Acme\Pay\Tests\AcmePayContract)
 ✔ The factory names the gateway it builds
 ✔ A missing required option is a configuration error
 ✔ What it does not support is refused as not supported
 ✔ What it supports fails as a provider error when the provider does
 ✔ A purchase is a transaction in minor units
 ✔ A fetched transaction keeps its invariants
 ✔ Capture and void answer transactions
 ✔ A refund is recorded in minor units
 ✔ Payment methods are described
 ✔ A signed notification is read and a tampered one refused
 ✔ A platform order keeps its invariants
 ✔ The catalogue is paged and its products keep their invariants

OK (12 tests, 67 assertions)

(run on 2026-10-25 in the harness image, PHP 8.4, PHPUnit 11.5.) The tests of what it does not do (capture, refunds, webhooks, the catalogue) only check that supports() says no. Narrowing the catch to HttpExceptionInterface makes the fourth test fail on the unreachable service: the failure is shown in Testing.

What the example follows:

  • the factory's name is snake_case (acme_pay) and every model carries it as provider; the exceptions carry it too;
  • the API client is built by a closure of the Config, so building the gateway calls nothing;
  • every failure of the service goes through one method (Api::call()) that turns it into a ProviderException: Symfony\Contracts\HttpClient\Exception\ExceptionInterface (a transport failure included), a body that is not JSON, an HTTP status of 400 or more;
  • money stays in minor units; a decimal service is read with Money::fromDecimal() and written with Money::decimal();
  • what the service answered is kept in raw.

The package

A provider package (omnitrade/<service> on GitHub as glitchr-omnisense/omnitrade-<service>, or any name for a provider of one's own) requires omnisense/omnitrade and symfony/http-client, and phpunit/phpunit in require-dev for the suite:

{
    "name": "omnitrade/acme-pay",
    "type": "library",
    "require": {
        "php": ">=8.2",
        "omnisense/omnitrade": "^1.0@dev",
        "symfony/http-client": "^6.4|^7.0|^8.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.0"
    },
    "autoload": {
        "psr-4": {"Omnitrade\\AcmePay\\": ""},
        "exclude-from-classmap": ["/Tests/"]
    },
    "autoload-dev": {
        "psr-4": {"Omnitrade\\AcmePay\\Tests\\": "Tests/"}
    }
}

The Symfony bundle registers the six providers it knows by class; another factory joins the registry once it is a service of the application (autoconfigured: tagged omnitrade.gateway_factory). Its README says what the service is, its credentials, its options and, as every provider of the family does, its Responsibility: an unofficial client, the service's terms named with a link.

Propose a change docs/providers.md

Verified, and not

What the tests prove

145 tests, green on 2026-10-25 in the workspace (the core and the six providers as path repositories, PHP 8.4.26, PHPUnit 11.5.57, run in the harness image with no network: 1244 assertions). All of them offline, on answers recorded in the shape each service documents.

Package Tests
omnisense/omnitrade 13 the gateway answers through the supporting action and refuses the rest naming itself, required options, the registry builds each gateway once, Money's minor units, Status; Reference and the catalogue requests; the Symfony bundle (gateways built from configuration, injected by name, an application's factory autoconfigured); Tests/BareTest.php: the registry built by hand, a product read and a payment asked with no class of a framework loaded, and the bundle loading the framework
omnitrade/stripe 38 the conformance suite (12); Checkout purchase (the shipping as the session's shipping rate, a discount as one line of the amount), fetch (a refunded session REFUNDED), refund with its idempotency key, signed webhooks, payment methods; Connect (Express account, destination charge and fee, the fee's checks, Stripe's errors), subscriptions (Checkout, read, cancel, portal, the webhook's account and subscription); the catalogue (pages, updatedSince among each page, search, a variant per price, recurring prices, no stock, product and price events)
omnitrade/paypal 17 the conformance suite on an order authorized then captured or voided; an order approved then captured; a refund on the order's capture; a webhook verified by PayPal
omnitrade/shopify 24 the conformance suite on Admin GraphQL answers; a draft order paid on its invoice, fetched as what became of it; a shop order; a webhook's HMAC and shop; the catalogue (pages, mapping, the currency option, finding by id, bare id and page, inventory, REST and GraphQL shapes giving the same product, untracked stock, product and inventory webhooks)
omnitrade/woocommerce 23 the conformance suite on REST v3 answers; an unpaid order paid on the shop's checkout, fetched by status, a refund and the payment methods, a signed webhook; the catalogue (pages, published only, simple and variable products, finding by id, slug or variation, inventory, product webhooks)
omnitrade/web 36 the conformance suite on a recorded product page; JSON-LD in a graph, a ProductGroup and its variants, microdata, OpenGraph, no product, the local network refused (IPv4 and IPv6, a redirect to it refused before it is requested, a name requested at the address checked), affiliate links, prices in every writing
omnitrade/amazon 16 the conformance suite on Creators API answers (credentials given); a product by its page, a search, the link alone without credentials, ASINs in addresses

The conformance suite (Omnitrade\Testing\GatewayContractTests, Testing) runs in each of the six providers; it found and fixed, per the providers' commits: omnipay's network failures escaping Stripe as omnipay's exception, a voided PayPal authorization read as AUTHORIZED, and Shopify's 429 retried after a fixed backoff whatever Retry-After said.

The examples of this manual were run on 2026-10-25 in the harness image (omnisense/omnitrade-harness, PHP 8.4) on the workspace's checkouts, with MockHttpClient answers: installation, payments, catalogue, testing, writing a provider, the capability tables of the index, the Symfony configuration tree and services, the error messages of Errors.

Not verified

  • No call was made to a live service or a sandbox: no recorded answer here was captured from a real account. The harness is where test keys exercise them; no run with keys is recorded in the packages' history.
  • Connected accounts and subscriptions are checked by the suite only for "supported or not" and their errors; their answers by Stripe's own tests, on recorded answers.
  • Amazon's Creators API: the tests follow its documentation as read on 2026-10-04; the field names of externalIds and availability.type are assumed from PA-API 5 (omnitrade/amazon's manual).
  • The Laravel service provider was written from the registry's API, not run in a Laravel application.

Known defects

None known: the last ones, found by the providers' manuals, were fixed on 2026-10-29.

Propose a change docs/verified.md