Quote lifecycle & pricing
Audience: developers & AI agents · Scope: quote statuses, deletion, and the pricing/totals chain · Last reviewed: 2026-07-20
TL;DR — A quote has four independent status axes and nothing unifies them: expiry, deletion, the
commercial outcome, and out-of-territory. Pricing is computed on read from the positions — no total is ever
stored on the quote. Two long-standing bugs are documented here rather than fixed; check
known issues (docs/_known-issues.md) before assuming either is news.
The four status axes
| Axis | Column(s) | Values | Driven by |
|---|---|---|---|
| Expiry | status_name, expires_on | CURRENT / EXPIRED | a cron, plus recalculation on every edit |
| Deletion | deleted, deleted_on | '0' / '1' | user action (soft) |
| Commercial outcome | pipedrive_status, pipedrive_agent_feedback_status | open / won / lost | agent, follow-up page, Pipedrive webhook |
| Out of territory | oot_status | not_oot / registered / processing / approved | quote maker / OOT form |
They are genuinely independent: a quote can be EXPIRED, deleted='0', pipedrive_status='won' and
oot_status='approved' at once. There is no single "state" field, and no state machine.
Expiry
expires_on = modified_on + <days_before_quote_expiry setting>, recomputed on every mutation (adding a
position, editing, copying, deleting). A cron then flips status_name in two blanket updates — anything past
its expiry becomes EXPIRED, anything not becomes CURRENT.
⚠️
getQuoteStatusByExpiryDate()can never returnEXPIRED. It compares dates withDateInterval::format('%a'), which returns the absolute day count — the sign requires%R— so itsif ($diff < 0)branch is dead and it always answersCURRENT. Consequences: a quote loaded with an emptystatus_namegets mislabelled, and the date-fix-up routine can flip a genuinely expired quote back toCURRENT. Only the cron produces correctEXPIREDvalues. Recorded in known issues (docs/_known-issues.md).
Deletion is soft — and inconsistently filtered
Deleting a quote sets deleted='1' and deleted_on, cascades the flag to every position, queues the GA4
remove_from_cart events and schedules a Pipedrive update. A hard delete exists but is used only by cleanup
and test tooling — there is no UI path to it.
⚠️ Soft-delete is not filtered consistently. The list queries (a user's quotes, a quote maker's quotes, an opportunity's quotes, the admin search) all filter
deleted='0'. But the single-record lookups — by id, by serial, and by deal id — do not. The follow-up landing page relies on the by-serial lookup and its own comment claims it handles a deleted quote; it does not. A deleted quote remains reachable and editable on the follow-up page.
Commercial outcome — "agents only suggest"
Status is derived from the chosen pipeline stage, not set directly: there is no "Won" button. A stage
flagged is_won_stage means won — the stages themselves, and how won-ness is derived from them, are owned by
quote-follow-up/pipeline-stages.md.
The guard is deliberate: the conditional updater refuses to act on a quote that is already won and
never propagates won — an agent can move a quote to open or lost, but not win it. The agent's own read
is stored separately in the pipedrive_agent_feedback_* columns from the authoritative pipedrive_* ones.
The follow-up landing page deliberately bypasses that guard and writes the deal status directly — so picking a winning stage there really does win the deal. That asymmetry is intentional; don't "fix" it.
Lost reasons come from a fixed list, where choosing Other unlocks free text — and the two land in different columns (the category on the agent-feedback field, the free text on the native one).
Pricing
Per-position, computed on read
Every position load derives a set of chosen_* values before anything else uses them:
chosen_unit_price_net = unit_price_net_override ?? unit_price_net
chosen_discount_percent = discount_percent_override ?? discount_percent
chosen_overage_percent = overage_percent_override ?? overage_percent ← an ID, not a percent
discount_rate = override, else fixed-mode ? chosen_discount_percent : user_picked_discount_percent
commission_rate = fixed-mode ? commission_percentage : user_picked_commission_percent
if any discount is non-zero → chosen_overage_percent is forced to the "No Overage" record
unit_price_subtotal = chosen_unit_price_net − volume_discount
unit_discount_net = round(unit_price_subtotal / 100 × discount_rate, 2)
discounted_unit_price_net = unit_price_subtotal − unit_discount_net
discounted_sub_total_net = discounted_unit_price_net × quantity
subtotal_commission = round(discounted_sub_total_net / 100 × commission_rate, 2)
Discounts and overages are mutually exclusive by design — any discount forces the overage back to "none".
Where the base price comes from
Setting a position's price resolves the customer group (the quote's override group, else the owner's), the branch's agent discount groups, the length of run and any appended CatRef, then prices each selection and sums them onto the position. A position with a custom selection is left at zero deliberately — custom work is quoted by hand.
Volume discount is tallied across regular positions only (alternatives excluded), per discount group, by total length — then walked down the tier levels from 10 to 1.
Setup charges are regenerated from scratch on every pricing run. Their price is currency-switched by literal and throws for any currency outside USD, CAD and GBP — worth knowing before adding a market.
Totals are computed, not stored
There is no total on the quote row. A totals object is constructed from the quote plus its positions at each of five call sites (the PDF, the customer-facing views, the quote-maker positions view, and one model method):
nonDiscountedNet = Σ chosen_unit_price_net × quantity
volumeDiscount = Σ volume_discount × quantity
discount = Σ unit_discount_net × quantity
commission = Σ subtotal_commission
subTotalVolumeDiscounted = nonDiscountedNet − volumeDiscount
discountedNet = subTotalVolumeDiscounted − discount
discountedTax = discountedNet × quote.tax_rate / 100
discountedGrs = discountedNet + discountedTax
The PDF and the customer-facing views pass regulars-only positions, so alternatives never reach a total. If a number differs between two screens, the position filter is the first thing to check.
Minor: the blended-discount-rate guard checks one denominator and divides by another, so it can still divide by zero when the volume discount equals the gross.
The updateTotals() cron
A cron recomputes every position and persists six post_calc_* columns.
⚠️ It is effectively broken. Its incremental
WHERE row_updated_on > <last run>clause is commented out and replaced with a hard-coded literal date, so it reprocesses the entire position history on every run — an unbounded job that grows forever. It still writes a "last run" system variable that nothing reads, and it copies a datetime into aDECIMALcolumn.Nothing in PHP reads the
post_calc_*columns. They exist for downstream BI only. Do not treat them as a pricing source, and do not "fix" a price by running this job.
What runs automatically
| Driver | Effect |
|---|---|
| cron — quote statuses | status_name CURRENT/EXPIRED |
| cron — quote totals | the post_calc_* snapshot (see the warning above) |
| cron — Pipedrive outbound | drains quotes flagged for a deal update, capped at 3 attempts |
| cron — cold-quote prompts | stamps the last-prompt date |
| cron — nudge / stage-sync | the follow-up sheets |
| inbound webhook | writes pipedrive_* with re-scheduling suppressed, to avoid echo loops |
| user | everything else |
See scheduled-jobs.md for the scripts themselves.
Related docs
- Overview · Data model
- Quote Follow-Up — the agent-feedback path
- Pipedrive — the deal mirror