Read-only sync against Bill.com. Two daily commands link sites to customers (via domain extraction from invoice line items) and flip care_plan_enabled based on whether the customer has a recent invoice with a care-plan line item.
Why we use it
Before this, care_plan_enabled was a manual toggle. the operator had to remember to flip it for new clients and demote when a client cancelled. The flag drives the Updates tab banner (“included” vs “billable”), the /maintenance-history rollup, and which sites get the daily Lighthouse + weekly Sucuri + daily core-checksums scans. A wrong flag is silent revenue leakage on one side and the wrong client experience on the other.
Bill.com is the source of truth for who’s actually paying for what. The sync makes the flag follow the invoicing.
Setup
-
Generate a developer key at
developer.bill.com. -
Set in
.env:CLOCKWORK_BILL_COM_ENABLED=true CLOCKWORK_BILL_COM_USERNAME=... CLOCKWORK_BILL_COM_PASSWORD=... CLOCKWORK_BILL_COM_ORG_ID=... CLOCKWORK_BILL_COM_DEV_KEY=... -
Test (always works regardless of the
ENABLEDtoggle):
php artisan clockwork:bill-com-test
Verifies credentials, lists first 5 customers + items.
Auth
Session-based. POST /Login.json with username/password/orgId/devKey returns a sessionId; we cache it in Laravel’s cache for 25 min. On 401 we re-login automatically (sessions expire after ~35 min of inactivity).
API base is v2, not v3. Bill.com’s docs claim v3 lives at gateway.bill.com — that hostname has no DNS records. v2 at api.bill.com/api/v2 is what actually works for standard accounts.
Endpoints we call
Base URL https://api.bill.com/api/v2.
| Method | Path | Purpose |
|---|---|---|
| POST | /Login.json |
Authenticate, get sessionId. |
| POST | /List/Customer.json |
Customer list, paginated 100/page. |
| POST | /List/Item.json |
Product / service catalog. |
| POST | /List/Invoice.json |
Invoice list with invoiceDate:gte:YYYY-MM-DD filter. |
We never write to Bill.com.
Files
app/Services/BillCom/BillComClient.php— HMAC + session client; generators forcustomers(),items(),invoicesSince()so callers don’t materialise the full result set.app/Services/BillCom/InvoiceDomainExtractor.php— pure-function regex extractor. Recognises a fixed TLD list (com / org / net / io / co / us / gov / edu).app/Services/BillCom/CustomerSyncService.php— pulls customers + invoices, links sites by domain.app/Services/BillCom/CarePlanSyncService.php— classifies items via regex, walks invoices, setscare_plan_enabled.app/Console/Commands/{BillComTest,SyncBillCustomers,SyncBillCarePlans}.phpapp/Http/Controllers/BillComSettingsController.php+resources/views/settings/bill-com.blade.php- Config:
config/clockwork.php→bill_comkey.
Scheduled jobs that depend on it
| Cadence | Command |
|---|---|
| daily 01:00 | clockwork:sync-bill-customers — pull customers + invoices in window, extract domains, link sites. Picks the most-recent invoice when a site appears in multiple. |
| daily 01:30 | clockwork:sync-bill-care-plans — classify items, walk invoices, set care_plan_enabled on linked sites whose care_plan_override IS NULL. |
Both gated on CLOCKWORK_BILL_COM_ENABLED. Both write bill_com_sync rows to action_logs so runs surface on /maintenance-history.
How linking actually works
Bill.com’s API has very thin metadata: customers have no custom fields and no notes; invoice line items have no product code, only a free-text description. Direct customer-to-site matching is impossible because the billing email rarely matches the site domain.
What works is two specific operator-side conventions:
- Every line item description contains the site domain. Examples observed:
"Website hosting for example-client.com","http://www.example-client.com - - Plugin updates, backups, security scans, etc.". The extractor pulls the first plausible domain and we match againstSite::domain. - The care-plan offering uses a consistent Item name — default regex
/care plan/i. Override viaCLOCKWORK_BILL_COM_CARE_PLAN_ITEM_REGEX.
If either convention breaks (the operator stops including domains, or starts using inconsistent Item names), the relevant half silently degrades. The bill_com_linked_via_invoice audit column shows which invoice proved each link.
/settings/bill-com — control panel (BillComSettingsController)
The fleet-wide status page for this integration. Everything on it is read from either config('clockwork.bill_com') or already-synced local tables — the page never calls the Bill.com API itself except when you click a run button.
What it shows:
- Configured? —
credentialsConfigured()checks all fourCLOCKWORK_BILL_COM_*values (username, password, org ID, dev key) are non-empty. Credentials themselves are never displayed, only the boolean. - Enabled? — the separate
CLOCKWORK_BILL_COM_ENABLEDflag. You can have valid credentials configured but sync disabled (e.g. mid-migration to another accounting system). - Last 10 sync runs —
ActionLogrows whereaction_type = 'bill_com_sync', newest first. Same table/maintenance-historyreads, so this page and that page never disagree about run history. - Linked-site count —
Site::whereNotNull('bill_com_customer_id')->count()against the total site count, so you can eyeball how much of the fleet has a Bill.com match. - Cached customer count — size of the local
bill_com_customersmirror table. - Care-plan items — every row from
bill_com_care_plan_itemswhereis_care_plan = true: the cached, classified Bill.com product/service catalog. Each Item is classified by theCLOCKWORK_BILL_COM_CARE_PLAN_ITEM_REGEXregex at sync time (regex_set = true), but a human can flipis_care_planby hand for one Item without touching the regex — whenregex_setis false, the sync won’t re-classify that Item on the next run. The page shows the regex string alongside the list so you can see why each Item did or didn’t match.
Two buttons, each gated on credentialsConfigured() returning true (otherwise a flash error tells you to set .env first):
- Run customer sync now (
POST /settings/bill-com/run-customer-sync) — runsCustomerSyncService::sync()synchronously, in-request, using the configuredcustomer_link_window_days. Flashes a summary: customers cached, sites linked, unmatched-domain count. - Run care-plan sync now (
POST /settings/bill-com/run-care-plan-sync) — runsCarePlanSyncService::sync()synchronously usingcare_plan_window_days+ the classification regex. Flashes: sites flipped on, sites flipped off, sites skipped due to a manualcare_plan_override.
Both run inline rather than queued — at fleet scale (~150 sites, ~50 customers, low hundreds of invoices/year) a full sync finishes in well under a minute, so there’s no need for a job + polling UI.
See Features → Care plans + billing for the per-site override UI and the day-to-day operator workflow this page supports.
Manual override
sites.care_plan_override:
NULL→ sync may writecare_plan_enabledfreely.true/false→ manual override, sync must not touch the column.
UI: Settings → Billing on the per-site page. “Mark on/off care plan” sets the override; “Let Bill.com decide” clears it back to NULL.
Window tuning
| Variable | Default | What it does |
|---|---|---|
CLOCKWORK_BILL_COM_CUSTOMER_LINK_WINDOW_DAYS |
1095 |
3 years of invoice history walked for site↔customer linking. |
CLOCKWORK_BILL_COM_CARE_PLAN_WINDOW_DAYS |
400 |
Catches both monthly and annual invoicing. A yearly invoice from 13 months ago still flags the customer. |
Gotchas
- Filter syntax is
field:op:value. We useinvoiceDate:gte:YYYY-MM-DD. - Pagination cursor is
nextPagein the response, passed back as thenextPagequery parameter. - No sandbox in use. the operator’s account doesn’t have one; read-only is fine against production.
- Pagination cap is 200 pages (20k rows) per endpoint to prevent runaway loops. Adjust if the agency grows past that.
