Search by

justinholtweb / craft-lexies

justinholtweb

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

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-23 14:49 UTC

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

  1. In Lexware Office, go to Extensions → Public API and create an API key.

  2. Put it in your .env:

    LEXWARE_API_KEY="your-key"
    
  3. 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.

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

  5. 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.

  6. 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/sync again.

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:

  1. A create is never retried automatically unless Lexware definitively rejected it. A timeout or a 5xx parks the document as needs checking and stops.
  2. 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.