justinholtweb / craft-freshh
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
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
- craftcms/commerce: ^5.0.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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
- Create an app at my.freshbooks.com/#/developer.
- 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.
- Paste the client ID and secret into Freshh's settings and save.
- 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:
- Claim a row in
freshh_links, unique on(resourceType, localKey). Two workers cannot both own one order. - 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.
- 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.