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()returnstruefor the order.
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:
Billing\Infrastructure\Invoicing\InvoicingException:
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 intoInvoicingException, 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 callsissue()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 whenappliesTo()returnsfalse, so don’t rely onappliesTo()having filtered the order. - Choose
retryabledeliberately. 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 tofailed, 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
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:
acme_ledger.gateways. Set extra.default_url to the same path. For the form itself, see Admin settings pages.
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 callfetchPdf().
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.
php bin/console app:queue:work --stop-when-empty -vv after paying for an order to issue the invoice without waiting for cron.