Dentolize · Suppliers (Marketplace) Walkthrough
On this pageBusiness viewTechnical view

RFQs & Offers

Business view

An RFQ ("Request for Quote") is how a buyer asks multiple suppliers to bid on a list of items at once, instead of shopping one supplier's catalog at a time — useful for a bulk or recurring order where price matters. This PR does not build the buyer-facing side of RFQs (creating one, inviting suppliers, picking a winner) — that belongs to a sibling branch. What this PR builds is the supplier's side: an inbox of RFQs addressed to them, and a form to submit a priced offer against each line.

This is the first capability suppliers see gated behind a paid plan: responding to RFQs requires "the Growth plan or higher" (Tier 1). A supplier on the free tier sees the RFQs tab in their portal navigation, but clicking it (or navigating to it directly) shows a locked message rather than the RFQ list — even before they'd try to submit a bid.

Technical view

Ownership boundary

Per docs/trade-platform/FEATURE-SUPPLIERS.md, the RFQ engine — creating RFQs, resolving which vendors get invited, sealed-bid reveal timing, and awarding a winning offer into a TradeOrder — is owned by the sibling feat/marketplace-core branch. This module (packages/server/src/supplier/offerService.js) only lets an invited/reachable vendor see the RFQs addressed to them and respond by writing Offer/OfferLine rows. There is no award logic here.

Which RFQs a vendor can see (reachableRfqIds, offerService.js:72-99)

The union of three sources:

  1. RFQs where the vendor is an explicit RfqInvitee.
  2. RFQs the vendor already has an Offer on (so a vendor keeps visibility even if later un-invited).
  3. Geo-broadcast RFQs: status: 'OPEN' and either scope: 'ALL', or scope: 'CITY' matching the vendor's cityId/serviceCityIds, or scope: 'REGION' matching regionId/serviceRegionIds.

A gap worth knowing: the entitlement registry defines separate broadcast-reach tiers — RFQ_BROADCAST_CITY (Tier 1) and RFQ_BROADCAST_REGION_ALL (Tier 2) — implying city-wide reach should require a different tier than region/all-wide reach. reachableRfqIds doesn't check either key; any vendor whose geo matches gets broadcast visibility unconditionally, gated only by the flat RFQ_RESPOND check applied one layer up. If differentiated broadcast-tier gating is a real product requirement, it isn't enforced yet.

Submitting an offer (submitSupplierOffer, offerService.js:133-223)

An upsert keyed on the schema's @@unique([rfqId, vendorCompanyId]) constraint on Offer — a vendor gets exactly one offer per RFQ; resubmitting replaces it (deletes the old OfferLines, updates the Offer, resets status: 'SUBMITTED' and submittedAt).

Validation, in order:

  1. lines must be non-empty.
  2. The RFQ must be in reachableRfqIds for this vendor, else 'You are not invited to this RFQ'.
  3. The RFQ's status must be OPEN or DRAFT, else ` RFQ is ${rfq.status}; bidding is closed `.
  4. Every line.rfqLineId submitted must belong to that RFQ's own lines, else 'Offer line references an unknown RFQ line'.

Totals: subtotal = Σ(price × qty), total = subtotal + delivery − discount (both rounded to cents). After writing the offer, it also upserts an RfqInvitee row for (rfqId, vendorCompanyId) — so a vendor that bid on a broadcast RFQ (without having been explicitly invited) keeps visibility on it going forward.

Entitlement gating — enforced three times, on purpose

This is a deliberate defense-in-depth pattern, not redundancy:

LayerWhat it checksBehavior when not entitled
Shield (permissions.js)Role permission VIEW_VENDOR_RFQS / SUBMIT_VENDOR_OFFERScan this staff member act for the vendor at allDenies the field entirely
Query resolver (supplierQueries.js:33-42)hasVendorEntitlement(..., 'RFQ_RESPOND')does the vendor's plan include thissupplierRfqs[], supplierRfqnull (silent, not an error)
Mutation resolver (supplierMutations.js:30-36)Same entitlement checkThrows "Responding to RFQs requires the Growth plan or higher."

offerService.js itself has no entitlement awareness — the check lives entirely in the two resolver files that call into it, which is why a tier downgrade locks bidding "even for a permitted user" (the role permission alone isn't enough).

One asymmetry: supplierOffers (list of the vendor's own already-submitted offers) does not re-check RFQ_RESPOND — a vendor can still see offers it submitted while entitled, even if its plan is later downgraded.

The RFQs page (supplier-web)

packages/supplier-web/src/modules/rfqs/RfqsPage.tsx independently queries SUPPLIER_ENTITLEMENTS itself — even reached by direct URL, it renders a locked empty state (with an upsell message) if rfqRespond is false, and the SUPPLIER_RFQS query is skipped entirely in that case (not even fetched). When entitled: a table of RFQ reference/status/notes; clicking a row opens a drawer showing the RFQ's lines, any existing offer(s), and a submission form — one row per RFQ line with editable qty/price/availability, plus delivery, discount, lead time, and notes fields. The Submit offer button additionally requires the submitOffers permission flag — so a user could be allowed to view RFQs but not submit on the vendor's behalf.