Status: 🟡 Live in-app, does NOT round-trip to QuickBooks. The whole CO
lifecycle — draft, itemized pricing, Client portal + field capture, dual e-sign,
and the on-approval fan-out (contract value moves, schedule auto-shifts, draft
invoice, execute-task) — is 🟢 live against real data. The one honest hole:
an approved CO moves contract value inside GoBuild only. There is no
push_change_order— the added revenue never reaches QuickBooks Online as its
own artifact. It reaches QBO only ifinvoice_on_approvalis set and the office
sends the resulting invoice (whichpush_invoicedoes sync). See
The honest gap.
Audience: engineers + finance-literate operators. This is the deep reference for
how a Change Order (CO) amends a Job's scope, money, and timeline. Companion
reading: Job costing (where the money lands),
Scheduling & Gantt (where the days land), and
Documents & e-sign (how the Client signs).
Legend: 🟢 live · 🟡 WIP / config-gated · ⚪ dormant / stub · ❌ not built.
House terms: a unit of work is a Job (jobs); the person you build for is a
Client (clients). Never "Project", never "Customer".
A CO is a signed amendment to a Job. It carries two prices on purpose — a builder
cost and a marked-up Client price — plus a day impact and optional jobsite photos.
The model is ChangeOrder (app/models/money.py:243):
cost_delta_cents — the Client price delta. Drives contract value andapp/models/money.py:254)cost_cents — the builder cost delta. Drives budget / job-costing andapp/models/money.py:256)markup_pct — markup applied to cost → Client price when repriced from lineapp/models/money.py:258)schedule_impact_days — working/calendar days the approved CO pushes into theapp/models/money.py:264)status — draft → sent → approved (or declined), enumChangeOrderStatus at app/models/base.py:116.applied_to_scheduleapp/models/money.py:271), applied_to_po (:282), invoice_id (:284).Derived money lives on the model as properties (app/models/money.py:291):
cost_delta (Client price), cost (builder cost), margin = price − cost, and
margin_pct. The command center renders cost / price / margin per CO from these.
Two shapes of CO:
| Shape | How it prices | cost_cents source |
|---|---|---|
| Itemized | Σ line costs, then × markup → Client price | line items |
| Lump-sum | manual Client price, cost defaults to the same | caller's cost_delta |
At creation a lump-sum CO seeds cost_cents = cost_delta_cents; recalc() overrides
that the moment usable line items exist (app/models/money.py:82, :311).
Each itemized row is a builder-cost line, COLineItem (app/models/money.py:330):
description, quantity, unit (ea/sf/hr/ls), unit_cost_cents, cost_code,
category (material / labor / subcontractor / equipment), source
(manual | po | field), and a soft-delete removed flag. Line cost is a computed
quantity × unit_cost_cents (app/models/money.py:351). The cost_code is what
lets an approved CO roll into the job-costing grid on the
same dimension as budgets, POs and bills.
There are three entry points, all landing in change_order.create()
(app/services/change_order.py:62):
POST …/jobs/{job_id}/change-orders, create_change_orderapp/routers/money.py:718). Line items arrive as parallel form arraysline_desc, line_qty, line_unit_cost, line_cost_code, line_category).po_action = none | new | amend (:791) so anco_create (app/routers/field.py:1848), gated bycaps.can_change_order. A crew member snaps photos, types a note, andai_draft() turns it into a Client-ready title + descriptionapp/services/change_order.py:315; falls back to the raw note if AI is down).app/routers/client_portal.py:388, app/routers/field.py:2335).Creation flow inside the service:
next_number() assigns the per-Job human number CO-001app/services/change_order.py:24).replace_lines() skips blank rows, builds COLineItems, then callsco.recalc(markup.for_job_id(...)) so each line prices at its own category'sapp/services/change_order.py:30, :59).status = draft. Nothing touches money or schedule yet — only anapp/services/change_order.py:212).ChangeOrder.recalc() (app/models/money.py:311) is the single source of truth:
cost_cents = Σ active line costs
cost_delta_cents = Σ (line.cost_cents × (1 + category_markup/100)) # per-category
= cost_cents × (1 + markup_pct/100) # flat fallback
Lump-sum COs (no active lines) keep their manually entered cents untouched
(app/models/money.py:317). margin and margin_pct fall out of the two numbers
(app/models/money.py:300). This is why a CO can show the Client one price while the
builder tracks a different cost basis and a live margin per amendment.
show_items_to_client (app/models/money.py:260) decides whether the homeowner sees
the itemized breakdown or just the lump price.
Send. The office/field "Send" action calls send_for_signature()
(app/services/change_order.py:100; routes at app/routers/money.py:809 and
app/routers/field.py:1866). It flips draft → sent, ensures the Job has a
client_token, then best-effort requests an e-signature — degrading to just
"sent" when Documenso or a recipient email isn't configured. Recipient resolution
prefers the Billing-tagged contact, else the Client's email
(app/services/change_order.py:114). Because Documenso doesn't email signers, GoBuild
sends the homeowner their own signing link via the co_sent template
(app/services/change_order.py:132).
Sign = approve. COs use dual signing by default — the homeowner and your
rep both sign (signing_mode = "dual"; "client_only" skips the counter-signer).
See request_change_order_signature() (app/services/esign_service.py:189), which
reuses the same Documenso engine as Purchase Orders. Approval fires only when the
Documenso document reaches COMPLETED (all signers), at which point the webhook
runs _on_signed() → the CO branch sets approved and calls on_approved()
(app/services/esign_service.py:386, :480). The certified PDF is auto-filed into
the Job's Document Hub. The signature is the approval.
One-click portal approve. The Client portal also exposes a plain approve button —
approve_change_order (app/routers/client_portal.py:472) — that sets
status = approved, stamps approved_at, and calls on_approved() without any
signature check. Fast, but see open questions on enforcement.
The portal lists every non-draft CO with its live sign-off state
(app/routers/client_portal.py:214); the office command center renders the same
sign-off dict via co_signoff() / cc_cards() (app/services/change_order.py:143,
:192).
Every approval path funnels through one idempotent entry point,
on_approved() (app/services/change_order.py:212). It:
co_schedule.apply_impact() (guarded; never blocksapp/services/change_order.py:223)_maybe_bill() when invoice_on_approval is set.app/services/change_order.py:227, :252)_spawn_co_task() creates one high-priority task linkedlinked_type = "co"), the "self-creating task" story.app/services/change_order.py:231)Note what is not in this list: moving contract value. That isn't an event — it's a
derived read (next section).
There is no "write the contract up" step. reporting.job_costing recomputes
contract value on every read by summing the Job's estimate/override plus the Client
price of every approved CO (app/services/reporting.py:57):
co_approved = Σ approved_co.cost_delta # client price → contract value
contract_value = base_contract + co_approved # reporting.py:59, :66
The rollup surfaces it as a change_orders figure alongside contract_value
(app/services/reporting.py:142), and approved-CO cost flows into the per-cost-code
grid via each line's cost_code (app/services/reporting.py:268). So the instant a CO
is approved, both margin sides move: contract value up by the Client price, projected
cost up by the builder cost. Declined/draft/sent COs are invisible to the math — the
filter is strictly status == approved. Full model in
Job costing.
co_schedule.apply_impact() (app/services/co_schedule.py:20) pushes
schedule_impact_days into the live schedule once, guarded by
applied_to_schedule. For every incomplete task on the Job
(app/services/co_schedule.py:37):
start_date >= today) → shift both start_date and due_date.due_date >= today) → extend only the due_date.percentage_done >= 100) → untouched.Moved tasks are flagged schedule_manually = True, pushed to OpenProject, and the
project is re-cascaded (app/services/co_schedule.py:53). It then diffs a before/after
snapshot and dispatches schedule-change notifications, and writes a
change_order_schedule JobEvent to the Job timeline
(app/services/co_schedule.py:69). Mirrors Buildertrend's "change orders automatically
update timelines." Details in Scheduling & Gantt.
When invoice_on_approval is set, _maybe_bill() creates a draft invoice for the
Client price — itemized from the CO's lines (carrying cost code + category) or a
single lump line — links it one-time via co.invoice_id, and notifies the office that
a draw is ready to review and send (app/services/change_order.py:252). The office
still reviews and sends it; nothing bills the Client automatically.
🟡 Approved COs move contract value in-app but do NOT sync to QuickBooks. The QBO
push surface is push_invoice (app/services/quickbooks_sync.py:537), push_bill
(:642), and the payroll journals (:355, :448). There is no
push_change_order — grep the tree and it does not exist. Consequences:
invoice_on_approval,push_invoice syncs that invoice like anyapplied_to_po,app/services/change_order.py → purchase_orders.amend_po_from_co), the PO costNet: for QBO-synced orgs, GoBuild's contract value and QBO's can legitimately
diverge by the sum of un-invoiced approved COs. This is a known, documented hole —
not a bug in the reconciliation.
| Field | Guards | File:line |
|---|---|---|
status |
draft → sent → approved / declined |
app/models/base.py:116 |
applied_to_schedule |
one-time schedule push | app/models/money.py:271 |
applied_to_po |
one-time PO amendment | app/models/money.py:282 |
invoice_id |
one-time draw creation | app/models/money.py:284 |
on_approved() |
re-entrant safe (all guards) | app/services/change_order.py:212 |
on_approved() early-returns unless status == approved, and each downstream step
re-checks its own guard, so every approval path (webhook, portal button, selection
upgrade) can call it safely.
| Action | Route | File:line |
|---|---|---|
| Create (office) | POST …/change-orders |
app/routers/money.py:718 |
| Create (field) | POST /field/co |
app/routers/field.py:1848 |
| Send for signature | POST …/change-orders/{id}/send |
app/routers/money.py:809 |
| Send (field) | POST /field/co/{id}/send |
app/routers/field.py:1866 |
| Sync signature state | POST …/change-orders/{id}/sync |
app/routers/money.py:824 |
| CO PDF | GET …/change-orders/{id}/pdf |
app/routers/money.py:846 |
| Upload CO file | POST …/change-orders/{id}/files |
app/routers/money.py:887 |
| Client approve (portal) | POST /{token}/change-orders/{id}/approve |
app/routers/client_portal.py:472 |
| Client CO list | portal home | app/routers/client_portal.py:214 |
schedule_impact_days.