tmonier / sylius-einvoice-pennylane-plugin
Pennylane adapter for the Sylius 2 e-invoice transmission plugin: finds or creates the customer, then imports the Factur-X invoice into Pennylane through the Company API v2
Package info
github.com/thibmonier/sylius-einvoice-pennylane-plugin
Type:sylius-plugin
pkg:composer/tmonier/sylius-einvoice-pennylane-plugin
Requires
- php: ^8.2
- sylius/sylius: ^2.2.9 || ^2.3
- symfony/http-client: ^6.4 || ^7.4 || ^8.0
- symfony/http-client-contracts: ^3.0
- symfony/mime: ^6.4 || ^7.4 || ^8.0
- tmonier/sylius-einvoice-transmission-plugin: ^0.2
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-webmozart-assert: ^2.0
- phpunit/phpunit: ^10.5
- rector/rector: ^2.0
- sylius-labs/coding-standard: ^4.4
- sylius/test-application: ^2.0.0@alpha
- symfony/browser-kit: ^6.4 || ^7.4 || ^8.0
- symfony/css-selector: ^6.4 || ^7.4 || ^8.0
- symfony/debug-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/dom-crawler: ^6.4 || ^7.4 || ^8.0
- symfony/dotenv: ^6.4 || ^7.4 || ^8.0
- symfony/intl: ^6.4 || ^7.4 || ^8.0
- symfony/runtime: ^6.4 || ^7.4 || ^8.0
- symfony/web-profiler-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/webpack-encore-bundle: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-13 06:47:26 UTC
README
Français : README.fr.md
Send the Factur-X invoices of your Sylius shop to Pennylane: the customer is found or created, then the invoice is imported through the Pennylane Company API v2.
First real adapter of tmonier/sylius-einvoice-transmission-plugin: that plugin provides the port, the journal of the sends and the listener of the invoices generated by tmonier/sylius-facturx-plugin; this one implements InvoiceTransmitterInterface for Pennylane.
No regulatory promise. This plugin is a technical building block that calls an API. It does not make an invoice compliant, and it does not state that Pennylane is an approved e-invoicing platform. Whether Pennylane is on the list of approved platforms must be checked on the official list of the DGFiP. It is not legal or tax advice. The calls were written from the Pennylane API reference and are tested against simulated answers, never against the real service: see What is not confirmed yet.
Features
- Adapter
Tmonier\SyliusEinvoicePennylanePlugin\Transmission\PennylaneInvoiceTransmitter, to be set as thetransmitterof the transmission plugin. The first send of an invoice goes through the idempotent listener of that plugin; a send in error can be relaunched with itsRelaunchTransmissionHandler. - Customer found or created. The buyer is read from the Factur-X XML (
BuyerTradeParty: name, address, SIREN, VAT number, e-mail). The customer is searched in Pennylane byexternal_reference; it is created when it does not exist, and never created twice for the same reference. An existing customer is not updated. - Invoice imported. The Factur-X PDF is sent to Pennylane for that customer. On success the line of the journal gets the status
depositedand the messageImported in Pennylane: invoice id <id> (customer id <id>). - Errors written for the journal. Every failure becomes a status and a message of the journal (table below). The messages carry the HTTP status only: never the token, never the body of the request or of the answer.
- No automatic retry. A failed call is reported once; the send is relaunched explicitly.
- Token from the environment.
PENNYLANE_API_TOKEN, never stored in the code. Without a token no request is sent and the send ends inerror.
Pennylane endpoints used
All on the host of base_url (default https://app.pennylane.com), with the bearer token:
| Call | Endpoint | Used for |
|---|---|---|
findCustomerId() |
GET /api/external/v2/customers, filter=[{"field":"external_reference","operator":"eq","value":"<reference>"}], limit=1 |
Find the customer |
createCustomer() |
POST /api/external/v2/customers (JSON) |
Create it when not found: external_reference, billing_address, emails, and for a company customer_type, name, reg_no, vat_number; for an individual customer_type, first_name, last_name |
importInvoice() |
POST /api/external/v2/customer_invoices/e_invoices/imports (multipart) |
Import the invoice: file (the PDF, application/pdf) and invoice_options ({"customer_id": <id>}) |
API reference: customers, create a customer, e-invoice import, changelog of the import endpoint.
The token needs the scope customer_invoices:all (import). The messages of the plugin and the configuration also name customers:all for the customer search and creation: grant both.
The external_reference of the customer
The Factur-X document does not carry the Sylius customer code, so the reference is built from the document:
- buyer with a SIREN:
siren-<SIREN>; - any other buyer (an individual, or a company that only has a VAT number in the document):
buyer-followed by the first 32 characters of a SHA-256 of the lower-cased name, the lower-cased postal code and the country code. A buyer with a VAT number is still created as a company in Pennylane.
Limit: an individual who changes name or postal code is seen as a new customer in Pennylane. Two different individuals with the same name and postal code are the same customer. Companies are identified by their SIREN, which does not change.
Statuses of the journal
| Situation | Status | Message |
|---|---|---|
| Invoice imported | deposited |
Imported in Pennylane: invoice id … (customer id …). |
| No token configured | error |
token not configured |
| HTTP 401 or 403 | error |
token or scope refused (HTTP code) |
| HTTP 429 | error |
rate limit reached, with Retry-After in seconds when given |
| HTTP 5xx, timeout, connection failure | error |
Pennylane unavailable |
Answer without an integer id, or list not understood |
error |
answer not understood |
Factur-X XML invalid or without BuyerTradeParty |
error |
nothing is sent |
| Any other HTTP status from 300 up (400, 404, 422…) | rejected |
Pennylane refused the request (HTTP code) |
The statuses are those of the transmission plugin (TransmissionStatus). A send in error can be relaunched; a rejected one is the answer of Pennylane to the content and is not meant to be retried unchanged.
What is not confirmed yet
Verify these against the Pennylane sandbox before relying on the plugin; the unit and integration tests use a scripted HTTP client, so they cannot tell:
- the
invoice_optionsfield is sent as a JSON-encoded form field of the multipart request, next tofile; the way the API expects it is not confirmed; - every HTTP 400, 404 and 422 is mapped to
rejected; whether some of them should be retried (error) is not confirmed; - the exact fields accepted when creating a customer, and the scopes required, follow the reference at the time of writing and may change;
- the status of Pennylane as an approved platform (see the notice above).
Screenshots
This plugin has no user interface (neither has the transmission plugin): it acts when the Factur-X invoice is generated and feeds the journal table tmonier_einvoice_transmission. Example of a line after a successful send (fictitious values):
invoice_id : 0b6f1c2e-5d3a-4c1b-9a77-2f4e8d1c6a10
invoice_number : 2026/00042
status : deposited
message : Imported in Pennylane: invoice id 123456 (customer id 7890).
Requirements
| Supported | |
|---|---|
| PHP | 8.2 to 8.4 |
| Sylius | 2.2.9+ and 2.3 |
| Symfony | 6.4, 7.4 |
tmonier/sylius-einvoice-transmission-plugin |
^0.2 |
| Database | MySQL 8.4 (the one the CI runs; the plugin has no table of its own) |
The transmission plugin itself needs tmonier/sylius-facturx-plugin. A Pennylane account with API access and a token are needed.
The plugin is at version 0.x: its configuration may change between minor versions, read the CHANGELOG before upgrading.
Installation
-
Install and configure the transmission plugin first (its README), then:
composer require tmonier/sylius-einvoice-pennylane-plugin
-
Register the bundle in
config/bundles.php:Tmonier\SyliusEinvoicePennylanePlugin\TmonierSyliusEinvoicePennylanePlugin::class => ['all' => true],
-
Put the token in the environment, never in the code (
.env.local, or the secrets of your host):PENNYLANE_API_TOKEN=...
-
Configure the adapter (
config/packages/tmonier_einvoice_pennylane.yaml):tmonier_sylius_einvoice_transmission: transmitter: Tmonier\SyliusEinvoicePennylanePlugin\Transmission\PennylaneInvoiceTransmitter # optional, enables the relaunch of a send in error (see the transmission plugin) document_provider: App\Einvoice\MyInvoiceDocuments tmonier_einvoice_pennylane: api_token: '%env(PENNYLANE_API_TOKEN)%'
There is no migration, route or template: the plugin only adds services.
Configuration
Key (tmonier_einvoice_pennylane) |
Default | Meaning |
|---|---|---|
api_token |
null |
The token, as %env(PENNYLANE_API_TOKEN)%. Without it every send ends in error. |
base_url |
https://app.pennylane.com |
Host of the API. Change it only in the configuration (for example for the sandbox), never from a user input. |
timeout |
10.0 |
Seconds to wait for an answer, per request (minimum 1). |
http_client |
http_client |
Id of the Symfony HTTP client service used for the calls. |
Limits
- No automatic retry, no queue: one attempt per send, relaunched explicitly.
- No admin screen: the result is read in the journal of the transmission plugin (table
tmonier_einvoice_transmission, status and message of each invoice). - The status of the invoice in Pennylane afterwards (acceptance, payment, lifecycle) is not read back.
- The Pennylane customer is created from the invoice and never updated; credit notes and other documents are not handled.
- Tests never call Pennylane: the HTTP client is scripted.
Development
composer install docker compose up -d --wait vendor/bin/phpunit vendor/bin/ecs check vendor/bin/phpstan analyse
Author
Thibaut Monier – Delivery Manager IA / Tech Lead
License
MIT, see LICENSE.