Status: 🟡 Live engine, config-gated edges. The costing engine — the whole
plan-vs-actual-vs-committed rollup, the per-cost-code grid, budget/PO/bill/CO
wiring, invoicing, and cashflow forecast — is 🟢 live against real data with no
external dependency. Everything at the edges that moves money or leaves the
building — online payment (Stripe), AI bill capture (Gemini), e-signature
(Documenso), and accounting sync (QuickBooks Online) — is real code that stays
dormant until a per-org credential is set. So the loop closes fully in-app today;
the question is only how much of it round-trips to Stripe/QBO for a given org.
Audience: engineers + finance-literate operators. This is the deep reference for
GoBuild's money model — how a Job accrues cost, earns a live margin, and bills a
Client back. Companion source: docs/job-costing.md and
the rendered flowchart docs/job-costing-flow.svg.
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 signed proposal materializes a Job with its Estimate (contract value + cost
basis) and its draw schedule. The estimate seeds a Budget (the editable plan).
As the Job runs, three actual-cost streams accrue — Bills (AP), Purchase
Orders (committed), and labor (time-clock shifts). The engine
(reporting.job_costing) reconciles contract vs plan vs actual vs committed into a
live projected margin and profit fade. On the revenue side, schedule-driven
draws become Invoices (AR); when paid, they cascade status and push to
QuickBooks. Change orders amend both sides (added cost + added contract value).
Everything rolls up on one dimension: the cost code.
ESTIMATE ─seed→ BUDGET ─┐
(contract + cost) │ ┌─ committed ── PURCHASE ORDERS (open)
├─► ENGINE ◄─┼─ actual ───── BILLS (approved/paid) + LABOR
CHANGE ORDERS ───────────┘ reporting │ └─ Cost Inbox (capture→review)
(+contract, +cost) job_costing │
└─ invoiced ──── INVOICES ◄─ draws ◄─ SCHEDULE
│ │
CASHFLOW forecast (money calendar) CHANGE ORDERS
│
QUICKBOOKS (invoice·bill·payroll JE)
Every amount is stored in integer cents (amount_cents), never floats, with a
.amount property that divides by 100 (app/models/cost.py:120, :187, :356). All
finance tables are tenant-scoped (organization_id) and mostly Job-scoped
(job_id). Two numbers anchor the whole model:
Σ Estimate.total (marked-up client+ Σ approved-change-order client price, or a manual job.contract_value_centsreporting.py:64-67).Σ Estimate.cost_total is the as-sold cost⚠️ Honest caveat on planned margin. For a proposal-originated Job the "cost
basis" is the as-sold price components, not your true internal cost — so out of the
box, planned margin ≈ your proposal markup. Truth arrives as actuals (bills/labor)
and hand-entered budget lines land (docs/job-costing.mdStage 1).
Every costing surface is keyed on cost codes. The library is CostCode
(app/models/cost.py:43), per-org and editable, seeded on signup with a CSI-lite
~120-code default chart organized by MasterFormat division (01 General → 16
Electrical), each trade split rough/finish and labor/material/sub
(app/services/costcodes.py:24-143, seed_default_cost_codes :146). Each code
carries a default CostCategory that actuals bucket into
(CostCategory = material/labor/subcontractor/equipment/permit/other,
app/models/base.py:123).
cost_code string — intentional (by design)Owner decision (2026-07-08): free-text cost codes are intentional — the
cost_codescatalog is a suggestion list, not a constraint. This is not a bug
to fix. The caveats below are documented so users understand the trade-off.
cost_code is a free-text String on every finance row, with NO foreign key to the
cost_codes table (a repo-wide grep for ForeignKey("cost_codes… returns nothing) —
deliberately, to let a Job use ad-hoc codes without pre-registering them. It lives as a
loose string on:
| Table | Field | Source |
|---|---|---|
EstimateLine |
cost_code |
(money model) |
BudgetLine |
cost_code: String(64) |
cost.py:117 |
PurchaseOrder |
cost_code: String(32) |
cost.py:146 |
POLineItem |
cost_code: String(64) |
cost.py:239 |
Bill |
cost_code: String(32) |
cost.py:334 |
BillLineItem |
cost_code: String(64) |
cost.py:428 |
ForecastAdjustment |
cost_code: String(32) |
cost.py:79 |
Shift (labor) |
cost_code |
(timeclock model) |
Consequences (real, live behavior):
job_costing_by_code labels eachcode_map dict (reporting.py:183, :190); areporting.py:192). Nothing is silently dropped.String(32) on Bill/PO vsString(64) on lines) — a 33–64-char code truncates differently depending on wherereporting.py:186-198, key = ""); labor with no code lands in a "Labor — timereporting.py:314-327).guess_code suggests one: keyword-match the bill text against acostcodes.py:305). Surfaced as an ✨ AI suggestion in bill review.suggest_code_for_item either matches an existingDD-NNN code incostcodes.py:246). This is one/portal/cost-codes (cost.py:1065, seed :1088, reorder:1127, delete :1144).There is no separate "cost center" table — the roll-up dimensions are cost code
(fine-grained, ~120) and CostCategory (coarse, 6 buckets). Categories drive: the
budget-vs-actual-by-category report (cost_center.py:107 _estimate_vs_actual), the
default bucket a code's actuals land in, and (🟡) the per-category → QBO expense-account
mapping (quickbooks_sync.py:236 _account_for_category). The Cost Center service
(app/services/cost_center.py:192 assemble) is pure composition — it stitches the
grid, WIP, EVM, AR aging, cashflow, and code-drill details into one payload for the
Financials tab, so every surface shows the same numbers.
An accepted proposal runs convert_proposal() (app/services/conversion.py:27) which
in one transaction creates the Job, an approved Estimate (carrying markup_pct),
EstimateLine[], ScheduleItem[], and PaymentMilestone[] draws. The money
translation (conversion.py:69-80): a proposal line's price becomes the estimate line's
unit_cost, and unit_price = unit_cost × (1+markup) — so Estimate.total =
contract value and Estimate.cost_total = cost basis.
The BudgetLine (cost.py:107) is the editable plan you measure against — one per
estimate line, amount_cents = cost (budget = cost basis). Seeded by
budgeting.seed_from_estimate (app/services/budgeting.py:21), invoked either
automatically on acceptance (skip_if_exists=True so it never double-seeds) or via the
manual Seed from estimate button (cost.py:560). Hand-edited on the Costs & budget
tab (cost.py:503, :525). This is the grid's Revised budget column.
A PurchaseOrder (cost.py:125) is cost you've committed to a vendor/sub but not yet
been billed for. Its lifecycle is draft → released → sub_approved → received → billed → closed (POStatus, base.py:134); an open PO (is_open, cost.py:200) is any
status in PO_COMMITTED_STATUSES (released, sub_approved, draft_amended, received, sent
— base.py:158) and counts toward committed cost. POs carry itemized POLineItems
(cost.py:223) with per-line partial receiving (received_fraction) and a
billed_cents rollup for over-billing detection. Vendor/sub e-sign approval runs through
Documenso (procurement.py:645); off-platform approval is a guarded exception
requiring a written reason (procurement.py:542). Routes: create procurement.py:419,
release :528, receive :587, amend :797.
A Bill (cost.py:310) is an actual cost. Lifecycle BillStatus: inbox → approved → paid (+ void) (base.py:164). Only approved/paid bills feed job-costing
actuals (reporting.py:40 _ACTUAL_BILL_STATUSES) — inbox bills are captured but
invisible to the numbers.
The Cost Inbox flow:
cost.py:167). 🟡 Gemini visionbill_capture.extract_bill, app/services/bill_capture.py:49) reads vendor, date,{}bills.ingest_ocr_lines (app/services/bills.py:34), each inheritingcost.py:363) — flips to approved; bills.recompute_for_billbills.py:83) rolls billed-to-date onto matched PO lines.cost.py:380) — sets paid_at, then fires quickbooks_sync.push_billcost.py:402). Advisory gates warn-but-allow if a lien waiver iscost.py:389-408).Line-item attribution (🟢): Bill.cost_breakdown (cost.py:361) returns
(category, cost_code, amount) per line when itemized, else the header — so a
multi-code bill lands on the right rows. Over-billing warnings come from
bills.overbilled_lines (bills.py:88) when a matched PO line's billed-to-date would
exceed its value.
A ChangeOrder carries builder-cost line items (cost_cents, the budget/PO side) and
a marked-up client price (cost_delta_cents, the contract/billing side), plus
schedule_impact_days (app/services/change_order.py). Create at money.py:717; the
service change_order.create (change_order.py:62) sets lump-sum defaults, then
replace_lines (:30) recalcs cost + price via per-job/per-category markup.
Only an approved CO touches costing/schedule. change_order.on_approved
(change_order.py:212) is the single idempotent entry point: it (1) pushes schedule
impact once, (2) auto-creates a draft invoice for the client price if
invoice_on_approval is set — itemized from the CO's lines, carrying each line's cost
code (_maybe_bill, :252), and (3) spawns an "execute the work" task. In the engine,
approved-CO client price adds to contract_value (reporting.py:59) and builder cost
adds to projected_cost — except where the cost is already carried by a non-draft PO,
which is skipped to avoid double-counting (reporting.py:112-114, :271). A lump-sum CO
lands on the "(Uncoded)" row (reporting.py:280-283).
Progress billing closes the loop against the schedule:
PaymentMilestone draws are bound to a schedule item or phase at conversion. A drawinvoices.trigger_met (app/services/invoices.py:126)money.py:1292) creates a draft Invoice, links it, sets theinvoiced. Invoices can also come from an Estimate (money.py:379,cost_code + category) or a CO (above).money.py:579) — mints a Stripe checkout link (🟡, if configured), markssent, emails a branded invoice, and requests an e-signature (🟡 Documenso).invoices.mark_invoice_paid (invoices.py:95) flips paid, cascadesPaymentMilestone → paid, fires a "💰 Payment received" notification,money.py:667) pushes to QuickBooks inline + requests a Client review. Publicinvoice_portal.py:107).Invoice amount is the final Client price — no markup talk. Numbers are
INV-0001 per org (invoices.py:205).
cashflow.forecast (app/services/cashflow.py:46) is the org-wide money calendar:
it aggregates pending draws across live Jobs into a forward monthly timeline. Each
draw's projected date derives from its schedule trigger (projected_date, :29), and
the forecast refreshes each draw's stored due_date in place so reports stay honest
as the schedule slips (it reuses the baseline slippage, :60-68). It surfaces three
totals: ready to bill now, upcoming, and outstanding (invoiced, awaiting
payment). Per-Job cashflow (billed-vs-collected by month) and AR aging live in
cost_center.py:157 (_cashflow) and :170 (_ar_aging).
A ScheduleBaseline freezes each task's start/finish (baseline.capture,
app/services/baseline.py:42; auto-captured first time a Job has a dated schedule,
ensure_baseline :69). baseline.compare (:80) measures slip_days of the live
schedule vs the frozen plan — which the cashflow forecast folds into each Job's draw
timing. It's the schedule counterpart to profit fade: fade is margin drift, slip is
schedule drift.
reporting.job_costingreporting.job_costing(job, prefs) (app/services/reporting.py:49) is the single source
of truth. It reconciles:
contract_value = base_contract (Σ Estimate.total or manual override) + Σ approved CO client price
cost_to_date = labor (Σ shift hours × crew rate) + bills(approved/paid) + equipment
committed = Σ open POs
budget = Σ BudgetLine
planned_cost = budget (else estimate_cost)
projected_cost = max(planned_cost, cost_to_date + committed) + uncovered approved-CO cost
margin = contract_value − cost_to_date # today
planned_margin = contract_value − planned_cost # as-sold
projected_margin = contract_value − projected_cost # at-completion
fade = projected_margin% − planned_margin% # −ve = bleeding profit
invoiced, remaining_to_invoice from Invoice[]
job_costing_by_code (reporting.py:168) does the same per cost code — one row:
original budget → revised budget (budget lines + approved-CO scope) → committed → actual
→ manual ForecastAdjustment → projected → client price → projected profit → variance
(reporting.py:299-312). ForecastAdjustment (cost.py:67) is a manual signed
projected-cost nudge for one code, folded into projected (reporting.py:295) — a
forecast without a formal CO or booked bill. There's also job_costing_by_task
(reporting.py:337) rolling cost up by the schedule task a PO/bill attaches to.
Budget vs actual by category is cost_center._estimate_vs_actual (cost_center.py:107):
planned = budget lines (falling back to estimate cost basis when no budget exists),
actual = the engine's category costs, with variance and variance%.
The same engine surfaces five ways (docs/job-costing.md Stage 7): the Job Billing
tab, /portal/jobs/{id}/costing (grid + AI panel), /portal/job-costing (portfolio),
Metabase snapshots, and Ask Keystone.
QBO is fully built but config-gated — it activates only when Intuit env vars are set
(qbo.is_configured()) and the org has an active OAuth connection
(quickbooks_sync.active_connection, app/services/quickbooks_sync.py:33). Every push
is best-effort: it opens its own session and swallows/records failures so it never
breaks the triggering request (quickbooks_sync.py:11-14, _record_error :791).
| Entity | Direction | QBO type | Trigger | Ref |
|---|---|---|---|---|
| Invoice (+Payment) | push | Invoice, Payment | invoice mark-paid, inline | push_invoice :537; called money.py:667 |
| Bill | push | A/P Bill | bill mark-paid, background task | push_bill :642; called cost.py:403 |
| Payroll | push | Journal Entry | weekly payroll post | push_payroll_journal :355 |
| Timesheets | push | TimeActivity | payroll post (best-effort/slow) | push_payroll_timeactivity :448 |
| Invoice paid-state | pull | Invoice.Balance | Intuit webhook (HMAC) | handle_webhook :732, _reconcile_invoice :764 |
Cost codes ride along as QBO Classes. _ensure_class (:213) finds/creates a Class
named after each line's cost code, attached per-line or per-transaction depending on the
company's class-tracking mode (_class_mode, :197) — so job-cost reporting exists on
the QBO side. Per-category → expense-account mapping is honored on bills
(_account_for_category, :236), configured in Settings.
What does NOT sync (❌ / planned): Change Orders and Purchase Orders have no push
— there is no push_change_order/push_purchase_order (build plan R2·A3 lists these as
planned, docs/financial_loop_build_plan.md:116). QBO has no native change-order concept,
so the mapping is an open design question (see below). Note the code has moved ahead of
the build plan here: cost-codes-as-Classes and payroll JE/TimeActivity are already live,
though the plan filed them as future work.
| Capability | Status | Note |
|---|---|---|
Costing engine (job_costing, by-code, by-task) |
🟢 | No external dep; live on real data |
| Budget seed + edit, budget-vs-actual | 🟢 | Auto + manual seed from estimate |
| Cost-code library + AI suggest/mint | 🟢 | Free-text by design (see note above) |
| Purchase orders, receiving, PO↔bill matching | 🟢 | Over-billing detection live |
| Change orders → cost + contract + auto-invoice | 🟢 | Approved-only; PO double-count guarded |
| Invoices, draws, cascade-to-paid, AR aging | 🟢 | In-app end-to-end |
| Cashflow forecast + baseline slippage | 🟢 | Refreshes draw due-dates in place |
| AI bill capture (Gemini vision) | 🟡 | Config-gated; graceful manual fallback |
| Online payment (Stripe checkout) | 🟡 | Config-gated per org |
| E-signature (Documenso) on invoices/POs/COs | 🟡 | Config-gated; guarded manual override |
| QBO sync — invoice/bill/payroll/classes | 🟡 | OAuth + env-gated; best-effort |
| QBO sync — change orders / purchase orders | ❌ | Not built (planned R2·A3) |
| Lien waivers (two stores can disagree) | 🟡 | Bill.lien_waiver_status vs LienWaiver |
A homeowner accepts a $120,000 kitchen-remodel proposal (20% markup → $100,000
cost basis).
convert_proposal creates the Job, an approved Estimatetotal=$120k, cost_total=$100k → contract value $120k), a draw scheduleskip_if_exists seed$100k in BudgetLines, coded (e.g. 06-100 Rough Framing — Labor,15-100 Plumbing — Rough-in). Planned margin: $20k / 16.7%.06-100, releasedmax($100k, $0 + $18k) = still $100k; margin holds.06-110; PM approves it → actual $22.4k. A06-100.INV-0002; the Client pays by Stripe → mark_invoice_paid cascadespaid, notifies the office, and (org is QBO-connected) pushes the16-300) is approved → contract value $123.6k; 16-300'sChange order — … invoice is queued.06-110 $2,400 over its budgetmax(planned, actual+committed) + uncovered CO, and if projected margin dips below the as-sold 16.7%Every number above comes from the same reporting.job_costing call; the five surfaces
just render it differently.
cost_code is a soft String with no FK to cost_codes.String(32) vs String(64) width mismatch across_reconcile_invoice). Which system is authoritative on adocs/financial_loop_build_plan.md:164). Until decided, approved COs move contractBudgetLines and ForecastAdjustments, orhourly_ratelabor_rate). Is a single default rate accurate enough forapproved/paid bills count as cost. A largeinbox (un-reviewed) backlog therefore understates real cost-to-date and inflatesBill.lien_waiver_status (typed-name stub) and theLienWaiver model are two disconnected stores that can disagreeclosed. Is there areceived/billed POs