Status: 🟢 Built & wired end-to-end. · Audience: GoBuild staff (eng, sales, ops) — internal.
A GoBuild contractor turns a lead into a priced, branded, e-signable Proposal; when the homeowner accepts, it converts (once) into a live Job + an approved Estimate, materializes the schedule and payment draws, and seeds the job budget. This is the "land" half of the sales loop.
Status badges: 🟢 Live · 🟡 WIP / partial · ⚪ Dormant / not modeled · ❌ Dead-end
House terms: a Client is the contractor org running on GoBuild (the tenant); a Job is one of their projects. On the quote itself the two parties are the contractor (tenant staff writing the quote) and the homeowner (the person receiving it). "Estimate" and "Proposal" are two distinct records — read §1 before anything else.
Cross-links: Sales Command Center · AI Estimator · Job Costing · Documents & E-sign
The single most important thing to understand: "estimate" means two different tables, and they live on opposite sides of the sale.
| Proposal | Estimate | |
|---|---|---|
| Model | Proposal — app/models/sales.py:147 |
Estimate / EstimateLine — app/models/money.py:36 / :117 |
| Owns | a Lead (pre-sale) | a Job (post-sale) |
| Lines | JSON snapshot on the row (lines — app/models/sales.py:181) |
real rows with cost basis (EstimateLine) |
| Markup | one flat markup_pct applied at the total (:170, :230) |
per-line → per-category → estimate-default resolution (app/models/money.py:149) |
| Cost basis | none — line prices are the client price | yes — unit_cost (your cost) vs unit_price (client) (:126, :127) |
| Client-facing | yes — the branded magazine + e-sign | no — internal only |
| Purpose | win the job | plan the money once won |
The Proposal is the quote builder the sales team lives in. The Estimate is the cost-based costing artifact — mostly created by conversion, but also buildable directly on a Job (§7). They are bridged in both directions: Estimate → Proposal (estimate_to_proposal, app/routers/money.py:138) and Proposal → Estimate (conversion, §6).
⚠️ Cost basis lives in different places. A Proposal line's
unit_priceis the client price (pre-markup at the line, markup added at the total). An EstimateLine'sunit_priceis the client price with markup already baked into each line (reprice,app/models/money.py:160), andunit_costis your true cost. When a Proposal converts, its line price is carried as the estimate'sunit_cost— see §6 for why.
There are four front doors, all landing in the same builder (/portal/proposals/{id}, app/routers/sales.py:1110):
POST /leads/{id}/proposals → create_proposal (app/routers/sales.py:933). Bumps the lead to estimating.POST /proposals/new → new_proposal_standalone (:1002). A proposal always needs a client, so a fresh one spawns an estimating lead inversely (or attaches an existing directory Person).POST /proposals/from-plan (:1042). Express lane: attaches a hidden draft "estimate-workspace" Job to host the plan + AI takeoff, drops into the plan viewer in proposal mode. The workspace job only surfaces if the client accepts (it's promoted then — §6).build_quote_from_photos, app/services/sales_estimator.py:188).Every path runs through _build_proposal (app/routers/sales.py:863), which:
default_markup_pct from preferences.ai_assistant.draft_full_proposal), recording the draft to AI feedback.catalog.resolve_lines, app/routers/sales.py:928) — lines not in the price book keep their own price (graceful fallback for one-offs).Proposal lines are edited inline in the "Your investment" section:
POST /proposals/{id}/lines (app/routers/sales.py:1357): description, quantity, unit, unit_price, optional area.POST /proposals/{id}/lines/{idx} (:1385). Edits merge so a line keeps its hidden area / rough-measurement dims / cost_code even though the builder only exposes the visible columns.🟡 Cost codes & categories are not first-class on the Proposal. A proposal line can carry a
cost_code(from a template, catalog match, or AI draft) and it's carried forward on conversion (app/services/conversion.py:107), but the proposal builder UI has no field to set the cost code or a cost category by hand — those become editable only once it's an Estimate. Categories (material/labor/subcontractor/equipment/permit/other,app/models/base.py:123) don't exist on the proposal side at all.
Flat markup only, on the whole quote:
subtotal = Σ(qty × unit_price) # app/models/sales.py:224
total = subtotal × (1 + markup_pct/100) # :228
Set via POST /proposals/{id} → update_proposal (app/routers/sales.py:1229). There is no per-line or per-category markup, and no target-margin solve, on the Proposal — those are Estimate-only features (§7).
Multi-area quotes (bathroom + basement in one proposal) are supported: POST /proposals/{id}/area/add (:1470) appends lines + a per-area schedule phase, optionally seeded from a template and priced from the catalog. The builder header shows a per-area SF/LF/$ rollup (area_rollup, :1180).
A Proposal renders as a multi-section magazine, not a flat PDF. Sections live on Proposal.sections as an ordered, per-proposal editable list of {key, title, body, visible} (app/models/sales.py:166; scaffolding in app/services/proposals.py).
Default running order (SECTION_LIBRARY, app/services/proposals.py:17): cover → letter ("A note for you") → about → scope → investment → timeline → terms → warranty → next_steps.
cover and investment are structural (prose=False) — rendered from the brand kit and the line-item table/total respectively.Editing (all in app/routers/sales.py):
POST .../sections/{key} (:1250); edit body/title, toggle visible.POST .../sections/{key}/move (:1272).POST .../sections/{key}/rewrite (:1290).POST .../ai-draft (:1319).The magazine restyles per contractor. Six visual themes (THEMES, app/services/proposals.py:104): editorial (default), noir, bold, blueprint, sand, slate. resolve_theme (:116) resolves CSS tokens against the org's brand kit — accent = brand_color, ink = brand_color_dark — so every theme adapts to the contractor's colors and logo (branding, :157). Set via POST /proposals/{id}/theme (app/routers/sales.py:1213).
Walkthrough photos and 360° panoramas attach to the proposal (photos, pano_document_ids) and stream through a token-scoped media route (app/routers/proposal_portal.py:40) — no login, but only docs belonging to this proposal's set.
Send — POST /proposals/{id}/send (app/routers/sales.py:1610):
draft → sent, stamps sent_at, clears the needs_measurements field-draft flag.outbound_email) and logs a lead activity.Public view — GET /proposal/{token} → proposal_view (app/routers/proposal_portal.py:59). No login. Renders the themed magazine, the schedule with projected dates (schedule_with_dates), and payment amounts computed live from total × percent. A ?preview=1 view (contractor previewing) suppresses accept/decline and is not logged.
Open tracking — a real client view records a ProposalEvent(kind="viewed") and bumps view_count / first_viewed_at (record_event, app/services/proposals.py:167). The proposals dashboard (app/routers/sales.py:958) derives a viewed display status that sits between sent and accepted. ProposalEvent (app/models/sales.py:233) is the audit trail: sent | viewed | accepted | declined, each with IP + user-agent for a defensible record.
Acceptance is a real e-signature, not just a typed name — via Documenso.
request_proposal_signature, app/services/esign_service.py:70), so it works no matter how the client got the link (forwarded, copied, etc.). Guarded by a back-off so a wedged Documenso isn't hammered once per pageload (_MAX_AUTO_ATTEMPTS).send_email=False, app/services/esign_service.py:51) — no second Documenso email.GET /proposal/{token}/sign-status (app/routers/proposal_portal.py:105) while signing; sync (app/services/esign_service.py:365) pulls Documenso status and, on COMPLETED, _on_signed (:457) calls conversion (§6) and flips the proposal to accepted.Graceful fallback 🟡 — if Documenso is not configured, there's no recipient email, or it repeatedly fails, the page falls back to typed-name acceptance: POST /proposal/{token}/accept (app/routers/proposal_portal.py:124) captures the typed signature + IP + user-agent and converts directly. Same conversion either way.
Decline — POST /proposal/{token}/decline (:149): status → declined, and the originating lead is marked lost ("Proposal declined") unless already won.
convert_proposal (app/services/conversion.py:27) is the "land" loop. It's called from two places — the typed-name accept route and the Documenso _on_signed hook — so it is deliberately idempotent: both the public route (app/routers/proposal_portal.py:133) and _on_signed (app/services/esign_service.py:467) guard on status != accepted, and conversion only runs once.
What it materializes, in one transaction:
Person so the homeowner threads into the Messages inbox (app/services/conversion.py:34).:55), avoiding a duplicate. Copies the lead's contacts.:87). ⭐ The cost-basis bridge: a proposal line's client price is carried as the estimate line's unit_cost (cost basis), and unit_price is set to cost × (1 + markup) so the client-facing totals still match (:103). Cost codes carry forward.budgeting.seed_from_estimate, guarded skip_if_exists, app/services/conversion.py:114).ScheduleItems with dates + dependencies (:128). OpenProject push is deferred to a background task so the homeowner's click stays instant.PaymentMilestones, with ungated draws auto-positioned onto schedule phases (deposit at signing, final draw on the last phase) (:165).won, linked to the job, activity logged. A branded welcome email goes to the client.The AI scheduler and OpenProject are intentionally not called inline in conversion (
app/services/conversion.py:9) — it runs on a homeowner's public click and must be fast and dependency-free.
Beyond conversion, an Estimate can be built directly on a Job — this is the real cost-based costing tool (app/routers/money.py):
POST /jobs/{id}/estimates (:207); optionally AI-drafts lines from a scope, using the contractor's preset markup (not the AI's guess) and per-job/per-category markup (:242).unit_cost, per-line markup_pct, category, cost_code, vendor_id, section, optional (:266, :316).POST /estimate-lines/{id}/cost-code (:347).POST /estimates/{id}/target-margin (:413): back-solves the default markup to hit a target margin (markup = margin / (1 − margin)) then reprices.:433), or an invoice (:379).The math (app/models/money.py:57): subtotal is the marked-up client total; cost_total is your cost; profit = total − cost_total; margin_pct = profit / total. Optional lines are excluded from totals and shown separately. by_category() (:81) and grouped_lines() (:103) drive the internal rollups. Markup resolution per line: explicit override → job per-category override → estimate default (effective_markup, :149).
Estimate cost basis feeds planned margin. The budget seeded from the estimate uses each line's cost_amount (budget = cost, app/services/budgeting.py:41). Contract value comes from the marked-up estimate/proposal total. Job Costing then reconciles that planned margin against committed and actual cost — so whatever cost basis you set at estimate time is the anchor the entire financial loop measures against.
Both travel with the proposal and are agreed at signature:
payment_schedule JSON (app/models/sales.py:195). Edit via POST /proposals/{id}/payments (app/routers/sales.py:1560) or AI-draft (draft_payments, app/services/sales_estimator.py:99). A draw can be progress-billing-gated to a schedule phase or task by name (no ids exist pre-acceptance; they're resolved to real ids on conversion — app/services/conversion.py:174).schedule JSON (:192), edited/AI-drafted/template-loaded (app/routers/sales.py:1430+), shown to the homeowner as a kanban-by-phase timeline.There is no proposal versioning or revision history. Editing a sent proposal edits it in place — re-sending just updates sent_at. The only history is the append-only ProposalEvent audit trail (§4). A generic revisions service exists (app/services/revisions.py) but is not wired to Proposals or Estimates (the entity_type="proposal" strings in the routers feed AI feedback, not EntityRevision). There's also no "duplicate proposal" action. If a client asks for a v2, today you edit the same record and lose the prior scope/pricing.
| Concept | Enum / source | Values |
|---|---|---|
| Proposal status | ProposalStatus, app/models/base.py:183 |
draft → sent → accepted / declined (+ derived viewed) |
| Estimate status | EstimateStatus, app/models/base.py:102 |
draft → sent → approved / declined |
| Proposal event | ProposalEvent.kind, app/models/sales.py:245 |
sent \| viewed \| accepted \| declined |
| Cost category | CostCategory, app/models/base.py:123 |
material \| labor \| subcontractor \| equipment \| permit \| other |
status != accepted guard or you'll double-create Jobs/Estimates.lines/sections/schedule are plain JSON — mutate a copy and reassign (or flag_modified), or SQLAlchemy won't detect the change (see the comments at app/routers/sales.py:1250, :1502).unit_cost, which means the "cost basis" recorded on a won job is really the pre-markup client price of the proposal line, not a true supplier cost — fine when the proposal was catalog/AI-priced, but worth knowing when you read margins on converted jobs.estimate_to_proposal sets markup_pct=0 (app/routers/money.py:178) because estimate lines already carry the marked-up client price — don't let a later edit re-apply markup on top.