justinholtweb/craft-holding

Craft Commerce → Holded. Orders become invoices, customers become contacts, payments and refunds land where your accountant expects them — with Spanish and EU VAT handled properly.

Maintainers

Package info

github.com/justinholtweb/craft-holding

Type:craft-plugin

pkg:composer/justinholtweb/craft-holding

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

5.0.0 2026-08-23 14:47 UTC

This package is auto-updated.

Last update: 2026-08-23 19:08:17 UTC


README

Craft Commerce → Holded. Orders become invoices, customers become contacts, payments and refunds land where your accountant expects them — with Spanish and EU VAT handled properly.

Holded is the accounting and ERP platform most Spanish SMEs run their books on. Holding is the bridge from a Craft Commerce shop to it: every completed order arrives as a real document in Holded, numbered from your own series, addressed to a real contact, carrying the VAT treatment the sale actually calls for and the legal wording that treatment requires.

Requires Craft CMS 5.3+, Craft Commerce 5.0+ and PHP 8.2+. $149, one edition — everything below is in it.

What it does

  • Orders → documents. An invoice (factura), a sales receipt (ticket) or a sales order (pedido), on order completion, on payment, on reaching a status, or only when you say so.
  • Customers → contacts. Matched on tax id and email before anything is created, so one customer is one contact.
  • Payments → payments. Successful Commerce transactions are registered against the document, so nobody chases a customer who has already paid.
  • Refunds → credit notes. A refund in Spain is a factura rectificativa, not an edit to an invoice that is already locked. Each refund gets its own, and partial refunds get partial ones.
  • VAT, classified properly. Domestic, reverse charge, export, One Stop Shop, and the territories that are inside a member state but outside the VAT area.
  • Products and stock. SKU-matched catalogue sync, and stock read back from Holded — reported by default, applied only if you say Holded is the authority.
  • Every document checked. Holding reads each document back from Holded and compares its total with the order's. A mismatch becomes a flag on the document, not a surprise in a quarterly return.

Getting started

  1. Install the plugin and open Settings → Plugins → Holding.

  2. In Holded, go to Settings → Developers → Credentials and add an API token with invoicing permissions. Put it in your .env:

    HOLDED_API_KEY="your-token"
    

    and reference it in the settings screen as $HOLDED_API_KEY, so it never enters project config.

  3. Press Test the connection, then Re-read Holded's lists. That fills the numbering series, bank account, payment method, warehouse and tax dropdowns from your own account.

  4. Map your VAT rates to your Holded tax keys — 21s_iva_21 and so on. They are listed in Holded under Settings → Billing → Taxes.

  5. Send one order by hand from its order screen and look at what arrives in Holded before you switch Send orders automatically on.

How VAT is decided

Holding never changes what a customer was charged. Commerce already took the money, and an invoice that disagrees with the payment is worse than no invoice at all. What Holding decides is how the sale is classified, which controls the tax key on each line, the legal wording on the document, and whether you get told that the charge and the classification disagree.

The rules, in the order they are applied:

Situation Treatment VAT
Postcode in Canarias, Ceuta, 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 getting that ordering wrong is the most common way a Spanish shop invoices incorrectly.

Every order's classification, and the reasoning behind it, is shown on the order screen before anything is sent.

Tax identifiers

NIF, NIE and CIF are validated properly — check letter and all — and the VAT numbers of all 27 member states are format-checked before VIES is ever bothered. A tax id that does not check out is a warning on the document by default, not a refusal; turn on Refuse to sync an invalid Spanish tax id if you would rather the order waited for a human.

The tax id goes into Holded's contact code field. Holded has no vatnumber field on a contact — writing one is accepted and silently discarded.

VIES

VIES is the only thing that can tell you a VAT number is really registered, and it is regularly unavailable — individual member states' registers go down for hours at a time. So a VIES answer here is one of three things and never two: valid, invalid, or unknown. What happens on unknown is your decision (Charge VAT or Accept a well-formed number), because charging VAT you did not need to and not charging VAT you did are both expensive, in different directions.

One Stop Shop

Holding ships the EU standard rates, dated, and the settings screen tells you when they were last checked. Rates move; anything you put in the override table wins. Turning OSS on does not change what Commerce charges — set your Commerce tax rates to match, and Holding will tell you when they have drifted apart.

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 that was actually charged on it — including tax-inclusive pricing, where the VAT is taken back out of the price.
  • Shipping as its own line, with the tax that was 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. If another plugin has put an adjustment on the order that Holding knows nothing about, it appears as a line rather than as a document that quietly comes to a different number than the customer paid.

The document then gets read back from Holded and its total compared with the order's. If they disagree, the document is flagged. That check is the single most useful thing this plugin does, and it is why the Line subtotal setting is safe to get wrong: you find out immediately.

Approving

Holded files a document as a draft unless it is told to approve it, and a draft is invisible in Sales → Invoices, on the contact and in search — it exists, but nobody can find it. Holding approves by default. The trade is that Holded locks an approved document permanently, so re-syncing one is refused with an explanation rather than attempted: issue a credit note, or delete it in Holded first.

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>         # send one order (id, number or reference)
php craft holding/sync/all --since=2026-01-01 --limit=500
php craft holding/sync/retry                 # everything that failed and has attempts left
php craft holding/products/push              # catalogue → Holded
php craft holding/products/stock --apply     # stock ← Holded
php craft holding/log/prune                  # housekeeping, for cron

holding/sync/preview is the one to reach for when something looks wrong: it prints the VAT classification, the reasoning behind it, every line with its rate and tax key, and the document total against the order total, without touching Holded.

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.document(order, docType) the document Holding holds for an order
craft.holding.documents(order) all of them, credit notes included
craft.holding.invoiceNumber(order) the number Holded gave it, or null
craft.holding.isSynced(order) whether it made it
craft.holding.downloadUrl(order) the customer's own PDF link
craft.holding.treatment(order) how the sale is classified, and why

Every method takes the order element or its id, because an email template and an account page rarely have the same one to hand.

The customer PDF download is fetched from Holded on demand, so it is always the real document. It requires the order's full number and, by default, the email address on the order; failures are throttled per IP and every failure answers identically, because telling a stranger that an order number exists is itself a disclosure.

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) {
    // Anything Holded takes that Holding does not know about — a custom field, a project id.
    $e->payload['customFields'] = [['id' => 'abc', 'value' => $e->order->myField]];

    // Or stop the sync entirely.
    if ($e->order->getTotalPrice() < 1) {
        $e->isValid = false;
    }
});

EVENT_AFTER_PUSH_DOCUMENT fires once Holded has accepted it, with the document id and number filled in.

What it costs

$149, one edition. There is no free tier and no feature held back: a shop that needs the VAT engine needs the credit notes and the OSS rates too, and a plugin that files an accountant's documents is a poor place to discover which half you bought.

The licence covers one Craft site, with updates for a year. Craft's own licensing handles the rest.

Things worth knowing

  • Holding is one-way for documents. It writes to Holded and reads back to check its work. It does not import documents that were created in Holded by hand.
  • A SKU is an identity. Renaming a product in either system is fine. Changing a SKU deliberately creates a new Holded product rather than silently re-pointing an existing one, because doing otherwise corrupts a stock history.
  • Stock writes need Commerce 5.3+, where stock moved into the inventory system. Below that, Holding reports differences rather than pretending it can apply them.
  • Rate limits are by plan, by month. Holded meters API use over a month rather than per second. The calls-per-minute ceiling exists so a backfill of six months of orders does not spend the month's allowance in an afternoon.
  • Two Holded endpoint names are ambiguous across account ages — the numbering-series list and the field a numbering series is requested under. Holding asks for the list under each known name and sends the series under both known keys, then confirms which was used by reading the document number back. If your documents come out on the wrong series, the document detail screen will show you what Holded actually did.

Support

justin@justinholt.com