Holding for Craft CMS

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:

SituationTreatmentVAT
Postcode in Canarias, Ceuta or Melilla — or Büsingen, Livigno, Åland, Mount AthosExcluded territoryExempt, art. 3 LIVA
Same country as the shopDomesticWhatever was charged
Another EU state, business, valid VAT numberReverse chargeExempt, art. 84 LIVA / art. 196 of the Directive
Another EU state, consumer, OSS onOne Stop ShopDestination country's standard rate
Another EU state, consumer, OSS offDomesticHome rate
Outside the EUExportExempt, 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.