Quotes — the central business object
Audience: developers & AI agents · Scope: the quote object graph, identity, revisions, roles and entry points · Last reviewed: 2026-07-25
TL;DR — Almost everything on the B2B side of this site hangs off a quote: an agent builds one from configured products, it mirrors to a Pipedrive deal, it drives the follow-up nudges, its line items carry BOM data from Infor, and it can be pushed into a sales order. This hub owns the vocabulary; the data model and lifecycle & pricing spokes own the detail.
Two things to internalise before reading anything else:
- A quote has no branch column and no customer column. Both are derived from the owning user.
- A quote has no stored totals. They are computed on read, every time.
The object graph
#__configbox_users (the agent / customer)
├─ custom_2 ──────────────► branch (yes: the branch id lives in a "custom_2" column)
└─ group_id ──────────────► customer group ──► currency
#__configbox_external_user_opportunities (a grouping; quote makers only)
└── 1..n ── #__configbox_external_user_quotes
│ user_id ─────► the user the quote is FOR
│ serial ──────► shared with its sibling revisions
│
├── 1..n ── ..._user_positions (line items, CASCADE)
│ ├─ parent_position_id ──► itself (sub-positions)
│ ├── 1..n ── ..._user_selections (one per configurator answer)
│ └── 1..n ── ..._user_materials (BOM, from Infor)
├── 1..n ── ..._user_email_history
├── 1..n ── ..._user_pipedrive_history_items
└── 1..n ── ..._quote_stage_syncs (follow-up stage log)
Infor / Syteline sales orders ◄── push-only, no stored link back
| Concept | Table | Notes |
|---|---|---|
| Opportunity | #__configbox_external_user_opportunities | An optional grouping of quotes ("project"). Created only by quote makers. |
| Quote | #__configbox_external_user_quotes | The subject of these docs. |
| Position | #__configbox_external_user_positions | A line item. Can have sub-positions via parent_position_id. |
| Selection | #__configbox_external_user_selections | One row per configurator answer on a position. |
| Sales order | none | Sales orders live in Infor, read over the REST API. |
There is no local sales-order table and no link back from a quote to its order. A quote is pushed into an existing Syteline order by the quote maker, who types the order number — it is never persisted. Reading orders means asking Infor (
SLCos/SLCoItems), where objects prefixedQ-are Syteline quotes and are deliberately excluded.
Identity — the serial
A quote's real identity is its id. Its human identity is the serial, e.g. AAA1000, generated from a
single-row counter table:
division = floor(num / 9000) letters = 3-letter code from division ("AAA", "AAB", …)
digits = (num % 9000) + 1000 serial = letters . digits
The serial is not unique. All revisions of a quote deliberately share one — the UNIQUE index was dropped
to allow exactly that. Look quotes up by id internally; treat serial as a user-facing label that may match
several rows.
Claiming a serial is optimistic: getNextQuoteSerial() only takes the number if the counter still holds
the value it just read, so of two concurrent quote creations one succeeds and the other throws (surfaced to
the user as a creation error) rather than both minting the same serial.
Revisions vs alternatives
Easy to conflate; completely unrelated.
| Revision | Alternative | |
|---|---|---|
| Level | the quote | a position (line item) |
| Mechanism | a new quote row reusing the same serial | the is_alternative flag on a position |
| Means | "version 2 of this quote" | "the customer could pick this instead" |
| Effect | old rows get is_latest_revision = '0' and drop out of every list | excluded from totals, the PDF and the volume-discount tally |
Revisions: copying a quote as a revision preserves created_on (deliberately — "as per instruction from
sales"), increments revision_number, clears the latest flag on all rows sharing the serial, and copies
every non-deleted top-level position. Old revisions are not deleted; they simply stop matching the
is_latest_revision = '1' filter every list query applies.
A plain duplicate is different: it mints a new serial, resets revision_number to 1, and nulls every
pipedrive_* property so the copy doesn't fight the original for the same deal.
Toggling a position's alternative flag also flips it on all of that position's children.
Who may act on a quote
| Role | How it's decided |
|---|---|
| Agent (owner) | the user's customer group has is_agent — see customer groups |
| Quote maker | membership of the Joomla group named in the joomla_group_id_quote_makers setting. Returns false if that setting is unset. |
| Admin | com_configbox.core.manage |
The recurring guard is "not a quote maker and doesn't own this quote → reject". Ownership is
quote.user_id == currentUser.
Two pages relax this for emailed links. The follow-up landing page: an emailed magic link mints a
short-lived, HttpOnly grant cookie that is user-scoped, not quote-scoped — the agent can act on any of
their own follow-up quotes without logging in, and per-quote ownership is still checked. A non-owner sees the
same "not found" screen as a bad serial, so serials cannot be probed. See
quote-follow-up/authentication.md. The cold-quote page
(controllers/bccoldquote.php) is deliberately anonymous — its emailed links carry only the bare quote
serial (an explicit $allowAnonymous switch in display()), and its AJAX quote re-render goes through the
controller's own serial-keyed getQuoteHtml task, not bcquote's.
Every quote controller enforces its check by overriding isAuthorized() (the inherited
KenedoController default returns true for any non-admin controller — a controller that forgets the
override is public). Two details worth knowing: ConfigboxControllerBcqmquotedetails requires a quote maker
for every task except saveQuoteAsRevision, which admits any logged-in user and then enforces
owner-or-quote-maker itself; and the columns its save tasks may write are allowlisted in
ConfigboxModelBcquotes::EDITABLE_QUOTE_COLUMNS — enforced inside editQuote() and, for the revision path
(which writes via copyQuote()), in the controller. Anything not on that list — serial, deleted,
pipedrive_deal_id, … — is rejected, so add new editable screen fields there too.
Entry points
| Surface | Controller | Notes |
|---|---|---|
| My Quotes (agent) | controllers/bcmyquotes.php | The main agent surface. SEF: one path segment = the serial. Add/copy/revise/delete quotes and positions, exports (PDF, XLSX, Cahill, Oasis). |
| Quote detail fragments | controllers/bcquote.php | AJAX HTML for the quote and its positions; quote-maker-or-owner per quote. |
| Quote Maker console | controllers/bcquoteconsole.php | Search across quotes and opportunities; quote-makers only. |
| Quote Maker — details | controllers/bcqmquotedetails.php | Edit the commercial fields, save as revision, push to an Infor order. |
| Opportunities | controllers/bcopportunities.php | Quote-maker only. |
| Follow-up landing page | controllers/bcquotelandingpage.php | The no-login page — see Quote Follow-Up. |
| Backend | controllers/adminbcquotes.php and friends | All force is_latest_revision = '1'. |
PDF: rendered from the quote views into HTML, then DomPDF (letter, 300 DPI). Cached at
<store>/private/quote_pdfs/{serial}-{revision_number}.pdf; downloaded as {serial} - {name}.pdf. Also
available from the CLI.
Where other systems attach
Each of these is documented elsewhere — this is only the map.
| System | Attaches at | Doc |
|---|---|---|
| Pipedrive | ~20 pipedrive_* columns on the quote, written only through BcQuotePipedriveGateway | pipedrive/ |
| Quote Follow-Up | the agent-feedback and stage columns, plus the stage-sync log table | quote-follow-up/ |
| Infor / BOM | the position level: bom_* columns and the materials table | infor/ |
| Analytics | GA4 fields on positions; add_to_cart / remove_from_cart on position changes | features/tracking.md |
Never hand-write SQL against
pipedrive_*. The gateway owns them, and inbound webhook writes deliberately suppress re-scheduling to avoid echo loops.
Related docs
- Data model — the tables and columns in detail
- Lifecycle & pricing — the four status axes, soft delete, and how totals are computed
- Customer groups & visibility — where currency and pricing come from
- Admin Guide: Sales agent (My Beta Calco) (
admin-guide/sales-agent/README.md)