justinholtweb / craft-econz
e-conomic integration for Craft CMS and Craft Commerce — turn orders into draft or booked invoices, keep customers in sync, and see every API call in a log.
Package info
github.com/justinholtweb/craft-econz
Type:craft-plugin
pkg:composer/justinholtweb/craft-econz
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
Suggests
- craftcms/commerce: Required for order-to-invoice syncing. Econz runs without it, but only the manual and programmatic invoicing is available.
This package is auto-updated.
Last update: 2026-08-27 19:21:53 UTC
README
e-conomic bookkeeping for Craft CMS 5 and Craft Commerce 5. Orders become draft invoices — optionally booked on the spot — against a customer Econz finds or creates, with the VAT zone worked out from the buyer's country and VAT number, and every API call in a log you can read.
e-conomic (Visma e-conomic) is what most small and mid-size Danish companies keep their books in. Econz is built for how those books actually work: a VAT zone rather than a tax rate, a CVR on the customer, an EAN for the public sector, and a bookkeeper who wants to see the invoice before it is booked.
What Econz will not do
It will not invent a total. Econz never sends netAmount, grossAmount or vatAmount —
e-conomic computes those from the lines, and two systems each computing the total is how a ledger
ends up a krone out. What Econz does instead is work out what e-conomic will compute and compare
it against Commerce before sending. Disagree by more than your tolerance and it warns you, or
refuses, whichever you asked for.
It will not overrule Commerce on tax. Commerce decided what the customer paid; that decision is final. When the VAT zone Econz resolves would charge tax that Commerce did not (or the reverse), it says so on the order screen rather than quietly booking the difference.
It will not book anything twice. {{%econz_orderlinks}} has the order id as its primary key,
not as an indexed column, so an order physically cannot hold two drafts however many queue jobs,
bulk actions and console runs race each other. Every write also carries an Idempotency-Key
derived from the payload, which e-conomic honours for an hour.
Requirements
- Craft CMS 5.3+
- PHP 8.2+
- Craft Commerce 5.0+ for order syncing (Econz installs and runs without it)
- An e-conomic agreement, an app secret token and an agreement grant token
Installation
composer require justinholtweb/craft-econz php craft plugin/install econz
Then open Econz → Settings:
- Connection — paste the two tokens. Both accept
$ENV_VARreferences, and they belong in.env, not project config. Hit Test connection; it calls/selfand tells you which company you just connected to. - Mapping — pick a layout, payment terms, a customer group and a unit. e-conomic requires the first two on every invoice, so Econz refuses to push without them rather than guessing.
- VAT — map Domestic / EU / Abroad to your agreement's vat zone numbers.
php craft econz/connection/test says the same things from a terminal, and
php craft econz/connection/catalog layouts lists what you can map to.
Demo mode
Turn demo mode on and Econz talks to e-conomic's public demo agreement using the token demo.
It is read-only, so pushes are refused before they leave the plugin — it exists so you can
look at real reference data before you have tokens of your own.
Editions
| Lite (free) | Pro | |
|---|---|---|
| Connection, connection test, agreement readout | ✅ | ✅ |
| Draft invoices with full line, shipping, discount and adjustment mapping | ✅ | ✅ |
| VAT zone resolution from country + VAT number | ✅ | ✅ |
| CVR / EU VAT / EAN capture, with format validation | ✅ | ✅ |
| Customer find-or-create, and a link table that survives renames | ✅ | ✅ |
| Panel on Commerce's order screen, with a byte-exact payload preview | ✅ | ✅ |
| Totals reconciliation against Commerce | ✅ | ✅ |
Connection log, craft.econz.* Twig API, console commands |
✅ | ✅ |
| Send one order at a time from the order index | ✅ | ✅ |
| Automatic push on completion or a chosen status, queued | — | ✅ |
| Auto-booking, with e-invoicing / email hand-off | — | ✅ |
| Credit notes when a Commerce refund goes through | — | ✅ |
| Product catalogue sync, with a dry run | — | ✅ |
Bulk push from the order index, and econz/sync/retry |
— | ✅ |
| Reconciliation — read the outstanding amount back off booked invoices | — | ✅ |
| Departments and projects on invoice lines | — | ✅ |
VAT zones
e-conomic does not want a tax rate. It wants a zone, and the zone decides whether VAT is charged at all. Econz resolves it in this order:
- The home country (Denmark by default) is always Domestic — a Danish buyer stays domestic even with a CVR.
- An EU country with a well-formed VAT number is EU. That is what reverse charge needs.
- An EU country without one is Domestic. Distance selling to a consumer carries your own VAT, and putting a private buyer in the EU zone silently zero-rates the sale. If you are registered for OSS and account for it differently, there is a switch.
- Everything else is Abroad.
The VAT number itself is checked for format, never against VIES. VIES is a third-party service that goes down, and no shop should have a checkout that stops taking money when it does. A malformed number therefore falls back to Domestic — the safe direction.
Where does the number come from? Craft 5 addresses already have an Organization Tax ID field, and Econz reads it: a value with a country prefix is treated as an EU VAT number, a bare one as a CVR. Failing that, from your own order fields (name the handles in settings), or from what your checkout posted to Econz.
Money
e-conomic invoice lines carry net, ex-VAT unit prices. Commerce may or may not have VAT inside
salePrice, so for each line:
line net = subtotal + discount − included tax
…and the unit price is that divided by the quantity, carried at six decimals so that
3 × 33.333333 still lands on 100.00. Shipping, order-level discounts and other adjustments
become their own lines; order-level included tax is apportioned across them in proportion,
because Commerce records the amount but not which adjustment it was charged on.
Line-level discounts are folded into the unit price by default, which always reconciles exactly. Switch to a discount percentage if you would rather it printed that way on the invoice — Econz will tell you when the rounding drifts.
Excluded tax is never sent. e-conomic computes VAT from the zone and the product's sales account; sending our own would double it.
Capturing a CVR at checkout
<form method="post"> {{ csrfInput() }} {{ actionInput('econz/checkout/save-business') }} {{ craft.econz.businessFields(cart) }} <button>Save</button> </form>
businessFields() is deliberately plain and unstyled — it exists so you can add B2B invoicing to
an existing checkout in one line, then replace it with your own markup the moment you want to. The
action validates the CVR's modulus-11 check digit, the VAT number's country format and the EAN's
GS1 check digit, and answers JSON with the resolved zone so you can tell the buyer what will
happen:
{% set zone = craft.econz.vatZone(cart.billingAddress.countryCode, vatNumber) %}
{% if zone.zone == 'eu' %}
<p>VAT will be reverse-charged. {{ zone.reason }}</p>
{% endif %}
The action only ever writes to the requester's own cart. There is no order id parameter, on purpose: accepting one would let anyone attach their CVR to somebody else's order.
Twig
craft.econz.status(order) |
the link row, or null |
craft.econz.isSynced(order) |
whether it reached e-conomic |
craft.econz.invoiceNumber(order) |
booked number if there is one, else the draft |
craft.econz.vatZone(country, vatNumber) |
{zone, reason, vatNumberValid} |
craft.econz.businessDetails(order) |
{cvr, vatNumber, ean, attention} |
craft.econz.businessFields(order) |
ready-made checkout inputs |
craft.econz.isValidCvr / isValidVatNumber / isValidEan |
format checks |
craft.econz.counts() |
how many orders sit in each state |
Console
php craft econz/connection/test # call /self and print what came back php craft econz/connection/catalog vatZones # list a reference resource php craft econz/sync/preview <order> # the exact payload, without sending it php craft econz/sync/order <order> # send one order php craft econz/sync/pending # send every completed order not yet sent php craft econz/sync/retry # retry the failures (Pro) php craft econz/sync/reconcile # read outstanding amounts back (Pro) php craft econz/products/sync # dry run by default (Pro) php craft econz/log/prune --days=30 # good cron fodder
<order> takes an order number, a short number, a reference or an element id.
Extending
use justinholtweb\econz\events\DraftPayloadEvent; use justinholtweb\econz\services\Invoices; use yii\base\Event; Event::on(Invoices::class, Invoices::EVENT_MODIFY_DRAFT_PAYLOAD, function(DraftPayloadEvent $e) { $e->payload['references']['other'] = $e->order->reference; });
Sync::EVENT_BEFORE_PUSH is cancelable — set $event->isValid = false to skip an order and mark
it skipped, which is how you keep staff test orders out of a real ledger.
Sync::EVENT_AFTER_PUSH carries the SyncResult.