Royal Glow internal docs · now fully interactive — Steps, API tables, file trees & live status
Royal Glow Docs
System Design

Low-Level Design

Database schema details, booking state machine, background job specifications, and sequence diagrams for Royal Glow.

Low-Level Design

This document captures the mechanics: the booking state machine, ID/number formats, GST and gems math, all 19 background jobs, request lifecycles, indexes, and security implementation. Money is always integer paise; status transitions are always logged in booking_status_log.

Booking State Machine

pending
  ├── confirmed   (receptionist approves + assigns staff)
  ├── rejected    (receptionist rejects)
  └── cancelled   (customer cancels before confirmation)

confirmed
  ├── in_progress  (service started)
  ├── cancelled    (customer cancels, within window)
  ├── rescheduled  (customer reschedules)
  └── no_show      (customer didn't arrive, +15min after end_time)

in_progress
  └── completed    (service done, invoice generated, gems awarded)

Walk-ins skip pending — they go directly to confirmed. Every status transition is recorded in booking_status_log with timestamp, actor (user ID), and reason.

ID & Number Formats

BK-{branch_code}-{YYMM}-{H|S}-{5_random}[-M]

Examples:
  BK-RS-2605-H-38291    ← Salon booking, Rayasandra, May 2026
  BK-RS-2605-S-72841    ← SPA booking
  BK-RS-2605-S-91023-M  ← SPA membership session
  • H = salon (hair/beauty)
  • S = spa
  • -M suffix = membership session
INV-{branch_number}-{financial_year}-{5_digit_random}

Example: INV-1-2627-92921
  ← Branch 1, Financial Year 2026-27, random 92921

Indian financial year runs April–March. A date in May 2026 → FY "2627". A date in February 2026 → FY "2526".

GST Calculation

All prices are GST-inclusive (18%, SAC 999721). Back-calculate the taxable base:

const GST_RATE = 0.18

function splitGST(inclusivePaise: number) {
  const basePaise = Math.round(inclusivePaise / (1 + GST_RATE))
  const gstPaise = inclusivePaise - basePaise
  const cgstPaise = Math.floor(gstPaise / 2)   // CGST = half of GST
  const sgstPaise = gstPaise - cgstPaise         // SGST = remainder
  return { basePaise, gstPaise, cgstPaise, sgstPaise, totalPaise: inclusivePaise }
}

CGST and SGST are equal halves (intra-state, Karnataka). The base is rounded; GST is the remainder — so base + gst always reconstructs the original inclusive amount exactly.

Gems (Loyalty) Calculation

// Earn rate: 1 gem per ₹100 invoiced (floor)
function calculateGemsEarned(invoiceTotalPaise: number): number {
  return Math.floor(invoiceTotalPaise / 10000)  // 10000 paise = ₹100
}

// Only on invoice_type = 'service'
// NOT on membership purchases or membership sessions
// Gems expire 365 days from earn date

No-Show Policy Logic

No-show count (last 90 days)Action
1–3CRM tag "No-Show Risk", no booking restriction
4+booking_requires_approval = true on customer_profile → Manager must approve future bookings

Recovery: 3 consecutive completed bookings reset no_show_count. Walk-in no-shows do NOT count.

Background Jobs (19 Total)

QStash scheduled jobs — including the seven that were formerly pg_cron.

JobSchedule (UTC)What It Does
1. Nightly sales summary0 18 * * * (11:30 PM IST)Aggregates daily revenue into daily_sales_summary
2. Membership auto-expire30 18 * * * (midnight IST)Sets status = 'expired' on memberships past expires_at
3. Offer auto-expire0 18 * * *Sets is_active = false on offers past end_date
4. Session cleanup0 21 * * 0 (2:30 AM IST Sunday)Deletes expired Better Auth sessions
5. pprd sync30 19 * * * (1 AM IST)Triggers GitHub Actions to reset pprd from prod
6. Monthly GST summary0 18 1 * * (11:30 PM IST, 1st of month)Aggregates monthly GST into monthly_gst_summary
7. Gems auto-expire0 18 * * *Offsets earned transactions older than 365 days

QStash scheduled jobs delivered as HTTP callbacks.

JobScheduleWhat It Does
8. Appointment remindersEvery 15 minSends push + email for bookings in next 24h and 1h
9. Membership expiry alertsDailySends alerts at 30d/7d/1d before expiry
10. Birthday emailsDailySends birthday offer to customers with birthday today
11. Membership usage nudgesWeeklyNudges members with >50% hours unused
12. Lead follow-upsEvery 4hAlerts receptionist about leads with no contact in 48h
13. Daily sales reportDaily 9 PM ISTSends Slack + email report to owner/manager
14. Weekly reportMonday 9 AM ISTSends weekly summary
15. Gems expiry reminderDailyPush notification 7 days before gems expire

QStash triggered jobs fired by API routes with a delay.

JobTriggerDelayWhat It Does
16. Post-service follow-upBooking completed+24hSends review request email/WhatsApp
17. Stale booking alertBooking created (pending)+2hAlerts receptionist if still pending
18. No-show checkBooking confirmed+15min after end_timeMarks no-show if customer didn't arrive
19. Membership expired noticeMembership expires+1hSends expiry notice to customer

Realtime Channels (Ably)

ChannelWho SubscribesEvents
booking:{bookingId}Customerstatus_changed
admin:bookings:{branchId}Receptionist/Managerbooking_created, booking_updated
admin:schedule:{date}Managerschedule_updated, leave_approved

Token Auth: clients receive a scoped Ably JWT from POST /api/ably/token. The token is scoped to only the channels the user is allowed to subscribe to (based on their role and user ID).

Request Lifecycles

The four core write paths, step by step.

Customer submits the booking form → POST /api/bookings.

Validate session (requireSession).

Zod-validate the request body.

Check slot availability (Redis cache → DB).

Validate all service IDs exist and are active.

Get the default staff for each service.

Generate the booking number (BK-RS-YYMM-H/S-XXXXX).

INSERT the booking row (status: pending).

INSERT booking_service rows (price snapshot).

Enqueue QStash job 17 (stale booking alert, +2h).

Invalidate the Redis slot cache.

Publish an Ably event to admin:bookings:{branchId}.

Return { success: true, data: { bookingId, bookingNumber } }.

Receptionist clicks "Mark Complete" → POST admin.theroyalglow.in/api/bookings/{id}/complete.

Validate session (requireRole: receptionist+).

Validate the booking exists and is in_progress.

Calculate the invoice total (sum of booking_service prices).

splitGST(totalPaise) → base, CGST, SGST.

Generate the invoice number (INV-1-YYZZ-XXXXX).

INSERT the invoice row.

INSERT invoice_item rows (price + name snapshot).

UPDATE booking status → completed.

Calculate gems earned (floor(total / 10000)).

UPSERT loyalty_account, INSERT loyalty_transaction.

Generate the PDF (React Email → PDF → R2 upload).

Send the invoice email via Resend (synchronous).

Fire the Meta CAPI Purchase event (server-side).

Enqueue QStash job 16 (post-service follow-up, +24h).

Publish an Ably event to booking:{bookingId}.

Return { success: true, data: { invoiceId, invoiceNumber } }.

Receptionist records a SPA session → POST admin.theroyalglow.in/api/memberships/{id}/sessions.

Validate session (requireRole: receptionist+).

Validate the membership is active and has sufficient hours.

Validate the service is a SPA service.

Deduct hours from spa_membership.used_hours_minutes.

INSERT the booking row (status: completed, total: ₹0).

INSERT the booking_service row.

INSERT the invoice row (type: membership_session, total: ₹0).

INSERT the invoice_item row.

NO gems awarded (membership sessions don't earn gems).

Return { success: true, data: { bookingId, remainingMinutes } }.

Customer submits the /book form (Meta ad landing page) → POST /api/leads.

Rate-limit check (3 per minute per IP).

Zod-validate (name, phone, serviceInterestedId).

Normalise the Indian phone number (+91XXXXXXXXXX).

INSERT the lead row (status: new, source: meta_ad).

Fire the Meta CAPI Lead event (server-side).

Enqueue QStash job 12 (lead follow-up, +48h).

Redirect to /?book=1&leadId={id}. When the customer books, lead.converted_booking_id is set and lead.status → booked.

Database Indexes

Key indexes for query performance:

-- Booking queries
CREATE INDEX idx_booking_customer ON booking(customer_id);
CREATE INDEX idx_booking_branch_date ON booking(branch_id, booking_date);
CREATE INDEX idx_booking_status ON booking(status);

-- Availability queries
CREATE INDEX idx_booking_staff_date ON booking(assigned_staff_id, booking_date)
  WHERE status IN ('confirmed', 'in_progress');

-- Membership queries
CREATE INDEX idx_membership_customer_active ON spa_membership(customer_id)
  WHERE status = 'active';

-- Loyalty queries
CREATE INDEX idx_loyalty_tx_account ON loyalty_transaction(loyalty_account_id);
CREATE INDEX idx_loyalty_tx_expiry ON loyalty_transaction(expires_at)
  WHERE transaction_type = 'earn' AND is_expired = false;

-- Lead queries
CREATE INDEX idx_lead_status ON lead(status);
CREATE INDEX idx_lead_source ON lead(source);

Security Implementation

OpenReport an issue

Was this page helpful?

On this page