Waver for Craft CMS

Usage

What an entry looks like

A completed order becomes one Wave money transaction: a deposit into the account the money arrived in, split across the accounts it belongs to.

Order #A1B2C3                                      DEPOSIT  $178.45  ->  Business Checking
  Items                          Sales                                       credit   150.00
  Shipping                       Shipping Income                             credit     9.95
  Sales tax                      Sales Tax                                   credit    30.50
  Discounts                      Discounts                     debit  12.00

The line items balance the deposit exactly. Wave rejects an entry where they do not — and, worse, accepts one that balances for the wrong reason, so Waver proves the arithmetic before it sends.

Preview before you send

Every order screen has a Preview entry button. It builds the entry through the same code path the real sync uses, so what you see is byte-identical to what Wave receives — including the blockers, if any.

php craft waver/sync/order 1234 --dry-run

The Records screen

Waver → Records lists everything Waver has recorded or tried to record, filterable by status.

StatusMeans
syncedIt is in Wave. The Wave id is on the record
failedWave refused it, or Waver refused to send it. The reason is on the record
skippedWaver decided not to record this order — not paid in full, zero total, not complete
pendingSent, and nothing came back. See below

Open a record to see the exact payload that was sent.

Records that are in doubt

Wave's API cannot read a money transaction back. There is no transactions query, and Wave's Transaction type exposes exactly one field: an id. Nothing can be looked up by external id, date or amount.

So when Waver sends a transaction and never hears back, nothing — no code, no retry, no clever reconciliation — can determine whether it landed.

Waver does not guess. The record sits as pending with the attempt counted, and it is never re-sent automatically: not by the queue, not by waver/sync/retry, not by a later completion event. Auto-retrying is the one thing that would double a merchant's revenue in exactly the case nobody can detect afterwards.

The record screen gives you three ways out:

  • Ask Wave — works for invoices, which can be found again by number. For a money transaction it tells you plainly that Wave cannot be asked, and what to search for instead.
  • Mark as recorded — you looked in Wave and it is there. Optionally paste Wave's id.
  • Force resend — you looked in Wave and it is not.

The last two need the Force a resend, or mark a record as recorded by hand permission.

When Waver refuses

By design it would rather do nothing than do something wrong:

  • The order is not paid in full — recorded as skipped, with the shortfall.
  • An account is not mapped — named per account, before anything is sent.
  • The entry does not balance beyond the rounding tolerance — refused, with the drift.
  • The business is on Wave's classic accounting — money transactions do not work there.

Backfill

Bring in orders that completed before Waver arrived. Newest first, and it will show you everything before writing anything:

php craft waver/sync/backfill --dry-run                    # what it would do
php craft waver/sync/backfill --limit=200                  # do it
php craft waver/sync/backfill --since=2026-01-01           # only this year

Backfill only picks up orders Waver has never considered. An order with a record in any state — even skipped — is left alone.

Retrying failures

php craft waver/sync/retry --limit=50

Only records that are actually failed. A doubtful pending record is deliberately left alone, for the reason above.

Refunds (Pro)

A successful Commerce refund becomes its own withdrawal in Wave, queued the moment the refund transaction saves. The sale and the refund stay two entries — Waver never edits or deletes the original, because a ledger records what happened rather than the current state.

Invoice mode (Pro)

Instead of a ledger entry, create a Wave invoice you can send, optionally approved, marked paid and emailed by Wave.

It is the right choice less often than it looks, and Waver is direct about why:

  • Every Wave invoice line must name a product. productId is required — Wave has no free-text line — so invoice mode needs a Wave product per SKU. Waver creates and maps them, but Wave products carry no SKU of their own, so the local map is the only durable join. Clearing it will duplicate your catalogue.
  • Wave has no order-level charge, so shipping cannot appear on an invoice at all. Waver refuses the order and says so rather than quietly dropping the shipping from the total.
  • Wave computes the tax itself from the sales taxes on each line, so the invoice total can differ from Commerce's by a cent. Map your tax rates so it computes the right thing.
  • Wave allows one invoice discount, so several Commerce discounts collapse into a single fixed line.

Transaction mode has none of these limits. Use invoice mode when you want the document.

Invoice creation is deliberately not all-or-nothing: Wave has no transaction wrapper, so if the invoice is created and the payment call then fails, the invoice exists. Waver keeps its id and reports the partial state, because treating it as a clean failure would have the next attempt create a second invoice.

Console commands

php craft waver/sync/order <id> [--dry-run] [--force]
php craft waver/sync/backfill [--limit=50] [--since=2026-01-01] [--dry-run]
php craft waver/sync/retry [--limit=50]
php craft waver/sync/status <orderId>

php craft waver/wave/check
php craft waver/wave/accounts [--anchors] [--flush]
php craft waver/wave/taxes [--flush]

php craft waver/log/tail [limit] [--level=error]
php craft waver/log/prune [--days=30]
php craft waver/log/clear

--force on sync/order sends an order Waver already has a record for. Because Wave cannot be asked whether the first attempt landed, this can genuinely double an entry. Use it after looking at Wave, not instead of.

Twig

craft.waver is read-only. A template render is not a place from which books should change.

{% if craft.waver.isRecorded(order) %}
    Recorded in Wave.
{% endif %}

{% set record = craft.waver.recordForOrder(order) %}
{{ record.status }} {{ record.waveId }}

{% for row in craft.waver.previewEntry(order).toRows() %}
    {{ row.account }} {{ row.debit }} {{ row.credit }}
{% endfor %}
MethodReturns
recordsForOrder(order)Every record, including refunds
recordForOrder(order)The sale record, or null
isRecorded(order)Whether the sale reached Wave
previewEntry(order)The entry the order would produce, unsent
statusCounts()A count per status