Sager for Craft CMS

Usage

How an order reaches Sage

When an order meets your trigger, Sager puts a job in Craft's queue. Nothing touches Sage in the request that completes the checkout. The customer never waits on Sage, and a Sage outage can't stop anyone paying.

The job then:

  1. Checks the order still qualifies. It has to be completed, meet the trigger, and not be a zero-total order while Skip zero-total orders is on. An order that doesn't qualify is marked skipped, with the reason.
  2. Finds or creates the customer's Sage contact. See Contacts.
  3. Builds the invoice from the order. See What goes on the invoice.
  4. Posts it to Sage as a sales_invoice, and records the Sage ID and invoice number against the order.
  5. Releases it, if Release invoices is on. If the release fails, the invoice still exists in Sage as a draft, and the failure is logged.
  6. Sends any captured payments, if Send payments is on.
  7. Marks the order synced.

One invoice per order, always

Sager records every document it creates, keyed by order, document type and source. An order's invoice is keyed by its order number, and a database unique index makes a second one impossible. Only one process at a time can sync a given order, so two queue workers can't both reach Sage with the same invoice. Sending an order that already has an invoice skips straight to its payments.

What goes on the invoice

Every amount is taken from Commerce's own figures, not recalculated from a tax rate, and sent to Sage explicitly. All arithmetic is done in whole minor units (cents, pence), never floating point.

CommerceSage
Each line itemOne invoice line: description and SKU, quantity, unit price, net, tax and total
A line's ledger accountThe product type's mapped ledger account, or the Sales ledger account
A line's tax rateThe rate mapped to its tax category, or the zero-rated or default rate
A per-line discountThe line's discount_amount, with the net reduced to match
Tax-inclusive pricesunit_price_includes_tax, with the included tax taken out of the net
Order-level discountsOne negative Order discount line, on the Discount ledger account
ShippingSage's own shipping_net_amount, shipping_tax_amount and shipping_tax_rate_id fields. Never a fake line item
Order-level taxShipping tax, on the shipping fields
Any other order-level adjustmentIts own line, named after the adjustment
Billing and shipping addressesmain_address and delivery_address
Customer noteThe invoice's notes, if Copy the customer note is on
Order dateThe invoice date, in your site's time zone
CurrencyThe order's currency

Adjustments marked included are already inside the item prices, and Commerce leaves them out of the order total, so Sager doesn't give them a line of their own. Only included tax is handled, by taking it out of the net.

Reconciling against the order total

Once the invoice is built, Sager adds it up and compares it with the order's total in Commerce.

  • Exact match, the normal case: it's sent.
  • Within the rounding tolerance (5 minor units by default): a visible Rounding line is added, on the Rounding ledger account and the zero-rated rate, so the totals agree. The preview notes it.
  • Beyond the tolerance: the order fails with The rebuilt invoice is X away from the order total. Sager won't post numbers it can't explain.

One case always reaches reconciliation: order-level tax on an order with no shipping. Sage only holds that tax on the shipping fields, so Sager doesn't invent a place for it. It either becomes a rounding line, if it's small enough, or fails the order.

Previewing the payload

Every order's Sager page has a Preview payload button. It shows the JSON Sage would receive, plus a plain-English note for every decision taken while building it, and any error that would stop the order being sent.

The preview is built by the same code that does the real push, so it's what Sage receives, not an approximation. Previewing never creates anything in Sage. If the customer doesn't have a Sage contact yet, contact_id is left out of the preview and filled in when the order is really sent.

On the console:

php craft sager/sync/preview AB-1234

Contacts

Every invoice needs a Sage contact. Finding an existing one matters more than creating one: if you've been trading for a while, your customers are probably already in Sage.

Sager looks, in this order:

  1. Its own link table. A customer it has seen before.
  2. Sage, by Sager's reference. A contact Sager created before, such as WEB-12, even if the link was lost in a database restore.
  3. Sage, by email. Only if exactly one customer contact has that address. Two contacts sharing an email is a decision for a person, not for Sager.
  4. Then it creates one.

A registered customer is linked by user ID, so changing their email doesn't orphan them. A guest is linked by email.

A new contact gets:

  • Name: the billing address's organisation, then its full name, then the customer's name, then the email
  • Reference: the Contact reference prefix plus the user ID, or a short hash of a guest's email
  • Addresses: billing as the main address, shipping as the delivery address if it's different
  • Main contact person: name, email and, if your address field layout has a field with the handle phone, phoneNumber or telephone, the phone number

Payments

With Send payments on, every successful purchase or capture on the order becomes a Sage contact_payment, allocated against the invoice. Authorisations aren't money yet, so they aren't sent.

  • Each payment goes to the mapped Bank account, with the Payment method if you mapped one.
  • The date is the transaction's date. The reference is the order reference, plus the gateway's transaction reference when there is one.
  • A payment captured before the invoice exists is sent straight after the invoice. One captured later is queued as soon as it's recorded.
  • Payments are never allocated beyond the invoice total. If the invoice is already fully paid, any further payment is left out and the log says why.
  • Each transaction is sent once. Re-check on a synced order sends any payments Sage hasn't had yet, and nothing else.

Refunds

With Send refunds on, every successful refund becomes a Sage sales_credit_note.

  • A full refund, one equal to the order total, mirrors the invoice line for line, with the shipping on the same fields. The reference is Refund plus the invoice reference.
  • A partial refund can't be attributed to lines, because Commerce refunds an amount, not a basket. It becomes one credit note line on the Sales ledger account, with the tax split in the invoice's own net-to-tax ratio.
  • Each refund transaction is credited once, so several partial refunds make several credit notes.
  • A refund that arrives before its invoice exists isn't lost. Sager queues the order, and retries the refund once the invoice is there.

Refunds are sent when they happen, while Sync automatically is on. There's no button to send an older refund afterwards.

Sending orders by hand

From the control panel

WhereButtonWhat it does
Commerce's order screen, Sage panelSend to Sage / Re-checkQueues the order
Sager → Orders, each rowSend / Re-checkQueues the order
Sager → OrdersQueue everything not sentQueues up to 500 completed orders with no Sage invoice
An order's Sager pageSend to Sage nowSends it straight away and shows the result

A manual send only works on a completed order. Carts are refused. It does bypass the Trigger and Skip zero-total orders rules, so you can send an order the trigger would have skipped. It doesn't bypass the mapping or the reconciliation.

Queue everything not sent doesn't bypass anything. Each order is checked against the trigger and the zero-total rule as normal.

Sending orders that came before Sager

Orders completed before you connected, or while Sager was disconnected, aren't sent on their own. To send them all, press Queue everything not sent on Sager → Orders, or run:

php craft sager/sync/pending --limit=500 --queue

Preview a few first. Old orders are the ones most likely to have adjustments your mapping doesn't cover yet.

Retries and failures

Each order's sync state is one of:

StatusMeaning
pendingQueued, or waiting to retry
syncedThe invoice exists in Sage
failedSager stopped trying. The error is shown on the order
skippedThe order didn't qualify, such as a zero total or the wrong status. The reason is shown on the order's Sager page

What happens after an error depends on the error:

  • Temporary: a 429 rate limit, a Sage 5xx, a network failure, or another worker already syncing the order. Sager retries, up to Maximum attempts. The wait doubles each time from First retry delay, up to an hour, unless Sage sends a Retry-After, which always wins.
  • Permanent: Sage rejected the data (422), or the invoice didn't reconcile. Retrying would get the same answer, so the order is marked failed straight away. Fix the cause, then send it again.
  • Not connected: the connection has expired or been removed. The order is marked failed and isn't retried. Retrying a dead connection only hides the thing you need to fix.

A 401 from Sage usually just means the five-minute access token expired mid-request. Sager refreshes it and retries once, without counting an attempt.

In the control panel

Sager → Overview

Whether Sager is working, and if not, the one thing to fix:

  • Connection: the business, its base currency, and when the authorisation expires
  • Setup: a checklist of credentials, connection, business, sales ledger account, tax rate and bank account, each with a hint if it isn't done
  • Orders: how many are sent, waiting and failed, and how many completed orders have never been sent
  • Log: entry and failure counts

Sager → Orders

Every completed order, newest first, with its total, Sage invoice number and status. Filter to Not sent, Sent or Failed. A failed order shows its error in the list.

Open an order to see every Sage document created for it (invoice, payments, credit notes) with Sage numbers, amounts and IDs, its sync status and attempt count, its last 20 log entries, and the Preview payload and Send to Sage now buttons.

The panel on the order screen

Commerce's order edit screen gets a Sage panel, for users with View Sage sync status. It shows the order's status, its last error if it failed, the documents created, a Details link to the order's Sager page, and Send to Sage or Re-check for users who can send orders.

Sager → Log

Every HTTP round trip with Sage: token requests, contact lookups and creations, invoices, payments, credit notes, releases and list refreshes. Each row has the action, HTTP status, duration and a message. Filter to Failures.

Open an entry to see the method, URL, status, Sage's data code, the order it belongs to, and the full request and response bodies. Client secrets, refresh tokens, authorisation codes and access tokens are redacted. Customer names, emails and addresses are not, because they're what you need to see when something goes wrong. Bodies are cut off at 64 KB.

Old entries are pruned automatically during Craft's garbage collection, after Log retention days. Admins can Clear the log entirely.

Console commands

CommandWhat it does
sager/connect/statusCredentials, connection, business, token expiry, the key mappings and order counts. Exits non-zero if not connected
sager/connect/businessesThe Sage businesses this connection can reach, with their IDs and currencies
sager/connect/refreshReloads the cached ledger accounts, tax rates, bank accounts, payment methods and transaction types
sager/sync/order <number>Sends one completed order now, by number, short number or reference. --queue queues it instead. --force bypasses the trigger and zero-total rules
sager/sync/pendingSends every completed order with no Sage invoice, oldest first. --limit (default 100) and --queue
sager/sync/preview <number>Prints the invoice payload and build notes, sending nothing. Exits non-zero if the invoice wouldn't be valid
sager/log/failures [limit]The last failures, 20 by default
sager/log/pruneDeletes log entries older than Log retention, or --days=N
php craft sager/connect/status
php craft sager/sync/preview AB-1234
php craft sager/sync/order AB-1234 --force
php craft sager/sync/pending --limit=500 --queue
php craft sager/log/failures 50
php craft sager/log/prune --days=7

sager/sync/preview works without a connection, so you can check a payload on any environment. It shows <contact_id> where the contact's Sage ID would go.