Status: 🟡 Live in-app; online payment is config-gated. Everything that
doesn't need a card processor — drafting invoices, progress-billing draws,
the schedule-triggered "draw ready" fan-out, the themed Client pay page,
manual mark-paid, AR aging, WIP/holdback, and the inline QuickBooks push —
is 🟢 live against real data. Stripe Checkout (ACH + cards) is ⚪ dormant
until an org has a Stripe secret key; without one the invoice still sends and
shows on the portal, it just has no Pay button (app/routers/money.py:614).
One honest hole: the Client invoice portal has no auth gate — the token
alone renders financials and the Pay button. See
known-risks C4.
Audience: engineers + finance-literate operators. This is the deep reference
for how a Job gets billed and paid. Companion reading:
Job costing (where costs and contract value live),
Reports & Finance (AR/WIP/holdback dashboards), and
Integrations (Stripe + QuickBooks wiring).
Legend: 🟢 live · 🟡 WIP / config-gated · ⚪ dormant / stub · ❌ not built.
House terms: a unit of work is a Job (jobs); the person you bill is a
Client (clients). Never "Project", never "Customer".
All financials are hub-owned — OpenProject's cost API is a read-only stub, so
GoBuild owns estimates, invoices, draws and change orders outright
(app/models/money.py:1).
| Object | Model | What it is |
|---|---|---|
| Estimate | Estimate (app/models/money.py:36) |
Cost-based, marked-up quote. Lines carry both unit_cost and resolved unit_price. |
| Invoice | Invoice (app/models/money.py:165) |
A bill to the Client. amount_cents, currency, a JSON lines snapshot, a public_token for the no-login page, a stripe_url. |
| PaymentMilestone | PaymentMilestone (app/models/money.py:211) |
A draw / deposit on the Job's payment schedule. Bills a slice of the contract; generates an Invoice when due. |
| ChangeOrder | ChangeOrder (app/models/money.py:243) |
Scope amendment; can auto-spawn an invoice on approval. See Change Orders. |
Invoice status is the enum InvoiceStatus (draft → sent → paid, plus void).
The Invoice Hub shows a derived status via display_status() that adds
viewed (opened) and overdue (past due date) on top of the stored value
(app/services/invoices.py:27). Amounts are always final Client prices — no
markup talk on an invoice (app/services/invoices.py:1).
Invoice numbers are sequential per org: INV-0001, from a row count
(next_invoice_number, app/services/invoices.py:205).
POST /portal/estimates/{est_id}/invoiceapp/routers/money.py:379). Snapshots the estimate's marked-up lines into thelines, sets amount_cents from est.total, and flips theapproved. This is the "quote → bill" path.POST /portal/invoices/new (app/routers/money.py:983). Ajob_financial_context,app/services/invoices.py:51 — approved-estimate lines minus what's alreadyPOST /portal/jobs/{job_id}/invoices (app/routers/money.py:554).POST /portal/milestones/{mid}/invoiceapp/routers/money.py:1292). Covered below.Every draft opens in the themed invoice builder (app/routers/money.py:1027)
where lines, a cover note, a due date, and a visual theme are edited. Saving lines
always re-derives the header via lines_total_cents so the headline amount can't
drift from the line items (app/services/invoices.py:43, app/routers/money.py:1072).
A payment schedule is a list of PaymentMilestone rows on the Job. Each is a
deposit or draw that bills a percent or fixed slice of the contract. Two ways to
build one:
POST /portal/jobs/{job_id}/schedule/ai proposes milestonesapp/routers/money.py:1184). It replaces anyPOST /portal/jobs/{job_id}/schedule/milestone adds one row with aapp/routers/money.py:1241).A draw's trigger decodes to a schedule item or a phase (_parse_trigger,
app/routers/money.py:1219):
task:<uuid> → releases when that one schedule item hits 100% done.phase:<name> → releases when every task in that phase is 100%.trigger_met() is the gate (app/services/invoices.py:126), reading
ScheduleItem.percentage_done >= 100. When a trigger flips true,
notify_ready_draws() fires a deduped "Draw ready to invoice" notification and
emails/pushes the office (app/services/invoices.py:165). The office then clicks
Invoice on the draw: POST /portal/milestones/{mid}/invoice generates a
one-line Invoice ("<milestone> — <job>", unit ls), marks the milestone
invoiced, links invoice_id, and opens the builder (app/routers/money.py:1292).
🟡 Draws are surfaced, not auto-invoiced. Reaching 100% raises a
notification; a human still creates and sends the invoice. There is no cron that
auto-bills a ready draw. (A CO withinvoice_on_approvalis the one auto-spawn
path — see Change Orders.)
There is no separate deposit object — a deposit is simply the first milestone
in the schedule (the default "Deposit" at 25%), usually with no trigger so it
can be billed immediately (app/routers/money.py:1204).
POST /portal/invoices/{inv_id}/send (app/routers/money.py:579) does the fan-out:
public_token (themed page) and a client_token on the Job if absent.amount_cents from the lines.stripe_url + stripe_session_id. OnStripeError (no key yet) it silently continues — the invoice sends with noapp/routers/money.py:600).app/routers/money.py:620).esign_service.request_invoice_signature, best-effort).sent.The public page is GET /invoice/{token} (app/routers/invoice_portal.py:34) —
a branded, themed, no-login invoice mirroring the proposal portal. Opening it
records a view (record_view, app/services/invoices.py:20) which drives the
"Viewed" state in the Hub.
❌ No auth gate — known-risks C4. Unlike the client portal,
invoice_portal.pyhas zeroportal_otpreferences: the token alone renders
the invoice and the Pay button. Anyone with a forwarded link can view
financials and pay. This is a hard-fail item before a real paying tenant is
onboarded.
GET /invoice/{token}/pay?method=… (app/routers/invoice_portal.py:107) creates a
fresh Checkout session for the chosen method and redirects to Stripe. Method
maps to Stripe payment-method types (app/routers/invoice_portal.py:103):
| Friendly | Stripe types | Note |
|---|---|---|
bank |
us_bank_account |
ACH bank transfer — the default & preferred (far lower fees on large construction invoices). |
card |
card |
|
any |
us_bank_account, card |
Both offered. |
Checkout itself is a thin REST call — inline price_data, no pre-made Price,
returns the hosted URL (create_checkout, app/integrations/payments/stripe_client.py:35).
The secret key is resolved per-org through the credential wall-off
(preferences.stripe_config, app/services/preferences.py:214): encrypted per-org
store → legacy Settings → .env, where the .env fallback applies only to
siloed/single-tenant orgs. A pooled org with no key of its own gets nothing — its
checkouts can never fall through to a shared Stripe account.
🟡 Pay-button visibility uses the global env key, not the org's. The page
setsonline_pay = bool(get_settings().stripe_secret_key)
(app/routers/invoice_portal.py:78), while the pay endpoint uses the per-org
stripe_configsecret. A pooled org with its own key but no global.envkey
could render without a visible Pay button even though checkout would work.
Two paths converge on one idempotent function, mark_invoice_paid
(app/services/invoices.py:95), which flips sent → paid, sets paid_at,
cascades the linked draw to paid, and fires a deduped "💰 Payment
received" notification.
1. Stripe (authoritative) — the webhook. POST /webhooks/stripe
(app/routers/webhooks.py:76) verifies the signature (verify_webhook, HMAC-SHA256
with a replay window, stripe_client.py:88), then on
checkout.session.completed (card, instant) or
checkout.session.async_payment_succeeded (ACH, settles days later) marks the
matching invoice paid. Verification is org-scoped: it tries the global .env
secret first, then each org's own webhook secret, and only marks that org's
invoice — so one tenant can't forge an event to pay another's invoice in a pooled
DB (app/routers/webhooks.py:88).
Belt-and-suspenders: the success redirect (?paid=1) re-fetches the session and
only marks paid if Stripe says payment_status == "paid" — cards confirm instantly;
ACH is left to the webhook (app/routers/invoice_portal.py:48).
2. Manual — mark-paid. POST /portal/invoices/{inv_id}/mark-paid
(app/routers/money.py:650) for cheque/e-transfer/cash. It runs an advisory
lien-waiver check (warn + allow override), marks paid, pushes to QBO inline (next
section), and — on the final payment — asks the Client for a review.
When an org has an active QBO connection, mark_invoice_paid (the router)
pushes synchronously so it can report the outcome to the user in-band
(app/routers/money.py:665); no-op otherwise. The push itself is
quickbooks_sync.push_invoice (app/services/quickbooks_sync.py:537):
SyncToken (QBOPayment linked to the invoiceapp/services/quickbooks_sync.py:613).The router surfaces the result (synced | failed | skipped, plus the QBO doc
number) as query params so the Command Center can show a "synced to QuickBooks"
banner (app/routers/money.py:681). Failures never break the mark-paid request.
🟢 Invoice and its payment round-trip to QBO. Note the reverse also runs:
a nightly reconcile pulls invoices paid in QBO back onto the hub
(app/routers/webhooks.py:59). Change-order revenue only reaches QBO via the
resulting invoice — there is nopush_change_order.
ar_aging (app/services/reporting.py:656) buckets unpaid (sent) invoices by
days past due into Current / 1–30 / 31–60 / 61–90 / 90+. Due date falls back to
sent_at then today. Rows sort worst-overdue-first; totals split out overdue
(everything not current). This feeds the AR card on
Reports & Finance.
Note the bucketing keys only on status == sent, so a draft invoice is invisible
to AR and a paid/void one drops out.
Holdback is not a field on the invoice — it's computed in the WIP/holdback
reports from the Job's billings. holdback_policy resolves the percentage and lien
period: an explicit Settings override, else the province/state statutory default
(Ontario Construction Act = 10% for 60 days; US "Retainage") — the label itself is
region-aware (app/services/preferences.py:60, :64).
wip_one computes per-Job (app/services/reporting.py:440):
holdback_retained = billed × holdback_pct — a per-Job job.holdback_pctapp/services/reporting.py:481).holdback_receivable — the retained amount still held (0 once released).collectible_now = billed − holdback_retained.substantial_completion_date + lien_period_days; released onceapp/services/reporting.py:487).holdback_report (app/services/reporting.py:1765) rolls this org-wide (retained /
releasable / releasing-in-30-days), and there's an AIA G702/G703 progress-billing
export that applies the same retainage % (app/services/reporting.py:2500+).
🟡 Holdback is reporting-only — it tells you what to hold and when it
releases. It does not automatically reduce an invoice'samount_centsor
spawn a separate holdback-release invoice. Whoever cuts the invoice bills the
collectible-now figure by hand.
GET /portal/cashflow (app/routers/money.py:76) renders a money calendar —
projected billing across live Jobs driven by the schedule, with a cumulative draw
curve and KPIs for upcoming / ready-now / invoiced-awaiting-payment / draws not
yet invoiced (app/routers/money.py:106). Data comes from services/cashflow.py.
| Action | Route | Code |
|---|---|---|
| Cash-flow calendar | GET /portal/cashflow |
money.py:76 |
| Estimate → invoice | POST /portal/estimates/{id}/invoice |
money.py:379 |
| AI invoice wizard | POST /portal/invoices/new |
money.py:983 |
| Quick invoice | POST /portal/jobs/{id}/invoices |
money.py:554 |
| Invoice builder | GET /portal/invoices/{id} |
money.py:1027 |
| Send (Stripe + email + e-sign) | POST /portal/invoices/{id}/send |
money.py:579 |
| Mark paid (+ QBO push) | POST /portal/invoices/{id}/mark-paid |
money.py:650 |
| AI payment schedule | POST /portal/jobs/{id}/schedule/ai |
money.py:1184 |
| Add draw / trigger | POST /portal/jobs/{id}/schedule/milestone |
money.py:1241 |
| Invoice a draw | POST /portal/milestones/{id}/invoice |
money.py:1292 |
| Public Client page | GET /invoice/{token} |
invoice_portal.py:34 |
| Stripe pay (ACH default) | GET /invoice/{token}/pay |
invoice_portal.py:107 |
| Stripe webhook | POST /webhooks/stripe |
webhooks.py:76 |
These are unresolved product/ops decisions, not code bugs — flagged for finance +
product to settle.
job.holdback_pct and Settings can override per-Job. Is_US_HOLDBACK_DEFAULT?us_bank_account (US ACH) is the default and