Skip to main content

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

AxisColumn(s)ValuesDriven by
Expirystatus_name, expires_onCURRENT / EXPIREDa cron, plus recalculation on every edit
Deletiondeleted, deleted_on'0' / '1'user action (soft)
Commercial outcomepipedrive_status, pipedrive_agent_feedback_statusopen / won / lostagent, follow-up page, Pipedrive webhook
Out of territoryoot_statusnot_oot / registered / processing / approvedquote 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 return EXPIRED. It compares dates with DateInterval::format('%a'), which returns the absolute day count — the sign requires %R — so its if ($diff < 0) branch is dead and it always answers CURRENT. Consequences: a quote loaded with an empty status_name gets mislabelled, and the date-fix-up routine can flip a genuinely expired quote back to CURRENT. Only the cron produces correct EXPIRED values. 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 a DECIMAL column.

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

DriverEffect
cron — quote statusesstatus_name CURRENT/EXPIRED
cron — quote totalsthe post_calc_* snapshot (see the warning above)
cron — Pipedrive outbounddrains quotes flagged for a deal update, capped at 3 attempts
cron — cold-quote promptsstamps the last-prompt date
cron — nudge / stage-syncthe follow-up sheets
inbound webhookwrites pipedrive_* with re-scheduling suppressed, to avoid echo loops
usereverything else

See scheduled-jobs.md for the scripts themselves.