Frontend / Next.js
Backend / NestJS
Database / PostgreSQL
External API / Algolia / Razorpay
Notifications Queue / Service Timer
Socket.IO Event
Error / Rejection
Success / Terminal
Warning / Guest Gate
Scope
Guest (Unauthenticated)
Cart Storage
Zustand + sessionStorage
Wishlist Storage
Zustand + localStorage
Backend Calls
Zero — until authenticated
Post-Login Sync
Independent per-item APIs
Guest
Feature 04g — Guest Buyer Experience: Local-First State, No POST APIs
Unauthenticated users receive a fully functional browsing experience — including cart, wishlist, product browsing, search, and comparison — without any server-side session. All guest data persists exclusively in the browser (Zustand + sessionStorage/localStorage). No POST, PUT, or DELETE API calls are made until the user successfully authenticates. Upon login or signup, independent per-item APIs sync guest state to the database; no single merge endpoint is used, ensuring each API remains single-purpose and individually testable.
Next.js
Zustand
sessionStorage (Cart)
localStorage (Wishlist)
📋 Full Flow Description▼
═══════ GUEST CONTEXT ASSIGNMENT ═══════
FRONTEND → User arrives without a valid JWT cookie → No server-side session exists →
Frontend operates in guest mode (Zustand store, no userId bound)
═══════ CRITICAL RULE: NO POST APIS FOR GUESTS ═══════
Guest users NEVER trigger POST / PUT / DELETE API calls.
IF any of these actions occur → store locally only:
"Add to Cart" → Zustand store + sessionStorage (survives refresh; clears on browser close)
"Add to Wishlist" → Zustand store + localStorage (persists across sessions)
"Update Cart Quantity" → local state only
"Remove from Cart" → local state only
"Toggle Wishlist" → local state only
═══════ GUEST-ACCESSIBLE FEATURES (NO LOGIN REQUIRED) ═══════
✓ View home page (hero, categories, promotions, new arrivals)
✓ View product listing page with all filters
✓ View product detail page (images, description, bullet points, variants, seller info, Q&A, reviews, A+ content)
✓ Compare products
✓ Search products (Algolia — productName, category, SKU; PostgreSQL fallback)
✓ Add to cart (local Zustand + sessionStorage only — no API)
✓ Add to wishlist (local Zustand + localStorage only — no API)
✓ Update cart quantity (local only)
✓ Remove from cart (local only)
✓ Toggle wishlist (local only)
═══════ FEATURES BLOCKED FOR GUESTS ═══════
✗ Proceed to checkout
✗ Place an order
✗ Track an order
✗ Submit a product review
✗ Ask or answer a product question
✗ Click "Notify Me" on out-of-stock products
✗ Access saved addresses
✗ View order history
✗ Initiate a return or refund
✗ Send a message to a seller
═══════ CHECKOUT AUTHENTICATION GATE ═══════
Guest clicks "Proceed to Checkout" →
FRONTEND checks: is user authenticated?
IF NOT authenticated:
→ Do NOT proceed to checkout
→ Show message: "You are not logged in. Please sign in to complete your purchase.
Your cart items will be preserved."
→ Store redirect_url = /checkout in sessionStorage
→ Show [Log In] and [Sign Up] buttons
→ Redirect to /login or /signup on button click
═══════ POST-LOGIN SYNC — CURRENT BULK SYNC APIS ═══════
DESIGN DECISION: No merge API is used. The current code calls dedicated bulk sync endpoints for cart and wishlist after successful buyer auth.
After successful login or signup:
Sync Cart:
→ POST /api/v1/buyer/cart/sync { items: [{ productId, variantId?, qty }] }
→ Backend logic: if item already in DB cart → keep max(guestQty, dbQty); if new → insert
Sync Wishlist:
→ POST /api/v1/buyer/wishlist/sync { items: [{ productId, variantId? }] }
→ Backend: if already in wishlist → skip (no duplicate); if new → insert (max 100 items enforced)
After sync completes:
→ Clear sessionStorage guest cart
→ Clear localStorage guest wishlist
→ GET /api/v1/buyer/cart → hydrate Zustand cart from DB
→ GET /api/v1/buyer/wishlist → hydrate Zustand wishlist from DB
→ Redirect to /checkout (or stored redirect_url from sessionStorage)
🔌 API Reference — Post-Login Cart & Wishlist Sync▼
POST Cart Sync
// Called once after login/signup when guest cart exists
{
"method": "POST",
"endpoint": "/api/v1/buyer/cart/sync",
"headers": {
"Content-Type": "application/json",
"Cookie": "access_token=httpOnly"
},
"body": {
"items": [{ "productId": "prod_abc123", "variantId": "var_xyz789", "qty": 2 }]
}
}
Response — Cart Sync Success
{
"status": 200,
"body": {
"cartItemId": "ci_001",
"productId": "prod_abc123",
"qty": 2,
"action": "UPSERTED",
"resolvedQty": 2
// max(guestQty, dbQty)
}
}
POST Wishlist Sync
{
"method": "POST",
"endpoint": "/api/v1/buyer/wishlist/sync",
"headers": {
"Cookie": "access_token=httpOnly"
},
"body": {
"items": [{ "productId": "prod_abc123" }]
}
}
Response — Wishlist Sync
{
"status": 200,
"body": {
"action": "INSERTED",
// or "SKIPPED" if already exists
"productId": "prod_abc123",
"wishlistCount": 12
}
}
Search Engine
Algolia (Primary)
Fallback
PostgreSQL ILIKE
Searchable Fields
productName · category · SKU
Autocomplete
300ms debounce · 8 results
Fallback
PostgreSQL product name · SKU · category
Buyer
Feature 15 — Product Search: Algolia IDs · PostgreSQL Product Cards · Faceted Filters
Global debounced autocomplete calls
GET /api/v1/products/autocomplete and returns up to 8 suggestions. Full search calls GET /api/v1/products/search. Algolia stores only product id, productName, category, and SKU. The backend uses Algolia to resolve matching product ids, then loads current product cards, facets, images, stock, seller rating, and A+ flags from PostgreSQL. If Algolia is unavailable or not configured, PostgreSQL ILIKE fallback searches product name, SKU, and category name.
Next.js
NestJS
Algolia
PostgreSQL (Fallback)
PostgreSQL product read
SearchIndexService
📋 Full Flow Description▼
═══════ ALGOLIA CONFIGURATION ═══════
Index name: products
record fields: objectID, productName, category, sku
Algolia returns matching product ids; PostgreSQL remains the source of truth for product cards, filters, prices, stock, seller rating, and A+ flags.
CRITICAL: Algolia stores only productName, category, and SKU. Description, bullet points, images, stock, and pricing are not indexed.
═══════ AUTOCOMPLETE FLOW ═══════
USER types in global search bar (any page, guest or authenticated)
→ Frontend debounce: waits 300ms after last keystroke
→ GET /api/v1/products/autocomplete?q={query}
→ BACKEND: Query Algolia for product ids when configured
IF Algolia returns ids → load those products from PostgreSQL and preserve Algolia order
IF Algolia unavailable → PostgreSQL ILIKE fallback on itemName, sku, and category name
→ Dropdown: 8 suggestions (image + name + price + category)
→ Click suggestion → Navigate to /products/{slug}
═══════ FULL SEARCH PAGE (/search) ═══════
URL structure: /search?q=shirt&categoryId=X&brandId=Y&minPrice=500&maxPrice=2000&rating=4&sort=price_asc&page=2
→ GET /api/v1/products/search?q=...&{all filters}&limit=24
→ BACKEND builds Algolia query:
Search record fields: productName, category, sku
Result: matching product ids only
PostgreSQL applies ProductStatus.ACTIVE, isDeleted=false, filters, sorting, pagination, and card selection
Sort: createdAt | price asc/desc | viewCount | unitsSold | MRP discount-style ordering
Facets are loaded from PostgreSQL through /api/v1/products/facets
Pagination: default 24 per page
IF Algolia unavailable:
→ PostgreSQL ILIKE fallback:
WHERE (itemName ILIKE '%{q}%' OR sku ILIKE '%{q}%'
OR category.name ILIKE '%{q}%')
AND status = 'ACTIVE'
RESULTS PAGE LAYOUT:
→ Header: "Showing 42 results for 'cotton shirt'"
→ Left sidebar (desktop) / bottom drawer (mobile): Filter panel
→ Product grid: image, name, brand, price, MRP strikethrough, discount%, rating, review count
→ Active filters as removable chips above grid
→ × on chip removes that filter → URL updated → re-fetch → no full page reload
→ "Clear All Filters" → reset all → re-fetch
→ Sort dropdown top-right: New Arrivals / Price Low-High / Price High-Low / Rating / Discount
→ Pagination: 20 products per page
ALGOLIA INDEX SYNC (SearchIndexService fire-and-forget):
→ Product created/published/edited/resumed/inventory-updated → upsert product record
→ Product paused/deleted or no longer public → remove product record
→ Record contains only objectID, productName, category, and SKU
→ Failures are logged as warnings and do not block seller product requests
🔌 API Reference — Search Endpoints▼
GET Autocomplete
{
"method": "GET",
"endpoint": "/api/v1/products/autocomplete",
"query": {
"q": "cotton sh"
}
// No auth required — guest accessible
}
Response — 8 Suggestions
{
"status": 200,
"body": {
"items": [
{
"id": "prod_001",
"name": "Cotton Shirt Slim Fit",
"slug": "cotton-shirt-slim-fit",
"price": 799,
"category": "Men's Clothing",
"mainImageUrl": "https://res.cloudinary.com/..."
}
],
// Ordered by Algolia id order when Algolia is available
}
}
GET Full Search
{
"method": "GET",
"endpoint": "/api/v1/products/search",
"query": {
"q": "cotton shirt",
"categoryId": "cat_men",
"minPrice": 500,
"maxPrice": 2000,
"ratingMin": 4,
"sortBy": "PRICE_ASC",
"page": 1
}
}
Response — Results + Facets
{
"status": 200,
"body": {
"items": [/* 24 products by default */],
"meta": {
"page": 1,
"limit": 24,
"total": 42,
"totalPages": 2
}
}
}
Design Model
Amazon Product Detail Page (Clone)
Media Storage
Cloudinary (all images + video)
Availability
Guest + Authenticated
Cart/Wishlist
Local for guests · DB for auth
Buyer
Product Detail Page — Amazon-Clone Layout with A+ Content, Q&A, and Reviews
The product detail page replicates Amazon's full product detail layout, delivering all sections in the correct order: image gallery with zoom, title and brand, ratings summary, price block with MRP and discount, bullet points, variant selector, sticky Add to Cart / Buy Now, seller information card, full rich-text description, A+ content (banner-like, cohesive layout identical to Amazon's A+ module), Q&A section, and full reviews section. Variant selection dynamically updates price, stock, and the primary image. A+ content renders below the description and is retrieved only when present; the section is omitted if the seller has not published any.
Next.js (SSR)
NestJS
PostgreSQL
Cloudinary
📋 Full Flow Description▼
═══════ PAGE LOAD — SERVER-SIDE RENDERING ═══════
USER navigates to /products/{slug} (guest or authenticated)
→ Next.js SSR: GET /api/v1/products/{slug}
→ BACKEND: Load product record with all relations:
Product fields: id, title, brand, description, bulletPoints[], price, mrp,
discountPct, sku, hsnCode, itemCondition, targetGender, weight, dimensions,
countryOfOrigin, stock, status, images[], video, tags[], createdAt
Seller: id, displayName, avgRating, reviewCount, responseTime, positiveFeedbackPct
Variants: size, color, material, price, mrp, stock, imageUrl
A+ content: blocks[] with type+content+imageUrl — ordered list
Review summary: avgRating, totalCount, distributionByStars{ 1,2,3,4,5 }
Top Q&A: first 5 answered questions (isPublic=true, isAnswered=true)
→ Return rendered HTML for SEO
═══════ SECTION LAYOUT — IN ORDER (AMAZON CLONE) ═══════
1. Image Gallery
→ Main image large (Cloudinary URL)
→ Thumbnail strip: all product images in order
→ Zoom on hover (CSS transform + backdrop)
→ Video if uploaded: play inline below thumbnails
→ Min 2 images required; max 10
2. Product Title + Brand
→ Full title (max 200 chars)
→ Brand name — links to brand page
3. Ratings Summary
→ Aggregate star rating (1.0–5.0)
→ Total review count — anchor-scrolls to reviews section on click
→ "X verified purchases" indicator
4. Price Block
→ Selling price: ₹{price} (INR)
→ MRP: ₹{mrp} (strikethrough)
→ Discount: {discountPct}% off
→ No GST breakdown shown to buyer (price is inclusive)
5. Bullet Points
→ Rendered exactly as entered by seller
→ Rich text HTML — sanitised with DOMPurify server-side
→ First 3 bullets required, additional are optional
6. Variant Selector
→ Available variant dimensions: Size, Color, Material
→ On variant selection: client-side state update only (no API call)
→ Recalculate: price, MRP, discount%, stock badge, main image
→ URL updated: /products/{slug}?variantId={id}
7. Add to Cart / Buy Now (Sticky CTA)
→ Sticks to top of viewport on scroll
→ Quantity selector: min 1, max = available stock
→ [Add to Cart]:
- Guest → Zustand + sessionStorage (no API)
- Authenticated → POST /api/v1/buyer/cart/items { productId, variantId, qty }
→ [Buy Now]:
- Guest → redirect to login with return URL
- Authenticated → POST to cart + navigate to checkout
→ Out of Stock → shows "Notify Me" button (Feature 22)
8. Seller Information Card
→ Seller display name + avatar
→ Avg rating (stars) + total feedback count
→ Response time: e.g., "Usually responds within 2 hours"
→ Positive feedback percentage
→ [View Seller Profile] link
9. Full Product Description
→ Rich text HTML rendered safely (DOMPurify sanitised)
10. A+ Content (only if seller published A+ content)
→ Fetched with the main product data
→ Rendered as cohesive, banner-like section
→ Block types: full-width images, side-by-side images, H2/H3 headings,
rich text paragraphs, product demo video
→ Visual appearance: identical to Amazon's A+ content module
11. Product Q&A
→ Only answered questions (isPublic=true) loaded on page
→ Buyer name shown as: "Rahul M." (first name + last initial only)
→ [Ask a question] field — login required; guest redirected to login
→ First 5 shown; "See all questions" loads more
12. Reviews and Ratings
→ Rating distribution bar chart (1★ to 5★)
→ Individual reviews: star rating, headline, body text, date
→ Verified Purchase badge if buyer actually purchased the product
→ Helpful votes: [Helpful?] [Yes/No]
→ Default sort: Most Helpful; also: Most Recent, Critical
13. Compare Products
→ Side-by-side comparison table with similar category products
14. Recently Viewed
→ Horizontal scrollable row
→ Guest: from localStorage; Auth buyer: from DB (Feature 23)
VIEW TRACKING (fire-and-forget):
→ navigator.sendBeacon POST /api/v1/products/{id}/view fires on page load
→ Backend deduplicates (same user + product within 24h = 1 unique view)
→ Increments product.viewCount if unique
🔌 API Reference — Product Detail Page▼
GET Product Detail
{
"method": "GET",
"endpoint": "/api/v1/products/:slug",
"query": {
"variantId": "var_xyz"
// optional — preselect variant
}
}
Response — Full Product
{
"status": 200,
"body": {
"id": "prod_abc",
"title": "Cotton Slim Fit Shirt",
"price": 799,
"mrp": 1299,
"discountPct": 38,
"images": [/* cloudinary URLs */],
"variants": [/* size, color, price, stock */],
"seller": {
"name": "Rahul Textiles",
"avgRating": 4.3
},
"aplusContent": [/* blocks or null */],
"reviewSummary": {
"avg": 4.2,
"total": 134
}
}
}
POST Add to Cart (Authenticated)
{
"method": "POST",
"endpoint": "/api/v1/buyer/cart/items",
"headers": {
"Cookie": "access_token=httpOnly"
},
"body": {
"productId": "prod_abc",
"variantId": "var_xyz",
"qty": 1
}
}
Response — Added to Cart
{
"status": 200,
"body": {
"cartItemId": "ci_999",
"productId": "prod_abc",
"qty": 1,
"cartTotal": 799,
"cartItemCount": 3
}
}
Max Addresses
5 per buyer
Deletion Strategy
Soft delete — isDeleted = true
Address Snapshot
Immutable JSON stored on order
Buyer
Feature 14 — Address Management: CRUD with 5-Address Limit and Immutable Order Snapshots
Buyers may store up to five delivery addresses. One address may be designated as the default, which is pre-selected during checkout. When an order is placed, the full address is snapshot as a JSON field on the Order record, ensuring that subsequent address edits or deletions do not alter historical order data. Soft deletion is used; deleted addresses are hidden from the buyer but retained in the database for audit purposes.
Next.js
NestJS
PostgreSQL
📋 Full Flow Description▼
═══════ ADDRESS LIST ═══════
GET /api/v1/buyer/addresses → Load buyer's addresses (isDeleted=false) → Ordered: default first, then by createdAt DESC
Max 5 addresses per buyer enforced.
Each card displays: Full Name, Phone, Address Line 1, Address Line 2 (if any), City, State, PIN, Type badge (Home/Work/Other), Default badge (if applicable).
═══════ ADD NEW ADDRESS ═══════
→ Check count: if 5 already saved → return 422 "Maximum 5 addresses reached"
→ Form fields (all server-side validated):
fullName (required), phone (10-digit Indian mobile), addressLine1 (required),
addressLine2 (optional), city, state (dropdown), pinCode (6 digits), type (Home/Work/Other)
isDefault (boolean)
→ POST /api/v1/buyer/addresses { all fields }
→ BACKEND: IF isDefault=true → UPDATE all buyer's addresses SET isDefault=false first
→ INSERT new Address record
═══════ EDIT ADDRESS ═══════
→ PATCH /api/v1/buyer/addresses/{id} { changed fields }
→ Backend verifies address.buyerId = currentUser.id
→ UPDATE record
NOTE: Address edits do NOT affect historical orders.
Order.addressSnapshot is an immutable JSON field copied at the time of order placement.
Editing or deleting an address never modifies existing order address data.
═══════ DELETE ADDRESS (SOFT) ═══════
→ DELETE /api/v1/buyer/addresses/{id}
→ Backend: UPDATE Address SET isDeleted=true (never physically removed)
→ IF deleted address was default AND other addresses exist → promote next address as default
→ All queries ALWAYS include WHERE isDeleted=false (enforced at ORM level)
═══════ SET DEFAULT ═══════
→ PATCH /api/v1/buyer/addresses/{id}/default
→ Serializable transaction: SET isDefault=false for all, then SET isDefault=true for selected
→ Default address is pre-selected in checkout (Feature 13)
🔌 API Reference — Address Management▼
POST Add Address
{
"method": "POST",
"endpoint": "/api/v1/buyer/addresses",
"body": {
"fullName": "Rahul Kumar",
"phone": "9876543210",
"addressLine1": "12 MG Road",
"addressLine2": "Near Station",
"city": "Ahmedabad",
"state": "Gujarat",
"pinCode": "380001",
"type": "Home",
"isDefault": true
}
}
Response — Address Created
{
"status": 201,
"body": {
"addressId": "addr_001",
"fullName": "Rahul Kumar",
"city": "Ahmedabad",
"isDefault": true,
"addressCount": 2
}
}
// 422 when limit reached:
{
"status": 422,
"code": "ADDRESS_LIMIT_EXCEEDED",
"message": "Maximum 5 addresses allowed."
}
Payment
Razorpay Only — No COD
Initial Order Status
PENDING_SELLER_APPROVAL
Seller Window
24 hours to accept or reject
Auto-Accept
After 24h inactivity → PROCESSING
Idempotency
Redis UUID key · TTL 24h
Buyer
Feature 13 — Checkout: Stock Revalidation → Address → Order Summary → Razorpay · No COD · PENDING_SELLER_APPROVAL
Three-step checkout flow — stock revalidation, address selection, order summary and payment. Cash on Delivery does not exist in any form. After Razorpay payment confirmation and the database transaction commit, seller orders are created with status PENDING_SELLER_APPROVAL. The backend creates in-app notifications, dispatches seller email/SMS through the notifications queue, and emits RealtimeService events. Sellers must accept or reject within 24 hours; the current OrdersService in-process timer checks due orders every 60 seconds and auto-accepts expired pending orders.
Next.js
NestJS
PostgreSQL
Razorpay
Redis
NotificationsService + OrdersService timer
PENDING_SELLER_APPROVAL
→
PROCESSING
→
SHIPPED
→
OUT_FOR_DELIVERY
→
DELIVERED
|
CANCELLED (seller rejected)
⚠ Security: Grand Total Computed Server-Side
The grand total is always calculated server-side from database prices. The frontend amount is never trusted. An idempotency key (UUID) stored in Redis (TTL 24h) prevents double charges if the buyer's browser submits the payment verification request more than once.
📋 Full Flow Description▼
═══════ PRE-CHECKOUT STOCK REVALIDATION ═══════
BUYER clicks "Proceed to Checkout" (authenticated only — guests redirected to login)
→ GET /api/v1/buyer/cart/check-stock (real DB stock — never Redis cache)
→ BACKEND: For each cart item, SELECT stock FROM Products/Variants WHERE id=X
IF any item's stock < requested quantity:
→ Return 422 with: [{ productId, name, requestedQty, availableStock }]
→ Frontend highlights affected items: "Only {n} left" or "Out of Stock"
→ Buyer must remove or reduce quantity before continuing
IF all stock OK → Continue to Step 1
═══════ STEP 1 — ADDRESS SELECTION ═══════
→ GET /api/v1/buyer/addresses → Load buyer's saved addresses (max 5)
→ Default address pre-selected (if set)
→ Buyer may: ① Select existing address ② Add new address inline (same form as Feature 14)
→ Selected address stored in checkout Zustand state (not yet committed to DB)
═══════ STEP 2 — ORDER SUMMARY ═══════
→ Display per-item: product image, name, variant, quantity, unit price (GST inclusive), item total
→ Summary breakdown:
Items Total: ₹{subtotal}
Shipping: ₹{shippingCharge} or "Free"
Coupon Discount: -₹{couponDiscount} (if validated by /buyer/coupons/validate)
─────────────────────────────
Grand Total: ₹{grandTotal}
NOTE: No CGST/SGST/IGST breakdown shown to buyer — price is inclusive.
→ Coupon field: POST /api/v1/buyer/coupons/validate { code }
→ Validate: coupon active + schedule window + seller scope + redemption limits
→ Preview discount (CouponRedemption is reserved only when create-order runs)
→ Delivery address card shown for review; Edit link returns to Step 1
═══════ STEP 3 — PAYMENT ═══════
Payment method options (Razorpay only):
✓ UPI — GPay, PhonePe, Paytm, any VPA
✓ Credit/Debit Card
✓ Net Banking
✓ Digital Wallet
✗ Cash on Delivery — DOES NOT EXIST in this system in any form
→ Buyer clicks "Pay ₹{grandTotal}"
→ BACKEND: POST /api/v1/payments/create-order
→ Use UUID idempotency key → Redis NX idempotency lock TTL 24h
→ Pending checkout snapshot stored under CHECKOUT:{razorpayOrderId} with Redis TTL 900s
→ Re-validate stock one final time (Serializable transaction)
→ If couponCode exists, create CouponRedemption status=RESERVED
→ Calculate grandTotal server-side (NEVER trust frontend amount)
→ POST razorpay.com/v1/orders { amount: grandTotal×100, currency: "INR", receipt: idempotencyKey }
→ Return { razorpayOrderId, amount, currency, keyId }
→ FRONTEND: Open Razorpay payment modal (pre-filled: buyer name, email, phone)
→ Buyer completes payment inside Razorpay modal
IF failure → Show "Payment Failed" + [Try Again] button
→ Increment retry_count in Zustand (max 3)
IF retry_count < 3 → Re-open modal (new Razorpay order ID)
IF retry_count ≥ 3 → Redirect to cart with failure message
IF success → Receive: { razorpay_payment_id, razorpay_order_id, razorpay_signature }
→ POST /api/v1/payments/verify { all three fields }
═══════ SERVER-SIDE VERIFICATION AND ORDER CREATION ═══════
→ Compute HMAC-SHA256(razorpay_order_id + "|" + razorpay_payment_id, RAZORPAY_KEY_SECRET)
→ Compare with razorpay_signature (constant-time comparison)
IF mismatch → Return 400 PAYMENT_SIGNATURE_INVALID → no order created → log to AuditLog
IF match → BEGIN Serializable DB transaction:
1. Re-validate and decrement stock: UPDATE Products SET stock = stock - qty WHERE id=X AND stock >= qty
2. Create Order { buyerId, sellerId, status: PENDING_SELLER_APPROVAL, paymentStatus: PAID,
addressSnapshot: JSON.stringify(selectedAddress), ← IMMUTABLE COPY
shippingCharge, couponDiscount, grandTotal }
3. Create SellerOrder rows with sellerDiscount and sellerProceeds
4. Apply CouponRedemption and increment Coupon totals for matching seller order
5. Create OrderItem records for each cart item
6. Soft-delete all cart items (clear cart)
COMMIT
→ Create buyer/seller in-app notifications
→ NotificationsService.dispatch queues buyer email and seller email/SMS
→ RealtimeService emits buyer:data-changed, seller:data-changed, notifications:changed
Seller message: "You have a new order. Please accept or reject within 24 hours."
→ Return 201 { orderId }
→ FRONTEND: redirect to /order-confirmation/{orderId}
Show: "Order Pending Approval — Payment confirmed. Awaiting seller acceptance."
═══════ SELLER APPROVAL WINDOW — 24 HOURS ═══════
Seller ACCEPTS:
→ Order.status → PROCESSING; fulfillment begins
→ Buyer notified: "Your order has been accepted by the seller."
Seller REJECTS (must provide rejection reason):
→ POST Razorpay /refunds { payment_id, amount }
→ Buyer notified: "Your order was cancelled by the seller. A refund has been initiated
and will be processed within 5–7 business days."
No action in 24 hours:
→ OrdersService timer checks autoAcceptDeadlineAt every 60 seconds
→ Auto-accept: Order.status → PROCESSING
→ Buyer notified: "Your order has been accepted."
🔌 API Reference — Checkout▼
POST Create Razorpay Order
{
"method": "POST",
"endpoint": "/api/v1/payments/create-order",
"headers": {
"Cookie": "access_token=httpOnly"
},
"body": {
"addressId": "addr_001",
"couponCode": "SAVE10",
"idempotencyKey": "uuid-v4-here"
}
}
Response — Razorpay Order Created
{
"status": 200,
"body": {
"razorpayOrderId": "order_XXXX",
"amount": 79900,
// in paise
"currency": "INR",
"keyId": "rzp_test_...",
"grandTotal": 799
}
}
POST Verify Payment + Create Order
{
"method": "POST",
"endpoint": "/api/v1/payments/verify",
"body": {
"razorpay_payment_id": "pay_XXXX",
"razorpay_order_id": "order_XXXX",
"razorpay_signature": "hmac_sha256_hash"
}
}
Response — Order Created
{
"status": 201,
"body": {
"orderId": "ord_001",
"status": "PENDING_SELLER_APPROVAL",
"grandTotal": 799,
"paymentStatus": "PAID",
"message": "Order Pending Approval — Payment confirmed. Awaiting seller acceptance."
}
}
// 400 on signature mismatch:
{
"status": 400,
"code": "PAYMENT_SIGNATURE_INVALID"
}
Gateway
Razorpay (test mode — rzp_test_...)
Signature
HMAC-SHA256 · constant-time compare
Webhook Security
Raw body preserved · X-Razorpay-Signature
Idempotency
ProcessedWebhook table · razorpayEventId
Buyer
Feature 16 — Payment: Razorpay Integration + Secure Webhook Processing (No COD)
All buyer payments route through Razorpay using UPI, Card, Net Banking, or Digital Wallet. The webhook endpoint is public (no JWT) but protected by HMAC-SHA256 signature verification against the raw request body. Webhook idempotency is enforced via a ProcessedWebhook table. The current webhook logic records the Razorpay event and, for
payment.captured, attempts to complete a pending checkout by Razorpay order id.
Next.js
NestJS
Razorpay API
PostgreSQL
ProcessedWebhook + CheckoutService
⚠ Webhook Security Rules
Raw body must be preserved before JSON parsing — use NestJS RawBodyMiddleware. The HMAC is computed over the raw bytes, not the parsed JSON object. The ProcessedWebhook table INSERT happens before any business logic to prevent duplicate processing under concurrent webhook retries. Always return HTTP 200 to Razorpay, even on idempotent/already-processed events, to prevent Razorpay from retrying.
📋 Full Flow Description▼
═══════ RAZORPAY MODAL FLOW ═══════
FRONTEND opens Razorpay checkout modal (SDK call with razorpayOrderId, amount, keyId)
Payment options shown: UPI (GPay, PhonePe, Paytm, any VPA) | Credit/Debit Card | Net Banking | Digital Wallet
No COD option is shown — Cash on Delivery does not exist in this system.
ON PAYMENT SUCCESS (Razorpay JS SDK callback):
→ Receives: { razorpay_payment_id, razorpay_order_id, razorpay_signature }
→ Immediately POST /api/v1/payments/verify { all three fields }
ON PAYMENT FAILURE (modal dismissed, card declined, UPI timeout):
→ Frontend: Show "Payment Failed" with [Try Again] button
→ Increment retry_count in Zustand (scoped to this checkout session, max 3)
IF retry_count < 3 → Re-call POST /payments/create-order → New Razorpay order ID → Reopen modal
IF retry_count ≥ 3 → Redirect to cart: "Payment failed. Please check your payment details or try a different method."
═══════ BACKEND SIGNATURE VERIFICATION ═══════
→ Compute: HMAC-SHA256(razorpay_order_id + "|" + razorpay_payment_id, RAZORPAY_KEY_SECRET)
→ Compare with razorpay_signature using constant-time comparison (prevents timing attacks)
IF mismatch → Return 400 PAYMENT_SIGNATURE_INVALID → no order created → log to AuditLog + Sentry
IF match → Proceed to serializable DB transaction (see Feature 13)
═══════ RAZORPAY WEBHOOK — PARALLEL SERVER-SIDE CONFIRMATION ═══════
Endpoint: POST /api/v1/payments/webhook
Decorators: @SkipCsrf + @Public (no JWT required — this endpoint is public for Razorpay)
Step 1: RawBodyMiddleware captures raw bytes BEFORE JSON parsing
Step 2: Compute HMAC-SHA256(rawBody, RAZORPAY_WEBHOOK_SECRET)
Step 3: Compare with X-Razorpay-Signature header (constant-time)
IF mismatch → Return 400 → Log to AuditLog → stop
Step 4: Parse JSON → Extract razorpayEventId
Step 5: Check ProcessedWebhook table WHERE razorpayEventId = incoming
IF already processed → Return 200 (idempotent — do not reprocess)
Step 6: INSERT INTO ProcessedWebhook { razorpayEventId } FIRST (before any business logic)
→ Prevents duplicate processing under concurrent webhook retries
Step 7: Handle event type:
payment.captured → Confirm Payment.status = PAID (redundant safety check)
payment.failed → Update Payment.status = FAILED → notify buyer
EDGE CASE: payment captured but Order record not found:
→ Create PaymentReceivedNoOrder { razorpayPaymentId, amount, buyerId }
→ Alert admin via Sentry → Manual resolution required
Step 8: Return 200 to Razorpay (always — prevents retry storm)
═══════ TEST MODE ═══════
RAZORPAY_KEY_ID=rzp_test_... | RAZORPAY_KEY_SECRET=test_secret
Test UPI: success@razorpay | failure@razorpay
No real money is processed in test mode.
🔌 API Reference — Payment Webhook▼
POST Razorpay Webhook (from Razorpay servers)
{
"method": "POST",
"endpoint": "/api/v1/payments/webhook",
"headers": {
"X-Razorpay-Signature": "hmac_sha256_raw_body",
"Content-Type": "application/json"
},
"body": {
"entity": "event",
"event": "payment.captured",
"payload": {
"payment": {
"entity": {
"id": "pay_XXXX",
"order_id": "order_XXXX",
"amount": 79900,
"status": "captured"
}
}
}
}
}
Response — Webhook Acknowledged
{
"status": 200,
"body": {
"received": true
}
}
// ALWAYS return 200 to Razorpay
// even for idempotent/already-processed events
// to prevent Razorpay retry storms
// 400 on signature mismatch only:
{
"status": 400,
"code": "WEBHOOK_SIGNATURE_INVALID"
}
Order Statuses
7 statuses — full Amazon-style timeline
Real-time Updates
Socket.IO — no page refresh needed
Invoice
Current API returns pending status
Return Window
7 days from delivery
Buyer
Feature 17 — Order Tracking: Timeline + Realtime Refresh + Current Invoice Endpoint
Orders list with tab navigation (All / To Ship / To Deliver / Delivered / Cancelled). Order detail page shows a visual timeline where each status transition is a step with a timestamp. Realtime updates arrive through user-scoped RealtimeService events such as
buyer:data-changed and notifications:changed; the page refetches affected order data without requiring a full reload. The current invoice endpoint does not generate a PDF yet: it returns invoiceUrl: null with PENDING_GENERATION after delivery or AVAILABLE_AFTER_DELIVERY before delivery.
Next.js
NestJS
PostgreSQL
Socket.IO
BuyerOrdersService invoice status
PENDING_SELLER_APPROVAL
→
PROCESSING
→
SHIPPED
→
OUT_FOR_DELIVERY
→
DELIVERED
|
CANCELLED
→
RETURN_INITIATED / RETURN_ACCEPTED
→
REFUND_PROCESSING
→
REFUNDED
📋 Full Flow Description▼
═══════ ORDERS LIST PAGE ═══════
GET /api/v1/buyer/orders?page={n}&limit=50
Backend also supports an optional exact seller-order status filter: status=DELIVERED, status=PROCESSING, etc.
Current frontend tabs fetch the list and filter sellerOrders client-side:
All → all seller orders for this buyer, newest first
In-transit → status IN (PENDING_SELLER_APPROVAL, PROCESSING, SHIPPED, OUT_FOR_DELIVERY)
Delivered → status = DELIVERED
Cancelled → status = CANCELLED
Return orders → status starts with RETURN_ or REFUND_
Each order card: Order ID, date, product thumbnail + name + variant + qty, status badge, grand total, [View Details] button.
═══════ ORDER DETAIL PAGE ═══════
GET /api/v1/buyer/orders/{id}
Backend verifies order.buyerId = currentUser.id (prevents IDOR)
Renders: Order ID, purchase date, estimated delivery date, seller name + link, delivery address from addressSnapshot (immutable JSON — unaffected by later address edits), payment method + amount.
═══════ VISUAL STATUS TIMELINE (AMAZON-STYLE) ═══════
Vertical timeline, each status = one step:
1. Order Placed → timestamp of order creation
2. Seller Accepted → timestamp of acceptance (or auto-accept)
3. Packed / Ready to Ship → timestamp when seller marks ready
4. Shipped → timestamp + tracking number + courier name
5. Out for Delivery → timestamp (if applicable)
6. Delivered → timestamp of delivery confirmation
Step rendering:
Completed → filled green circle + ✓ checkmark + timestamp
Current → highlighted circle + pulsing animation + "In Progress"
Future → empty greyed circle + status name only
Cancelled → red circle + × icon + cancellation reason
═══════ REAL-TIME UPDATES — SOCKET.IO ═══════
Authenticated sockets join the user-scoped room
user:{userId}.
Realtime events: buyer:data-changed and notifications:changed
Payloads include order-related areas and ids when available.
→ Frontend refetches order/notification data and updates the timeline without requiring a full page reload.
═══════ TRACKING INTEGRATION ═══════
When seller marks order as Shipped: POST /api/v1/seller/orders/:id/ship { trackingNumber, courierName }
→ Order.trackingNumber + Order.courierName saved
→ On order detail page: [Track Package] button → opens courier tracking page in new tab
═══════ CURRENT INVOICE ENDPOINT ═══════
GET /api/v1/buyer/orders/{sellerOrderId}/invoice
→ Current code verifies seller order ownership and returns:
{ sellerOrderId, invoiceUrl: null, status: "PENDING_GENERATION" } when delivered
{ sellerOrderId, invoiceUrl: null, status: "AVAILABLE_AFTER_DELIVERY" } before delivery
→ PDF generation/upload is not implemented in the current BuyerOrdersService.
═══════ RETURN WINDOW ═══════
Return button shown ONLY if:
Order.status = DELIVERED AND
NOW() < Order.deliveredAt + 7 days
→ Clicking opens Return flow (Feature 20)
After 7 days → button hidden → window closed message if buyer asks
🔌 API Reference — Order Tracking▼
GET Orders List
{
"method": "GET",
"endpoint": "/api/v1/buyer/orders",
"query": {
"status": "DELIVERED",
"page": 1,
"perPage": 10
}
}
Response — Order List
{
"status": 200,
"body": {
"orders": [
{
"id": "ord_001",
"status": "DELIVERED",
"grandTotal": 799,
"createdAt": "2025-04-01T10:00:00Z",
"trackingNumber": "DTDC123456",
"items": [/* orderItems */]
}
],
"total": 14,
"page": 1
}
}
Return Window
7 days from delivery date
Return Statuses
Request → accept/reject → ship → refund
Investigation
No delayed investigation worker in current code
Refund Channel
Razorpay /refunds · 5–7 business days
Buyer
Feature 20 — Return & Refund: Buyer Request → Seller Decision → Return Shipping → Seller Receipt → Razorpay Refund
The current return flow is route-driven and does not use a delayed investigation worker. Buyer initiates a return on a delivered seller order within the 7-day window. Seller approves or rejects from seller orders. If approved, buyer marks the return shipment out for delivery. Seller then marks the returned product received safely, inventory is restocked, seller proceeds are reversed, and Razorpay refund processing starts. RealtimeService updates buyer/seller order and notification areas.
Next.js
NestJS
PostgreSQL
Razorpay Refunds
RealtimeService + OrdersService
Cloudinary
REQUESTED / RETURN_INITIATED
→
RETURN_ACCEPTED
→
RETURN_SHIPPED
→
REFUND_PROCESSING
→
REFUNDED
|
RETURN_REJECTED
📋 Full Flow Description▼
═══════ PRE-CONDITION ═══════
SellerOrder.status = DELIVERED AND deliveredAt + 7 days > NOW()
"Return This Order" button visible on buyer's Order Detail page.
BuyerReturnsService.assertReturnWindow: IF not delivered, missing deliveredAt, or window expired → 403 RETURN_WINDOW_CLOSED → stop
═══════ BUYER — INITIATES RETURN ═══════
Clicks "Return This Order" → Frontend shows return form:
Reason (select — required):
Damaged or Defective | Wrong Item Delivered | Not as Described |
Missing Parts or Accessories | Other
Description (text — required if "Other")
Proof Images (Cloudinary upload — up to 5 image URLs)
→ POST /api/v1/buyer/orders/{sellerOrderId}/return { reason, reasonDetail?, imageUrls[] }
→ BACKEND:
Create ReturnRequest { status: REQUESTED, refundAmount: sellerOrder.sellerSubtotal }
Update SellerOrder.status = RETURN_INITIATED
Create seller notification row and order status history
Emit seller:data-changed, buyer:data-changed, and notifications:changed
═══════ SELLER — REVIEWS RETURN REQUEST ═══════
Seller sees "Return Requested" badge on order in dashboard Active Status tab.
Opens review modal: reason, description, proof images.
Decision:
IF ACCEPT: → PATCH /api/v1/seller/orders/{sellerOrderId}/return/approve
→ Update ReturnRequest.status = RETURN_ACCEPTED
→ Update SellerOrder.status = RETURN_ACCEPTED
→ Create buyer notification and emit seller/buyer data changes
IF REJECT: → PATCH /api/v1/seller/orders/{sellerOrderId}/return/reject { rejectionReason }
→ Update ReturnRequest.status = RETURN_REJECTED with rejectionReason
→ Update SellerOrder.status = RETURN_REJECTED
→ Create buyer notification and emit seller/buyer data changes
═══════ BUYER — SHIPS PRODUCT BACK ═══════
After RETURN_ACCEPTED: Buyer uses own courier.
→ PATCH /api/v1/buyer/orders/{sellerOrderId}/return/ship { returnTrackingNumber, returnCourierName }
→ Update ReturnRequest.status = RETURN_SHIPPED
→ Update SellerOrder.status = RETURN_SHIPPED
→ Create seller notification and emit seller/buyer data changes
═══════ SELLER — CONFIRMS RECEIPT ═══════
Seller physically receives and inspects product.
→ PATCH /api/v1/seller/orders/{sellerOrderId}/return/received
→ Update SellerOrder.status = REFUND_PROCESSING
→ Update ReturnRequest.status = REFUND_PROCESSING
→ Restock returned items and reverse seller proceeds through OrderAccountingService
═══════ REFUND PROCESSING ═══════
→ POST Razorpay /refunds { payment_id, amount }
IF Razorpay status = processed:
→ Update SellerOrder.status = REFUNDED
→ Update ReturnRequest.status = REFUNDED
→ Update refundStatus = REFUNDED and write order status history
IF Razorpay is missing, pending, or fails:
→ Keep SellerOrder.status = REFUND_PROCESSING
→ refundStatus = REFUND_INITIATED for retry/admin follow-up
→ Create buyer notification and emit seller:data-changed, buyer:data-changed, notifications:changed
🔌 API Reference — Return & Refund▼
POST Initiate Return
{
"method": "POST",
"endpoint": "/api/v1/buyer/orders/:sellerOrderId/return",
"body": {
"reason": "Damaged or Defective",
"reasonDetail": "Screen cracked on arrival",
"imageUrls": [
"https://res.cloudinary.com/..."
]
}
}
Response — Return Initiated
{
"status": 201,
"body": {
"id": "ret_001",
"sellerOrderId": "seller_order_001",
"status": "REQUESTED",
"message": "Return request submitted. The seller will review it shortly."
}
}
// 403 if window expired:
{
"status": 403,
"code": "RETURN_WINDOW_CLOSED",
"message": "Return window has closed."
}
Visibility
Private until seller answers
Rate Limit
1 question per buyer per product per 24h
Buyer Name
First name + Last initial only (e.g. Rahul M.)
Archive Job
No auto-archive cron in current code
Buyer
Feature 21 — Product Q&A: Buyer Asks → Private → Seller Answers → Public
A Q&A section appears on every product detail page below the reviews. Questions remain private and invisible to all other users until the seller provides an answer, at which point the Q&A entry becomes public. Buyers are limited to one question per product per 24 hours as an anti-spam measure. The current code does not auto-archive unanswered questions. Buyer names in public Q&A display only as "First name + Last initial" (e.g., Rahul M.) for privacy.
Next.js
NestJS
PostgreSQL
RealtimeService
📋 Full Flow Description▼
═══════ Q&A SECTION LOAD (PUBLIC VIEW) ═══════
GET /api/v1/qa/{productId} → Returns only answered public questions for one product.
GET /api/v1/qa?page=1&limit=50 → Returns answered public questions across all active/out-of-stock products.
Ordered by createdAt DESC. "See all questions" can use the all-products route.
Buyer name format: "First name + Last initial" only (e.g., "Rahul M.") — privacy.
═══════ BUYER ASKS A QUESTION ═══════
Buyer must be authenticated to ask. Guests see: "Please log in to ask a question." → redirect to login → return to product page after.
→ POST /api/v1/qa { productId, questionText }
→ BACKEND:
Rate limit check: SELECT COUNT FROM QA WHERE buyerId=X AND productId=Y AND createdAt > NOW-24h
IF count ≥ 1 → Return 422 "You can ask one question per product per 24 hours"
IF OK → INSERT ProductQuestion { buyerId, productId, questionText, status: UNANSWERED, isPublic: false }
→ Create seller notification row
→ Emit notifications:changed + seller:data-changed
→ Buyer sees: "Question submitted. We will notify you when the seller replies."
→ Question is NOT visible to any other user until the seller answers.
═══════ SELLER ANSWERS ═══════
Seller sees unanswered questions in dashboard Q&A section.
Badge count on sidebar nav item shows unanswered count.
→ POST /api/v1/seller/questions/{questionId}/answer { answerText }
→ BACKEND: UPSERT ProductAnswer and UPDATE ProductQuestion { status: ANSWERED, isPublic: true }
→ Create buyer in-app notification
→ Emit buyer:data-changed with areas: questions + notifications
→ Q&A entry now appears publicly on the product detail page.
═══════ CURRENT NO-CRON BEHAVIOUR ═══════
No scheduled archive job exists in the current code. Unanswered questions remain private in the seller question queue until answered, hidden, reported, or deleted by a future/admin workflow.
🔌 API Reference — Q&A▼
POST Ask Question
{
"method": "POST",
"endpoint": "/api/v1/qa",
"headers": {
"Cookie": "access_token=httpOnly"
},
"body": {
"productId": "prod_abc",
"questionText": "Is this available in XL size?"
}
}
Response — Question Submitted
{
"status": 201,
"body": {
"questionId": "qa_001",
"message": "Question submitted. We will notify you when the seller replies."
}
}
// 422 on rate limit:
{
"status": 422,
"code": "QA_RATE_LIMIT",
"message": "You can ask one question per product per 24 hours."
}
Max Alerts
50 active alerts per buyer
Notification
Socket.IO + in-app notification rows
Guest
No API — show login message + store slug
One-time alert
Marked notified after firing — must re-register
Buyer
Feature 22 — Stock-Back Alert: Notify Me — Guest Gate · Authenticated Flow · Current Restock Notification
When a product goes out of stock, authenticated buyers can register a "Notify Me" alert. Once the seller restocks the product or variant from zero to available stock, the inventory service creates in-app notification rows for waiting buyers and the seller, marks up to 100 matching alerts as notified, and emits Socket.IO realtime refresh events. Alerts are one-time — after firing they are marked notified=true; the buyer must click "Notify Me" again for future restocks. Guest users are shown a login prompt; the product slug is stored in sessionStorage for seamless return after authentication.
Next.js
NestJS
PostgreSQL
Socket.IO
InventoryService
📋 Full Flow Description▼
═══════ GUEST BEHAVIOUR ═══════
Guest clicks "Notify Me" on an out-of-stock product:
→ Do NOT call any API
→ Do NOT auto-redirect
→ Show message: "You are not logged in. Please log in to track the product stock."
→ Show a [Log In] button
→ Store current product slug in sessionStorage
→ After login: return user to the product detail page
→ User clicks "Notify Me" again → API is called → alert registered
═══════ AUTHENTICATED BUYER FLOW ═══════
→ POST /api/v1/products/{productId}/stock-alert { variantId? }
→ BACKEND: Check product or variant is out of stock, then check buyer active alert count (notified=false, isDeleted=false) < 50
IF ≥ 50 → Return 422 "Maximum 50 active stock alerts reached"
IF product is in stock → Return 422 STOCK_ALERT_NOT_REQUIRED
IF OK → Create StockBackAlert { buyerId, productId, variantId?, notified: false }
(Skip if already exists — idempotent)
→ Create seller notification "Buyer requested stock alert"
→ Create buyer notification "Stock alert enabled"
→ Emit notifications:changed for buyer + seller, and seller:data-changed
→ Button changes to "You will be notified" with [Remove Alert] option
REMOVE ALERT:
→ DELETE /api/v1/products/{productId}/stock-alert { variantId? } → isDeleted=true, deletedAt=NOW()
→ Button reverts to "Notify Me"
═══════ SELLER RESTOCKS — NOTIFICATION PIPELINE ═══════
Seller updates product stock → effectiveStock goes from 0 to positive
→ BACKEND: Update listingStatus=ACTIVE
→ SearchIndexService.upsertProduct(productId) updates Algolia
→ Create in-app Notification rows for waiting buyers and seller
→ SELECT * FROM StockBackAlert WHERE productId=X AND notified=false AND isDeleted=false
→ Process up to 100 alerts in the same inventory transaction:
For each subscriber:
→ Socket.IO notificationsChanged + buyerDataChanged
Payload: { productId, productName, slug, imageUrl }
→ UPDATE StockBackAlert SET notified=true, notifiedAt=NOW()
→ Alert is consumed (one-time). Buyer must click "Notify Me" again for future restocks.
🔌 API Reference — Stock Alert▼
POST Register Stock Alert
{
"method": "POST",
"endpoint": "/api/v1/products/:productId/stock-alert",
"headers": {
"Cookie": "access_token=httpOnly"
},
"body": {
"variantId": "variant_uuid_optional"
}
}
Response — Alert Registered
{
"status": 200,
"body": {
"id": "sba_001",
"buyerId": "buyer_001",
"productId": "prod_abc",
"variantId": null,
"notified": false
}
}
Guest Storage
localStorage · max 10 items
Auth Storage
PostgreSQL ProductView · max 20
Display
Horizontal scrollable row — home + PDP
Login Sync
localStorage → DB (independent per slug)
Buyer
Feature 23 — Recently Viewed Products: localStorage (Guest) + PostgreSQL (Auth) + Login Sync
Guests: up to 10 recently viewed product slugs stored in localStorage as structured JSON (slug, name, imageUrl, price, updatedAt) — no API call needed for display. Registered buyers: stored in the ProductView table (last 20 by viewedAt DESC). On login, guest localStorage data is synced to the database using independent per-slug API calls (no bulk merge endpoint). The "Recently Viewed" section renders as a horizontal scrollable row on the home page and every product detail page. The row is hidden entirely if no history exists.
Next.js
NestJS
PostgreSQL
📋 Full Flow Description▼
═══════ GUEST — localStorage TRACKING ═══════
localStorage key: "recentlyViewed"
Value: JSON array of { slug, name, imageUrl, price, updatedAt }
On every Product Detail page load:
→ Read current array from localStorage
→ IF product already in array → move to position 0 (de-duplicate, update timestamp)
→ IF not in array → prepend new item
→ Truncate array to max 10 items (remove oldest from tail)
→ Write back to localStorage
→ No API call needed for display — data already in localStorage
═══════ AUTHENTICATED — DATABASE TRACKING ═══════
On Product Detail page load:
→ POST /api/v1/products/viewed/{productId} (fire-and-forget view event)
→ BACKEND: UPSERT ProductView { buyerId, productId, viewedAt: NOW }
(INSERT if new, UPDATE viewedAt if exists — always upsert)
VIEW COUNT (universal — all users):
→ navigator.sendBeacon POST /api/v1/products/{id}/view (no UI block)
→ Backend deduplicates: same user + product within 24h = 1 unique view
→ Increments product.viewCount
═══════ LOGIN SYNC — localStorage → DATABASE ═══════
After successful login or signup:
→ Read localStorage "recentlyViewed" (if any items)
→ For each slug — independent API call:
POST /api/v1/products/recently-viewed/sync { slug }
→ Backend: lookup productId from slug
→ IF ProductView doesn't exist for this buyer+product → INSERT
→ IF exists → skip (preserve existing DB history)
→ Each call is independent — one failure does not abort others
→ Clear localStorage "recentlyViewed" after sync (DB is now source of truth)
═══════ DISPLAY — HORIZONTAL SCROLLABLE ROW ═══════
Shown on: Home page (for all users with history) and Product Detail page (for all users)
Guest: rendered from localStorage — no API
Authenticated: GET /api/v1/products/recently-viewed → last 20, ordered by viewedAt DESC
Each card: product thumbnail, name, price, star rating
Cards link to /products/{slug}
Row is hidden entirely if no history exists — do not render empty section.
Max shown: 10 for guests (localStorage cap) / 20 for authenticated buyers (DB)
🔌 API Reference — Recently Viewed▼
GET Recently Viewed (Auth)
{
"method": "GET",
"endpoint": "/api/v1/products/recently-viewed",
"headers": {
"Cookie": "access_token=httpOnly"
}
}
Response — Last 20 Products
{
"status": 200,
"body": {
"products": [
{
"id": "prod_abc",
"slug": "cotton-slim-shirt",
"name": "Cotton Slim Fit Shirt",
"price": 799,
"mainImageUrl": "https://res.cloudinary.com/...",
"avgRating": 4.3,
"viewedAt": "2025-04-27T09:00:00Z"
}
]
}
}
Coupon API
POST /api/v1/buyer/coupons/validate
Checkout API
POST /api/v1/payments/create-order
Testing UI
/buyer/api-testing
Invoice
GET /api/v1/buyer/orders/:sellerOrderId/invoice
Current Code Sync
Current Buyer Implementation — Coupon Checkout, Reviews, Q&A, Returns, API Testing
This section records the routes currently implemented in code. Buyer checkout can validate a seller coupon,
reserve it during Razorpay order creation, and apply it after payment verification. The buyer API testing
dashboard loads fixtures and lets you run real buyer endpoints manually for screenshots.
Next.js Buyer UI
NestJS Buyer APIs
Prisma/Postgres
Razorpay
📋 Current Buyer Route Reference▼
CHECKOUT AND COUPONS
POST /api/v1/buyer/coupons/validate validates active seller coupon scope and returns discount preview.
POST /api/v1/payments/create-order accepts couponCode, reserves CouponRedemption, and creates a Razorpay order.
POST /api/v1/payments/verify creates the order transaction and applies the reserved coupon.
BUYER API TESTING DASHBOARD
/buyer/api-testing is role-protected and uses live fixture defaults. It can run cart, coupon, order, and Q&A APIs for screenshots.
Q&A AND REVIEWS
GET /api/v1/qa lists all public Q&A. POST /api/v1/qa asks a product question. POST /api/v1/buyer/reviews creates rating/review data and feeds seller rating analytics.
RETURNS AND REFUND PATH
Buyer initiates return, seller approves or declines, buyer ships return, seller marks returned safely, then refund flow can be initiated.