Skip to content

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

  1. One RFQ = one category. Multi-category needs (venue + catering) = multiple RFQs under the same event; the event dashboard groups them.
  2. A vendor gets exactly one live quote per RFQ (revisions replace, never add).
  3. 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).
  4. 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.
  5. budget_indication is shown to vendors only if the customer opts in.
  6. Accepting a quote requires the event to be active and the quote non-expired; acceptance and booking creation are atomic.
  7. Customers may have at most 10 open RFQs per event (abuse guard, admin-configurable).
  8. 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_receivedaccepted | 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

  • Full happy path — create RFQ targeting 3 vendors, 2 quotes in, 1 negotiation round, accept — completes with a booking created and both losing vendors notified, verified by an end-to-end test.
  • A Free-plan vendor stops receiving open-to-category RFQs after their 5th received RFQ in a month and resumes on the 1st.
  • Line items that don't sum to total (beyond ±0.001 KWD) are rejected with a localized error.
  • Hourly expiry job transitions overdue RFQs/quotes and emits exactly one notification per transition (idempotent on re-run).
  • A phone number typed into a quote's notes triggers the blocked-content warning flow.
  • Compare view renders 5 quotes in < 1.5 s p95 and is fully usable in RTL Arabic.
  • Concurrent double-accept returns 409 to exactly one caller; exactly one booking exists.

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