Status: π’ Live, in-app, no external dependency. The cash-flow forecast
(cashflow.forecast), the WIP schedule (reporting.wip_report), forecast
adjustments, and the projected-margin / profit-fade engine all run against real
tenant data with zero third-party calls. They read the same tables the rest of
the money model writes. The one honest asterisk is input completeness, not
plumbing: un-reviewed inbox bills sit outside the actuals, so cost reads low and
margin reads high until someone approves them (see The honest caveat).
Audience: engineers + finance-literate operators. This is the deep reference for
how GoBuild turns a Job's schedule, budget, POs, bills, and draws into a
forward-looking money picture. Companions: Job costing (the
underlying rollup) and Reports & analytics (where WIP
and cash flow surface as reports).
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".
Forecasting in GoBuild splits cleanly along the timeline:
app/services/cashflow.py):reporting.job_costing, app/services/reporting.py:49), fed by committedThe bridge between them is WIP (reporting.wip_report,
app/services/reporting.py:520): it reconciles what you've earned (contract Γ
% complete) against what you've billed, exposing over/under-billing β the single
biggest driver of whether a profitable Job still bleeds cash.
The forecast is inflow-only and driven entirely by the schedule. Every
PaymentMilestone (a "draw") is tied to a schedule trigger β a specific
ScheduleItem or a whole phase β and bills when that work finishes. So a draw's
projected date is the linked schedule date, and it slips automatically as the
schedule slips (app/services/cashflow.py:1-8).
projected_date(m, items) (cashflow.py:29-37) resolves in priority order:
trigger_schedule_item_id β that item's due_date (fall back to the draw's owndue_date for manual draws).trigger_phase β the latest due_date across all items in that phase_phase_end, cashflow.py:24-26).due_date.forecast(db, org_id) (cashflow.py:46) then, for every live Job (active /
on_hold):
due_date in place to the live projection for triggeredcashflow.py:87-89, :100). This is a deliberate write during a read."%B %Y", or "Unscheduled" when undated),cashflow.py:102-111).cashflow.py:60-68).The dict (cashflow.py:113-119) carries the whole calendar plus headline totals:
| Field | Meaning |
|---|---|
months[] |
month buckets, each {label, total, count, entries[]} |
upcoming_total |
Ξ£ of all pending (not-yet-invoiced) draws |
ready_total / ready_n |
draws whose trigger is met right now (inv.trigger_met) β billable today |
outstanding_total / outstanding_n |
already invoiced, awaiting Client payment |
max_month |
tallest month bar (for the chart y-scale) |
Status of draws (cashflow.py:78-93): paid draws are skipped; invoiced draws
roll into outstanding_*; everything else is upcoming, split further into ready to
bill vs future.
/portal/cashflow (app/routers/money.py:78-103) β renderscashflow.html with a client-side cumulative curve built from flat dated draws, plusfinance_readiness(..., "cashflow") checklist./portal/cashflow/insights (money.py:106) β an async Keystone AI readapp/services/command_center.py:297) β forecast()Scope note: the cash-flow forecast today models inflows only (draws β
invoices β payment). There is no symmetric outflow calendar β bills, POs, and
payroll are not projected onto future dates here. The "outflow" side of forecasting
lives in the projected-cost math below (a total-at-completion, not a dated
schedule of payments). See open questions.
Cost accrues from three fact streams, reconciled by job_costing
(reporting.py:68-114):
Shift hours Γ the crew's rate (or org default)reporting.py:68-80).approved/paid supplier invoices, bucketed byBill.cost_breakdown (per-line when itemized)reporting.py:82-95, _ACTUAL_BILL_STATUSES :40). inbox bills are excluded βΞ£ p.amount for p in pos if p.is_openreporting.py:105). is_open = status in PO_COMMITTED_STATUSESapp/models/cost.py:200-202) β committed but not yet billed cost.Equipment assignments add to other_cost (reporting.py:96-101).
Two guards keep committed and change-order cost from being counted twice:
is_open.co_cost β unless that CO has been_covered_by_po (reporting.py:112-114) excludes those; a still-draft PO doesThis is the number contractors live by. From reporting.py:124-165:
margin = contract_value - total_cost # cost-to-date only
planned_cost = budget or estimate_cost or 0.0 # the plan
planned_margin = contract_value - planned_cost
projected_cost = max(planned_cost, total_cost + committed) + co_cost # at completion
projected_margin = contract_value - projected_cost
fade = projected_margin_pct - planned_margin_pct # βve = fading
Read the projected_cost line carefully β it's the heart of forecasting:
max(planned_cost, total_cost + committed) β a Job is projected to cost at least+ co_cost β approved change-order builder cost that isn't already carried by aProfit fade (reporting.py:163) is the delta between planned margin % and
projected margin %. Negative = the Job is eating into the margin you sold. This single
figure is the early-warning light for a Job going underwater.
job_costing_by_code (reporting.py:168) computes the same shape per cost code, so
you see fade line by line:
projected = max(revised_cost, actual + committed) + forecast # reporting.py:295
to_complete = max(projected - actual, 0.0) # reporting.py:296
variance = projected - revised_cost # +ve = over budget (:311)
Here revised_cost = budget (or original cost) + approved CO scope for that code
(reporting.py:292-293), and forecast is the manual adjustment described next.
ForecastAdjustment (app/models/cost.py:67-85) is a builder's manual projected-cost
override for one cost code on one Job β a signed amount_cents (Β± over/under) with
an optional note. It mirrors Buildertrend's "projected cost adjustment": it lets a PM
forecast an expected over/under without a formal change order or a booked bill.
forecast columnreporting.py:229-231), where it folds straight into projected (:295).π‘ Reach caveat: forecast adjustments feed the per-code grid
(job_costing_by_code) but are not added into the top-leveljob_costing
projected_cost(reporting.py:131uses onlyplanned/actual/committed/co_cost).
So the roll-up margin and the code-grid projection can diverge when adjustments exist.
See open questions.
Two independent "vs plan" comparisons run in GoBuild β keep them distinct:
app/services/baseline.py freezes each task's start/finish at a point in time (e.g.
schedule approval) as a ScheduleBaseline snapshot, then measures slippage of the live
schedule against it (baseline.py:80-114). compare() returns slip_days (current
finish β baseline finish) plus the biggest individual task moves. A baseline is
auto-captured the first time a Job has a dated schedule (ensure_baseline,
baseline.py:69-77), so every Job has something to measure against. The cash-flow
forecast reuses this exact slip_days per draw (cashflow.py:64-66).
The budget is the cost plan, seeded from the accepted estimate: each EstimateLine
becomes a BudgetLine where budget = cost (app/services/budgeting.py:21-44,
seed_from_estimate). estimate_vs_actual (reporting.py:607) then reports planned
(budget) vs actual by category β "where am I bleeding", variance +ve = over budget. This
budget is exactly the planned_cost anchor in the margin math above.
The Work-in-Progress schedule (wip_report, reporting.py:520; per-row math in
wip_one, reporting.py:440) is the report banks, bonders, and CPAs ask for. It's
where forecasting meets reality:
eac = projected_cost # estimated cost at completion (reporting.py:454)
pct = min(cost_to_date / eac, 1.0) # cost-to-cost % complete (:460)
earned = contract * pct # revenue you've genuinely earned (:472)
over = billed - earned # +ve over-billed, βve UNDER-billed (:473)
percent_complete_override (reporting.py:456-461).over) is the cash killer: you've done the work butwip_reportreporting.py:563) so the jobs eating your cashreporting.py:480-516): the owner retains holdback_pct ofcollectible_nowreporting.py:543-559): a CPI (earned_hrs / actual_hrs) perestimated_hours vs clocked hours.A Job with no contract value can't be over/under-billed (any billing would read 100%
over) β those are flagged needs_contract instead of inventing a variance
(reporting.py:465-470).
Because eac = projected_cost, every input problem in the cost forecast flows
straight into WIP's % complete and earned revenue. That is why the caveat matters.
Un-reviewed inbox bills understate cost and inflate margin. π‘ (data-completeness,
not a bug)
A freshly captured Bill lands in status inbox (app/models/cost.py:10-11,
:328-330). Actual cost counts only approved/paid bills
(_ACTUAL_BILL_STATUSES, reporting.py:40, :84). So until someone reviews the Cost
Inbox:
total_cost reads low β margin and projected_margin read high.projected_cost reads low β WIP % complete reads low, earned reads low,The forecast is only as honest as the inbox is empty. GoBuild surfaces this directly:
finance_readiness(scope="costing") flags uncoded approved bills ("they skew the
grid", reporting.py:1391-1395), and the cashflow/WIP readiness panels
(reporting.py:1355-1424) nudge the operator toward the Cost Inbox before trusting the
numbers. There is no auto-approval β a human must review each bill, by design (an
AI-extracted amount is a suggestion, Bill.ai_extracted, cost.py:341-342).
Committed cost + inbox gating together define the forecast's blind spots: committed
(open POs) is counted eagerly and honestly, but the actual side lags reality by exactly
the size of the un-reviewed inbox.
job_costing engine these forecasts read from.| Concern | File Β· symbol |
|---|---|
| Inflow calendar | app/services/cashflow.py:46 Β· forecast |
| Draw dating / slip | app/services/cashflow.py:29 Β· projected_date |
| Cost rollup + margin/fade | app/services/reporting.py:49 Β· job_costing |
projected_cost formula |
app/services/reporting.py:131 |
| Per-code grid + forecast col | app/services/reporting.py:168, :295 |
| Forecast adjustment model | app/models/cost.py:67 Β· ForecastAdjustment |
| WIP schedule | app/services/reporting.py:520 Β· wip_report / wip_one :440 |
| Schedule baseline | app/services/baseline.py:80 Β· compare |
| Budget seeding (plan) | app/services/budgeting.py:21 Β· seed_from_estimate |
| Readiness checklists | app/services/reporting.py:1325 Β· finance_readiness |
| Money-calendar page | app/routers/money.py:78 |