justinholtweb / craft-knox
Fortnox invoicing for Craft Commerce — turn completed orders into real Swedish customer invoices, with VAT resolved per order and öresavrundning that never silently disagrees with what the customer paid.
Package info
github.com/justinholtweb/craft-knox
Type:craft-plugin
pkg:composer/justinholtweb/craft-knox
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
- craftcms/commerce: ^5.0.0
README
Fortnox invoicing for Craft Commerce.
Completed orders become real Swedish customer invoices in Fortnox — with the VAT resolved per order, the right BAS accounts, and a total that matches what the customer actually paid. Payments flow both ways. Refunds raise credit invoices.
- Craft CMS 5.3+, Craft Commerce 5.0+, PHP 8.2+
- $99 one-off, $79/year renewal — one edition, everything in it
composer require justinholtweb/craft-knox
Why it exists
The usual options are a Zapier hop or hand-keying orders. Both produce invoices that look right, and both are quietly wrong in the same way.
A Swedish Fortnox company almost always has öresutjämning switched on: Fortnox rounds every invoice total to the nearest whole krona and books the difference to account 3740. A webshop, at the same time, charges the customer an exact amount to the öre. Those two facts cannot both hold, so the invoice and the payment differ by a few öre on most orders — and neither system ever says so. It surfaces at the bank reconciliation, months later, as a pile of small unexplained differences.
Knox's edge is that it refuses to be quiet about it. It models Fortnox's own arithmetic before sending, tells you what the company's rounding setting is about to do in plain words, re-checks the total Fortnox returns, and flags the invoice if the two disagree. Every other feature here follows from the same principle: an invoice that does not match the order is not an invoice.
What it does
| Create invoices from completed orders | ● |
| VAT resolved per order, with the reasoning recorded | ● |
| BAS account mapping, validated against Fortnox's chart | ● |
| Shipping and discounts mapped correctly | ● |
| Reconciliation guard — never sends an invoice that disagrees with the order | ● |
| Öresavrundning modelled, predicted and explained | ● |
| Re-check the total Fortnox returns after every create | ● |
| Never retries a create whose outcome is unknown | ● |
| Fortnox customers created and reused per buyer | ● |
| Reverse-charge, OSS and export invoices | ● |
| Bookkeeping — book the invoice, with a financial-year pre-check | ● |
| Register the checkout payment in Fortnox | ● |
| Poll Fortnox for invoices paid there, and mark the order paid | ● |
| Credit invoices when a refund is captured in Commerce | ● |
| Match order lines to Fortnox articles by SKU | ● |
| Customer-facing invoice download | ● |
| Order panel, invoice list and PDF in the control panel | ● |
| Full request/response connection log | ● |
| Backfill, preview and reconcile from the console | ● |
| Trigger on an order status rather than completion | ● |
Getting started
-
Register an integration at apps.fortnox.se/developer and note its Client ID and Client Secret.
-
Put them in your
.env:FORTNOX_CLIENT_ID="your-client-id" FORTNOX_CLIENT_SECRET="your-client-secret" -
In Craft, go to Settings → Plugins → Knox and set the two credentials to
$FORTNOX_CLIENT_IDand$FORTNOX_CLIENT_SECRET. Save. -
Copy the Redirect URI Knox shows you and register it, character for character, on your Fortnox integration. Fortnox compares it as a string.
-
Pick your scopes before connecting. Fortnox freezes the scope set at the moment the merchant approves the integration — adding one later does nothing at all for an already-connected account until they reconnect.
-
Press Connect to Fortnox and approve.
-
Press Detect from Fortnox in the Öresavrundning section. Knox reads the company's recent invoices and works out whether öresutjämning is on. Read what it says.
-
Press Check these exist in Fortnox under Accounts. An invoice row posted to an account that is not in the chart is rejected, and finding that out on order 1 beats finding it out on order 200.
-
Set a collective customer number, or turn on Sync customers to give each buyer their own record. Fortnox requires an existing customer on every invoice — there is no such thing as an invoice without one.
-
Try a dry run before you let it loose:
php craft knox/sync/backfill --dry --limit=10
That prints what each order would be invoiced at, what Knox thinks the VAT treatment is, and which orders it would refuse and why. Nothing is sent.
The öresavrundning problem, in detail
Say a customer pays 499,50 kr.
- With öresutjämning off, Knox sends rows totalling 499,50, Fortnox invoices 499,50, and the invoice matches the payment. Done.
- With öresutjämning set to nearest krona, Fortnox will invoice 500,00 and book 0,50 to 3740 — whatever Knox sends. No payload can produce a 499,50 invoice in that company.
Knox handles this in three places:
- Before sending, it models the rounding and says so: "Fortnox will round this invoice to 500,00 because öresutjämning is set to krona on the company — 0,50 away from the 499,50 the customer paid. No invoice can match an öre amount while that setting is on; it lives under Inställningar → Fakturering → Registreringsvy."
- When the rows themselves come to a few öre off — because Fortnox groups VAT by rate and Commerce rounds per line — Knox adds an explicit öresavrundning row at 0% VAT against 3740, which closes the gap exactly and is what a bookkeeper does by hand. You can make it refuse instead.
- After sending, it compares the total Fortnox actually returned against what was charged. A disagreement makes the invoice Does not match the order in the control panel, with both figures and the cause on it — which is also how you find out that someone changed the setting three months after go-live.
If you want invoices that match payments, turn öresutjämning off in Fortnox. Knox will keep working either way; it just will not pretend.
VAT
auto resolves the treatment per order from three facts and nothing else: where the goods go,
whether the buyer gave a VAT number, and whether you are registered for the One Stop Shop.
| Destination | Buyer | Fortnox VAT type | Default account |
|---|---|---|---|
| Sweden | anyone | SEVAT |
3001 / 3002 / 3003 / 3004 by rate |
| EU | gave a VAT number | EUREVERSEDVAT |
3108 goods, 3308 services |
| EU | consumer, OSS on | EUVAT |
3106 |
| EU | consumer, OSS off | SEVAT |
as domestic |
| Outside the EU | anyone | EXPORT |
3105 goods, 3305 services |
The reasoning behind each decision is stored on the invoice and shown in the control panel.
EUREVERSEDVAT is a claim to Skatteverket that lands in your periodisk sammanställning with the
buyer's VAT number attached; when it turns out to be wrong, you need to see why it was chosen.
Two things Knox deliberately does not do:
- It does not validate VAT numbers. It checks the shape and says so. Confirming registration is a VIES lookup and your responsibility — a plugin that asserted otherwise would be asserting something it cannot know.
- It does not turn on OSS for you. Below the SEK 99,680 distance-sales threshold, charging Swedish VAT on an EU consumer sale is correct. Above it, it is not. Knox says which one it is doing and why.
Everything except a plain Swedish sale needs a real Fortnox customer record, because Fortnox keeps the VAT type on the customer rather than the invoice. So with customer syncing off, Knox refuses a reverse-charged or export order rather than invoicing it as a domestic Swedish sale, which would charge VAT on a sale that does not owe it.
Never twice
A Swedish customer invoice is a numbered document in a consecutive series. A duplicate is not an inconvenience; it is a credit invoice, an explanation, and a question from your accountant.
So Knox never repeats a create it cannot prove failed. A timeout leaves the invoice in an Unknown state and nothing more happens. To resolve it, Knox looks:
php craft knox/sync/reconcile
Every invoice Knox creates carries the Craft order reference in ExternalInvoiceReference1, which
is a field Fortnox lets you filter on — so this is a lookup, not a guess. It either claims the
invoice Fortnox already holds, or reports that nothing is there and it is safe to send again.
Console
php craft knox/connect/status # what Knox knows about the connection php craft knox/connect/refresh # rotate the tokens now php craft knox/connect/rounding # detect the company's öresutjämning setting php craft knox/sync/order <orderId> --dry # what would be sent, without sending it php craft knox/sync/order <orderId> # invoice one order php craft knox/sync/backfill --dry # what every uninvoiced order would do php craft knox/sync/backfill --since=2026-01-01 --queue php craft knox/sync/reconcile # resolve every unknown outcome php craft knox/payments/poll # ask Fortnox which invoices are paid php craft knox/log/prune php craft knox/log/last-error # exits non-zero if anything is wrong
Two of those are worth a cron entry:
*/15 * * * * php craft knox/payments/poll
0 3 * * * php craft knox/sync/reconcile
And on a low-traffic shop, one more. Fortnox invalidates a refresh token 45 days after it is issued, and the clock only restarts when it is used — so a shop that takes no orders over a quiet season comes back to a dead connection:
0 4 * * 0 php craft knox/connect/refresh
Knox also warns in the control panel once the deadline is inside a fortnight.
Templates
{% set invoice = craft.knox.invoice(order) %}
{% if invoice and invoice.existsInFortnox() %}
<p>Faktura {{ invoice.documentNumber }}</p>
{% set url = craft.knox.downloadUrl(order) %}
{% if url %}<a href="{{ url }}">Ladda ner PDF</a>{% endif %}
{% endif %}
{# Tell an EU business buyer what will happen, before they pay #}
{% set tax = craft.knox.taxTreatment(cart) %}
{% if tax.zeroRated %}
<p>Omvänd betalningsskyldighet — no VAT will be charged.</p>
{% endif %}
craft.knox is read-only. Nothing in it creates, credits or books anything: a template render is
not a place to write to someone's ledger, and a page that got refreshed twice must not produce two
invoices.
Where the buyer's VAT number comes from
Commerce has no VAT-number field of its own. Knox reads, in order:
- the address's
organizationTaxId(where Commerce's own EU VAT integrations put it), - a custom field on the address called
vatNumber,vatIdormomsnummer.
If your shop keeps it somewhere else, copy it onto organizationTaxId when the order completes.
Requirements and limits
- The Fortnox account needs an API licence. Knox reports error 2001103 in plain words when it does not.
- Fortnox allows 25 requests per rolling five seconds per client ID and company. Knox paces itself across every queue worker on the site so they share one budget rather than each politely staying under the limit and collectively blowing it.
- A Fortnox invoice row carries VAT as a whole percentage: 25, 12, 6 or 0. An order taxed at a rate Fortnox cannot represent is refused rather than rounded into a different tax.
- A row carries one VAT rate. An order line with two is refused.
- An order with VAT both included in its prices and added on top is refused — a Fortnox invoice is one or the other, and guessing produces something 25% wrong that still looks plausible.
- Partial refunds: Fortnox's credit action credits a whole invoice, and they publish no documented way to credit part of one. Knox refuses by default and says so. There is an opt-in mode that raises a separate linked credit invoice — the shape Fortnox's own data model describes, through a path they do not document, so confirm it on your account before relying on it.
- Knox does not use Fortnox webhooks. Their coverage is a small and shifting set of resources,
and a webhook that never arrives is indistinguishable from a quiet week. A poll over
lastmodifiedcannot miss anything and costs a handful of requests a run.
Nothing Knox does can break a checkout
Invoicing happens on the queue. The order-complete handler pushes a job inside a try/catch. An accounting outage is never the customer's problem.
Licence
The Craft License. See LICENSE.md. Knox is commercial software with a single edition at $99 and
a $79/year renewal; the price lives on the Craft Console listing.