Search by

justinholtweb / craft-sager

Sage Business Cloud Accounting for Craft Commerce — orders become sales invoices, payments and refunds follow, and nobody retypes an order again.

Maintainers

Package info

github.com/justinholtweb/craft-sager

Documentation

Type:craft-plugin

pkg:composer/justinholtweb/craft-sager

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-20 14:23 UTC

This package is auto-updated.

Last update: 2026-08-29 14:17:56 UTC


README

Sage Business Cloud Accounting for Craft Commerce. Orders become sales invoices, customers become contacts, payments and refunds follow them across, and nobody retypes an order again.

Sager talks to the Sage Accounting v3.1 REST API (api.accounting.sage.com/v3.1) — the product Sage sells as Sage Business Cloud Accounting, and formerly as Sage One.

  • Craft 5.3+, Commerce 5.0+, PHP 8.2+
  • $129, $99 to renew. One edition; everything below is in it.

What it does

Craft Commerce Sage Accounting
A completed order sales_invoice, with per-line ledger accounts and tax rates
The customer contact (found first, created only if genuinely new)
A captured payment contact_payment, allocated against the invoice
A refund sales_credit_note
Shipping cost Sage's native shipping fields, not a fake line item
Order-level discounts Their own negative line, so they stay visible in the books

Everything runs in Craft's queue. Nothing Sager does can slow down a checkout or stop a customer paying: a Sage outage is Sager's problem, never the shopper's.

Why it exists

The hard part of this integration is not the HTTP. It is that Sage refuses an invoice line without a ledger_account_id and a tax_rate_id, and both are GUIDs that are different in every business and every country. Generic connectors hand you a text box and wish you luck.

Sager reads your actual chart of accounts and your actual tax rates out of your own Sage business and puts them in dropdowns. You never type a GUID, and you never hand-build a field to hold one.

Setup

1. Create a Sage app

In the Sage developer portal, create an app and register the callback URL that Sager shows you on its Settings screen. It has to match byte for byte.

2. Give Sager the credentials

Settings → Sager. Put the client ID and secret in environment variables and reference them:

# .env
SAGE_CLIENT_ID="..."
SAGE_CLIENT_SECRET="..."

Plugin settings are project config, and project config gets committed. A client secret in your git history is a client secret in your git history forever.

3. Connect

Hit Connect to Sage, approve, and pick the business this store posts to. If your login only reaches one business, Sager picks it for you.

4. Map

Sager sends you to the Mapping screen, which cannot be skipped in spirit even though nothing stops you leaving it. The two that genuinely matter:

  • Sales ledger account — where product revenue lands.
  • Default tax rate — every line needs one, even a zero-rated one.

Then, worth doing:

  • Zero-rated tax rate, used for lines Commerce did not tax and for discount and rounding lines. Without it, Sage recalculates VAT onto a line that never had any.
  • Bank account, required if you are syncing payments.
  • Tax categories — map each Commerce tax category to the Sage rate that represents it.
  • Product types — optional, for sending a product type's revenue to its own nominal.

The Overview screen lists these as a checklist, so you are never guessing which one is stopping an order.

How the money is handled

This is the part worth reading, because it is the part your accountant will ask about.

Everything is computed in integer minor units. No float ever holds a running total.

Every line's net, tax and total are taken from Commerce's own adjustments rather than recalculated from a rate, and sent to Sage explicitly — so the two systems cannot drift:

  • line net_amount = subtotal + discount, less included tax when your prices include tax
  • line tax_amount = Commerce's tax for that line, with unit_price_includes_tax set when it applies
  • per-line discounts become Sage's discount_amount
  • order-level discounts become their own negative line
  • shipping goes on Sage's shipping_net_amount / shipping_tax_amount fields
  • adjustments Sager does not recognise get their own named line rather than vanishing

Then the invoice is reconciled against order.total:

  • exact match — the normal case — and it is sent
  • within the rounding tolerance (5 minor units by default), a visible Rounding line is added so the totals agree, and the reason is shown in the preview
  • beyond it, the sync fails with a diagnostic instead of posting. Sager will not put numbers in your ledger that it cannot explain.

Seeing what it will send

Every order has a Preview payload button. It is built by the same code that does the real push — literally the same method — so it is what Sage receives, not an approximation of it. It shows the JSON, plus a plain-English note for every decision taken while building it.

On the console:

php craft sager/sync/preview AB-1234

Console commands

php craft sager/connect/status          # connection, mapping and order counts; non-zero if not connected
php craft sager/connect/businesses      # the businesses this connection can reach
php craft sager/connect/refresh         # reload the cached ledger accounts, tax rates, etc.

php craft sager/sync/order AB-1234      # one order, by number, short number or reference
php craft sager/sync/order AB-1234 --queue
php craft sager/sync/pending            # every completed order Sage has never seen
php craft sager/sync/pending --limit=500 --queue
php craft sager/sync/preview AB-1234    # the payload, sending nothing

php craft sager/log/failures            # the last few failures
php craft sager/log/prune --days=30

Twig

{% if craft.sager.isSynced(order) %}
  Invoice {{ craft.sager.invoiceNumber(order) }} in {{ craft.sager.businessName() }}.
{% endif %}

{{ craft.sager.status(order) }}      {# synced | pending | failed | skipped | null #}
{{ craft.sager.documents(order) }}   {# every Sage document created for this order #}

When things go wrong

Everything Sager sends and everything Sage says back is in the log, bodies intact, with credentials and tokens redacted. That is the first place to look, and each order's screen links straight to its own entries.

Some specifics:

  • "Reconnect Sager to Sage." Sage's refresh tokens live 31 days and rotate on every use. If one is lost — a database restored from an old backup, a Craft security key rotated — it cannot be recovered, and Sager stops rather than burning retries on a token that will never work again.
  • A 429. Sage rate-limits per company and per app. Sager honours the Retry-After it sends and re-queues; you do not need to do anything.
  • A 422 mentioning a ledger account or tax rate. Something is unmapped. The Overview checklist will say which.
  • "The rebuilt invoice is X away from the order total." An adjustment on the order is not being accounted for. Run sager/sync/preview on it — the notes explain what Sager did with each piece.

Limits worth knowing

  • Sage has no webhooks. Sync is push-from-Craft. Changes made in Sage do not come back.
  • Partial refunds cannot be attributed to lines, because Commerce refunds an amount rather than a basket. They become one credit note line with tax split in the invoice's own net-to-tax ratio, and the preview says so.
  • Product and stock sync are not in this release. They need per-merchant tuning and deserve more than a checkbox.
  • Mappings are project config, so they deploy with your code. If your staging site points at a different Sage business, its GUIDs will not match — connect staging to the same business, or override the mapping per environment.

Testing

There is no PHP on the host; everything runs in the shared plugin-testing container:

cd ~/Sites/plugin-testing
ddev exec php /var/www/craft-sager/tests/integration/checks.php

121 checks. The suite drives the whole pipeline against a scripted Sage — a Guzzle mock handler injected into the API service — so the request envelope, Sage's error array, the 401-refresh-and-retry, the 429 backoff and the idempotency index are all exercised for real. It is self-cleaning: fixtures, rows and settings are restored in a finally, pass or fail.

License

Proprietary. One license per Craft installation.