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
- Installation: requirements, a first script in plain PHP that runs as it is.
- Symfony and Laravel: wiring the registry in a framework.
- Concepts: gateways, factories, the registry, requests and actions, every model and every field.
- Usage: payments (purchase, authorize, capture, void, refund, fetch, payment methods), webhooks, the catalogue and affiliate links, connected accounts, subscriptions.
- Configuration reference: the registry's configuration and every
provider's options, and which
Paymentfields each provider reads. - Errors: the four exceptions, when each is thrown, what to catch.
- Testing: testing an application that uses Omnitrade, and the conformance suite.
- The Docker harness: every provider installed, a console to call them.
- Writing a provider, the errors' rule and the conformance suite.
- Verified, and not.
Proposer une modification 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/omnitraderequiressymfony/http-client-contracts(^3.0) and nothing else.- Each provider requires
omnisense/omnitradeandsymfony/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, aMockHttpClientin 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(), andcreate($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\OmnitradeBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnitrade.yamland 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
Registryonce, 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.
Proposer une modification 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.
Proposer une modification 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.
Proposer une modification 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():
createConfig($options)- aConfig(anArrayObject) holding the options given, then the provider'spopulateConfig()fills each key the options did not set with its default;- the keys listed in
omnitrade.required_optionsare checked: one missing,''or[]is anInvalidConfigExceptionlisting every missing key; omnitrade.api- the provider's API client, usually a closure of theConfig- is built;- every key starting with
omnitrade.action.that is (or whose closure returns) anActionInterfacebecomes an action, in the order of the keys; a closure returningnullregisters no action, which is how a provider makes an operation depend on an option (Amazon's reads, which need credentials). Actions implementingApiAwareInterfacereceive the API client; - a
Gatewayis returned, named byomnitrade.factory_nameand titled byomnitrade.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
providerand a non-emptyreference. ProductPage::$nextisnullon the last page, never an empty string.rawkeeps what the provider answered: anything the models leave out is there.
The conformance suite checks these on every provider (testing).
Proposer une modification 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 |
Proposer une modification 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.
Proposer une modification 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
}
Affiliate links
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.
Proposer une modification 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.
Proposer une modification 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::$transactionPAID, itsraw['subscription']the subscription's id,raw['customer']the customer; - the subscription's life (
customer.subscription.created,.updated,.deletedwith Stripe):Notification::$subscription,isSubscription()true,$referenceits id. A renewal movescurrentPeriodEnd; a payment that fails makes itpast_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();
Proposer une modification 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.
Proposer une modification 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 |
Proposer une modification 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
GatewayFactorywhose actions answer fixed transactions. The family has no ready-made fake gateway: a factory of a few lines does it, andsupports(),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()inomnitrade/woocommerceandHmac::sign()inomnitrade/shopifycompute it) and hand it tonotify(); the payments example signs a Stripe event. PayPal's are verified by PayPal: record the answer ofPOST /v1/notifications/verify-webhook-signature. - In a Symfony application, replace the
http_clientservice in thetestenvironment with aMockHttpClient(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, onephpunit.xml.distwith 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.
Proposer une modification 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.
Proposer une modification 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 optionalHttpClientInterfacein its constructor and filling theConfiginpopulateConfig():omnitrade.factory_name(the namefactory:names in the configuration),omnitrade.factory_title,omnitrade.required_options, the defaults of its other options,omnitrade.api(its API client, a closure of theConfig) and oneomnitrade.action.<name>per thing it does; - its actions (
Omnitrade\Action\ActionInterface,ApiAwareInterfacefor 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
Registrybuilds it from{factory, options}; - each required option missing or empty is an
InvalidConfigException(no required option: it builds with none); - every request
supports()denies throwsRequestNotSupportedException, 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
ProviderExceptionnaming the provider - nothing else;fetchProduct()on a 404 answersnullwhen 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 areInvalidNotificationExceptions.
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 asprovider; 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 aProviderException: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 withMoney::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.
Proposer une modification 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
externalIdsandavailability.typeare 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.