Search by

justinholtweb / craft-freshh

justinholtweb

FreshBooks accounting for Craft Commerce — orders become invoices, customers become clients, transactions become payments, and refunds become credit notes, with the books reconciled both ways.

Package info

github.com/justinholtweb/craft-freshh

Type:craft-plugin

pkg:composer/justinholtweb/craft-freshh

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

5.0.0 2026-08-23 14:43 UTC

This package is auto-updated.

Last update: 2026-08-26 23:58:05 UTC


README

FreshBooks accounting for Craft Commerce. Orders become invoices, customers become clients, transactions become payments, refunds become credit notes — and FreshBooks tells Craft when an invoice gets paid.

Requires Craft CMS 5.3+, Craft Commerce 5.0+ and PHP 8.2+.

What it does

Craft Commerce FreshBooks
Completed order Invoice, marked as sent
Customer (or guest email) Client
Successful purchase/capture transaction Payment against the invoice
Refund transaction Credit note
Purchasable (optional) Catalogue item
Order status (optional) Invoice status

Install

composer require justinholtweb/craft-freshh
php craft plugin/install freshh

Connect

  1. Create an app at my.freshbooks.com/#/developer.
  2. Copy the redirect URI from Freshh's settings screen into the app. FreshBooks refuses a redirect URI that is not HTTPS or that carries a query string, which is why Freshh uses a plain control-panel path rather than an action URL.
  3. Paste the client ID and secret into Freshh's settings and save.
  4. Press Connect to FreshBooks and approve the connection.

FreshBooks only supports the authorization code grant — there is no machine-to-machine flow — so a human has to approve the connection once. After that Freshh keeps it alive by refreshing.

Preview before you trust it

The settings screen has a Preview the latest order button, and every order has a preview of its own. Both run the same Mapper::build() the queue job runs, so what you are shown is byte-for-byte what FreshBooks will receive — including the running total FreshBooks will arrive at, next to the total the customer actually paid.

That comparison is the point. FreshBooks builds an invoice from qty × unit_cost plus percentage taxes; Commerce stores totals and tax amounts. Freshh does that arithmetic itself before sending, and if the two disagree it either adds a rounding line (when the gap is cents) or refuses to send at all (when it is not). An invoice that is a pound out is worse than no invoice.

How money is mapped

  • Line items carry the net unit price and quantity. FreshBooks multiplies them back out.
  • Tax is a percentage on the line, taken from the Commerce tax rate's own snapshot where there is one and derived from the amount where there is not. FreshBooks allows two taxes per line; a third and fourth are summed into one, and the invoice says so.
  • Inclusive tax has no FreshBooks equivalent. By default Freshh sends the net price plus the rate, which comes to the same total and keeps the tax visible in FreshBooks' reports.
  • Discounts get their own negative line by default, or can be folded into the unit price.
  • Order-level tax — tax Commerce attached to the order rather than a line — goes onto the shipping line when it came from shipping, and becomes a charge line otherwise. Either way the total is right, and the mapping notice tells you which happened.

Duplicate invoices, and why you will not get any

FreshBooks accepts no idempotency key, and a queue worker can die between creating an invoice and recording that it did. Freshh therefore never creates blind:

  1. Claim a row in freshh_links, unique on (resourceType, localKey). Two workers cannot both own one order.
  2. Reconcile. A claim that finds a half-finished row asks FreshBooks for the invoice number this order would have used. If it is already there, it is adopted.
  3. Create, then record.

The same pattern protects payments (matched on a marker in the payment note) and credit notes.

Getting state back

FreshBooks callbacks tell Craft when an invoice is viewed, paid or written off. Press Register callbacks on the settings screen; FreshBooks will post a verification code to the site and Freshh completes the handshake automatically.

The endpoint checks the HMAC signature, then throws the payload away and re-fetches the resource from the API. A forged callback costs one wasted API call and cannot change a number in anyone's books.

If FreshBooks cannot reach the site — local development, staging behind basic auth, an intranet — use freshh/sync/pull on a schedule instead.

Console

php craft freshh/sync/push --order=1234        # one order
php craft freshh/sync/push --limit=500         # backfill
php craft freshh/sync/push --dry-run           # what would be sent, and whether it balances
php craft freshh/sync/push --queue             # hand it to the queue instead
php craft freshh/sync/preview 1234             # the exact payload for one order
php craft freshh/sync/retry                    # everything that failed
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

Templating

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

{% if invoice and invoice.isSynced() %}
    <p>Invoice {{ invoice.remoteNumber }} — {{ invoice.remoteStatus }}</p>
{% endif %}

Nothing on craft.freshh triggers a sync. A page render is not a place to make an API call.

Extending

Mapper::EVENT_AFTER_BUILD_INVOICE fires once the payload is built and reconciled, before anything is sent — the place to add a PO number, a project id or an extra line:

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.

Safety

  • Nothing about FreshBooks happens during checkout. Syncing is queued. A FreshBooks outage cannot fail an order.
  • Tokens are encrypted with Craft's security key, and never appear in the log — every payload is redacted on its way in.
  • Refresh happens under a mutex. FreshBooks refresh tokens are one-time-use and only one is valid at a time; two workers refreshing at once would kill the connection outright.

Licence

The Craft License. One Craft installation per licence — see LICENSE.md.