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:
- 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.
- Finds or creates the customer's Sage contact. See Contacts.
- Builds the invoice from the order. See What goes on the invoice.
- Posts it to Sage as a
sales_invoice, and records the Sage ID and invoice number against the order. - 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.
- Sends any captured payments, if Send payments is on.
- 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.
| Commerce | Sage |
|---|---|
| Each line item | One invoice line: description and SKU, quantity, unit price, net, tax and total |
| A line's ledger account | The product type's mapped ledger account, or the Sales ledger account |
| A line's tax rate | The rate mapped to its tax category, or the zero-rated or default rate |
| A per-line discount | The line's discount_amount, with the net reduced to match |
| Tax-inclusive prices | unit_price_includes_tax, with the included tax taken out of the net |
| Order-level discounts | One negative Order discount line, on the Discount ledger account |
| Shipping | Sage's own shipping_net_amount, shipping_tax_amount and shipping_tax_rate_id fields. Never a fake line item |
| Order-level tax | Shipping tax, on the shipping fields |
| Any other order-level adjustment | Its own line, named after the adjustment |
| Billing and shipping addresses | main_address and delivery_address |
| Customer note | The invoice's notes, if Copy the customer note is on |
| Order date | The invoice date, in your site's time zone |
| Currency | The 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:
- Its own link table. A customer it has seen before.
- Sage, by Sager's reference. A contact Sager created before, such as
WEB-12, even if the link was lost in a database restore. - 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.
- 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,phoneNumberortelephone, 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
| Where | Button | What it does |
|---|---|---|
| Commerce's order screen, Sage panel | Send to Sage / Re-check | Queues the order |
| Sager → Orders, each row | Send / Re-check | Queues the order |
| Sager → Orders | Queue everything not sent | Queues up to 500 completed orders with no Sage invoice |
| An order's Sager page | Send to Sage now | Sends 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:
| Status | Meaning |
|---|---|
| pending | Queued, or waiting to retry |
| synced | The invoice exists in Sage |
| failed | Sager stopped trying. The error is shown on the order |
| skipped | The 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
429rate limit, a Sage5xx, 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 aRetry-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
| Command | What it does |
|---|---|
sager/connect/status | Credentials, connection, business, token expiry, the key mappings and order counts. Exits non-zero if not connected |
sager/connect/businesses | The Sage businesses this connection can reach, with their IDs and currencies |
sager/connect/refresh | Reloads 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/pending | Sends 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/prune | Deletes 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.