Skip to content

Comp Reservations — Generic Free-Booking Primitive

A single public endpoint and Offer-row-driven configuration for any zero-cost reservation campaign. Replaces the one-off mms-comp pattern with a reusable primitive: marketers (or engineers) create an Offer of type=FREE_BOOKING, and the same endpoint, validation, stub-user, and tracking machinery handle every comp use case. First two campaigns on the new primitive: MMS Salem 2026 (migrated from the existing dedicated endpoint) and Salem First-Hour-Free (new, supports the Phase-1 paid Meta funnel rebuild — see paid-meta.md for context).

What this is (and is not)

  • Is: a data-driven comp primitive. New comp campaign = new Offer row + a marketing LP that calls the same endpoint. No code change per campaign.
  • Is: the back-end for any free-hour, free-tour, partner-comp, refer-a-friend, or festival-comp marketing motion we run in 2026+.
  • Is not: a credit-grant flow (that's type=CREDIT via CreditRedemptionService — unchanged).
  • Is not: a Stripe coupon, discount, or money-applied-to-real-payment path. Comp = paymentMethod: 'FREE', $0 charged, no Stripe touch.
  • Replaces: /api/mms-comp/reservations, services/scheduling/mms-comp.ts (MMS_COMP.resourceIds hardcoded allowlist), AllowFreeContext = 'mms-comp'. All migrated.

Architecture in one breath

Offer model carries the eligibility config (resource scope, location scope, max duration, per-user redemption cap, validity window). A single public endpoint loads the Offer by code, validates the request against the Offer's config, resolves a stub user by email, and writes the reservation with paymentMethod: 'FREE' + allowFreeContext: 'comp'. PromoRedemption enforces 1-per-user when configured; multiple-per-user is achieved by setting maxRedemptionsPerUser = null.

An offer can additionally set requiresVerifiedPhone, which layers a cross-identity redemption cap on top of the per-user one: PromoRedemption.phone (unique per offer) caps redemptions per phone number regardless of which stub/member account redeems, closing the free-email-address loophole in the per-user cap alone. Proving phone possession for unauthenticated guests, and the redemption-cap mechanics, are a separate capability — see ADR-024 and features/guest-phone-verification/spec.md. CHERRY_CITY_FIRST_PRACTICE is the first offer with the flag on.

Locked decisions

Decision Value
Endpoint path POST /api/comp/reservations (public, no auth)
Identity model Stub user resolved by email (resolveStubUserFromStaff from existing infra)
Required fields email, offerCode, resourceId, startTime, endTime, timezone, appointmentTypeId
Optional fields firstName, lastName, phone, artistName, smsMarketingConsent, website (honeypot), attribution
Payment paymentMethod: 'FREE' + new allowFreeContext: 'comp'
Dedup primitive PromoRedemption (@@unique([userId, offerId])) plus, when requiresVerifiedPhone is set, @@unique([offerId, phone]) — the cross-identity cap (ADR-024)
Per-user cap Offer.maxRedemptionsPerUser — null = unlimited (MMS pattern), 1 = first-hour pattern
Phone gate Offer.requiresVerifiedPhone — guests prove possession via OTP (phoneVerificationId), members pass via their mirrored verified phone; see ADR-024
Duration cap Offer.maxDurationMinutes — null = no cap
Resource scope Offer.allowedResourceIds (null = any) AND Offer.allowedResourceTypes (null = any matching STUDIO_HOURLY by default)
Location scope Offer.allowedLocationIds (null = any)
Validity window Offer.startsAt / Offer.endsAt — existing fields, unchanged
Already-redeemed UX 409 CONFLICT with { error: 'ALREADY_REDEEMED', message }. Frontend triggers friendly modal, copy says "one per person" (accurate now that the cap can span accounts via phone).
Post-commit side-effects Marketing consent record + account-setup-link email (same as MMS today)
Tracking hourly-booked PostHog event w/ isComp: true, offerCode; Meta CAPI Lead (not Purchase) — value=0 Purchase events are the documented noise per paid-meta.md

Schema delta

enum OfferType {
  CREDIT
  PREPAID_MARKETING
  SUBSCRIPTION_UPSELL
  REFERRAL
  INSURANCE_UPSELL
  FREE_BOOKING
}

model Offer {
  // ... existing fields unchanged
  type                  OfferType        @default(CREDIT)  // was: String

  // FREE_BOOKING-specific (empty array / null = no restriction; ignored for non-FREE_BOOKING types)
  maxDurationMinutes    Int?             @map("max_duration_minutes")
  maxRedemptionsPerUser Int?             @map("max_redemptions_per_user")  // null = unlimited, 1 = first-hour pattern
  allowedResourceIds    String[]         @default([]) @map("allowed_resource_ids") @db.Uuid
  allowedResourceTypes  ResourceType[]   @default([]) @map("allowed_resource_types")
  allowedLocationIds    String[]         @default([]) @map("allowed_location_ids") @db.Uuid
}

Migration is backward-compatible. The first 5 enum values mirror the values already in offers.type (CREDIT, PREPAID_MARKETING, SUBSCRIPTION_UPSELL, REFERRAL, INSURANCE_UPSELL); the migration uses ALTER COLUMN ... USING so any row with an unknown value would loudly fail rather than silently downgrade. Array fields use empty-array as the "no restriction" sentinel rather than null (Prisma doesn't support nullable arrays).

A later migration (ADR-024) adds Offer.requiresVerifiedPhone (default false), PromoRedemption.phone (nullable, @@unique([offerId, phone])), and a standalone PhoneVerification model for possession proofs. See features/guest-phone-verification/spec.md for that schema in full.

Validation layer

// apps/api/src/services/scheduling/ReservationValidationService.ts
export type AllowFreeContext = 'tour' | 'dedicated' | 'comp' // was: ... | 'mms-comp'

validatePaymentMethod allowlist check unchanged in structure; 'mms-comp' literal becomes 'comp'.

Endpoint

POST /api/comp/reservations

Request body (Zod schema):

{
  offerCode: string                    // required
  email: string                        // required (used to resolve/create stub user)
  resourceId: string                   // required
  startTime: string                    // ISO
  endTime: string                      // ISO
  timezone: string
  appointmentTypeId: string
  firstName?: string
  lastName?: string
  phone?: string
  artistName?: string
  smsMarketingConsent?: boolean
  website?: string                     // honeypot — silently 201 if present
  attribution?: AttributionSchema
}

Server flow (mirrors mms-comp route, with Offer-driven gates):

  1. Honeypot: if website present → return 201 { reservationId: 'declined' }, no side effects
  2. Load Offer by code; require type='FREE_BOOKING', deletedAt: null, within startsAt/endsAt window (or null on either)
  3. Validate resource:
  4. Load by resourceId, require deletedAt: null
  5. Require resource.isPubliclyVisible OR resourceId listed on offer.allowedResourceIds (hidden resources, e.g. lockout rooms temporarily flipped to hourly for MMS, are only bookable when explicitly allowlisted)
  6. If offer.allowedResourceIds is non-empty: require membership
  7. If offer.allowedResourceTypes is non-empty: require resource.resourceType in list
  8. If offer.allowedLocationIds is non-empty: require resource.locationId in list
  9. Validate duration: endTime - startTime <= offer.maxDurationMinutes * 60_000 (skip if null)
  10. Validate timing: startTime > now, endTime > startTime
  11. Resolve stub user via resolveStubUserFromStaff({ firstName, lastName, email, phone })
  12. Inside tx:
  13. ensureStubUserRow(stub, tx)
  14. If offer.maxRedemptionsPerUser != null: check PromoRedemption.findUnique({ userId_offerId }); if exists → throw AppError('ALREADY_REDEEMED', ErrorType.CONFLICT, 409)
  15. ReservationCreationService.commitReservation({ ... paymentMethod: 'FREE' }, tx, { allowFreeContext: 'comp' })
  16. If offer.maxRedemptionsPerUser != null: tx.promoRedemption.create({ data: { userId: stub.userId, offerId: offer.id } })
  17. Update User.artistName if provided and currently null (MMS parity)
  18. After commit (via after()):
  19. ConsentService.recordMarketingOptIns(...)
  20. ReservationSideEffectService.executeCreationSideEffects(...)
  21. If new stub user: sendAccountSetupForUser(...)
  22. Write conversion_attributions row with sourceType: 'INQUIRY' and utmCampaign/utmContent from attribution payload

Error responses

Code Reason
400 Invalid input, expired offer, resource not allowed, duration over cap
404 Offer code unknown, resource not found
409 ALREADY_REDEEMED — user has used this offer's quota; or Acuity slot taken
500 Internal — captured to Sentry; auth user row rolled back via rollbackCreatedAuthUser

Initial Offer rows (seeded as part of the migration)

code type allowedResourceIds allowedResourceTypes allowedLocationIds maxRedemptionsPerUser maxDurationMinutes startsAt endsAt
MMS_SALEM_2026 FREE_BOOKING [<3 UUIDs>] (from existing MMS_COMP.resourceIds) null null null (unlimited) null 2026-06-15T07:00Z 2026-06-22T07:00Z
CHERRY_CITY_FIRST_PRACTICE FREE_BOOKING null [STUDIO_HOURLY] [<MG10 Cherry City UUID>] 1 240 now null

Renamed 2026-05 from CHERRY_CITY_FREE_HOUR (60-min cap) → CHERRY_CITY_FIRST_PRACTICE (240-min cap) to support the "First practice is on us — up to 4 hours" positioning. Old offer row is soft-deleted by the seed script; old LP slug 301s to the new one.

Seed via a one-off script apps/api/scripts/marketing/seed-comp-offers.ts. Run on prod once, post-migration.

Frontend

Booking flow

Comp booking runs on the /book page (scheduler refactor, PR #880): /book?type=hourly&offer=<code>. BookClient reads the offer query param and passes it as compOfferCode into BookingFlow, which drives comp mode through HourlyBranch. The pre-refactor modal stack (HourlyBookingWithPicker compMode, HourlyBookingContent, ModalWrapper) is deleted.

In comp mode:

  • Public page, no login required; with no resource param the guest picks a studio in the native StudioStep grid, which shows the studios' location (name, city/state, address) above the picker — comp links arrive from anywhere with no market context
  • Details form requires firstName + lastName + email; phone optional; artistName collected only when the offer's COMP_OFFER_PRESENTATION entry (apps/web/src/lib/comp-offers.ts) sets collectArtistName (MMS only)
  • No payment step, no cart $ total, no per-slot price chips, no peak/off-peak hint
  • Durations are filtered to offer.maxDurationMinutes (currently 240 for CHERRY_CITY_FIRST_PRACTICE) via useAppointmentTypes
  • Submit POSTs to /api/comp/reservations with the form data + offerCode + stored UTM attribution (useHourlyCheckout)
  • Studio list comes from /api/comp/resources?offerCode=<code> instead of the public resources endpoint (useHourlyStudios)
  • When the offer has requiresVerifiedPhone (as reported by /api/comp/resources), phone becomes required and submit interposes an OTP code step before the booking call; see features/guest-phone-verification/spec.md for the possession-proof flow, member-routing interstitial, and phoneVerificationId handoff
  • On 409 ALREADY_REDEEMED: opens <AlreadyRedeemedModal /> — copy says "one per person" (not "one per email"), since the cap can span accounts sharing a phone; CTA falls through to normal paid hourly booking
  • On success, renders the shared ReservationConfirmationClient (the same card authed/paid bookings use) instead of a hand-rolled comp-only panel — fed from in-memory flow state since guests can't hit the session-gated /reservations/confirmation route. freePaymentLabel: 'Promotional offer' replaces the subscription-style amount copy; guests see the account-setup notice in the accountSetupNotice slot (same pattern as lockout invitations) instead of the auth-only footer buttons, and gain Add to Calendar
  • Open item: the old modal's CmHelpFooter (tel: link to the location CM) has no /book successor; useHourlyResource still fetches the CM fields but nothing renders them

Markets / LPs

LP route Offer code Notes
/lp/mms2026 MMS_SALEM_2026 Consolidated MMS 2026 LP (restructured from /lp/make-music-salem in #710). Free Practice Week CTA navigates to /book?type=hourly&offer=MMS_SALEM_2026; Green Room registration is the primary CTA. Post-event teardown is date-gated in page.tsx.
/lp/salem/free-first-practice (moved 2026-07 from /lp/cherry-city-first-practice, itself renamed from /lp/cherry-city-free-hour — both 301) CHERRY_CITY_FIRST_PRACTICE Dedicated offer LP nested under the market slug, driven by MARKET_COMP_OFFERS (see free-first-practice). Content-first hero, "Claim your free practice" CTA navigates to /book?type=hourly&offer=<code>. Market-LP hero integration shelved.

Tracking

  • hourly-booked PostHog event: extended with isComp: boolean + offerCode: string properties (defaults false / empty) so dashboards can filter comp bookings.
  • comp_booked PostHog event: distinct event fired from the comp checkout path only — carries reservationId, resourceId, durationMinutes, startTime, isNewUser, offerCode. (Renamed from the legacy mms_comp_booked event.)
  • Meta CAPI: fire Lead (not Purchase) for comp bookings — apps/api/src/lib/meta/track-comp-lead.ts, called from the endpoint after commit. value: 0, content_name: 'comp-hourly', content_category: offer.code.

Email chain for stub users

A returning-but-never-claimed user can't open /account/reservations/<id> directly — they have no password and the page is auth-gated. To handle this:

  • After a successful comp booking, the endpoint detects unclaimed users via isStubUser(user) (a check against invitedAt + setupCompletedAt + lastLoginAt from @mg/shared-utils). This covers both brand-new users and returning users from prior comps who never claimed.
  • For unclaimed users only, the endpoint generates two one-time Supabase recovery tokens. One overrides the viewReservationUrlOverride in the booking confirmation email's "view reservation" CTA; the other powers the account-setup email's primary CTA. Both deep-link through /auth/confirm?token_hash=…&type=recovery&next=/auth/setup-account?returnTo=/account/reservations/<id>.
  • Fully claimed users (have setupCompletedAt or lastLoginAt) get the standard auth-walled URL and no setup email. They log in normally to view the reservation.

Magic-link-for-claimed-users decision (intentional scope choice): generating a recovery token for a fully claimed user would let them one-click view the reservation, but a booking-confirmation-email interception would then give an attacker full access to an account with payment methods + history. The current scope (magic link only for unclaimed stubs) trades a small login-friction cost for a meaningfully smaller blast radius. Reconsider if user friction shows up in support tickets.

Migration plan

Order matters — keep MMS bookable throughout. MMS is 2026-06-21; this work ships before then but does not block MMS.

  1. Schema migration (db-migration skill). New enum value, new columns, all nullable/backward-compatible. CREDIT path untouched.
  2. Offer.type data migration. Existing string type='CREDIT' values continue to work; new enum is a superset. Run via prisma migrate diff against the shadow DB.
  3. AllowFreeContext enum + validatePaymentMethod update. Adds 'comp', keeps 'mms-comp' temporarily as an alias for one deploy window.
  4. New /api/comp/reservations endpoint built alongside existing /api/mms-comp/reservations. Both live in prod simultaneously.
  5. Seed MMS_SALEM_2026 + CHERRY_CITY_FIRST_PRACTICE Offer rows via one-off script.
  6. /lp/make-music-salem LP updated to call new endpoint with compMode + compOfferCode="MMS_SALEM_2026" + collectArtistName. Smoke-test in staging w/ live Acuity calendar.
  7. /lp/cherry-city-first-practice LP shipped with compMode + compOfferCode="CHERRY_CITY_FIRST_PRACTICE" (no artist name). New ad creative cuts over (Use Existing Post mechanic per Lesson #6).
  8. Form-trim PR (parallel, no comp dependency): UserInfoForm.tsx + TourRequestForm.tsx make phone + last name optional.
  9. Deprecation sweep (one deploy window after #6 verified): delete /api/mms-comp/reservations, services/scheduling/mms-comp.ts, 'mms-comp' value in AllowFreeContext. Remove mms-comp/reservations route file. Update tests.
  10. Email nurture sequence (separate PR, after #1–#9 in prod): Resend templates triggered by Reservation creation w/ allowFreeContext: 'comp'. 5-touch sequence per paid-meta.md plan.

Open items

  • Conversion attribution sourceType. Comp bookings currently fit INQUIRY semantically (top-of-funnel lead). If reporting needs cleaner separation, add 'COMP_BOOKING' to ConversionSourceType enum in a follow-up. Not blocking.
  • Resend sequence content. Spec'd separately. Doesn't block this primitive shipping.
  • AlreadyRedeemedModal. Visual + copy. Spec'd in the LP rebuild design pass.