Search by

tmonier / sylius-einvoice-pennylane-plugin

thibmonier

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

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v0.1.0 2026-11-13 06:21 UTC

This package is auto-updated.

Last update: 2026-11-13 06:47:26 UTC


README

Français : README.fr.md

Build License: MIT

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 the transmitter of 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 its RelaunchTransmissionHandler.
  • 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 by external_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 deposited and the message Imported 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 in error.

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_options field is sent as a JSON-encoded form field of the multipart request, next to file; 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

  1. Install and configure the transmission plugin first (its README), then:

    composer require tmonier/sylius-einvoice-pennylane-plugin
  2. Register the bundle in config/bundles.php:

    Tmonier\SyliusEinvoicePennylanePlugin\TmonierSyliusEinvoicePennylanePlugin::class => ['all' => true],
  3. Put the token in the environment, never in the code (.env.local, or the secrets of your host):

    PENNYLANE_API_TOKEN=...
  4. 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.