Skip to main content
Implementation playbook: concrete data models, JSON payloads and automation rules for pool service quote‑to‑cash

Implementation playbook: concrete data models, JSON payloads and automation rules for pool service quote‑to‑cash

A reference engineers can actually copy from, not another high-level overview

Most quote-to-cash writeups stop at the workflow diagram. Fine for a strategy deck, useless when a developer sits down to wire your CRM to your accounting system and needs to know whether status is an enum or free text, what fires when a part gets received, and what the webhook payload actually looks like.

This is the missing layer. If you've already read the higher-level quote-to-cash playbook that maps estimates, parts-holds, dispatch and collections, this article is the implementation companion — the exact field names, state machines, and rule definitions you can hand to whoever is building or configuring your systems.

Scope is deliberately narrow: six core entities, their status models, the transitions between them, and the automations that connect them for a pool service business. Nothing about pricing strategy, nothing about staffing. Just the data model and the plumbing.

The six entities and why the status field breaks first

Before the schemas, one observation worth internalizing: the thing that quietly wrecks quote-to-cash integrations is inconsistent status vocabulary across systems.

A typical example: the field CRM calls jobstatus = "complete" maps to the accounting system's readyto_invoice, but the inventory system still shows the job's parts as allocated instead of consumed. Nobody notices until month-end reconciliation shows 40 jobs marked done with parts still "on hold." That's not a bug in any single system — it's a missing shared status contract.

Keep partsource at the line level (stocked vs specialorder) and enforce it across integrations.

So the first rule: define enums once, centrally, and force every integration to translate to them. The schemas below assume that contract.

1. Status models and schemas

Estimate

{ "estimateid": "EST-2026-004182", "customerid": "CUST-00913", "siteid": "SITE-01477", "status": "approved", "createdat": "2026-05-11T14:22:00Z", "updatedat": "2026-05-11T16:03:00Z", "expiresat": "2026-05-26T00:00:00Z", "lineitems": [ { "lineid": "L1", "type": "part", "sku": "PMP-STA-1.5HP", "description": "1.5HP variable-speed pump", "qty": 1, "unitprice": 689.00, "partsource": "specialorder" }, { "lineid": "L2", "type": "labor", "description": "Pump R&R", "qty": 2.5, "unitprice": 115.00 } ], "subtotal": 976.50, "tax": 78.12, "total": 1054.62, "approvedby": "customerportal", "linkedjob_id": null }

ValueMeaning
draftBeing built, not sent
sentDelivered to customer, awaiting response
viewedCustomer opened it
approvedCustomer accepted
declinedCustomer rejected
expiredPast expires_at with no response
convertedA Job was created from it

Job

{ "jobid": "JOB-2026-002210", "estimateid": "EST-2026-004182", "customerid": "CUST-00913", "siteid": "SITE-01477", "status": "awaitingparts", "assignedtechid": null, "scheduledstart": null, "priority": "normal", "partsrequired": [ { "sku": "PMP-STA-1.5HP", "qty": 1, "fulfillment": "specialorder", "poid": "PO-2026-00788", "partstatus": "onorder" } ], "createdat": "2026-05-11T16:03:00Z", "updated_at": "2026-05-11T16:05:00Z" }

ValueMeaning
createdFrom approved estimate, not yet triaged
awaiting_partsBlocked pending part receipt
readytoscheduleParts available, needs a slot
scheduledOn the calendar
in_progressTech on site / working
completedWork finished, pending invoice
invoicedInvoice generated
closedPaid and reconciled
cancelledJob killed

Part

{ "sku": "PMP-STA-1.5HP", "description": "1.5HP variable-speed pump", "onhandqty": 0, "allocatedqty": 0, "reorderpoint": 1, "stocktype": "specialorder", "defaultvendorid": "VEND-0042", "unit_cost": 512.00 }

allocatedqty vs onhandqty is the pairing that keeps trucks honest. A part physically in the warehouse but committed to three jobs is not available, and any rule reading only onhand_qty will happily double-book it.

Purchase Order (PO)

{ "poid": "PO-2026-00788", "vendorid": "VEND-0042", "jobid": "JOB-2026-002210", "status": "issued", "lineitems": [ { "sku": "PMP-STA-1.5HP", "qty": 1, "unitcost": 512.00, "receivedqty": 0 } ], "expecteddate": "2026-05-15", "createdat": "2026-05-11T16:05:00Z" }

ValueMeaning
draft
issued
partially_received
received
cancelled

Invoice

{ "invoiceid": "INV-2026-003391", "jobid": "JOB-2026-002210", "customerid": "CUST-00913", "status": "sent", "amountdue": 1054.62, "amountpaid": 0.00, "issuedat": "2026-05-16T18:40:00Z", "dueat": "2026-05-31T00:00:00Z", "paymentlink": "https://pay.example.com/INV-2026-003391", "is_partial": false }

ValueMeaning
draft
sent
viewed
partially_paid
paid
overdue
disputed
void

Payment

{ "paymentid": "PAY-2026-006677", "invoiceid": "INV-2026-003391", "amount": 1054.62, "method": "card", "status": "succeeded", "processedat": "2026-05-20T09:12:00Z", "processorref": "ch_3PqX7..." }

ValueMeaning
pending
succeeded
failed
refunded

One field people consistently forget: partsource at the line level (stocked vs specialorder). Half your automation branches depend on it, and if it lives only in the technician's head, none of the downstream rules fire correctly.

2. Finite-state machines and allowed transitions

The enums above only matter if you enforce which transitions are legal. Free-floating status changes are how a Job jumps from awaiting_parts straight to invoiced with no work logged.

Job FSM (allowed transitions only):

created ────────────► awaitingparts (if any line = specialorder & not in stock) created ────────────► readytoschedule (if all parts stocked & allocated) awaitingparts ─────► readytoschedule (on PO fully received) readytoschedule ──► scheduled (tech + slot assigned) scheduled ──────────► inprogress (tech checks in) in_progress ────────► completed (tech marks done + required photos attached) completed ──────────► invoiced (invoice generated) invoiced ───────────► closed (payment succeeded, full) any (except closed)─► cancelled

Everything not listed should be rejected. A useful guard on in_progress → completed: block it unless the required photo shot list is attached — that single constraint prevents a surprising number of later invoice disputes.

Estimate FSM:

draft → sent → viewed → approved → converted └─► declined sent/viewed → expired (past expires_at) No path back from converted. Once a Job exists, changes happen on the Job, not the Estimate. Teams that let both drift out of sync end up billing the wrong scope.

3. Automation rules (trigger → condition → action)

Two formats below: a readable connector-style (Zapier/Workato feel) and a generic rule-engine DSL you can drop into most engines.

Flow A — Estimate approved → create PO

  1. Trigger

    Estimate status changes to approved

  2. Condition

    any line where partsource == "specialorder" AND part onhandqty < qty

  3. Action 1

    Create Job (status = awaiting_parts)

  4. Action 2

    Create PO to defaultvendorid, link job_id

  5. Action 3

    Set line partstatus = onorder

DSL: RULE estimateapprovedcreatepo WHEN estimate.status -> "approved" IF exists(line in estimate.lineitems where line.partsource == "specialorder" and part(line.sku).onhandqty < line.qty) THEN create job {estimateid: estimate.id, status: "awaitingparts"} create po {vendor: part(line.sku).defaultvendorid, jobid: job.id, sku: line.sku, qty: line.qty} set line.partstatus = "on_order"

Flow B — Part received → update status + notify scheduler

RULE partreceivedadvancejob WHEN po.status -> "received" IF job(po.jobid).status == "awaitingparts" and allpartsavailable(job(po.jobid)) THEN set job.status = "readytoschedule" set part(po.sku).allocatedqty += po.receivedqty notify role:"scheduler" message: "Job {job.id} parts in. Ready to schedule at {site.address}."

Flow C — Tech completes job → generate invoice + send payment link

RULE jobcompletedinvoice WHEN job.status -> "completed" IF job.requiredphotosattached == true THEN create invoice {jobid: job.id, amountdue: job.total, status: "sent", paymentlink: generatelink(job.id)} set job.status = "invoiced" set part(each consumed sku).allocatedqty -= consumedqty set part(each consumed sku).onhandqty -= consumedqty send sms/email to customer with invoice.paymentlink

Here's a simple diagram of the trigger→condition→action flows for the common rules above.

Process diagram

Flow D — Disputed invoice → open dispute workflow

RULE invoicedisputed WHEN invoice.status -> "disputed" THEN create dispute {invoiceid: invoice.id, status: "open"} set job.status = "invoiced" // hold, do not close assign dispute to role:"opsmanager" attach job.photos, job.lineitems to dispute pause dunning_sequence(invoice.id)

Pausing the dunning sequence is easy to forget. Nothing damages a customer relationship faster than automated overdue reminders firing while a dispute is still open.

4. Integration mapping table and payloads

Field-level mapping across the three system layers. This is the translation contract mentioned at the top.

ConceptCRM / FSMInventory / POERP / Accounting
Customercustomer_idARcustomerref
Job / Work orderjob_idjob_id (allocation ref)salesorderno
Partskuskuitem_code
Job completestatus="completed"consume allocated_qtytrigger readytoinvoice
Invoiceinvoice_idARinvoiceno
Paymentpayment_idcashreceiptid
POpo_id (read)po_id (master)`APbillref on receipt

Sample inbound webhook — PO received (from inventory system):

{ "event": "po.received", "poid": "PO-2026-00788", "jobid": "JOB-2026-002210", "lines": [{ "sku": "PMP-STA-1.5HP", "receivedqty": 1 }], "receivedat": "2026-05-15T11:20:00Z" }

Sample outbound API call — create invoice in accounting:

POST /v1/invoices { "externalref": "JOB-2026-002210", "customerref": "CUST-00913", "lines": [ { "itemcode": "PMP-STA-1.5HP", "qty": 1, "amount": 689.00 }, { "itemcode": "LABOR-STD", "qty": 2.5, "amount": 287.50 } ], "tax": 78.12, "total": 1054.62, "due_days": 15 }

Keep externalref pointing at jobid everywhere. When accounting and CRM disagree, that shared key is what lets you reconcile instead of guessing.

5. Test records and three walk-through scenarios

Seed data for a test environment:

  1. CUST-00913 — residential, active
  2. SITE-01477 — in-ground pool, variable-speed pump
  3. Part CHL-CELL-T15 — stocked, onhandqty=4, reorder_point=2
  4. Part PMP-STA-1.5HP — special order, onhandqty=0
  5. VEND-0042 — pump supplier, ~4 day lead

Scenario 1 — Stocked part repair (salt cell replacement)

  1. Estimate for CHL-CELL-T15 + 1hr labor → customer approves.
  2. Rule A checks

    part is stocked, onhandqty=4 ≥ 1no PO. Job created as readytoschedule, allocated_qty on the cell goes to 1.

  3. Scheduled → tech checks in (in_progress) → photos attached → completed.
  4. Flow C fires

    invoice INV-... sent, payment link out, cell onhandqty drops to 3, allocated_qty back to 0.

  5. Customer pays → payment succeeded → job closed.

Expected messages: one customer invoice/payment SMS. No scheduler "parts in" alert, because nothing was on order.

Scenario 2 — Special-order pump with partial invoice

  1. Estimate approved. Rule A sees specialorder + onhandqty=0 → creates PO-2026-00788, job = awaitingparts.
  2. Customer agrees to a deposit. A partial invoice is issued

    amountdue = 1054.62, ispartial = true, first payment of $500 → invoice status partially_paid.

  3. PO received (webhook in Section 4) → Flow B → job readytoschedule, scheduler notified.
  4. Job scheduled, completed with photos. Remaining balance invoiced; on full payment invoice → paid, job → closed.

Expected messages: scheduler "parts in" alert, plus two customer payment touches (deposit, balance). Note the invoice never sits at sent — it moves sent → partially_paid → paid.

Scenario 3 — Warranty claim

  1. Job completed under a prior repair, pump fails inside warranty.
  2. New Job created with priority = normal but flagged billing_type = warranty. Estimate total to customer = $0; internal cost still tracked.
  3. If a replacement part is needed, Rule A still fires a PO — you still consume inventory — but Flow C is modified: invoice generated with amount_due = 0, status jumps to paid, no payment link sent.
  4. The vendor warranty credit is tracked as a separate AP adjustment against the PO, not against the customer.

Expected messages: scheduler alert if parts ordered; no customer payment link. This is the branch teams most often get wrong — they either bill the customer by accident or lose track of the vendor credit entirely.

When to enforce the full FSM (and when not to)

Enforce strictly if you run more than a handful of techs, use special-order parts regularly, or reconcile in a separate accounting system. The transition guards are what keep your three systems from drifting.

Skip the heavy version if you're a one- or two-person operation invoicing same-day from a single app. Forcing awaiting_parts and PO objects on a business that never special-orders just adds clicks with no payoff.

Who should not build this from scratch: anyone without a stable shared key across systems. If your CRM and accounting can't agree on a jobid/externalref, fix that first — the fanciest FSM in the world can't reconcile records it can't match.

Closing note

The value here isn't the diagrams; it's the discipline of one status vocabulary, enforced transitions, and a shared key across every system. Get those three right and the automation rules in Section 3 mostly configure themselves. Get them wrong and you'll spend every month-end manually untangling jobs marked done with parts still on hold. Hand this to whoever configures your platform, keep the enums in one place, and test against the three scenarios above before you trust any of it with live invoices.

Built for Pool Service Tailored to pool maintenance workflows and field service needs
Save Time Streamline scheduling, dispatch, and daily task management
Delight Customers Automated updates and reliable service delivery
Grow Revenue Increase repeat business and optimize technician utilization