Usage
The rule everything else follows
The invoice says what was charged. Commerce has already taken the customer's money, and an invoice that disagrees with the payment is worse than no invoice at all.
So Holding never changes a number. What it decides is how the sale is classified, which controls the Holded tax key on each line and the legal wording on the document — and when the classification and the charge disagree, that becomes a warning on the document rather than a quiet correction.
VAT and the rules
The rules, in the order they are applied:
| Situation | Treatment | VAT |
|---|---|---|
| Postcode in Canarias, Ceuta or Melilla — or Büsingen, Livigno, Åland, Mount Athos | Excluded territory | Exempt, art. 3 LIVA |
| Same country as the shop | Domestic | Whatever was charged |
| Another EU state, business, valid VAT number | Reverse charge | Exempt, art. 84 LIVA / art. 196 of the Directive |
| Another EU state, consumer, OSS on | One Stop Shop | Destination country's standard rate |
| Another EU state, consumer, OSS off | Domestic | Home rate |
| Outside the EU | Export | Exempt, art. 21 LIVA |
The excluded-territory check runs first, deliberately. Canarias is in Spain and is not in Spanish VAT, and testing the country before the postcode is the most common way a Spanish shop invoices incorrectly.
Every order's classification is shown on its order screen, with the reasoning:
Reverse charge · DE · DE123456789 B2B sale to Germany with VAT number DE123456789 — reverse charge.
Tax identifiers
NIF, NIE and CIF are validated properly — check character and all. The VAT numbers of all 27 member states are format-checked before VIES is ever asked.
A customer who pastes ES-12.345.678-Z gets it read as 12345678Z. A customer whose NIF does not
compute gets an invoice with a warning against it, unless you have told Holding to refuse those.
The tax id goes into Holded's contact code field, which is the only field a Holded contact has
for one. Only a customer in another member state gets the prefixed intra-community form — a
Spanish customer's contact carries the plain NIF, which is what your accountant will be matching
against.
What ends up on the document
Lines come from the order's own adjustments, not from a recalculation:
- One line per line item, at its net price with the VAT actually charged on it — including tax-inclusive pricing, where the VAT is taken back out of the price.
- Shipping as its own line, carrying the tax charged on shipping.
- Order-level discounts as their own line. Line-level discounts are already inside their line.
- An adjustment line for anything left over.
That last one is the safety net. If another plugin has put an adjustment on the order that Holding knows nothing about, it appears as a line — because an invoice that quietly comes to a different number than the customer paid is the failure worth engineering against.
Then the document is read back from Holded and its total compared with the order's. A mismatch is flagged on the document and counted on the Documents screen.
Payments and refunds
Successful purchase and capture transactions are registered against the document as they
happen. The unique index on the transaction id is what stops a retry — or two queue workers —
paying the same invoice twice.
Refunds become credit notes, one per refund transaction. A full refund credits the invoice line for line. A partial refund credits a single line at the order's own effective rate, because Commerce's refund is an amount and nothing in it says which goods came back.
The Documents screen
Holding → Documents lists everything sent, with four counts across the top: synced, pending, failed, and with warnings. That last one is the pile worth working through — those are documents that are in Holded but where something did not add up.
Filter by VAT treatment to answer the question an accountant actually asks: show me every reverse-charge sale this quarter.
Each document's detail screen has the payload that was sent, the response, the payments registered against it, and buttons to re-check the total, email it, or fetch the PDF.
Forget removes Holding's record of a document so the order can be sent again. The copy in Holded is left alone — this only clears Craft's side.
Console
php craft holding/sync/test # check the API key
php craft holding/sync/preview <order> # what would be sent, without sending it
php craft holding/sync/order <order> # one order, by id, number or reference
php craft holding/sync/all --since=2026-01-01 --limit=500
php craft holding/sync/retry # everything that failed with attempts left
php craft holding/products/push # catalogue → Holded
php craft holding/products/stock --apply # stock ← Holded
php craft holding/log/prune # housekeeping
preview is the one to reach for when something looks wrong. It prints the classification, the
reasoning, every line with its rate and tax key, and the document total against the order total,
without touching Holded:
Order #1042
Treatment: Reverse charge
· B2B sale to Germany with VAT number DE123456789 — reverse charge.
Note: Operación exenta de IVA por inversión del sujeto pasivo (art. 84.Uno.2º LIVA).
Lines
Ceramic tile, 30×30 12.00 × 14.50 VAT 0.00% s_iva_0
Gastos de envío 1.00 × 9.90 VAT 0.00% s_iva_0
Document total: 183.90 Order total: 183.90
Worth putting on cron:
*/15 * * * * php craft holding/sync/retry
0 4 * * * php craft holding/log/prune
Templates
{% set invoice = craft.holding.document(order) %}
{% if invoice and invoice.isSynced %}
<p>Factura {{ invoice.docNumber }}</p>
{% set url = craft.holding.downloadUrl(order) %}
{% if url %}<a href="{{ url }}">Descargar PDF</a>{% endif %}
{% endif %}
craft.holding.treatment(order) returns the classification and its reasoning, which is useful on a
checkout summary to tell a business customer why they were not charged VAT.
Every method takes the order element or its id, because an email template and an account page rarely have the same one to hand.
Extending it
use justinholtweb\holding\events\DocumentEvent;
use justinholtweb\holding\services\Documents;
use yii\base\Event;
Event::on(Documents::class, Documents::EVENT_BEFORE_PUSH_DOCUMENT, function(DocumentEvent $e) {
$e->payload['customFields'] = [['id' => 'abc', 'value' => $e->order->myField]];
if ($e->order->getTotalPrice() < 1) {
$e->isValid = false; // stop the sync
}
});
Holded ignores fields it does not recognise rather than rejecting them, so anything you add that it does not know about is dropped silently. Check the document after your first one.