Skip to main content
An invoicing engine creates the invoice for a paid order in an external service, such as an accounting or e-invoicing system, and fetches it back as a PDF when someone downloads it. Aikeedo ships a null engine, No invoicing, and administrators select an engine under Settings → Billing → Invoicing.

When it runs

An order is invoiced when all of these are true:
  • An engine other than No invoicing is selected, and its plugin is installed.
  • The order is fulfilled, and its total is above zero. Free orders are never invoiced.
  • The engine’s appliesTo() returns true for the order.
When the order is fulfilled, Aikeedo records a row in the invoice table with the status pending and queues an IssueInvoiceJob. The job calls your engine’s issue() in the background, so a slow or failing service never holds up the payment callback that fulfilled the order. Aikeedo keeps the external id and the invoice number your engine returns, never the document itself. Each time a customer or administrator downloads the invoice, Aikeedo calls fetchPdf() and streams the result, because invoices carry customer details and stored files can be served publicly from a CDN.

The interface

The value objects you return:
The invoice number comes from your engine, not from Aikeedo. It’s shown on the order page, in the admin panel and in the purchase emails. When something goes wrong, throw Billing\Infrastructure\Invoicing\InvoicingException:
The message is shown to administrators on the order, so write it in their terms. Set retryable to false when trying again can’t help until someone changes something, such as wrong credentials or a missing setting. Any other exception is recorded with its message and treated as retryable.

Implementation

This engine sends invoices to a fictional service, Acme Ledger. It’s split in two: a client that talks to the API and translates its errors into InvoicingException, and the engine that maps an order onto an invoice.
src/Client.php
src/AcmeLedgerEngine.php

Rules to follow

  • Make issue() safe to call twice. An attempt can create the invoice and then fail, for example on approval or on a timeout. The next attempt calls issue() again for the same order, so look the invoice up by a reference you control, such as the order id, before creating one.
  • Expect orders appliesTo() would skip. An administrator can create an invoice by hand for any fulfilled, paid order, from the order page in the admin panel. issue() is then called even when appliesTo() returns false, so don’t rely on appliesTo() having filtered the order.
  • Choose retryable deliberately. A non-retryable failure stops the retries at once and waits for an administrator. Use it for problems that need someone to change a setting, and let outages and rate limits retry.
  • Write messages for administrators. “Check the API keys” helps. A raw response body doesn’t.
  • Return quickly. The job that calls issue() has a 120-second timeout. When an administrator clicks Create invoice or Retry now, issue() runs inside that request.
  • Don’t cache the PDF yourself. fetchPdf() is called on every download by design.
  • Work in minor units. Order amounts, tax amounts and discounts are integers in the currency’s smallest unit.

Retries and failures

A failed attempt sets the invoice to failed, stores the message, and schedules the next attempt with a growing delay: After the eighth attempt, or straight away when your engine throws a non-retryable InvoicingException, no further attempt is scheduled. The invoice stays failed until an administrator clicks Retry now on the order page. The Retry invoices scheduled task (billing.retry_invoices) runs every minute and queues an IssueInvoiceJob for each invoice that is due, up to 50 per run. It also picks up an invoice whose first job was never run. If the engine an invoice was created with is no longer available, for example because its plugin is switched off, the attempt fails as retryable. Invoices keep the engine they were created with, so switching engines doesn’t move existing invoices to the new one.

Register it

src/Plugin.php
The engine appears in the Invoicing engine select under Settings → Billing → Invoicing, where an administrator selects it. The selected key is stored in option.billing.invoicing.engine. Every installed engine is also listed under Settings → Finance → Invoicing engines.

The settings page

Both the Configure selected engine link on the billing page and the Invoicing engines list point to /admin/settings/invoicing-engines/{key}, so declare that route:
The gateway factory iterates as key and gateway pairs, so the template can offer a checkbox per payment method and save the chosen keys to acme_ledger.gateways. Set extra.default_url to the same path. For the form itself, see Admin settings pages.
Let administrators choose which payment methods are invoiced. Orders paid by bank transfer or entered by hand often need a different treatment from card payments, and your appliesTo() is the place to decide.

What customers see

Once the invoice is issued, customers can download it from the order page in their billing area, and administrators from the order in the admin panel. Both downloads call fetchPdf(). The purchase confirmation email waits for the invoice so it can attach the PDF. If the invoice isn’t issued within 60 minutes, or issuing stops for good, the confirmation goes out without it, and an Invoice ready email follows when the invoice is issued. See Billing internals for the full sequence.

Reacting to invoices

Core dispatches two events you can listen to, both carrying $event->invoice: Listen to InvoiceFailedEvent with isFinal set to alert someone when an invoice needs attention. See Events.

Testing

A paid order for an invoiced payment method gets an invoice, and its number shows on the order.
A free order, or one for a payment method you don’t invoice, gets no invoice row.
Create invoice works on an order your engine skipped.
Calling issue() twice for the same order leaves one invoice in the service.
With a wrong API key, the invoice fails at once with a clear message and no next attempt.
With the service unreachable, the invoice fails with a next attempt scheduled, and checkout still completes.
The PDF downloads from both the customer’s order page and the admin panel.
Run php bin/console app:queue:work --stop-when-empty -vv after paying for an order to issue the invoice without waiting for cron.