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.

Maintainers

Package info

github.com/justinholtweb/craft-econz

Type:craft-plugin

pkg:composer/justinholtweb/craft-econz

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-27 19:21 UTC

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:

  1. Connection — paste the two tokens. Both accept $ENV_VAR references, and they belong in .env, not project config. Hit Test connection; it calls /self and tells you which company you just connected to.
  2. 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.
  3. 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:

  1. The home country (Denmark by default) is always Domestic — a Danish buyer stays domestic even with a CVR.
  2. An EU country with a well-formed VAT number is EU. That is what reverse charge needs.
  3. 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.
  4. 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.

Support

justin@justinholt.com