Skip to content

omnisense/omnibill

Omnibill: one contract for transmitting and receiving electronic invoices - submit a conformant file (Factur-X, UBL, CII), follow its lifecycle statuses, receive suppliers' invoices, declare a status, look a company up in the directory, transmit e-reporting - whoever carries it: an e-mail (omnibill/email), an approved platform's standard API XP Z12-013 (omnibill/afnor), Chorus Pro (omnibill/chorus-pro); and its Symfony bundle.

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

Omnibill is one PHP contract for transmitting and receiving electronic invoices. An application asks every channel the same questions - send this invoice, where does it stand, what arrived for me, this invoice is paid, where does this company receive its invoices, here is my e-reporting - and gets the same answers back: a Flow to keep, an Inbox, a list of Addressing, a Notification. Each channel is a package of its own: omnibill/email (the invoice attached to an e-mail), omnibill/afnor (any approved platform that implements the standard API of XP Z12-013), omnibill/chorus-pro (the public sector's portal, through PISTE).

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

$flow = $gateway->submit($invoice);                                      // keep $flow->reference
$flow = $gateway->fetchStatus($flow);                                    // ACCEPTED, DEPOSITED (200)...
$inbox = $gateway->receive($cursor);                                     // suppliers' invoices, statuses about yours
$gateway->setStatus(new StatusChange(LifecycleStatus::PAID, amount: '1250.00', file: $cdar), $flow);
$gateway->lookup('702042755');                                           // where a company receives
$gateway->notify($request->getContent(), $request->headers->all());      // a platform's webhook, its signature checked

It is built around the French reform of electronic invoicing - its lifecycle statuses and their codes (200 to 213), its approved platforms (plateformes agréées), its national directory - and keeps the reform's words where they matter. A gateway carries files already conformant: Omnibill produces no Factur-X, UBL, CII or CDAR file and reads none beyond what it must to route one (a lifecycle message's status); producing them is the application's job (horstoeko/zugferd, for instance). It keeps no books either: the application stores the flows' references and statuses.

The design is Payum's, as in Omnibus: a gateway's factory fills a configuration with its name, title, capabilities, options, its client (omnibill.api) and one action per request it answers; the gateway runs the action that supports each request; a registry holds the application's gateways by name. The core needs no framework: it requires symfony/http-client-contracts and nothing else. A Symfony bundle is included (Symfony).

Published as omnisense/omnibill, formerly glitchr/omnibill and omniscient/omnibill: the package replaces both names, so a composer.json that still requires one of them resolves to this package and never installs two. The namespace is Omnibill\; the gateways keep the omnibill/ vendor and the Omnibill\<Gateway>\ namespace.

When to use it

  • A French company's site or back office that issues invoices and must, as the reform's dates come, move from e-mail to an approved platform without rewriting the code that sends them: the gateway's factory changes, the calls stay.
  • A supplier of public entities depositing its invoices on Chorus Pro and following them.
  • An application that receives its suppliers' invoices and the statuses of its own through an approved platform, by polling (receive()) or by webhook (notify()).

It is not an approved platform, and not an invoicing tool: it does not number, compute, sign, archive, or produce invoices.

Gateways

Three packages. The operations are what each gateway's supports() answers with its required options set (the harness's bare lists them: harness).

Package Factory name Carries Submit Fetch status Receive Set status Lookup Report Notify Approved platform Public sector
omnibill/email email Factur-X, UBL, CII yes (an e-mail) yes (recorded) - yes (recorded) - - - no no
omnibill/afnor afnor Factur-X, UBL, CII, CDAR, FRR; 100 MB yes yes yes yes (a CDAR file) with directory_url yes with webhook_key yes yes
omnibill/chorus-pro chorus-pro Factur-X, UBL, CII; 10 MB yes yes - - - - - no yes

None of the three was run against its real service except the e-mail, sent through a local SMTP server (Verified, and not).

How this manual is organised

Propose a change docs/index.md

Installation

composer require omnisense/omnibill omnibill/email     # the invoice by e-mail: the default
composer require omnibill/afnor                      # an approved platform, XP Z12-013
composer require omnibill/chorus-pro                 # the public sector, through PISTE

Requirements

  • PHP 8.2 or later, with mbstring (error messages are cut with mb_substr()).
  • omnisense/omnibill requires symfony/http-client-contracts (^3.0, the interfaces, no client) and nothing else.
  • omnibill/email requires symfony/mailer (6.4, 7 or 8); omnibill/afnor requires symfony/http-client (6.4, 7 or 8) and ext-dom (it reads lifecycle messages); omnibill/chorus-pro requires symfony/http-client. Libraries, not a framework: each factory takes the application's mailer or client when it is given one.
  • For the Symfony bundle: symfony/config, symfony/dependency-injection, symfony/http-kernel (6.4, 7 or 8), suggested, not required.

Omnibill runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).

Plain PHP

<?php // bill.php

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

use Omnibill\Email\EmailGatewayFactory;
use Omnibill\Model\Invoice;
use Omnibill\Model\LifecycleStatus;
use Omnibill\Model\Party;
use Omnibill\Model\StatusChange;
use Omnibill\Model\Syntax;
use Omnibill\Registry;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;

$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN') ?: 'null://null'));
$registry = new Registry([new EmailGatewayFactory($mailer)], [
    'invoices' => ['factory' => 'email', 'options' => ['from' => 'Atelier Tilleul <factures@atelier.example>']],
]);

// The file is the application's, already conformant: here a Factur-X made by horstoeko/zugferd, say.
$invoice = new Invoice(
    content: "%PDF-1.7\n...",
    filename: 'F-2026-042.pdf',
    syntax: Syntax::FACTURX,
    number: 'F-2026-042',
    seller: new Party('Atelier Tilleul', siren: '123456789'),
    buyer: new Party('Maison Érable', siren: '702042755', email: 'compta@erable.example'),
    total: '1250.00',
);

$gateway = $registry->get('invoices');
$flow = $gateway->submit($invoice);                                       // keep $flow->reference
printf("%s: %s to %s, %s\n", $flow->invoiceNumber, $flow->status->label(), $flow->data['to'], $flow->reference);

// Weeks later, the payment arrives: the application says so.
$flow = $gateway->setStatus(new StatusChange(LifecycleStatus::PAID, amount: '1250.00'), $flow);
foreach ($gateway->fetchStatus($flow)->history as $change) {
    printf("  %s %s%s\n", $change->at->format('Y-m-d H:i'), $change->status->label(), null !== $change->status->code() ? ' ('.$change->status->code().')' : '');
}
$ php bill.php
F-2026-042: Envoyée to compta@erable.example, 08bf5ec24950b3d91b356c15f5a918d6@omnibill
  2026-10-08 00:54 Envoyée
  2026-10-08 00:54 Encaissée (212)

(2026-10-08; run again on 2026-10-26 on the packages' checkouts, PHP 8.4, the same lines with that day's times and another reference. With MAILER_DSN=smtp://... the e-mail leaves for real - the harness sent one to a local SMTP server on 2026-10-08, the PDF attached.)

That is all there is to it:

  • a factory per gateway package, which takes what its gateway works with - a mailer for the e-mail, an HTTP client for the platforms;
  • the registry, built by hand from the factories and the gateways' options, by name;
  • the gateways it gives, each answering the same questions (gateways).

null://null is Symfony Mailer's transport that sends nothing: the script runs as it is. The statuses are kept in the factory's status store - here, with none given, this process's memory (InMemoryStatusStore); an application gives its own, a table (usage).

In a framework

  • Symfony: Omnibill\Bridge\Symfony\OmnibillBundle registers the gateway factories (the e-mail's on the application's mailer, the platforms' on its http_client), builds the registry from config/packages/omnibill.yaml and makes each gateway injectable by its name: Symfony.
  • Laravel: there is no bridge package; a service provider builds the registry once: Laravel.
  • Any other: build the Registry once, where the framework builds its services.

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 omnibill bare (harness).

What the application does around a gateway

  1. Produce the file: a Factur-X, a UBL or CII invoice - not Omnibill's job.
  2. Submit it, and keep the flow's reference with the invoice, and the gateway's name.
  3. Follow it: fetchStatus() now and then, or a platform's webhook (notify()), and receive() for what comes in - suppliers' invoices, statuses about your own - from the cursor kept since last time.
  4. Declare what only the application knows - an invoice paid (LifecycleStatus::PAID, the reform's "Encaissée"), a supplier's invoice refused - with setStatus(). A platform carries it as a lifecycle message (CDAR) the application produced; the e-mail gateway records it.

Errors

The whole list, and what each gateway maps to which: Errors.

Exception When
NotSupportedException the gateway does not do that: supports() says so beforehand
InvalidConfigException a gateway not configured, a factory not installed, an option missing; an invoice without what the gateway needs (a buyer's e-mail, a number)
InvalidKeyException the platform refused the application's credentials
UnreachableException the platform did not answer: nothing was transmitted, try again
InvalidNotificationException a callback whose signature does not hold, or too old
ProviderException the platform refused the request (the three above extend it)

All implement Omnibill\Exception\OmnibillException.

Propose a change docs/installation.md

Symfony

Omnibill runs without a framework (installation); in a Symfony application its bundle does the wiring. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel - are not required by omnisense/omnibill: the application has them, and nothing of them is loaded outside Symfony. The bundle is part of the core (Omnibill\Bridge\Symfony\OmnibillBundle); omnibridge has no Omnibill bridge, and none is needed.

Registering the bundle

Register Omnibill\Bridge\Symfony\OmnibillBundle (no Flex recipe):

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

Configuration

# config/packages/omnibill.yaml
omnibill:
    gateways:                      # by name: a factory and its options
        invoices:
            factory: email
            options: { from: 'Atelier Tilleul <factures@atelier.example>', bcc: '%env(default::INVOICE_ARCHIVE)%' }
        platform:
            factory: afnor
            options:
                flow_url: '%env(PA_FLOW_URL)%'
                directory_url: '%env(PA_DIRECTORY_URL)%'
                token_url: '%env(PA_TOKEN_URL)%'
                client_id: '%env(PA_CLIENT_ID)%'
                client_secret: '%env(PA_CLIENT_SECRET)%'
                webhook_key: '%env(default::PA_WEBHOOK_KEY)%'
        public:
            factory: chorus-pro
            options: { client_id: '%env(PISTE_CLIENT_ID)%', client_secret: '%env(PISTE_CLIENT_SECRET)%', login: '%env(CPRO_LOGIN)%', password: '%env(CPRO_PASSWORD)%' }

The whole tree, from the bundle's configure():

Key Type Default
omnibill.gateways map, keyed by the gateway's name {} the application's gateways
omnibill.gateways.<name>.factory string, required, not empty - a factory's name: email, afnor, chorus-pro, or an application's own factory's getName()
omnibill.gateways.<name>.options any value (a map) [] handed as they are to the factory's create() (Configuration)

Processed with nothing set, the tree is {"gateways": []}; an empty factory is refused when the container compiles (The path "omnibill.gateways.x.factory" cannot contain an empty value, but got "".). The options are not validated then, 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 omnibill/* package installed registers its factory, autowired: omnibill/email gets the application's MailerInterface and, when it has one, a StatusStoreInterface of its own (a table: the statuses outlive the process); the platforms get the application's http_client.

#[AsAlias(StatusStoreInterface::class)]
final class InvoiceStatusRepository extends ServiceEntityRepository implements StatusStoreInterface { /* ... */ }

The bundle knows Omnibill\Email\EmailGatewayFactory, Omnibill\Afnor\AfnorGatewayFactory and Omnibill\ChorusPro\ChorusProGatewayFactory, and registers those whose class exists. A factory's $http argument is the http_client service (null when the application has none: the factory makes its own client); its other arguments are autowired - the e-mail's MailerInterface and StatusStoreInterface, each null when the application has no such service (no mailer: building the e-mail gateway is an InvalidConfigException; no store: this process's memory, lost at the end of the request).

Service id Class Autowiring
Omnibill\Registry Registry Registry $registry every configured gateway by name: get(), has(), names(), options(), all(), create() with other options. Public.
omnibill.gateway.<name> GatewayInterface GatewayInterface $<name> one gateway, built by Registry::get('<name>') the first time it is injected
the factories their class by their class tagged omnibill.gateway_factory

The argument's name selects the gateway: GatewayInterface $platform is omnibill.gateway.platform (Symfony's named autowiring aliases). A name that is not a valid PHP variable name (chorus-pro) 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). An application's own gateway

  • a class implementing GatewayFactoryInterface - is registered too, autoconfigured (tagged omnibill.gateway_factory), and its getName() can be named as a factory (Writing a gateway). The bundle registers no command, event, route or Twig function.

A platform's webhook

#[Route('/invoices/webhook', methods: ['POST'])]
public function __invoke(Request $request, GatewayInterface $platform): Response
{
    try {
        $notification = $platform->notify($request->getContent(), $request->headers->all());
    } catch (InvalidNotificationException) {
        return new Response('', 400);
    }
    $this->bus->dispatch(new ReceiveInvoices());     // receive() from the cursor kept, out of the request
    return new Response('', 200);
}

The route above is the application's: the bundle registers none. Answer fast and do the work out of the request; a callback sent twice has the same Notification::$id - deduplicate on it (Receiving).

The day a site's company must issue through an approved platform, invoices changes factory, from email to afnor: the code that submits stays.

Propose a change docs/symfony.md

Laravel

omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnibill provider, and there is no other Laravel package for it. The registry is plain PHP: a service provider of the application builds it once, from a config file. omnibill/email takes a Symfony Mailer (symfony/mailer, which it requires; Laravel's own mailer is built on it, but the factory takes Symfony\Component\Mailer\MailerInterface), the platforms a symfony/http-client client.

// config/omnibill.php
return [
    'mailer_dsn' => env('OMNIBILL_MAILER_DSN', 'smtp://localhost:25'),
    'gateways' => [
        'invoices' => ['factory' => 'email', 'options' => ['from' => 'Atelier Tilleul <factures@atelier.example>']],
        'platform' => ['factory' => 'afnor', 'options' => [
            'flow_url' => env('PA_FLOW_URL'),
            'directory_url' => env('PA_DIRECTORY_URL'),
            'token_url' => env('PA_TOKEN_URL'),
            'client_id' => env('PA_CLIENT_ID'),
            'client_secret' => env('PA_CLIENT_SECRET'),
            'webhook_key' => env('PA_WEBHOOK_KEY'),
        ]],
    ],
];
// app/Providers/OmnibillServiceProvider.php
namespace App\Providers;

use App\Billing\InvoiceStatusStore;            // the application's table, implementing Omnibill\Email\StatusStoreInterface
use Illuminate\Support\ServiceProvider;
use Omnibill\Afnor\AfnorGatewayFactory;
use Omnibill\Email\EmailGatewayFactory;
use Omnibill\Registry;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;

final class OmnibillServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function ($app): Registry {
            $mailer = new Mailer(Transport::fromDsn(config('omnibill.mailer_dsn')));

            return new Registry([
                new EmailGatewayFactory($mailer, $app->make(InvoiceStatusStore::class)),
                new AfnorGatewayFactory(HttpClient::create()),
            ], config('omnibill.gateways'));
        });
    }
}

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

use Omnibill\Registry;

final class SendInvoice   // a queued job
{
    public function handle(Registry $registry): void
    {
        $flow = $registry->get('invoices')->submit($this->invoice);
        // store $flow->reference with the invoice
    }
}

A webhook route reads the raw body and the headers: $gateway->notify($request->getContent(), $request->headers->all()), and answers 400 on an InvalidNotificationException.

Differences from the Symfony bundle: list in the provider every factory the application uses, and ask the registry for a gateway by name (no injection by argument name). Unlike Symfony's %env(default::X)%, an env() left unset is null, and a required option that is null is refused when the gateway is built. Configuration caching keeps working: the options are plain arrays. This page was written from the registry's API; it was not run in a Laravel application.

Propose a change docs/laravel.md

Concepts

Gateways

A gateway is one way to transmit and receive invoices, configured: Omnibill\GatewayInterface.

Method Request Answers
getName(): string - the factory's name: email, afnor, chorus-pro
getTitle(): string - a readable name: E-mail, Approved platform (XP Z12-013), Chorus Pro
capabilities(): Capabilities - what it carries, and whether it is an approved platform's channel
supports(string $request): bool - whether it answers that request class (Request\Receive::class...)
execute(Request $request): Request any the same request, answered
submit(Invoice $invoice, ?string $trackingId = null): Flow Request\Submit the invoice's flow: keep its reference
fetchStatus(Flow $flow): Flow Request\FetchStatus the flow as the gateway has it now
receive(?string $cursor = null, int $limit = 25): Inbox Request\Receive what arrived since the cursor
setStatus(StatusChange $change, ?Flow $flow = null): Flow Request\SetStatus the flow that carried it, or the invoice's, updated
lookup(string $identifier): array Request\Lookup list<Addressing>: where a SIREN or SIRET receives
report(EReport $report, ?string $trackingId = null): Flow Request\Report the report's flow
notify(string $body, array $headers = []): Notification Request\Notify a webhook's call, its signature checked

Each typed method is execute(new Request\...) and the request's result. A request no action supports is a NotSupportedException (The "chorus-pro" gateway does not support Receive.); supports() says so beforehand, by asking each action about an instance of the class built without its constructor.

Omnibill\Gateway is the one implementation: new Gateway(string $name, string $title, Capabilities $capabilities, array $actions). execute() hands the request to the first action that supports it; an action that returns without setting a result is a ProviderException (<class> left the request unanswered.).

Requests

Omnibill\Request\Request is abstract: setResult(mixed), getResult(), isAnswered(). The requests, with their public readonly properties and their typed getter:

Request Properties Result
Submit invoice (Invoice), trackingId (?string) getFlow(): ?Flow
FetchStatus flow (Flow) getFlow(): ?Flow
Receive cursor (?string), limit (int, 25) getInbox(): ?Inbox
SetStatus change (StatusChange), flow (?Flow) getFlow(): ?Flow
Lookup identifier (string) getAddressings(): ?array
Report report (EReport), trackingId (?string) getFlow(): ?Flow
Notify body (string), headers (array<string, string|string[]>) getNotification(): ?Notification; header(string $name): ?string whatever the case it came in

Actions

Omnibill\Action\ActionInterface: supports(Request $request): bool and execute(Request $request): void, which sets the request's result or throws an OmnibillException. An action that works through the gateway's client implements Action\ApiAwareInterface (setApi(object $api)); Action\ApiAwareTrait holds it in $this->api and refuses a client that is not of the class it names in $this->apiClass (InvalidConfigException).

Factories and their Config

Omnibill\GatewayFactoryInterface: getName(): string and create(array $options = []): GatewayInterface. The packages extend Omnibill\GatewayFactory, whose create():

  1. fills a Config with the options given and the factory's populateConfig() defaults under them;
  2. checks every key of omnibill.required_options is set and not empty (InvalidConfigException: The "afnor" gateway needs: client_secret.);
  3. builds the client: omnibill.api, a closure of the Config called now, or an object;
  4. reads omnibill.capabilities (a Capabilities, or a closure giving one; missing: InvalidConfigException);
  5. collects every omnibill.action.<name>: an ActionInterface, or a closure of the Config giving one - or null, which leaves the action out (that is how afnor drops lookup without a directory_url); each ApiAwareInterface action is handed the client;
  6. returns a Gateway of omnibill.factory_name, omnibill.factory_title, the capabilities and the actions.

Building calls nothing; a factory reached over HTTP takes its client in its own constructor.

Omnibill\Config is an ArrayObject of the options, with:

Method
defaults(array $defaults): self sets each key not set yet
validateNotEmpty(array $keys): void InvalidConfigException naming the keys unset, null, '' or []
get(string $key, mixed $default = null)
bool(string $key): bool a boolean, or "1", "true", "yes", "on" (any case) as an environment variable gives it
list(string $key): list<string> a list, each value split on commas and trimmed: 'a@x, b@x' is two

The registry

Omnibill\Registry, built from the factories and the gateways' configuration by name:

Method
get(string $name): GatewayInterface built once and kept. No "x" gateway; configured: invoices., No "x" factory for the "y" gateway; installed: email, afnor.
create(string $name, array $overrides = []): GatewayInterface built afresh, the configured options with $overrides over them (an instance typed in a back office); not kept
has(string $name): bool whether the name is configured
options(string $name): array its configured options
names(): list<string>, all(): array<string, GatewayInterface>
factories(): list<string> the factories installed: what factory may name

HTTP helpers

Two classes of the core every platform gateway calls through:

  • Omnibill\Http\Answer::send(HttpClientInterface $http, string $provider, string $method, string $url, array $options = []): Answer - one request, read whole, with a 10-second timeout unless the options give one. No connection, a timeout, a transport error, an HTTP 5xx or a 429 is an UnreachableException (the platform said nothing about the request: try again); any other answer comes back as an Answer: status, headers (lower-case names), body, header($name), and json() (the body as a JSON object, or a ProviderException).
  • Omnibill\Http\ClientCredentials - an OAuth 2.0 access token by the client credentials grant: new ClientCredentials($http, $provider, $tokenUrl, $clientId, $clientSecret, ?$scope, ?$clock), token() (asked once, kept until 60 seconds before expires_in - 3600 when the endpoint does not say), reset() (forget it: after a 401). A 400, 401 or 403 from the token endpoint, or an answer without access_token, is an InvalidKeyException.

The models these requests carry: Models.

Propose a change docs/concepts.md

Models

All models are readonly; times are DateTimeImmutable; amounts are decimal strings ("1250.00"). They carry what a gateway needs: a file and identifiers, never an entity of the application.

Invoice

Property
content, filename the file's bytes, its name with the extension
syntax Syntax::FACTURX, UBL, CII (also CDAR for a lifecycle message, FRR for e-reporting)
number the invoice's number: what its statuses are followed by
seller, buyer Party: name, SIREN, SIRET, electronic address, routing code, e-mail, country
issuedAt, total, currency what the application knows; not read from the file
profile, rule the platform's flowProfile (Basic, CIUS, Extended-CTC-FR) and processingRule (B2B, B2BInt, B2C, B2G...), where it asks

new Invoice(string $content, string $filename, Syntax $syntax, ?string $number = null, ?Party $seller = null, ?Party $buyer = null, ?\DateTimeImmutable $issuedAt = null, ?string $total = null, ?string $currency = 'EUR', ?string $profile = null, ?string $rule = null).

Invoice::fromFile($path, $syntax, $number, $buyer) reads a file (an \InvalidArgumentException when it cannot: Cannot read <path>.), its name the file's base name, its syntax the one given, else Factur-X for a .pdf and CII for anything else - a UBL file must say Syntax::UBL. sha256() is the content's hash, size() its length in bytes.

Party

new Party(?string $name = null, ?string $siren = null, ?string $siret = null, ?string $electronicAddress = null, ?string $routingCode = null, ?string $email = null, ?string $country = 'FR'): identifiers only. The e-mail gateway sends to the buyer's email; the platforms read no party (the file says who is who).

Syntax

Case Value mimeType() extension()
FACTURX Factur-X application/pdf pdf a PDF/A-3 with its CII XML inside (Factur-X, ZUGFeRD)
UBL UBL application/xml xml UBL 2.1
CII CII application/xml xml UN/CEFACT Cross Industry Invoice
CDAR CDAR application/xml xml a lifecycle status message
FRR FRR application/xml xml an e-reporting file

The values are XP Z12-013's flowSyntax names.

Flow

One transmission on a gateway: an invoice, a status, a report, sent or received.

Property
reference the gateway's name for it: a flow id, a deposit number, a message id. Keep it
kind FlowKind::INVOICE, LIFECYCLE, REPORT, OTHER
direction Direction::OUT (sent), IN (received)
state the platform's acknowledgement: FlowState::PENDING, ACCEPTED, REJECTED
status, history the invoice's latest LifecycleStatus, and every StatusChange known, oldest first
errors why the platform rejected it
trackingId, invoiceNumber, submittedAt, updatedAt, name, data

with(state, status, history, errors, updatedAt, data) gives the same flow updated (each argument null keeps the flow's). The enums:

Enum Cases (value)
FlowKind INVOICE (invoice), LIFECYCLE (lifecycle), REPORT (report), OTHER (other)
Direction OUT (out: sent by the application), IN (in: received)
FlowState PENDING (pending: received, not checked), ACCEPTED (accepted: checked and taken), REJECTED (rejected: refused by the platform's checks, see errors)

The flow's state is the platform's technical acknowledgement of the file; what the recipient did with the invoice is its status.

LifecycleStatus

The reform's statuses, as the DGFiP's external specifications list them (general file v3.2, 2026-04-30):

Case Code Label
DEPOSITED 200 Déposée mandatory: the issuing platform checked the invoice
ISSUED 201 Émise par la plateforme
RECEIVED 202 Reçue par la plateforme
MADE_AVAILABLE 203 Mise à disposition
TAKEN_OVER 204 Prise en charge
APPROVED 205 Approuvée
PARTIALLY_APPROVED 206 Approuvée partiellement
DISPUTED 207 En litige
SUSPENDED 208 Suspendue
COMPLETED 209 Complétée
REFUSED 210 Refusée mandatory: the recipient refuses it
PAYMENT_SENT 211 Paiement transmis
PAID 212 Encaissée mandatory: the supplier received the payment
REJECTED 213 Rejetée mandatory: a platform's checks found an anomaly
SENT - Envoyée the family's own: sent outside any platform (e-mail)

Values are lower-case snake case (deposited, made_available, payment_sent...). code() is the reform's code (null for SENT), label() the French name above, isMandatory() true for 200, 210, 212 and 213, isFinal() true for REFUSED, REJECTED and PAID (nothing follows them). LifecycleStatus::fromCode(int|string $code) reads 212, "212" or "fr:212" (leading non-digits are dropped); a code that is not one of 200 to 213 is null.

StatusChange

One step: status, invoiceNumber, at, reason, amount (for a payment), code (the platform's own), and file/filename - the lifecycle message itself (CDAR), where the platform carries one.

Inbox and Received

receive($cursor) answers an Inbox: items (each a Received: the flow, and an invoice or a status), cursor (give it to the next receive()), more. invoices() and statuses() sort them.

Addressing

A line of the national directory: siren, siret, routingCode, electronicAddress (the addressing identifier), name, platform (WK: an approved platform, DFH: the public portal), status (Enabled, Disabled, Upcoming), data. isEnabled().

EReport

An e-reporting file: content, filename, syntax (FRR), rule (B2C, B2BInt...).

Notification

A platform's callback, its signature checked: event (Event::FLOW_UPDATED flow_updated: a flow was taken or rejected; RECEIVED received: something arrived for the application; OTHER other), the flow, at, id (what a callback sent twice is recognised by), data (the platform's payload).

Capabilities

syntaxes (what it carries), approved (an approved platform's channel), publicSector, maxSize (bytes). Defaults: Factur-X, UBL and CII, not approved, not public sector, no size. carries(Syntax), toArray() (the syntaxes as their values, and the other fields). The operations are asked of the gateway: supports(Receive::class).

maxSize is said, not enforced: no gateway refuses a file for its size before sending it; the platform does (Chorus Pro: codeRetour 20001).

Propose a change docs/models.md

Gateways

One contract, Omnibill\GatewayInterface, whoever carries the invoices:

Method Request Answers
submit(Invoice) Request\Submit its Flow: keep the reference
fetchStatus(Flow) Request\FetchStatus the flow, updated: the platform's acknowledgement, the invoice's status
receive(?cursor) Request\Receive an Inbox: suppliers' invoices, statuses about yours, the next cursor
setStatus(StatusChange, ?Flow) Request\SetStatus the flow that carried it
lookup(siren or siret) Request\Lookup list<Addressing>: where the company receives
report(EReport) Request\Report its Flow
notify($body, $headers) Request\Notify a Notification, once the callback's signature holds

capabilities() says what it carries; supports(Request::class) what it answers. Each method is a shortcut for execute(new Request\...). What a gateway does not do throws NotSupportedException - nothing is guessed, nothing silently skipped.

omnibill/email omnibill/afnor omnibill/chorus-pro
Carries Factur-X, UBL, CII, attached to an e-mail Factur-X, UBL, CII, CDAR, FRR: flows Factur-X, UBL, CII: deposited flows
submit the e-mail sent: SENT POST /v1/flows: PENDING deposer/flux: PENDING
fetchStatus the statuses recorded the acknowledgement: Ok DEPOSITED, Error REJECTED the invoice's statut, read as the reform's
receive - flows coming in, their files -
setStatus recorded by hand the application's CDAR, as a flow -
lookup - the Directory Service -
report - an FRR flow -
notify - webhooks, Afnor-Signature checked -
Approved platform no yes the public sector's portal
Needs a mailer, a sender the platform's addresses, OAuth 2.0 client credentials a PISTE application, a technical account

Each gateway's details: submitting and following, receiving, statuses, the directory and e-reporting, and the gateways' own manuals.

Following an invoice

$flow = $gateway->submit($invoice);
$row->gateway = 'platform';
$row->reference = $flow->reference;

// later, from a cron job or on the platform's webhook
$flow = $gateway->fetchStatus(new Flow($row->reference, invoiceNumber: $row->number));
if ($flow->state === FlowState::REJECTED) {
    // $flow->errors: what the platform's checks found
}

// what came in since last time
$inbox = $gateway->receive($row->cursor);
foreach ($inbox->items as $received) {
    $received->invoice;   // a supplier's invoice: its file, to read with your own tools
    $received->status;    // a status about one of yours: ->invoiceNumber, ->status, ->reason
}
$row->cursor = $inbox->cursor;

Writing a gateway

A package omnibill/<platform>: a factory extending Omnibill\GatewayFactory that fills a Config - its name, title, capabilities, the options it needs, its client (omnibill.api) and one action per request it answers:

final class PeppolGatewayFactory extends GatewayFactory
{
    // A platform reached over HTTP takes its client here: the application's, a mock in a test.
    public function __construct(private readonly ?HttpClientInterface $http = null)
    {
    }

    protected function populateConfig(Config $config): void
    {
        $config->defaults([
            'omnibill.factory_name' => 'peppol',
            'omnibill.factory_title' => 'Peppol access point',
            'omnibill.required_options' => ['api_key'],
            'omnibill.capabilities' => new Capabilities([Syntax::UBL]),
            'omnibill.api' => fn (Config $c) => new Api((string) $c['api_key'], $this->http ?? HttpClient::create()),
            'omnibill.action.submit' => new SubmitAction(),
            'omnibill.action.fetch_status' => new FetchStatusAction(),
        ]);
    }
}

An action implements Action\ActionInterface (supports(Request), execute(Request) which sets the request's result) and, to be handed the client, Action\ApiAwareInterface. Call the platform through Omnibill\Http\Answer::send() (no answer, a server error or a quota: an UnreachableException) and Omnibill\Http\ClientCredentials for an OAuth 2.0 token. Leave out the actions the platform's documentation does not show. The whole guide, with an example run: Writing a gateway.

Propose a change docs/gateways.md

Submitting and following

The examples of these usage pages come from one script per gateway, run on 2026-10-26 on the packages' checkouts (PHP 8.4, no network) with a MockHttpClient serving the packages' recorded answers (docker/harness/config/recorded.php, the gateways' Tests/Fixtures); the > lines are the mock's log of each request. The platforms' answers are fixtures written from their published schemas: the ids and dates are theirs, not the call's.

Submitting

use Omnibill\Model\Invoice;
use Omnibill\Model\Party;
use Omnibill\Model\Syntax;

$invoice = new Invoice(
    content: file_get_contents('F-2026-042.pdf'),     // the application's Factur-X
    filename: 'F-2026-042.pdf',
    syntax: Syntax::FACTURX,
    number: 'F-2026-042',                              // what the statuses are followed by
    buyer: new Party('Maison Érable', siren: '702042755', email: 'compta@erable.example'),
    profile: 'Basic',                                  // flowProfile, where the platform asks
);
$flow = $gateway->submit($invoice, trackingId: 'order-1234');

$row->gateway = 'platform';
$row->reference = $flow->reference;                    // keep it, with the gateway's name

What each gateway does:

Gateway Call The flow answered
email an e-mail to the buyer's email, the file attached ACCEPTED, status SENT, reference the e-mail's Message-ID (<32 hex>@omnibill), data['to'] the address
afnor POST /v1/flows, multipart: flowInfo (JSON) and file PENDING, reference the platform's flowId
chorus-pro POST /v1/deposer/flux: the file in base64, its name, its syntaxeFlux PENDING, reference numeroFluxDepot

afnor's flowInfo is trackingId, name (the file name), processingRule (the invoice's rule, else the gateway's rule option), flowSyntax, flowProfile (the invoice's profile, else the profile option), and the file's sha256; empty fields are left out. The flow's state is PENDING whatever the answer says; its other fields are the platform's answer (trackingId as the platform echoes it).

submit
  > POST https://auth.pa.example/auth-service/acme/token
  > POST https://api.flow.pa.example/flow-service/v1/flows
  8f1d2c3b-4a5e-4f60-9a7b-1c2d3e4f5a6b pending invoice order-42

(the recorded answer's trackingId is order-42.) The token is asked once, before the first call, and kept.

chorus-pro maps the syntax to Chorus Pro's syntaxeFlux (the syntaxes option: Factur-X IN_DP_E2_CII_FACTURX, UBL IN_DP_E1_UBL_INVOICE, CII IN_DP_E1_CII_16B); a syntax it has no name for is an InvalidConfigException (Chorus Pro takes no CDAR file through this gateway.). It sends avecSignature: false. The flow's submittedAt is the answer's dateDepot.

  > POST https://sandbox-oauth.piste.gouv.fr/api/oauth/token
  > POST https://sandbox-api.piste.gouv.fr/cpro/factures/v1/deposer/flux  {"fichierFlux":"JVBERi0xLjcKLi4u","nomFichier":"F-2026-042.pdf","syntaxeFlux":"IN_DP_E2_CII_FACTURX","avecSignature":fal
CPP0021100000000000000023 pending 2026-10-08 00:00

email needs the invoice's buyer and the buyer's e-mail (InvalidConfigException without); the subject and the body are the gateway's templates with {number}, {seller}, {buyer}, {total}, {currency} filled; a mailer that fails is an UnreachableException and nothing is recorded. The first run of Installation shows it.

No gateway checks the file's size or content before sending: a file the platform refuses comes back as a ProviderException (Chorus Pro: codeRetour 20001, GDP_MSG_11.022, too large) or as a REJECTED flow once checked.

Following

use Omnibill\Model\Flow;
use Omnibill\Model\FlowState;

$flow = $gateway->fetchStatus(new Flow($row->reference, invoiceNumber: $row->number));
if (FlowState::REJECTED === $flow->state) {
    $flow->errors;                       // what the platform's checks found
}
$flow->status;                           // the invoice's latest LifecycleStatus
$flow->history;                          // every StatusChange known, oldest first

fetchStatus() takes the flow kept - rebuilt from the stored reference and number is enough - and answers it updated:

Gateway Asks Answers
email the status store, by the reference the recorded statuses as history, the latest as status
afnor GET /v1/flows/{flowId}?docType=Metadata the acknowledgement: Ok is ACCEPTED and, for an invoice sent, DEPOSITED (200); Error is REJECTED and REJECTED (213), with the details as errors (<reasonCode>: <reasonMessage> (<item>)); anything else PENDING. The status is added to the history given when it is not there yet
chorus-pro POST /v1/rechercher/fournisseur with the invoice's number the invoice Chorus Pro integrated - the one deposited in this flow when several bear the number, else the first found: ACCEPTED, its statut read as a reform status (the code kept), added to the history when it changed. None found yet: the flow as it was
fetchStatus
  > GET https://api.flow.pa.example/flow-service/v1/flows/8f1d2c3b-4a5e-4f60-9a7b-1c2d3e4f5a6b?docType=Metadata
  accepted deposited, history: deposited (200)
  > POST https://sandbox-api.piste.gouv.fr/cpro/factures/v1/rechercher/fournisseur  {"numeroFacture":"F-2026-042"}
accepted made_available, history: made_available (MISE_A_DISPOSITION)

Chorus Pro finds an invoice by its number: a flow without invoiceNumber is an InvalidConfigException. On an approved platform, the acknowledgement is all fetchStatus() tells: the recipient's statuses arrive as lifecycle flows, through receive(). Chorus Pro's statuses, and the reading of each: the chorus-pro manual.

A dateDepot or dateHeureEtatCourant that PHP cannot read as a date is no date in omnibill/chorus-pro: submittedAt the time of the deposit, the status change's at null.

Propose a change docs/usage-submitting.md

Receiving and webhooks

Only omnibill/afnor receives: an approved platform delivers the application's suppliers' invoices and the statuses of its own invoices as flows coming in. email and chorus-pro throw NotSupportedException (supports(Receive::class) is false).

Receiving, from a cursor

$inbox = $gateway->receive($row->cursor, limit: 25);
foreach ($inbox->items as $received) {
    if ($received->isInvoice()) {
        $received->invoice;              // a supplier's invoice: its file, to read with the application's tools
    } elseif ($received->status) {
        $received->status;               // a status about one of the application's invoices
    }
    $received->flow;                     // the flow it came in
}
$row->cursor = $inbox->cursor;           // the next receive() starts there
$inbox->more;                            // true: there is more, call again

receive() sends POST /v1/flows/search with where.flowDirection: [In], limit (1 to 100) and the cursor, then downloads each flow's file (GET /v1/flows/{flowId}?docType=Original): one call per flow. Each flow becomes a Received:

  • a lifecycle flow (flowSyntax CDAR, or a flowType ending in LC): its CDAR file read for the invoice's number, the status code (200 to 213, fr:212 taken too), the reason and the date - a StatusChange with the file kept in file; a message without a reform status gives no status;
  • an invoice flow (CII, UBL, Factur-X, or a flowType with Invoice): an Invoice of the file, its syntax the flow's (CII when unknown), its processingRule and flowProfile as rule and profile;
  • anything else: the flow alone.

The inbox's cursor is the platform's nextCursor, or the cursor given when there is none; more is whether the platform gave one.

receive
  > POST https://api.flow.pa.example/flow-service/v1/flows/search
  > GET https://api.flow.pa.example/flow-service/v1/flows/c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b?docType=Original
  > GET https://api.flow.pa.example/flow-service/v1/flows/d2b3c4d5-e6f7-4a80-9b0c-1d2e3f4a5b6c?docType=Original
  invoice in FA-77.xml -
  lifecycle in - F-2026-042 paid 212
  cursor 'eyJjcmVhdGVkX2F0IjoiMjAyNi0xMC0wOFQwOTowMDowMFoiLCJpZCI6MTIzNH0=', more true

The CDAR message is parsed with LIBXML_NONET: no entity is fetched, nothing leaves the machine. Inbox::invoices() and Inbox::statuses() give the invoices and the statuses alone.

Webhooks

An approved platform can call the application when a flow changes or arrives. The webhook is created on the platform (POST /v1/webhooks, not done by this package), which answers its signing key: the webhook_key option. With it, notify() is supported:

$notification = $gateway->notify($request->getContent(), $request->headers->all());

$notification->event;    // Event::RECEIVED (a flow coming in) or Event::FLOW_UPDATED
$notification->flow;     // the flow the callback carries
$notification->id;       // "<flowId>@<updatedAt>": the same for a callback sent twice

The check: Afnor-Signature-Timestamp is epoch seconds, within webhook_tolerance (300 s; 0: any age) of now; Afnor-Signature is the base64 of an HMAC-SHA-256, under the key (base64-decoded when it is base64), of the SHA-256 fingerprint of <body>@<timestamp> - over the fingerprint's 32 bytes or its 64 hexadecimal characters, both accepted since the OpenAPI does not say which. The body must be a JSON flow. Each failure is an InvalidNotificationException: no key configured, a header missing, too old, the signature not holding, not JSON.

Run with a signature computed as a platform would, then a forged one:

notify
  received c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b c1a2b3c4-d5e6-4f70-8a9b-0c1d2e3f4a5b@2026-10-26T09:00:00Z
  [afnor] The signature does not hold.

Answer the platform fast (200, or 400 on an InvalidNotificationException) and do the work out of the request: a callback announces; receive() from the kept cursor, or fetchStatus() of the flow, gets what it announces. Deduplicate on Notification::$id. The Symfony controller: Symfony.

Propose a change docs/usage-receiving.md

Declaring statuses

Some statuses only the application knows: its invoice was paid (PAID, the reform's "Encaissée", 212, mandatory), a supplier's invoice is refused (REFUSED, 210) or approved. setStatus() declares them.

use Omnibill\Model\LifecycleStatus;
use Omnibill\Model\StatusChange;

$flow = $gateway->setStatus(new StatusChange(
    LifecycleStatus::PAID,
    invoiceNumber: 'F-2026-042',
    at: new \DateTimeImmutable('2026-11-15'),
    amount: '1250.00',
    file: $cdar,                         // the lifecycle message, for a platform: the application's to produce
    filename: 'CDV-212-F-2026-042.xml',
), $flow);

StatusChange: status, invoiceNumber, at, reason, amount (for PAID or PARTIALLY_APPROVED, a decimal string), code (the platform's own), file and filename (the lifecycle message, CDAR).

On an approved platform (afnor)

A platform carries a status as a lifecycle message (CDAR) of its own flow. The family does not write one: StatusChange::$file is required (InvalidConfigException: An approved platform carries a status as a lifecycle message: give the StatusChange its CDAR file - the family does not write one.). The file is submitted as a flow (POST /v1/flows, flowSyntax CDAR, no processing rule or profile), named filename, else CDV-<code>-<invoice number>.xml. The answer is the CDAR's flow, PENDING, its status the one declared and its history the given flow's history plus the change.

setStatus
  > POST https://api.flow.pa.example/flow-service/v1/flows
  lifecycle refused pending

The $flow argument is optional here: it only carries the history over. Keep the returned flow's reference to follow the CDAR's own acknowledgement (fetchStatus()).

By e-mail

Nobody tells an e-mail's statuses: the application records them, and setStatus() records one in the gateway's status store under the flow's reference - nothing is sent. The flow is required (InvalidConfigException without: the store needs its reference). The change takes the flow's invoice number and the current time when it has none. fetchStatus() then reads the recorded history back.

The store is Omnibill\Email\StatusStoreInterface, given to the factory:

use Omnibill\Email\StatusStoreInterface;
use Omnibill\Model\StatusChange;

final class InvoiceStatusStore implements StatusStoreInterface
{
    public function __construct(private readonly \PDO $db)
    {
    }

    public function record(string $reference, StatusChange $change): void
    {
        $this->db->prepare('INSERT INTO invoice_status (reference, status, at, amount, reason) VALUES (?, ?, ?, ?, ?)')
            ->execute([$reference, $change->status->value, $change->at?->format(DATE_ATOM), $change->amount, $change->reason]);
    }

    public function history(string $reference): array
    {
        $rows = $this->db->prepare('SELECT * FROM invoice_status WHERE reference = ? ORDER BY id');
        $rows->execute([$reference]);

        return array_map(static fn (array $r) => new StatusChange(
            \Omnibill\Model\LifecycleStatus::from($r['status']), at: $r['at'] ? new \DateTimeImmutable($r['at']) : null, reason: $r['reason'], amount: $r['amount'],
        ), $rows->fetchAll(\PDO::FETCH_ASSOC));
    }
}

(run on 2026-10-26 with an SQLite database in memory behind the e-mail gateway: submit() then setStatus(PAID, amount: '10.00') read back as sent, then paid 10.00.)

Without one, InMemoryStatusStore keeps the statuses in the process's memory: right for a test or a script, lost at the end of a web request.

On Chorus Pro

Not supported: a supplier does not declare statuses on Chorus Pro through this API; they are read (fetchStatus()).

The statuses

The 15 cases of LifecycleStatus, their codes and the DGFiP's names: Models.

Propose a change docs/usage-statuses.md

The directory and e-reporting

Both are omnibill/afnor's alone; email and chorus-pro throw NotSupportedException.

Where a company receives (lookup)

foreach ($gateway->lookup('702 042 755') as $line) {
    $line->siren;                // 702042755
    $line->siret;                // null for the legal unit's line
    $line->routingCode;
    $line->electronicAddress;    // where to address its invoices
    $line->platform;             // WK: an approved platform, DFH: the public portal
    $line->isEnabled();          // status Enabled (or none)
}

Supported when the gateway has a directory_url (the platform's Directory Service). The identifier, spaces removed, must be a SIREN (9 digits) or a SIRET (14) - otherwise an InvalidConfigException before any call. The call is POST /v1/directory-line/search with filters.siren or filters.siret (op: strict) and include: [siren, siret, routingCode]; a 204 or an empty body is no line. Each line is an Addressing: the SIREN, SIRET and routing identifier (read flat or nested), the addressing identifier as electronicAddress, the legal unit's business name, platformType, directoryLineStatus (Enabled, Disabled, Upcoming) and the whole line in data.

lookup
  > POST https://api.directory.pa.example/directory-service/v1/directory-line/search
  702042755 - 702042755 WK Enabled
  702042755 70204275500240 702042755_70204275500240_ACHATS WK Upcoming

E-reporting (report)

use Omnibill\Model\EReport;

$flow = $gateway->report(new EReport(file_get_contents('ER-2026-10.xml'), 'ER-2026-10.xml', rule: 'B2C'));

The e-reporting file (transactions with private persons or abroad, and their payments), already produced, is submitted as a flow: POST /v1/flows, flowSyntax FRR (the report's syntax), the report's rule (else the gateway's rule option) as processingRule, no profile. The answer is its flow, PENDING, kind REPORT.

report
  > POST https://api.flow.pa.example/flow-service/v1/flows
  report pending

Propose a change docs/usage-directory.md

Configuration reference

The registry

new Registry(iterable $factories, array $config);
Key Type Required
<name> string - the gateway's name: get('<name>'), and in Symfony the argument's name
<name>.factory string yes a factory's getName(): email, afnor, chorus-pro, or an application's own
<name>.options map no, default [] handed to the factory's create(); create($name, $overrides) puts other values over them

The same tree is the Symfony bundle's omnibill.gateways (Symfony). An option is read as given: null and '' count as missing for a required option, and as "not set" for most optional ones (each factory's ?: or its own check, below).

E-mail (email)

new EmailGatewayFactory(?MailerInterface $mailer = null, ?StatusStoreInterface $store = null): the mailer is required to build a gateway (InvalidConfigException: The "email" gateway needs a mailer: give the factory a Symfony\Component\Mailer\MailerInterface.); without a store, the statuses live in this process's memory.

Option Type Default Required
from string - yes the sender: an address, or Name <address>
reply_to string none no where the buyer's answers go
bcc list or string [] no a copy of each invoice sent: a list, or one comma-separated string (Config::list())
subject string Facture {number} no the subject; whitespace runs become one space
body string Bonjour,\n\nVeuillez trouver ci-joint la facture {number}.\n\nCordialement,\n{seller} no the text part

Placeholders, in the subject and the body: {number}, {seller} (the seller's name), {buyer} (the buyer's name), {total}, {currency}; an unknown value is empty. Capabilities: Factur-X, UBL, CII; not approved; not public sector.

Approved platform (afnor)

new AfnorGatewayFactory(?HttpClientInterface $http = null).

Option Type Default Required
flow_url string - yes the Flow Service's root, before /v1 (a trailing slash is dropped)
token_url string - yes the OAuth 2.0 token endpoint (client credentials)
client_id string - yes
client_secret string - yes
directory_url string none no the Directory Service's root; without it, lookup() is not supported
scope string none no sent with the token request when set
organization_id string none no the Organization-Id header: the company acted for, under a delegation
webhook_key string none no the webhook's signing key (base64); without it, notify() is not supported
webhook_tolerance int 300 no seconds a callback may be old; 0: any age
profile string none no the invoices' flowProfile when the Invoice gives none (Basic, CIUS, Extended-CTC-FR); not sent for CDAR and FRR
rule string none no their processingRule (B2B, B2BInt, B2C, B2G...); not sent for CDAR; a report's own rule wins

Capabilities: Factur-X, UBL, CII, CDAR, FRR; approved; public sector; 100 MB.

Chorus Pro (chorus-pro)

new ChorusProGatewayFactory(?HttpClientInterface $http = null).

Option Type Default Required
client_id string - yes the PISTE application's (OAuth 2.0 client credentials, scope openid)
client_secret string - yes
login string - yes the Chorus Pro technical account (TECH_1_...@cpro.fr)
password string - yes sent with the login as cpro-account: base64(login:password)
sandbox bool false no PISTE's sandbox and Chorus Pro's qualification; read with Config::bool() ("1", "true", "yes", "on")
user_id int none no idUtilisateurCourant, when the account asks for it
syntaxes map [] no syntaxeFlux per syntax, over the defaults {Factur-X: IN_DP_E2_CII_FACTURX, UBL: IN_DP_E1_UBL_INVOICE, CII: IN_DP_E1_CII_16B}
api_url string https://api.piste.gouv.fr/cpro/factures (sandbox: https://sandbox-api.piste.gouv.fr/cpro/factures) no
oauth_url string https://oauth.piste.gouv.fr/api/oauth/token (sandbox: https://sandbox-oauth.piste.gouv.fr/api/oauth/token) no

Capabilities: Factur-X, UBL, CII; not approved; public sector; 10 MB.

Environment variables

The packages read no environment variable themselves. The names in these pages and in the harness are conventions: PA_FLOW_URL, PA_DIRECTORY_URL, PA_TOKEN_URL, PA_CLIENT_ID, PA_CLIENT_SECRET, PA_WEBHOOK_KEY; PISTE_CLIENT_ID, PISTE_CLIENT_SECRET, CPRO_LOGIN, CPRO_PASSWORD, CPRO_SANDBOX; INVOICE_FROM, INVOICE_TO, MAILER_DSN; the harness's OMNIBILL_PLUGINS and OMNIBILL_AUTOLOAD.

Propose a change docs/configuration.md

Errors

Every exception implements Omnibill\Exception\OmnibillException (an interface that extends \Throwable), except the \InvalidArgumentException of Invoice::fromFile() for a file it cannot read.

Exception Parent Thrown when
InvalidConfigException \LogicException a gateway not configured, a factory not installed, a required option missing; a factory without its mailer or its capabilities; an invoice or a status without what the gateway needs (a buyer's e-mail, a number, a CDAR file, a flow)
NotSupportedException \LogicException the gateway does not do that request: supports() says so beforehand
ProviderException \RuntimeException the platform refused the request or answered with an error; an action left a request unanswered
InvalidKeyException ProviderException the platform refused the application's credentials (the token endpoint's 400, 401, 403; an API's 401 after one token renewal, or 403)
UnreachableException ProviderException the platform did not answer: no connection, a timeout, a 5xx, a 429; the mailer could not send. Nothing was transmitted: try again
InvalidNotificationException ProviderException a webhook's call that does not hold: no key configured, a header missing, too old, a wrong signature, not JSON

ProviderException and its children carry provider (string: afnor, chorus-pro, email) and providerCode (?string: the platform's own code - XP Z12-013's errorCode, Chorus Pro's codeRetour, the HTTP status - or null); the message is [<provider>] <message>. There is no HTTP status property: it is in the message, and in providerCode when the platform gave no code.

By gateway

afnor chorus-pro email
token endpoint 400, 401, 403 InvalidKeyException InvalidKeyException -
401 the token renewed once, then InvalidKeyException the same -
403 InvalidKeyException InvalidKeyException -
other 4xx ProviderException (HTTP 422: <errorCode> <errorMessage> - <details>, providerCode the errorCode) ProviderException -
codeRetour not 0 - ProviderException (/v1/deposer/flux answered 20001: GDP_MSG_11.022, providerCode the code) -
5xx, 429, no answer UnreachableException UnreachableException the mailer's transport error: UnreachableException
a body that is not JSON ProviderException ProviderException -

Error messages carry at most 200 characters of a body that is not the platform's JSON error.

What to catch

use Omnibill\Exception\InvalidConfigException;
use Omnibill\Exception\InvalidKeyException;
use Omnibill\Exception\NotSupportedException;
use Omnibill\Exception\ProviderException;
use Omnibill\Exception\UnreachableException;

try {
    $flow = $gateway->submit($invoice);
} catch (UnreachableException) {
    // nothing transmitted: retry later (a delayed message)
} catch (InvalidKeyException) {
    // the credentials are refused: an administrator's problem, not the invoice's
} catch (ProviderException $e) {
    // the platform refused this invoice: $e->providerCode, $e->getMessage()
} catch (InvalidConfigException|NotSupportedException $e) {
    // a configuration error, or the invoice lacks what this gateway needs
}

A flow REJECTED by the platform's checks is not an exception: it is the answer of a later fetchStatus() (or a webhook), with its errors.

No secret in a failure

Answer::send() keeps the request's secrets out of what it throws. The HTTP client's message quotes the URL called: its key parameters, a password in its user part and every secret of the request's options are masked (api_key=***, 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), and no trace of the chain keeps its arguments, where the query, headers and body stood: a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key.

omnibill/email's UnreachableException wraps a copy of the mailer's exception the same way: an SMTP transport's trace held the credentials it sent (AUTH PLAIN).

Propose a change docs/errors.md

Testing

An application that uses Omnibill tests its own code, not the platforms: the platform gateways take the HTTP client they call with, and the e-mail gateway takes the mailer and the status store, so a test hands them a MockHttpClient, a mailer that keeps what it is given, an InMemoryStatusStore, and checks what was sent.

Omnibill ships no fake gateway and no conformance suite: there is no Testing\ namespace in the package. Omnibill\Email\InMemoryStatusStore is the one test helper shipped, and it is a real store (this process's memory).

A test case

use Omnibill\Afnor\AfnorGatewayFactory;
use Omnibill\Email\EmailGatewayFactory;
use Omnibill\Exception\UnreachableException;
use Omnibill\Model\FlowState;
use Omnibill\Model\Invoice;
use Omnibill\Model\LifecycleStatus;
use Omnibill\Model\Party;
use Omnibill\Model\Syntax;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;

final class InvoicingTest extends TestCase
{
    public function testAnInvoiceGoesToThePlatformAsAFlow(): void
    {
        $upload = new MockResponse('{"flowId":"f-1","submittedAt":"2026-10-26T09:00:00Z"}', ['http_code' => 202]);
        $http = new MockHttpClient([new MockResponse('{"access_token":"t","expires_in":3600}'), $upload]);
        $platform = (new AfnorGatewayFactory($http))->create(['flow_url' => 'https://pa.example/flow-service', 'token_url' => 'https://pa.example/token', 'client_id' => 'c', 'client_secret' => 's', 'rule' => 'B2B']);

        $flow = $platform->submit(new Invoice('%PDF-1.7', 'F-1.pdf', Syntax::FACTURX, 'F-1'), 'order-1');

        self::assertSame('f-1', $flow->reference);
        self::assertSame(FlowState::PENDING, $flow->state);
        self::assertSame('https://pa.example/flow-service/v1/flows', $upload->getRequestUrl());
        self::assertStringContainsString('"processingRule":"B2B","flowSyntax":"Factur-X"', $upload->getRequestOptions()['body']);
        self::assertContains('Authorization: Bearer t', $upload->getRequestOptions()['headers']);
    }

    public function testAPlatformDownIsUnreachable(): void
    {
        $http = new MockHttpClient([new MockResponse('{"access_token":"t"}'), new MockResponse('', ['http_code' => 503])]);
        $platform = (new AfnorGatewayFactory($http))->create(['flow_url' => 'https://pa.example/flow-service', 'token_url' => 'https://pa.example/token', 'client_id' => 'c', 'client_secret' => 's']);

        $this->expectException(UnreachableException::class);
        $platform->submit(new Invoice('%PDF-1.7', 'F-1.pdf', Syntax::FACTURX, 'F-1'));
    }

    public function testAnInvoiceByEmailIsSent(): void
    {
        $mailer = new class implements MailerInterface {
            /** @var list<Email> */
            public array $sent = [];

            public function send(RawMessage $message, ?Envelope $envelope = null): void
            {
                $this->sent[] = $message;
            }
        };
        $gateway = (new EmailGatewayFactory($mailer))->create(['from' => 'factures@atelier.example']);

        $flow = $gateway->submit(new Invoice('%PDF-1.7', 'F-1.pdf', Syntax::FACTURX, 'F-1', buyer: new Party('Érable', email: 'compta@erable.example')));

        self::assertSame(LifecycleStatus::SENT, $flow->status);
        self::assertSame('Facture F-1', $mailer->sent[0]->getSubject());
        self::assertSame('F-1.pdf', $mailer->sent[0]->getAttachments()[0]->getFilename());
    }
}
$ vendor/bin/phpunit --bootstrap vendor/autoload.php InvoicingTest.php
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.27

...                                                                 3 / 3 (100%)

Time: 00:00.078, Memory: 10.00 MB

OK (3 tests, 9 assertions)

(run on 2026-10-26 on the packages' checkouts, no network)

  • A platform gateway asks its token first: the first answer of the MockHttpClient's list is the token endpoint's. The token is kept for the gateway's life, so later calls need no other.
  • MockResponse::getRequestUrl() and getRequestOptions() give what was sent: the multipart body of a flow (its flowInfo JSON and the file), the headers.
  • An HTTP 5xx, a 429 or a transport error is an UnreachableException without a test having to build one.
  • The e-mail gateway's message is a Symfony\Component\Mime\Email: its subject, recipients, attachments and Message-ID header can be checked. Symfony Mailer's null://null transport sends nothing, for a script.

Recorded answers

The gateways' own tests keep answers written from the platforms' published schemas, in each package's Tests/Fixtures:

Package Fixtures
omnibill/afnor token.json, flow-post-202.json, flow-metadata-ok.json, flow-metadata-error.json, flow-search.json, supplier-invoice.xml, cdar-212.xml, directory-line-search.json, error-422.json
omnibill/chorus-pro token.json, deposer-flux.json, deposer-flux-trop-gros.json, rechercher-fournisseur.json, rechercher-fournisseur-vide.json

The core's docker/harness/config/recorded.php returns a MockHttpClient callback that serves each call from them by URL - the examples of the usage pages run on it:

$http = new MockHttpClient(require 'vendor/omnisense/omnibill/docker/harness/config/recorded.php');

(the path in a project that installed the core; the file is part of the package's sources.)

Webhooks

To test a webhook controller, sign the body as a platform does - the HMAC-SHA-256, under the decoded key, of the SHA-256 of <body>@<timestamp>, in base64:

$key = base64_encode('the-webhook-signing-key');      // the gateway's webhook_key
$ts = (string) time();
$signature = base64_encode(hash_hmac('sha256', hash('sha256', $body.'@'.$ts, true), base64_decode($key), true));

$client->request('POST', '/invoices/webhook', server: ['HTTP_AFNOR_SIGNATURE' => $signature, 'HTTP_AFNOR_SIGNATURE_TIMESTAMP' => $ts], content: $body);

In Symfony

The bundle builds the platforms' factories on http_client: replace it with a MockHttpClient in the test environment (framework.http_client.mock_response_factory), and the e-mail's on the application's mailer, which framework.mailer.dsn: 'null://null' silences. A gateway's service (omnibill.gateway.<name>) can be replaced with static::getContainer()->set(...) before it is first used.

The package's own tests

docker compose run --rm omnibill test runs every package's tests in the harness: the core's (the gateway and its requests, the factory and its config, the registry, the models, the HTTP helpers, the bundle, the plain-PHP run with no framework class loaded) and each gateway's on its fixtures.

Propose a change docs/testing.md

The Docker harness

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

cd docker && cp .env.dist .env       # a mailer, a platform, PISTE, when you have them
docker compose run --rm omnibill bare
Command
gateways the gateways installed and configured, what each carries and does - nothing called
bare plain PHP: the registry built by hand, an invoice through each gateway, what PHP loaded
bare --live the same, the e-mail sent for real through MAILER_DSN
bare --json the same, whole: every class and file loaded
test every package's tests

Bare: no bundle, no container

docker/harness/bin/bare requires the autoloader and nothing else. It builds the Registry from the gateway packages installed and sends an invoice through each - the e-mail to an in-memory transport (or MAILER_DSN with --live), the platforms answering from their packages' recorded answers - then lists what PHP loaded, and exits 1 if a class of a framework is among it:

$ docker compose run --rm omnibill bare
Omnibill in bare PHP: the registry built by hand, no bundle, no container; the platforms' recorded answers.

  email       E-mail: carries Factur-X, UBL, CII; approved platform no, public sector no; does submit fetch set
  afnor       Approved platform (XP Z12-013): carries Factur-X, UBL, CII, CDAR, FRR; approved platform yes, public sector yes; does submit fetch receive set lookup report
  chorus-pro  Chorus Pro: carries Factur-X, UBL, CII; approved platform no, public sector yes; does submit fetch

email: "Facture F-2026-042" to compta@erable.example, F-2026-042.pdf attached (application/pdf), through in memory
  statuses: sent -> paid

afnor: flow 8f1d2c3b-4a5e-4f60-9a7b-1c2d3e4f5a6b pending, then accepted (deposited)
  received: FA-77.xml; statuses: F-2026-042 paid
  directory, SIREN 702042755: 702042755 WK Enabled; 702042755_70204275500240_ACHATS WK Upcoming

chorus-pro: deposited as CPP0021100000000000000023; accepted, made_available (MISE_A_DISPOSITION)

Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Component\Mailer, Symfony\Component\Mime, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle or bridge, Doctrine's ORM or DBAL, Twig): none

(2026-10-08) doctrine/lexer may be loaded: symfony/mime reads addresses with it; it is a tokenizer, not Doctrine's ORM. Tests/BareTest.php runs bare --json in a process of its own and checks the list.

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

Run on 2026-10-26

The same script, bare with no option, run on the packages' checkouts through OMNIBILL_AUTOLOAD=vendor/autoload.php php core/docker/harness/bin/bare (PHP 8.4, no network), printed the lines above unchanged.

The entrypoint

docker/entrypoint.sh runs docker/harness/setup.php first: it writes the harness's composer.json and phpunit.xml in the harness volume - this core from its checkout, each gateway from its checkout under OMNIBILL_PLUGINS when one is there, from https://github.com/glitchr-omnisense/omnibill-<slug>.git (branch 1.x) otherwise. Then composer install the first time, composer update when composer.json is newer than the lock, and the command: test (PHPUnit), bare, composer, sh or php as they are, anything else the console (gateways by default).

docker/harness/config/gateways.php says which environment variables configure each gateway (INVOICE_FROM; PA_FLOW_URL, PA_TOKEN_URL, PA_CLIENT_ID, PA_CLIENT_SECRET, and PA_DIRECTORY_URL, PA_WEBHOOK_KEY; PISTE_CLIENT_ID, PISTE_CLIENT_SECRET, CPRO_LOGIN, CPRO_PASSWORD, CPRO_SANDBOX - 1 by default) and the example options bare uses instead.

Propose a change docs/harness.md

Writing a gateway

A gateway is a factory that extends Omnibill\GatewayFactory and one action per request it answers. It can live in the application (an access point the application uses) or be a package of its own, as omnibill/afnor and omnibill/chorus-pro are.

The rules

  • Carry, do not produce. A gateway sends the file it is given and reads none beyond what it must to route it (a lifecycle message's status). It keeps no books.
  • One action per request, only those the platform documents. A request no action supports is a NotSupportedException, and supports() says so beforehand; a factory may leave an action out when an option is missing (a closure of the Config that returns null).
  • Every action answers. execute() sets the request's result (setResult()), or throws an OmnibillException; returning without a result is a ProviderException of the gateway.
  • Through the helpers. Call the platform with Omnibill\Http\Answer::send(): no answer, a 5xx or a 429 is then an UnreachableException - nothing transmitted, try again - and the rest comes back to read. Ask an OAuth 2.0 token with Omnibill\Http\ClientCredentials, reset() it on a 401 and try once more.
  • Errors. Refused credentials are an InvalidKeyException; any other refusal a ProviderException with the platform's code; a callback that does not hold an InvalidNotificationException; what the invoice lacks for this gateway (a number, an e-mail) an InvalidConfigException before any call.
  • A flow to keep. submit(), report() and setStatus() answer a Flow whose reference is what the platform names it by; fetchStatus() takes a flow rebuilt from that reference and the invoice's number.

A gateway of the application's

A sketch of a Peppol access point with an API key and two routes:

<?php // peppol.php

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

use Omnibill\Action\ActionInterface;
use Omnibill\Action\ApiAwareInterface;
use Omnibill\Action\ApiAwareTrait;
use Omnibill\Config;
use Omnibill\Exception\ProviderException;
use Omnibill\GatewayFactory;
use Omnibill\Http\Answer;
use Omnibill\Model\Capabilities;
use Omnibill\Model\Flow;
use Omnibill\Model\FlowState;
use Omnibill\Model\Invoice;
use Omnibill\Model\Syntax;
use Omnibill\Request\FetchStatus;
use Omnibill\Request\Receive;
use Omnibill\Request\Request;
use Omnibill\Request\Submit;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/** An access point's API: a key, two routes. */
final class PeppolApi
{
    public function __construct(private readonly HttpClientInterface $http, private readonly string $url, #[\SensitiveParameter] private readonly string $key)
    {
    }

    public function call(string $method, string $path, array $options = []): array
    {
        $options['headers'] = ['X-Api-Key' => $this->key] + ($options['headers'] ?? []);
        $answer = Answer::send($this->http, 'peppol', $method, $this->url.$path, $options);
        if ($answer->status >= 400) {
            throw new ProviderException('peppol', sprintf('HTTP %d', $answer->status), (string) $answer->status);
        }

        return $answer->json();
    }
}

final class PeppolSubmitAction implements ActionInterface, ApiAwareInterface
{
    /** @use ApiAwareTrait<PeppolApi> */
    use ApiAwareTrait;

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

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

    public function execute(Request $request): void
    {
        \assert($request instanceof Submit);
        $sent = $this->api->call('POST', '/documents', ['body' => $request->invoice->content, 'headers' => ['Content-Type' => 'application/xml']]);
        $request->setResult(new Flow((string) $sent['id'], state: FlowState::PENDING, trackingId: $request->trackingId, invoiceNumber: $request->invoice->number));
    }
}

final class PeppolFetchStatusAction implements ActionInterface, ApiAwareInterface
{
    /** @use ApiAwareTrait<PeppolApi> */
    use ApiAwareTrait;

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

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

    public function execute(Request $request): void
    {
        \assert($request instanceof FetchStatus);
        $state = $this->api->call('GET', '/documents/'.rawurlencode($request->flow->reference))['state'] ?? null;
        $request->setResult($request->flow->with(state: match ($state) { 'delivered' => FlowState::ACCEPTED, 'failed' => FlowState::REJECTED, default => FlowState::PENDING }));
    }
}

final class PeppolGatewayFactory extends GatewayFactory
{
    public function __construct(private readonly ?HttpClientInterface $http = null)
    {
    }

    protected function populateConfig(Config $config): void
    {
        $config->defaults([
            'omnibill.factory_name' => 'peppol',
            'omnibill.factory_title' => 'Peppol access point',
            'omnibill.required_options' => ['url', 'api_key'],
            'omnibill.capabilities' => new Capabilities([Syntax::UBL]),
            'omnibill.api' => fn (Config $c) => new PeppolApi($this->http ?? HttpClient::create(), rtrim((string) $c['url'], '/'), (string) $c['api_key']),
            'omnibill.action.submit' => new PeppolSubmitAction(),
            'omnibill.action.fetch_status' => new PeppolFetchStatusAction(),
        ]);
    }
}

$http = new MockHttpClient([new MockResponse('{"id":"doc-1"}', ['http_code' => 201]), new MockResponse('{"state":"delivered"}')]);
$gateway = (new PeppolGatewayFactory($http))->create(['url' => 'https://ap.example/api', 'api_key' => 'test']);

$flow = $gateway->submit(new Invoice('<Invoice/>', 'F-1.xml', Syntax::UBL, 'F-1'));
echo $gateway->getTitle(), ': ', $flow->reference, ' ', $flow->state->value, "\n";
echo $gateway->fetchStatus($flow)->state->value, "\n";
var_dump($gateway->supports(Receive::class));
try {
    (new PeppolGatewayFactory())->create(['url' => 'https://ap.example/api']);
} catch (\Omnibill\Exception\InvalidConfigException $e) {
    echo $e->getMessage(), "\n";
}
$ php peppol.php
Peppol access point: doc-1 pending
accepted
bool(false)
The "peppol" gateway needs: api_key.

(run on 2026-10-26 on a MockHttpClient, PHP 8.4; no access point was called.)

In Symfony, the factory is autoconfigured (tagged omnibill.gateway_factory) as soon as it is a service, and named in omnibill.gateways.<name>.factory.

The factory's Config

populateConfig(Config $config) sets, with $config->defaults([...]):

Key
omnibill.factory_name the factory's name, what factory: names
omnibill.factory_title a readable name, getTitle()
omnibill.required_options the options create() refuses to go without
omnibill.capabilities a Capabilities (or a closure of the Config giving one): required
omnibill.api the client the actions work with, or a closure of the Config building it at create()
omnibill.action.<name> an action, or a closure of the Config giving one or null
every other option its default

Config::bool() and Config::list() read a boolean and a list as an environment variable gives them ("1", "a,b").

A package of its own

The shipped gateways' layout, to copy: name omnibill/<platform>, namespace Omnibill\<Platform>\, require php >=8.2, omnisense/omnibill ^1.0@dev and what the platform needs (symfony/http-client, ext-dom); an Api class and an Action/ directory with an AbstractAction holding the ApiAwareTrait; tests on answers written from the platform's published schemas, in Tests/Fixtures; a README with the sources read, what they leave out, and a "Responsibility" section. For the bundle to register the factory without the application declaring it, its class must be in OmnibillBundle::FACTORIES; for the harness, in docker/harness/plugins.php, config/gateways.php and config/recorded.php.

Propose a change docs/providers.md

Verified, and not

What the tests prove

Run on 2026-10-26 on the four packages' checkouts (the workspace's autoloader), PHP 8.4.27, PHPUnit 11.5.57, no network: 32 tests, 170 assertions, green.

  • The core (11 tests): an invoice submitted, followed and paid through a gateway; what a gateway does not do said beforehand and thrown; misconfiguration shown when the gateway is built; the lifecycle statuses with the reform's codes; invoices, flows updated not changed, inboxes; the token asked once and again a minute before it lapses; refused credentials and silence (unreachable); the Symfony bundle (gateways built and injectable by name, the packages installed registered with the application's client); the plain-PHP run with no framework class loaded and an invoice through each gateway (Tests/BareTest.php).
  • omnibill/afnor (9 tests): the upload with its flowInfo, the acknowledgement as the first status, receiving from the cursor, a status as the application's CDAR, the directory, e-reporting, the webhook's signature, errors and the token renewed once, a CDAR without a reform status.
  • omnibill/chorus-pro (5 tests): the deposit with the technical account, each syntax's syntaxeFlux and the sandbox's addresses, the statuses read as the reform's, errors and refusals, what Chorus Pro does not give a supplier.
  • omnibill/email (4 tests): the invoice attached and sent, its statuses kept by hand, what an e-mail cannot do, the mailer needed and the templates.

The examples of these pages were run on 2026-10-26 on the same checkouts, the platforms answering from the packages' fixtures through the harness's recorded.php.

What was run live, and what was not

  • The e-mail: sent for real on 2026-10-08 - the harness's bare --live through a local SMTP server (Mailpit, in a throwaway container): received from the sender, to the buyer, subject "Facture F-2026-042", the PDF attached, the Message-ID the flow's reference. Delivery to a real mailbox (SPF, DKIM, filters) is the application's mailer's.
  • An approved platform (afnor): not run against one - no account. Every answer in its fixtures is written from XP Z12-013's OpenAPI 1.3.0 and the DGFiP's Annex 2. Which routes a given platform implements is unknown until tried.
  • Chorus Pro: not run against PISTE or Chorus Pro - no PISTE application, no technical account. The fixtures are written from Chorus Pro's OpenAPI and the API services annex.
  • The Laravel page was written from the registry's API; it was not run in a Laravel application.
  • The known defects found while writing these pages: ext-mbstring not required though mb_substr() is called (fixed: composer.json requires it); a Chorus Pro date PHP cannot read throwing PHP's \Exception (Submitting).

Responsibility

omnisense/omnibill and its omnibill/* gateways are independent, unofficial clients: none is affiliated with, endorsed by or sponsored by the platforms or administrations it reaches. Each keeps its own terms (accounts, approvals, credentials, the reform's obligations), named with a link in each gateway's README. The MIT licence covers this code only; whoever runs these packages complies with those terms and with the invoicing obligations that apply to them.

Propose a change docs/verified.md