Coupons & Promo Codes
The atomic discount unit on ABc. An owner (or platform admin) generates a code, configures who/when/how-much, and the system applies it deterministically at checkout — on the public site or in a manual booking. Every redemption is tracked, capped and audit-logged.
Discount types
Percent off
e.g. 15 % off the room subtotal. Can carry a max-cap (max ₹ deduction).
Most commonFlat amount off
₹500 off any booking over ₹3,000. Predictable for finance.
Free nights
"Book 3, stay 4." Cheapest night becomes free. Min-stay enforced automatically.
V1.1Free upgrade
Book Standard, get Deluxe if available at check-in. Otherwise refund the upgrade fee.
V1.1Configurable rules — every coupon carries these
| Field | Type | Example |
|---|---|---|
| Code | text, 4–16 chars, uppercase | SUMMER25, FIRSTSTAY |
| Internal name | text | "Summer 2026 — Manali properties" |
| Discount type | enum | percent · flat · free_nights · free_upgrade |
| Discount value | numeric | 15 (for %) · 500 (for ₹) · 1 (for nights) |
| Max discount cap | ₹ (nullable) | Cap a % discount at ₹2,000 |
| Valid from / until | date | Booking window. 2026-06-01 → 2026-08-31 |
| Stay window from / until | date (nullable) | Check-in must be in this range. Often used with seasonal sales. |
| Property scope | all · selected · by_tag | "All Manali properties" or specific list |
| Room-type scope | all · selected | "Only Deluxe + Family Suite" |
| Channels | multi-select | Direct · Manual · (OTAs always excluded) |
| Min booking value | ₹ (nullable) | Min ₹3,000 |
| Min nights | int (nullable) | Min 2 nights |
| Max total uses | int (nullable) | 100 redemptions total, then auto-expire |
| Max uses per guest | int | 1 per email/phone, default |
| First-time guest only | bool | True for acquisition codes (no prior bookings on file) |
| Stackable | bool | Default false — one coupon per booking |
| Auto-apply | bool | True = applies without code entry if rules match (member discount) |
| Status | enum | draft · scheduled · active · paused · expired · exhausted |
Lifecycle states
Coupons list UI
| Code | Type | Scope | Used | Revenue impact | Status |
|---|---|---|---|---|---|
| SUMMER25 Summer 2026 sale |
25 % off capped at ₹2,000 |
All Manali · all rooms | 42 / 100 until 31 Aug |
+₹1,82,400 booked −₹38,200 discount |
Active |
| FIRSTSTAY New guest discount |
₹500 flat | All · min ₹3,000 | 118 / ∞ first-time only |
+₹6,42,100 booked −₹59,000 discount |
Active |
| DIWALI500 Festive — auto-apply |
₹500 flat auto-apply if eligible |
All · stay 20–28 Oct | 0 / 200 | — | Scheduled · Sep 15 |
| RETURN20 Loyalty |
20 % off | All · repeat guests | 61 / ∞ | +₹3,12,000 booked |
Active |
| MONSOON15 Low-occupancy push |
15 % off | Hillside · Jul–Sep stays | 100 / 100 | +₹2,18,000 booked |
Exhausted |
| WEEKDAY10 | 10 % off | All · check-in Mon–Thu | 8 / ∞ | — | Paused |
Create / edit coupon — UI
Where coupons get applied
Public checkout
An expander "Have a code?" sits above the totals. Live validation: invalid codes get a friendly error; valid codes update the total in place.
Manual booking (desk)
Front-desk agent has an "Apply coupon" picker that lists only coupons matching the current property + dates + room type.
Auto-apply
If auto_apply = true and rules match (e.g. logged-in returning guest), the discount is silently applied with a green note.
Public checkout — code entry
Validation logic — order of checks
Code exists & is active
Otherwise: "That code isn't valid."
Within booking window
Today must be between
valid_fromandvalid_until. Otherwise: "This code has expired."Stay window (if set)
Booking's check-in date must be in
stay_from..stay_until. Otherwise: "Not valid for these dates."Property + room-type scope
This booking's property and room type must match scope. Otherwise: "Not valid for this property."
Channel scope
Booking source must be in
channels. OTA bookings always rejected. Otherwise: "Not valid for OTA bookings."Min booking value & min nights
Otherwise: "Spend ₹X more to use this code."
Per-guest cap
How many times has this email/phone already used this code? Otherwise: "You've already used this code."
Global cap
max_total_usesnot yet reached. Otherwise: "This code is fully redeemed."First-time-only check
If
first_time_only, the guest must have zero prior confirmed bookings. Otherwise: "Only for new guests."Compute discount
Apply value, then cap. Return discount amount + line label.
Data model
coupons
- iduuid
- codetext uniq
- nametext
- owner_id→ users · nullable
- typeenum
- valuenumeric
- max_discount_capmoney · nullable
- valid_fromtimestamptz
- valid_untiltimestamptz
- stay_fromdate · nullable
- stay_untildate · nullable
- property_scopejsonb
- room_type_scopejsonb
- channelstext[]
- min_booking_valuemoney · nullable
- min_nightsint · nullable
- max_total_usesint · nullable
- max_per_guestint
- first_time_onlybool
- stackablebool
- auto_applybool
- statusenum
- campaign_id→ campaigns · nullable
coupon_redemptions
- iduuid
- coupon_id→ coupons
- booking_id→ bookings
- guest_id→ guests
- discount_amountmoney
- statusapplied · voided
- redeemed_attimestamp
- voided_attimestamp · nullable
API contract
// Request — sent during checkout when a user enters a code { "code": "SUMMER25", "booking_draft": { "property_id": "prp_…", "room_type_id": "rt_…", "check_in": "2026-07-12", "check_out": "2026-07-15", "subtotal": 12600, "channel": "direct" }, "guest": { "email": "priya@...", "phone": "+91…" } } // Response — 200 valid { "valid": true, "coupon_id": "cpn_01HX…", "label": "SUMMER25 — 25 % off (capped at ₹2,000)", "discount_amount": 2000, "new_subtotal": 10600 } // Response — 422 invalid (one example) { "valid": false, "reason": "channel_excluded", "message": "This code isn't valid for OTA bookings." }
Cancellation & the cap
When a booking with an applied coupon is cancelled, the coupon's redemption row is marked voided. This decrements used_count so another guest can still use it within the cap. If the coupon's cap is already past, the cancelled slot stays voided but the cap is not reopened (anti-gaming).
Anti-abuse
- Velocity limits — max 5 code-validation attempts per IP per minute (prevents brute-force guessing).
- Code complexity — system-generated codes are 8 random chars (≈ 2.8 × 10¹² combos); owner-typed codes must be ≥ 4 chars.
- Per-guest cap — enforced by verified email AND verified phone (anti same-person-two-accounts).
- First-time check — runs against all confirmed bookings for this phone OR email, across all owners.
- Audit log — every coupon create/edit/disable + every redemption + every void written to
audit_log. - Suspicious-burst alert — if a single coupon hits 50 redemptions in <1 hour, owner gets an alert.
Integration with other modules
| Module | Touch point |
|---|---|
| Pricing Engine | Coupon discount is applied after the rate engine returns subtotal. Coupon never alters the per-night rate — only adds a discount line item. |
| Booking | Each booking stores its applied coupon code + discount amount + redemption ID. Visible in detail. |
| Refunds | Cancellation voids redemption + refund includes the discount-adjusted total (not the gross). |
| Emails | Confirmation email shows the coupon line. New template variant for "coupon-driven booking". |
| Analytics | New report: revenue with-vs-without coupons, top codes by redemption. |
| Campaigns | A coupon can be linked to a parent campaign — that's how campaign-level attribution works. |
MVP vs later
MVP (Phase 1)
Percent + flat. Code entry at public checkout + manual booking. Per-guest + per-total caps. Cancellation void.
Phase 1V1.1
Auto-apply rules. Free-nights & free-upgrade types. First-time-only enforcement. Email blast of a code.
Phase 2V2
Stackable rules engine. Tiered codes (e.g. "10 % up to ₹500, 15 % up to ₹2000"). Referral codes that pay the referrer.
Phase 3