Freshh for Craft CMS

Usage

How an order becomes an invoice

  1. The order becomes eligible and a queue job is pushed.
  2. Freshh claims a row for the order, unique on (invoice, order:<id>). Two workers cannot both own one order.
  3. It resolves the FreshBooks client from the order's email, adopting an existing one or creating it.
  4. It builds the invoice payload, computes the total FreshBooks will reach, and compares it with the order total.
  5. It reconciles — if a previous attempt got as far as creating the invoice but died before recording it, Freshh finds it by invoice number and adopts it rather than creating a second.
  6. It creates the invoice, marks it as sent, and records the remote id.
  7. It records each successful transaction as a payment, and each refund as a credit note.

FreshBooks accepts no idempotency key, which is why steps 2 and 5 exist. A retry can never duplicate an invoice.

Preview before you send

Every order screen has a Preview button, and the settings screen previews your latest order. Both call the same Mapper::build() the queue job calls, so the preview is not an estimate.

What to look at:

  • FreshBooks would total X, the order came to Y. These should be equal. If they are not, the notices tell you which adjustment could not be expressed.
  • The mapping notices. A third tax combined into one, an order-level tax that became a charge line, an inclusive tax split out of the price — anything Freshh had to decide, it says.

Sending orders by hand

The FreshBooks panel on Commerce's order edit screen shows the invoice number, its FreshBooks status, how much has been paid and when it last synced, with a link straight to the invoice. The Send now / Re-send button pushes the order immediately.

The Freshh → Invoices screen lists every order Freshh knows about, filtered by state, with Send unsent orders to queue a batch and Refresh from FreshBooks to re-read invoice states.

Unlinking an order forgets the mapping without touching FreshBooks. That is recoverable rather than dangerous: the next sync looks for the invoice by number before creating one.

Backfilling an existing store

php craft freshh/sync/push --dry-run --limit=20   # rehearse; needs no connection
php craft freshh/sync/push --limit=500            # send
php craft freshh/sync/push --queue --limit=500    # hand it to the queue instead

The dry run prints each order's invoice number, the total FreshBooks would reach and the order total, and flags any that do not balance — before a single call goes out.

Console

php craft freshh/sync/push --order=1234        # one order
php craft freshh/sync/push --force             # re-send, updating the invoice in place
php craft freshh/sync/preview 1234             # the exact payload for one order
php craft freshh/sync/retry                    # everything that failed
php craft freshh/sync/skip 1234                # exclude an order from syncing
php craft freshh/sync/pull                     # read invoice states back
php craft freshh/sync/status                   # how much of the store is on FreshBooks

php craft freshh/connection/status
php craft freshh/connection/test
php craft freshh/connection/businesses
php craft freshh/connection/refresh

php craft freshh/webhooks/register
php craft freshh/webhooks/list
php craft freshh/webhooks/delete

php craft freshh/log/errors
php craft freshh/log/prune --days=14

freshh/sync/status is the one worth putting in a monitoring script: it exits non-zero when the connection is down.

Twig

{% set invoice = craft.freshh.invoice(order) %}

{% if invoice and invoice.isSynced() %}
    <p>Invoice {{ invoice.remoteNumber }} — {{ invoice.remoteStatus }}</p>
    {% if invoice.amountPaid %}
        <p>Paid: {{ invoice.amountPaid|commerceCurrency(order.currency) }}</p>
    {% endif %}
{% endif %}

Also available: craft.freshh.links(order) for every mapping including payments and credit notes, craft.freshh.isSynced(order), craft.freshh.isConnected() and craft.freshh.stats().

Nothing on craft.freshh triggers a sync. A page render is not a place to make an API call, and a template that quietly invoiced somebody when it was viewed would be a bug waiting to be cached.

Adding your own fields

Mapper::EVENT_AFTER_BUILD_INVOICE fires once the payload is built and reconciled, before anything is sent:

use justinholtweb\freshh\events\BuildInvoiceEvent;
use justinholtweb\freshh\services\Mapper;
use yii\base\Event;

Event::on(Mapper::class, Mapper::EVENT_AFTER_BUILD_INVOICE, function(BuildInvoiceEvent $event) {
    $event->mapped->payload['po_number'] = $event->order->getFieldValue('purchaseOrder');
});

Because every path goes through the mapper, a handler here reaches the queue job, the console command and the preview alike. Totals are recomputed afterwards, so a handler that adds a line cannot quietly unbalance the invoice.