Skip to main content

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:

  1. A quote has no branch column and no customer column. Both are derived from the owning user.
  2. 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
ConceptTableNotes
Opportunity#__configbox_external_user_opportunitiesAn optional grouping of quotes ("project"). Created only by quote makers.
Quote#__configbox_external_user_quotesThe subject of these docs.
Position#__configbox_external_user_positionsA line item. Can have sub-positions via parent_position_id.
Selection#__configbox_external_user_selectionsOne row per configurator answer on a position.
Sales ordernoneSales 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 prefixed Q- 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.

RevisionAlternative
Levelthe quotea position (line item)
Mechanisma new quote row reusing the same serialthe is_alternative flag on a position
Means"version 2 of this quote""the customer could pick this instead"
Effectold rows get is_latest_revision = '0' and drop out of every listexcluded 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

RoleHow it's decided
Agent (owner)the user's customer group has is_agent — see customer groups
Quote makermembership of the Joomla group named in the joomla_group_id_quote_makers setting. Returns false if that setting is unset.
Admincom_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

SurfaceControllerNotes
My Quotes (agent)controllers/bcmyquotes.phpThe main agent surface. SEF: one path segment = the serial. Add/copy/revise/delete quotes and positions, exports (PDF, XLSX, Cahill, Oasis).
Quote detail fragmentscontrollers/bcquote.phpAJAX HTML for the quote and its positions; quote-maker-or-owner per quote.
Quote Maker consolecontrollers/bcquoteconsole.phpSearch across quotes and opportunities; quote-makers only.
Quote Maker — detailscontrollers/bcqmquotedetails.phpEdit the commercial fields, save as revision, push to an Infor order.
Opportunitiescontrollers/bcopportunities.phpQuote-maker only.
Follow-up landing pagecontrollers/bcquotelandingpage.phpThe no-login page — see Quote Follow-Up.
Backendcontrollers/adminbcquotes.php and friendsAll 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.

SystemAttaches atDoc
Pipedrive~20 pipedrive_* columns on the quote, written only through BcQuotePipedriveGatewaypipedrive/
Quote Follow-Upthe agent-feedback and stage columns, plus the stage-sync log tablequote-follow-up/
Infor / BOMthe position level: bom_* columns and the materials tableinfor/
AnalyticsGA4 fields on positions; add_to_cart / remove_from_cart on position changesfeatures/tracking.md

Never hand-write SQL against pipedrive_*. The gateway owns them, and inbound webhook writes deliberately suppress re-scheduling to avoid echo loops.