justinholtweb / craft-lexies
Lexware Office (lexoffice) invoicing for Craft Commerce — turn completed orders into real German invoices, keep contacts in sync, and let paid-in-Lexware flow back into Commerce.
Package info
github.com/justinholtweb/craft-lexies
Type:craft-plugin
pkg:composer/justinholtweb/craft-lexies
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- craftcms/cms: ^5.3.0
- craftcms/commerce: ^5.0.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-29 13:31:51 UTC
README
Lexware Office (formerly lexoffice) invoicing for Craft Commerce.
Completed orders become real German invoices in Lexware Office — with the right tax treatment, the right customer record, and a voucher number your accountant will accept. Paid-in-Lexware comes back the other way. Refunds raise credit notes.
- Craft CMS 5.3+, Craft Commerce 5.0+, PHP 8.2+
- Lite is free. Pro is $99 with a $49/year renewal.
composer require justinholtweb/craft-lexies
What it does
| Lite | Pro | |
|---|---|---|
| Price | Free | $99, $49/year renewal |
| Create invoices from completed orders | ● | ● |
| Draft or finalised, your choice | ● | ● |
| Automatic tax type per order (net / gross / VAT-free) | ● | ● |
| Shipping and discounts mapped correctly | ● | ● |
| Reconciliation guard — never sends an invoice that disagrees with the order | ● | ● |
| PDF download in the control panel | ● | ● |
| Backfill and preview from the console | ● | ● |
| Lexware contacts created and reused per customer | ● | |
| Intra-community and third-country invoices (need a contact) | ● | |
| OSS distance sales with destination VAT rates | ● | |
| Webhooks — Lexware tells you when an invoice is paid | ● | |
| Record that payment as a Commerce transaction | ● | |
| Credit notes when a refund is captured in Commerce | ● | |
| Customer-facing invoice download | ● | |
| Full request/response connection log | ● | |
| Trigger on an order status rather than completion | ● |
Getting started
-
In Lexware Office, go to Extensions → Public API and create an API key.
-
Put it in your
.env:LEXWARE_API_KEY="your-key" -
In Craft, go to Settings → Plugins → Lexies, set the API key to
$LEXWARE_API_KEY, save, and press Test connection. It will tell you which organisation you are connected to, its tax type, and whether it is registered as a small business — the three facts every later decision depends on. -
Leave Finalise invoices off for the first few orders. Drafts can be deleted; finalised invoices carry a consecutive voucher number and can only be voided.
-
Complete a test order, or preview one that already exists:
php craft lexies/sync/order 1234 --dry-run
That prints the tax reasoning, both totals, and the exact JSON that would be sent.
-
When you are happy, backfill:
php craft lexies/sync/backfill --limit=200
Tax
Lexies works the tax type out per order rather than making you pick one:
| Situation | Tax type |
|---|---|
| Lexware organisation is a Kleinunternehmer (§19 UStG) | vatfree |
| Customer in Germany | net or gross, matching how your store enters prices |
| Customer in the EU with a VAT ID | intraCommunitySupply (reverse charge) |
| Customer in the EU without a VAT ID, OSS on | net/gross with taxSubType: distanceSales |
| Customer outside the EU | thirdPartyCountryDelivery (or …Service for digital goods) |
Whether a country counts as EU comes from Lexware's own countries endpoint, not a list baked into the plugin — it is the same table their voucher validation uses, so it stays right when a country joins or leaves.
Every decision, and the reasoning behind it, is recorded on the document. Open any document in Lexies → Documents to see why it was taxed the way it was.
You can pin a single tax type in the settings if you would rather decide yourself.
The reconciliation guard
Before anything is sent, Lexies builds the payload and then computes it the way Lexware will — unit price times quantity rounded to the cent per line, tax recomputed per rate — and compares the answer to what Commerce charged.
If they disagree by more than the rounding it can absorb into a line item, the invoice is not sent. It is marked blocked with the two figures, and you get to find out why today rather than at the next VAT return.
Things that block, on purpose:
- an order in a currency other than EUR (Lexware only issues EUR)
- a line item carrying two different tax rates (Lexware allows one per line)
- an intra-community or third-country invoice with no Lexware contact to reference
- totals that will not add up
Payments
Turn on Listen for Lexware webhooks and press Subscribe. Lexware then calls
/lexies/webhook whenever a payment or voucher status changes, and Lexies re-reads the voucher.
Every callback is verified against Lexware's published RSA-SHA512 signing key, checked against your organisation id, deduplicated, and handled in a queue job — Lexware's read timeout is five seconds and a slow endpoint gets retried up to 25 times.
Lexware deletes every subscription made with an API key when that key is revoked. After rotating a key, run
php craft lexies/webhooks/syncagain.
If you would rather not expose a webhook endpoint, poll instead:
php craft lexies/payments/poll
Record payments in Commerce is off by default and should stay off if you take payment at checkout — otherwise the same sale shows up as paid twice.
Front end
{% set invoiceUrl = craft.lexies.invoiceUrl(order) %}
{% if invoiceUrl %}
<a href="{{ invoiceUrl }}">Rechnung herunterladen</a>
{% endif %}
The URL is only served when Customer invoice downloads is on, and it authorises exactly the way Commerce's own order-details page does: a logged-in customer must own the order; a guest order is proved by its (unguessable, 32-character) order number.
Also available:
{{ craft.lexies.isInvoiced(order) }} {# bool #}
{{ craft.lexies.invoiceFor(order) }} {# the Document, or null #}
{{ craft.lexies.documentsFor(order) }} {# invoice + credit notes #}
Console
php craft lexies/sync/order 1234 # invoice one order php craft lexies/sync/order 1234 --dry-run # build it and show the JSON, send nothing php craft lexies/sync/backfill --limit=200 # invoice completed orders that have none php craft lexies/sync/reconcile # resolve documents whose fate is unknown php craft lexies/sync/refresh # re-read sent documents from Lexware php craft lexies/payments/poll # check open invoices for payment php craft lexies/webhooks/list # what Lexware currently sends us php craft lexies/webhooks/sync # subscribe / tidy up php craft lexies/webhooks/replay # handle callbacks that were never processed php craft lexies/log/prune --days=30 # housekeeping, for cron
Why an invoice is never sent twice
Lexware has no idempotency key, and their documentation is explicit that a gateway timeout may still have created your voucher. A duplicate invoice in German bookkeeping is not an inconvenience; it is a correction filing.
So Lexies does two things:
- A create is never retried automatically unless Lexware definitively rejected it. A timeout or a 5xx parks the document as needs checking and stops.
lexies/sync/reconcile, or the "Check with Lexware" button, looks before anything leaps — it searches Lexware's voucher list for a matching voucher in the window and either adopts it or confirms that nothing was created.
The (orderId, type, refKey) unique index handles the rest: a replayed queue job, a double
order-complete event and an impatient merchant all collide in the database instead of in Lexware's
voucher numbering.
Requirements and limits
- Lexware Office only issues vouchers in EUR.
- A voucher may carry at most 300 line items.
- Lexware allows two API requests per second across your whole key. Lexies paces itself to that behind a mutex, so several queue workers share one budget rather than each politely staying under it and collectively tripling it.
License
Proprietary. See LICENSE.md.