SRS — Quotations (RFQ Engine)
Module owner: Domain 5 — Booking (owns Quotations). The RFQ engine is Yam3at's transactional heart in MVP: a customer sends one request from an Event to multiple vendors, vendors respond with structured quotes, the customer compares, negotiates, and accepts — which creates a Booking.
Related: Events & Planner · Booking · Messaging & Notifications
Purpose
Replace "call ten vendors and lose track" with a structured request → quote → compare → accept pipeline, while enforcing vendor subscription-plan limits that drive Yam3at's revenue model.
Actors
| Actor |
Role |
| Customer |
Creates RFQs from an event, compares quotes, negotiates, accepts/rejects |
| Vendor |
Receives RFQs (within plan limits), submits/revises quotes, declines |
| Admin |
Oversight, compliance moderation, dispute context |
| System |
Vendor matching for open-to-category, expiry job, plan-limit accounting |
User stories
- As a customer, I want to send one request to several photographers at once, so that I get comparable offers without repeating myself.
- As a customer, I want a side-by-side compare view, so that I choose on price, inclusions and rating — not on who replied last.
- As a customer, I want to ask a vendor to revise their quote, so that I can negotiate without leaving the app.
- As a vendor, I want new RFQs matching my category and city pushed to me instantly, so that I respond before competitors.
- As a vendor, I want structured line items and a validity period on my quote, so that scope and pricing are unambiguous.
- As a vendor on the Professional plan, I want AI to draft my quote from the RFQ text (Scale), so that I reply in seconds.
Functional requirements
Request creation — MVP
| ID |
Requirement |
Phase |
| SRS-RFQ-001 |
Customer creates an RFQ from an Event (event is mandatory context) with: category (one per RFQ), title, description, budget_indication (KWD, optional, hidden-from-vendor toggle), needed_by_date (defaults to event date), attachments (up to 5 files), and targeting mode. |
MVP |
| SRS-RFQ-002 |
Targeting modes: (a) Targeted — customer selects up to 5 vendors in the category; (b) Open-to-category — system distributes to matching vendors (category + event city, active + approved, within plan receive limits), max 15 recipients ranked by rating, response rate, and plan tier (Enterprise/Professional get priority placement — a paid-plan benefit). |
MVP |
| SRS-RFQ-003 |
RFQ can be saved as draft; sending moves it to pending and notifies each recipient vendor individually. Vendors never see who else received the RFQ, nor how many. |
MVP |
| SRS-RFQ-004 |
Vendor RFQ receive limits are plan-based, counted per calendar month (Asia/Kuwait): Free 5, Basic 20, Professional 75, Enterprise unlimited. A vendor at their limit is excluded from open-to-category matching; a targeted RFQ to an at-limit vendor shows the customer "may respond late" and the vendor an upgrade prompt (RFQ is queued, not lost — counted upon vendor opening it in a new month or upgrading). |
MVP |
| SRS-RFQ-005 |
Customer can cancel an RFQ at any state before accepted; all recipients are notified and open quotes on it become void. |
MVP |
Quote response — MVP
| ID |
Requirement |
Phase |
| SRS-RFQ-010 |
Vendor responds with a Quote: total_price (KWD, 3 decimals), optional line items (name, qty, unit price — line sum must equal total when present), validity_days (1–30, default 7), delivery_notes (e.g. setup time, what's excluded), attachments (up to 5 files: portfolio samples, sample menu, contract draft PDF). First quote on an RFQ moves it pending → quoted. |
MVP |
| SRS-RFQ-011 |
Vendor can decline an RFQ with an optional reason (busy that date, out of area, budget mismatch). Declining releases nothing back to their monthly count (the receive counted; declining is free of further cost). |
MVP |
| SRS-RFQ-012 |
Vendor can revise their own quote while it is submitted or during negotiation; each revision is versioned and the customer sees the revision history. |
MVP |
| SRS-RFQ-013 |
Quote expiry: a quote past submitted_at + validity_days becomes expired and cannot be accepted; the customer may request a re-quote (one tap → vendor notified). |
MVP |
Negotiation — MVP
| ID |
Requirement |
Phase |
| SRS-RFQ-020 |
Customer can open a negotiation round on a quote: a structured counter (proposed price and/or message). This moves the RFQ to negotiating and the quote to counter_received for the vendor. Max 3 negotiation rounds per quote; after that, only accept/reject (keeps the flow from becoming an endless chat — free-form talk lives in Messaging, which is linked from the quote). |
MVP |
| SRS-RFQ-021 |
Vendor answers a counter by revising the quote (new version, resets validity) or holding firm ("final offer" flag). |
MVP |
Compare & decide — MVP
| ID |
Requirement |
Phase |
| SRS-RFQ-030 |
Compare view: all non-void quotes on an RFQ side by side — total price, line items, validity remaining, vendor rating & review count, response time, plan badge (verified/featured), attachments. Sortable by price and rating. Max 5 columns on web, swipeable cards on mobile. |
MVP |
| SRS-RFQ-031 |
Accept exactly one quote → RFQ becomes accepted, a Booking is created atomically (same DB transaction), all sibling quotes become not_selected and their vendors are notified with a courteous templated message. |
MVP |
| SRS-RFQ-032 |
Reject individual quotes (optional reason from a fixed list + free text) without closing the RFQ; rejecting the last live quote returns the RFQ to pending if within its needed-by window, else it expires. |
MVP |
| SRS-RFQ-033 |
Expiry job (hourly, Redis queue via Horizon): RFQs with no accepted quote past needed_by_date, and RFQs pending with zero quotes for 14 days, move to expired; individual quotes past validity move to expired. All transitions emit notification events. |
MVP |
Scale / Vision
| ID |
Requirement |
Phase |
| SRS-RFQ-040 |
AI quote drafting (Professional/Enterprise plans): from the RFQ text, event context and the vendor's service catalog & past quotes, generate a draft quote (line items + notes AR/EN) that the vendor edits before sending. Never auto-sends. |
Scale |
| SRS-RFQ-041 |
AI RFQ quality assistant for customers: suggests missing details (guest count, indoor/outdoor) before sending. |
Scale |
| SRS-RFQ-042 |
Auction-style open bidding with live ranking. |
Vision |
Business rules
- One RFQ = one category. Multi-category needs (venue + catering) = multiple RFQs under the same event; the event dashboard groups them.
- A vendor gets exactly one live quote per RFQ (revisions replace, never add).
- Quote prices are all-inclusive customer-facing prices in KWD with 3 decimals; no tax line at launch (Kuwait has no VAT as of launch — architecture keeps a
tax_amount field at 0.000 for KSA/UAE expansion).
- Plan receive limits apply to receiving RFQs, not to responding; responses are always free once received. Limits reset on the 1st of each month 00:00 Asia/Kuwait.
budget_indication is shown to vendors only if the customer opts in.
- Accepting a quote requires the event to be
active and the quote non-expired; acceptance and booking creation are atomic.
- Customers may have at most 10 open RFQs per event (abuse guard, admin-configurable).
- All customer/vendor identities stay in-platform: RFQ description and quote notes are scanned by the blocked-words engine (see SRS-MSG-030) to deter off-platform contact exchange (phone numbers, emails, Instagram handles) before acceptance.
Validation rules
| Field |
Rule |
title |
required, 5–120 chars |
description |
required, 20–3,000 chars |
category_id |
required, active category |
budget_indication |
optional, decimal(12,3) KWD, > 0 |
needed_by_date |
required, ≥ today, ≤ event date (when event date set) |
| Attachments (RFQ & quote) |
≤ 5 files each; jpg/png/webp/pdf; ≤ 10 MB per file; images virus-scanned & EXIF-stripped |
| Target vendors |
1–5 vendor IDs, each approved & active, each offering category_id |
total_price |
required, decimal(12,3), 1.000 ≤ value ≤ 999,999.999 KWD |
| Line items |
0–30 items; each: name 2–100 chars, qty 1–10,000, unit_price ≥ 0.000; Σ(qty × unit_price) must equal total_price ± 0.001 |
validity_days |
integer 1–30 |
delivery_notes |
optional, ≤ 2,000 chars |
| Counter offer |
proposed_price decimal(12,3) > 0 and/or message 1–1,000 chars |
Permissions matrix
| Action |
Customer (RFQ owner) |
Vendor (recipient) |
Vendor (non-recipient) |
Admin ops |
Admin super |
| Create/send/cancel RFQ |
✔ |
✘ |
✘ |
✘ |
✘ |
| View RFQ detail |
✔ |
✔ (own copy, no sibling info) |
✘ |
✔ read |
✔ read |
| Submit/revise/decline quote |
✘ |
✔ |
✘ |
✘ |
✘ |
| Open negotiation round |
✔ |
counter via revision |
✘ |
✘ |
✘ |
| Accept/reject quotes |
✔ |
✘ |
✘ |
✘ |
✘ |
| Void quote / force-expire (compliance) |
✘ |
✘ |
✘ |
✔ (logged) |
✔ (logged) |
State machine
stateDiagram-v2
[*] --> Draft : customer saves
Draft --> Pending : customer sends
Draft --> Cancelled : customer discards
Pending --> Quoted : first vendor quote received
Pending --> Expired : needed_by passed / 14d no quotes (job)
Pending --> Cancelled : customer cancels
Quoted --> Negotiating : customer opens counter round
Negotiating --> Quoted : vendor revises quote
Quoted --> Accepted : customer accepts a quote (booking created)
Negotiating --> Accepted : customer accepts a quote (booking created)
Quoted --> Rejected : customer rejects all quotes
Negotiating --> Rejected : customer rejects all quotes
Quoted --> Expired : all quotes expired / needed_by passed (job)
Negotiating --> Expired : needed_by passed (job)
Quoted --> Cancelled : customer cancels
Negotiating --> Cancelled : customer cancels
Accepted --> [*]
Rejected --> [*]
Expired --> [*]
Cancelled --> [*]
Per-quote sub-statuses: submitted → (revised versions) → counter_received → accepted | not_selected | rejected | expired | void | declined.
Edge cases
- Two customers' devices accept different quotes simultaneously: acceptance takes a row lock on the RFQ; second request gets 409 "already accepted".
- Quote expires while customer is on the compare screen: accept call re-validates server-side and returns 422 with a "request re-quote" action.
- Vendor suspended mid-RFQ: their live quotes become
void, customer notified; RFQ state recalculated (may fall back to pending).
- Vendor downgrade mid-month: already-received RFQs keep working; new receives use the new plan limit immediately.
- Open-to-category with zero matching vendors: send blocked with a clear message + option to widen city or notify-me-when-vendors-join.
- Attachment upload fails mid-send: RFQ stays
draft; send is all-or-nothing.
- Event date edited after quotes exist: quotes remain valid but compare view flags "quoted for previous date"; customer prompted to confirm with vendors.
- Negotiation round limit reached: counter UI replaced by accept/reject + "continue in chat" link.
Empty / loading / error states
| Surface |
Empty |
Loading |
Error |
| Vendor RFQ inbox |
"No requests yet — complete your profile to appear in more searches" |
Skeleton list |
Retry banner; unread badge from cache |
| Compare view |
1 quote → single card with "waiting on N vendors"; 0 → status timeline of pending vendors |
Column skeletons |
Per-quote fetch fallback; failed cards show retry |
| RFQ send |
— |
Blocking progress ("Sending to 4 vendors…") |
Transactional rollback, draft preserved, toast retry |
| Quote form (vendor) |
— |
Autosave indicator every 10 s |
Offline-safe local draft, banner "not sent yet" |
Acceptance criteria
Data entities touched
quotation_requests, quotation_request_recipients (per-vendor delivery + read state), quotes, quote_versions, quote_line_items, negotiation_rounds, rfq_attachments, quote_attachments, vendor_plan_usage (monthly receive counters), plus reads on events, vendors, vendor_subscriptions, categories. Writes to bookings on acceptance.
POST /api/v1/events/{eventId}/quotation-requests
GET /api/v1/quotation-requests # customer's list (filter by event/status)
GET /api/v1/quotation-requests/{id}
PATCH /api/v1/quotation-requests/{id} # draft edits
POST /api/v1/quotation-requests/{id}/send
POST /api/v1/quotation-requests/{id}/cancel
GET /api/v1/quotation-requests/{id}/compare
POST /api/v1/quotes/{quoteId}/accept
POST /api/v1/quotes/{quoteId}/reject
POST /api/v1/quotes/{quoteId}/counter # negotiation round
POST /api/v1/quotes/{quoteId}/request-requote
# vendor side
GET /api/v1/vendor/rfqs # inbox with plan-limit meta
GET /api/v1/vendor/rfqs/{id}
POST /api/v1/vendor/rfqs/{id}/quotes
PATCH /api/v1/vendor/quotes/{quoteId} # revision
POST /api/v1/vendor/rfqs/{id}/decline
POST /api/v1/vendor/quotes/{quoteId}/ai-draft # Scale