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:
- RFQs where the vendor is an explicit
RfqInvitee. - RFQs the vendor already has an
Offeron (so a vendor keeps visibility even if later un-invited). - Geo-broadcast RFQs:
status: 'OPEN'and eitherscope: 'ALL', orscope: 'CITY'matching the vendor'scityId/serviceCityIds, orscope: 'REGION'matchingregionId/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:
linesmust be non-empty.- The RFQ must be in
reachableRfqIdsfor this vendor, else'You are not invited to this RFQ'. - The RFQ's
statusmust beOPENorDRAFT, else `RFQ is ${rfq.status}; bidding is closed`. - Every
line.rfqLineIdsubmitted 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:
| Layer | What it checks | Behavior when not entitled |
|---|---|---|
Shield (permissions.js) | Role permission VIEW_VENDOR_RFQS / SUBMIT_VENDOR_OFFERS — can this staff member act for the vendor at all | Denies the field entirely |
Query resolver (supplierQueries.js:33-42) | hasVendorEntitlement(..., 'RFQ_RESPOND') — does the vendor's plan include this | supplierRfqs → [], supplierRfq → null (silent, not an error) |
Mutation resolver (supplierMutations.js:30-36) | Same entitlement check | Throws "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.