plans
2026-05-03
2026 05 03 Package Bundle Pricing

Package Bundle & Surcharge Pricing Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement the full pricing logic including bundle tiers (Ticket Only / Dinner Show / VIP Experience), day-of-week surcharges, and small-party surcharges. This is already partially specified in the booking-flow plan — this plan provides the complete pricing detail.

Architecture: Pricing is calculated server-side in Convex mutations (not in the frontend) to prevent tampering. The frontend displays prices fetched from Convex. Surcharges are applied at checkout before VNPay redirect.

Pricing Context (from TFB + PRD):

Bundle Tiers (Ticket Types)

Ticket TypeBase PriceIncludes
DINNER_THEATREdefaultDinnerPrice from show templateShow + dinner
SHOW_ONLYdefaultShowOnlyPrice from show template (if enabled)Show only

Per-occurrence overrides: dinnerPriceOverride, showOnlyPriceOverride on showOccurrences.

Day-of-Week Surcharge (per person)

DaySurcharge (VND)
Monday0
Tuesday0
Wednesday0
Thursday+50,000
Friday+100,000
Saturday+150,000
Sunday+100,000

Small Party Surcharge (per person)

ConditionSurcharge (VND)
< 15 guests+100,000
>= 15 guests0

Total Calculation

basePrice = occurrence.dinnerPriceOverride ?? template.defaultDinnerPrice
subtotal = basePrice × quantity
dayOfWeekSurcharge = daySurchargeMap[dayOfWeek(date)] × quantity
smallPartySurcharge = (quantity < 15 ? 100000 : 0) × quantity
total = subtotal + dayOfWeekSurcharge + smallPartySurcharge

Context & Key Constraints

P0 RULE — No dynamic URL segments: The booking flow uses nuqs URL state. No /booking/[occurrenceId]/tickets dynamic routes — use /booking?step=tickets&occurrenceId=xxx.

P0 RULE — Use staffMutation/adminMutation: These helpers ARE implemented in convex/functions/auth.ts. Use them for staff/admin operations:

import { staffMutation } from "./auth";
export const myStaffMutation = staffMutation({
  args: { ... },
  handler: async (ctx, args) => {
    // Already verified: ctx.auth.getUserIdentity() exists and role is STAFF or ADMIN
  },
});

P0 RULE — useQuery API calls: Never double-call API functions. Use useQuery(api.pricing.getPriceBreakdown, args) NOT useQuery(api.pricing.getPriceBreakdown(args)).

P1 RULE — Structured error codes: Do NOT throw plain Error objects. Use error code constants or structured error objects: throw new Error("OCCURRENCE_NOT_FOUND") → replace with a typed error or code constant that callers can handle.

P1 RULE — All user-facing strings use translation keys: Surcharge labels, ticket type names, and all UI copy must use translation keys — never hardcoded English strings.

P1 RULE — Structured logging: Use consola instead of console.log. Import: import { consola } from "consola";


Context & Business Rules

Day-of-week is determined by the show DATE, not booking date. A Saturday show gets the Saturday surcharge regardless of when the customer books.

Small party surcharge applies per person. A booking of 2 people pays 2 × 100K = 200K surcharge. A booking of 15 people pays 0 surcharge.

Zone surcharges (if seat selection is implemented): VIP +300K, Premium +300K, Standard +350K per person — these are ADDITIONAL to the ticket type price, not replacing it.

Add-ons are NOT subject to surcharges. Add-on prices are flat per-unit — no day-of-week or party-size adjustment.

Display order in checkout:

Ticket (Dinner Theatre × 2)          1,800,000 VND
Sat surcharge (× 2)                     300,000 VND
Small party surcharge (× 2)              200,000 VND
Welcome Cocktail Add-on (× 2)            300,000 VND
------------------------------------------
Total                                 2,600,000 VND

File Map

convex/
├── functions/
│   ├── reservations.ts      # MODIFY — add surcharge calculation
│   └── pricing.ts            # CREATE — dedicated pricing utility functions

apps/frontend/
├── components/booking/
│   └── sticky-cart.tsx       # MODIFY — display surcharges breakdown
│   └── checkout-form.tsx     # MODIFY — show surcharge details before Pay

Phase 1: Server-Side Pricing Utility

Task 1: Create Pricing Utility Module ✅ DONE

Files:

  • Create: convex/functions/pricing.ts

  • Step 1: Create day-of-week surcharge map and error codes

// convex/functions/pricing.ts
import { query, mutation } from "convex/_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { consola } from "consola";
 
// === Error codes (structured, not plain Error) ===
export const PRICING_ERROR_CODES = {
  OCCURRENCE_NOT_FOUND: "OCCURRENCE_NOT_FOUND",
  TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND",
  INVALID_QUANTITY: "INVALID_QUANTITY",
  ADDON_NOT_FOUND: "ADDON_NOT_FOUND",
  ADDON_UNAVAILABLE: "ADDON_UNAVAILABLE",
} as const;
 
export type PricingErrorCode =
  (typeof PRICING_ERROR_CODES)[keyof typeof PRICING_ERROR_CODES];
 
// === Constants ===
export const DAY_OF_WEEK_SURCHARGES: Record<number, number> = {
  0: 0, // Sunday
  1: 0, // Monday
  2: 0, // Tuesday
  3: 0, // Wednesday
  4: 50000, // Thursday
  5: 100000, // Friday
  6: 150000, // Saturday
};
 
export const SMALL_PARTY_THRESHOLD = 15;
export const SMALL_PARTY_SURCHARGE_PER_PERSON = 100000;
 
/**
 * Get the day of week index (0=Sun, 1=Mon, ..., 6=Sat)
 */
export function getDayOfWeek(dateStr: string): number {
  return new Date(dateStr).getDay();
}
 
/**
 * Calculate day-of-week surcharge for a given date and quantity
 */
export function calculateDayOfWeekSurcharge(
  dateStr: string,
  quantity: number,
): number {
  const day = getDayOfWeek(dateStr);
  return (DAY_OF_WEEK_SURCHARGES[day] ?? 0) * quantity;
}
 
/**
 * Calculate small party surcharge for a given quantity
 */
export function calculateSmallPartySurcharge(quantity: number): number {
  if (quantity < SMALL_PARTY_THRESHOLD) {
    return SMALL_PARTY_SURCHARGE_PER_PERSON * quantity;
  }
  return 0;
}
 
/**
 * Price breakdown returned to frontend
 */
export type PriceBreakdown = {
  basePrice: number; // Per-person base price
  quantity: number;
  subtotal: number; // basePrice × quantity
  dayOfWeekSurcharge: number;
  smallPartySurcharge: number;
  addOnsTotal: number;
  total: number;
  dayOfWeek: number; // 0=Sun, 1=Mon, ..., 6=Sat (frontend translates via i18n)
};
 
type PricingParams = {
  occurrenceId: Id<"showOccurrences">;
  ticketType: "DINNER_THEATRE" | "SHOW_ONLY";
  quantity: number;
  addOns: Array<{ addOnId: Id<"addOns">; quantity: number }>;
};
 
/**
 * Calculate total price including all surcharges.
 * Uses error codes (not plain Error) for caller handling.
 *
 * [P0 FIX] Uses proper Convex dataModel typing instead of unsafe Record<string, unknown> casts.
 * When Convex types are generated, import from: import type { DataModel } from "../_generated/dataModel";
 */
export async function calculateTotalPrice(
  ctx: {
    db: {
      get: (
        id: Id<"showOccurrences"> | Id<"showTemplates"> | Id<"addOns">,
      ) => Promise<{
        dinnerPriceOverride?: number;
        showOnlyPriceOverride?: number;
        defaultDinnerPrice?: number;
        defaultShowOnlyPrice?: number;
        date: string;
        available?: boolean;
        price?: number;
      } | null>;
    };
  },
  params: PricingParams,
): Promise<PriceBreakdown> {
  const { occurrenceId, ticketType, quantity, addOns } = params;
 
  if (quantity < 1) {
    throw new Error(PRICING_ERROR_CODES.INVALID_QUANTITY);
  }
 
  const occurrence = await ctx.db.get(occurrenceId);
  if (!occurrence) {
    throw new Error(PRICING_ERROR_CODES.OCCURRENCE_NOT_FOUND);
  }
 
  const template = await ctx.db.get(occurrence.templateId);
  if (!template) {
    throw new Error(PRICING_ERROR_CODES.TEMPLATE_NOT_FOUND);
  }
 
  // Base price — properly typed, no unsafe casts
  const basePrice =
    ticketType === "DINNER_THEATRE"
      ? (occurrence.dinnerPriceOverride ?? template.defaultDinnerPrice ?? 0)
      : (occurrence.showOnlyPriceOverride ??
        template.defaultShowOnlyPrice ??
        0);
 
  const subtotal = basePrice * quantity;
 
  // Surcharges
  const dayOfWeekSurcharge = calculateDayOfWeekSurcharge(
    occurrence.date,
    quantity,
  );
  const smallPartySurcharge = calculateSmallPartySurcharge(quantity);
 
  // Add-ons total
  let addOnsTotal = 0;
  for (const item of addOns) {
    const addon = await ctx.db.get(item.addOnId);
    if (!addon) {
      throw new Error(PRICING_ERROR_CODES.ADDON_NOT_FOUND);
    }
    if (!addon.available) {
      throw new Error(PRICING_ERROR_CODES.ADDON_UNAVAILABLE);
    }
    addOnsTotal += (addon.price ?? 0) * item.quantity;
  }
 
  const total =
    subtotal + dayOfWeekSurcharge + smallPartySurcharge + addOnsTotal;
 
  // Day of week (0-6) — frontend translates via i18n
  const dayOfWeek = getDayOfWeek(occurrence.date);
 
  consola.debug("Price calculated", {
    occurrenceId,
    ticketType,
    quantity,
    basePrice,
    subtotal,
    dayOfWeekSurcharge,
    smallPartySurcharge,
    addOnsTotal,
    total,
  });
 
  return {
    basePrice,
    quantity,
    subtotal,
    dayOfWeekSurcharge,
    smallPartySurcharge,
    addOnsTotal,
    total,
    dayOfWeek, // 0=Sun, 1=Mon, ..., 6=Sat — frontend translates via i18n
  };
}
  • Step 2: Add getPriceBreakdown query
export const getPriceBreakdown = query({
  args: {
    occurrenceId: v.id("showOccurrences"),
    ticketType: v.union(v.literal("DINNER_THEATRE"), v.literal("SHOW_ONLY")),
    quantity: v.number(),
    addOns: v.array(
      v.object({ addOnId: v.id("addOns"), quantity: v.number() }),
    ),
  },
  handler: async (ctx, args) => {
    try {
      return await calculateTotalPrice(ctx, args);
    } catch (err) {
      // Re-throw with error code for caller handling
      const message = err instanceof Error ? err.message : "UNKNOWN_ERROR";
      throw new Error(message);
    }
  },
});
  • Step 3: Commit
git add convex/functions/pricing.ts
git commit -m "feat(pricing): add surcharge calculation utilities"

Phase 2: Wire Pricing to Booking Flow

Task 2: Update Sticky Cart with Surcharge Display ✅ DONE

Files:

  • Modify: apps/frontend/components/booking/sticky-cart.tsx

  • Step 1: Fetch price breakdown in sticky cart

"use client";
 
import { useTranslations } from "next-intl";
import { useBooking } from "~/lib/booking-context";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
 
export function StickyCart() {
  const t = useTranslations();
  const { state } = useBooking();
  const { occurrenceId, ticketType, quantity, addOns } = state;
 
  // CORRECT: useQuery(api.pricing.getPriceBreakdown, args) NOT useQuery(api.pricing.getPriceBreakdown(args))
  const breakdown = useQuery(
    api.pricing.getPriceBreakdown,
    occurrenceId && ticketType
      ? { occurrenceId, ticketType, quantity, addOns }
      : "skip"
  );
 
  if (!breakdown) return null;
 
  const ticketTypeLabel = ticketType === "DINNER_THEATRE"
    ? t("booking.ticketTypes.dinnerTheatre")
    : t("booking.ticketTypes.showOnly");
 
  return (
    <div className="sticky-cart bg-[#1a1a1a] border-l border-[#333] p-4">
      <h3 className="text-[#C5A059] font-serif mb-4">
        {t("booking.yourBooking")}
      </h3>
 
      <div className="space-y-2 text-sm">
        {/* Ticket line */}
        <div className="flex justify-between">
          <span>
            {ticketTypeLabel} × {quantity}
          </span>
          <span>{breakdown.subtotal.toLocaleString()} {t("common.currency")}</span>
        </div>
 
        {/* Day of week surcharge — only show if > 0 */}
        {breakdown.dayOfWeekSurcharge > 0 && (
          <div className="flex justify-between text-[#808080]">
            <span className="flex items-center gap-1">
              {t("booking.surcharge.dayOfWeek", { day: t("common.days.short", { day: breakdown.dayOfWeek }) })}
              <span className="text-xs">({t("booking.surcharge.perPerson", { count: quantity })})</span>
            </span>
            <span>+{breakdown.dayOfWeekSurcharge.toLocaleString()} {t("common.currency")}</span>
          </div>
        )}
 
        {/* Small party surcharge — only show if > 0 */}
        {breakdown.smallPartySurcharge > 0 && (
          <div className="flex justify-between text-[#808080]">
            <span className="flex items-center gap-1">
              {t("booking.surcharge.smallParty")}
              <span className="text-xs">({t("booking.surcharge.perPerson", { count: quantity })})</span>
            </span>
            <span>+{breakdown.smallPartySurcharge.toLocaleString()} {t("common.currency")}</span>
          </div>
        )}
 
        {/* Add-ons */}
        {breakdown.addOnsTotal > 0 && (
          <div className="flex justify-between text-[#808080]">
            <span>{t("booking.addOns")}</span>
            <span>+{breakdown.addOnsTotal.toLocaleString()} {t("common.currency")}</span>
          </div>
        )}
      </div>
 
      {/* Total */}
      <div className="border-t border-[#333] mt-4 pt-4">
        <div className="flex justify-between font-bold text-[#C5A059] text-lg">
          <span>{t("booking.total")}</span>
          <span>{breakdown.total.toLocaleString()} {t("common.currency")}</span>
        </div>
        {breakdown.smallPartySurcharge > 0 && (
          <p className="text-xs text-[#808080] mt-2">
            {t("booking.surcharge.smallPartyApplied", { count: quantity })}
          </p>
        )}
      </div>
    </div>
  );
}
  • Step 2: Commit

Task 3: Update Checkout Form with Surcharge Display ✅ DONE

Files:

  • Modify: apps/frontend/components/booking/checkout-form.tsx

  • Step 1: Display price breakdown in checkout

"use client";
 
import { useTranslations } from "next-intl";
import { useBooking } from "~/lib/booking-context";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
 
export function CheckoutSummary() {
  const t = useTranslations();
  const { state } = useBooking();
  const { occurrenceId, ticketType, quantity, addOns } = state;
 
  // CORRECT: useQuery(api.pricing.getPriceBreakdown, args) NOT useQuery(api.pricing.getPriceBreakdown(args))
  const breakdown = useQuery(
    api.pricing.getPriceBreakdown,
    occurrenceId && ticketType
      ? { occurrenceId, ticketType, quantity, addOns }
      : "skip"
  );
 
  if (!breakdown) return null;
 
  const ticketTypeLabel = ticketType === "DINNER_THEATRE"
    ? t("booking.ticketTypes.dinnerTheatre")
    : t("booking.ticketTypes.showOnly");
 
  return (
    <div className="bg-[#1a1a1a] border border-[#333] rounded-lg p-4 space-y-3">
      <h3 className="font-serif text-[#C5A059]">
        {t("booking.orderSummary")}
      </h3>
 
      <div className="space-y-2 text-sm">
        <div className="flex justify-between">
          <span>{ticketTypeLabel} × {quantity}</span>
          <span>{breakdown.subtotal.toLocaleString()} {t("common.currency")}</span>
        </div>
 
        {breakdown.dayOfWeekSurcharge > 0 && (
          <div className="flex justify-between text-[#808080]">
            <span>{t("booking.surcharge.dayOfWeekShort", { day: t("common.days.short", { day: breakdown.dayOfWeek }) })} × {quantity}</span>
            <span>+{breakdown.dayOfWeekSurcharge.toLocaleString()} {t("common.currency")}</span>
          </div>
        )}
 
        {breakdown.smallPartySurcharge > 0 && (
          <div className="flex justify-between text-[#808080]">
            <span>{t("booking.surcharge.smallPartyShort")} × {quantity}</span>
            <span>+{breakdown.smallPartySurcharge.toLocaleString()} {t("common.currency")}</span>
          </div>
        )}
 
        <div className="border-t border-[#333] pt-2 flex justify-between font-bold text-[#C5A059] text-lg">
          <span>{t("booking.total")}</span>
          <span>{breakdown.total.toLocaleString()} {t("common.currency")}</span>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Commit

Phase 3: VNPay Integration Uses Correct Total

Task 4: Verify VNPay Uses Full Total (with surcharges) ⚠️ BLOCKED

Files:

  • Verify: convex/functions/reservations.tsbuildPaymentUrl mutation

  • Step 1: Check buildPaymentUrl includes surcharges

In the buildPaymentUrl mutation (from the payment plan), ensure it calls calculateTotalPrice to get the full amount including surcharges:

export const buildPaymentUrl = mutation({
  args: { reservationId: v.id("reservations") },
  handler: async (ctx, { reservationId }) => {
    const reservation = await ctx.db.get(reservationId);
    // ...
    const totalPrice = await calculateTotalPrice(ctx, {
      occurrenceId: reservation.occurrenceId,
      ticketType: reservation.ticketType,
      quantity: reservation.quantity,
      addOns: reservation.addOns,
    });
 
    const vnp_Amount = totalPrice.total * 100; // VNPay uses "dong"
    // ...
  },
});
  • Step 2: Ensure reservation stored total matches

When createPending is called, the initial totalAmount stored on the reservation should NOT yet include surcharges (since day-of-week is determined by the show date, not booking date — but wait, we know the show date at step 1, so we can include surcharges from the start).

Decision needed:

  • Option 1: Store totalAmount as base subtotal at step 1, then update it with surcharges before VNPay redirect
  • Option 2: Store totalAmount as the full amount (including surcharges) from the start

Option 1 is cleaner for accounting (shows base ticket price vs surcharges separately). The sticky cart calculates surcharges on-the-fly.

Verification: Ensure the checkout flow's buildPaymentUrl calls calculateTotalPrice which includes surcharges, and that the reservation's stored totalAmount is updated before VNPay redirect if needed.

  • Step 3: Commit

NOTE (2026-05-05): Task 4 is BLOCKED. The buildPaymentUrl mutation does not exist in reservations.ts. Currently payment URL is constructed in apps/frontend/app/actions/payment.ts as a stub server action. This task depends on the payment plan (2026-05-04-payment-onepay.md) which will implement the actual VNPay/OnePay integration with calculateTotalPrice.


Phase 4: Display Pricing Context in Booking Pages

Task 5: Add Surcharge Explanations to Booking UI ✅ DONE

Files:

  • Modify: apps/frontend/components/booking/sticky-cart.tsx (add tooltip/info)

  • Step 1: Add explanatory text

Add a small info icon or tooltip near the surcharge lines:

{breakdown.dayOfWeekSurcharge > 0 && (
  <div className="flex justify-between text-[#808080]">
    <span className="flex items-center gap-1">
      {t("booking.surcharge.dayOfWeek", { day: t("common.days.short", { day: breakdown.dayOfWeek }) })} × {quantity}
      <Tooltip content={t("booking.surcharge.dayOfWeekTooltip")}>
        <InfoIcon className="w-3 h-3" />
      </Tooltip>
    </span>
    <span>+{breakdown.dayOfWeekSurcharge.toLocaleString()} {t("common.currency")}</span>
  </div>
)}
  • Step 2: Commit

Phase 5: Schema Extensions for Bundle Pricing

Task 6: Add Bundle Fields to Reservations ✅ DONE

Files:

  • Modify: convex/schema.ts

  • Step 1: Add bundle fields to reservations

// Add to reservations table
bundleType: v.optional(v.union(
  v.literal("EARLY_BIRD"),
  v.literal("GROUP"),
  v.literal("VIP")
)),
discountAmount: v.number().default(0),
discountPercent: v.number().default(0),
vipSurcharge: v.number().default(0),
  • Step 2: Commit

Enrichment Sections

1. Zod Schemas

// convex/functions/pricing.ts
import { z } from "zod";
 
export const GetPriceBreakdownSchema = z.object({
  occurrenceId: z.string().min(1, "Occurrence ID is required"),
  ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
  quantity: z.number().int().positive("Quantity must be at least 1"),
  addOns: z
    .array(
      z.object({
        addOnId: z.string().min(1, "Add-on ID is required"),
        quantity: z
          .number()
          .int()
          .positive("Add-on quantity must be at least 1"),
      }),
    )
    .default([]),
});
 
// pricing calculation input (for mutations)
export const CalculatePriceParamsSchema = z.object({
  occurrenceId: z.string().min(1, "Occurrence ID is required"),
  ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
  quantity: z.number().int().positive("Quantity must be at least 1"),
  addOns: z
    .array(
      z.object({
        addOnId: z.string().min(1, "Add-on ID is required"),
        quantity: z
          .number()
          .int()
          .positive("Add-on quantity must be at least 1"),
      }),
    )
    .default([]),
  bundleType: z.enum(["EARLY_BIRD", "GROUP", "VIP"]).optional(),
});
 
export type PriceBreakdownInput = z.infer<typeof GetPriceBreakdownSchema>;

2. Error Handling

export const PRICING_ERROR_CODES = {
  OCCURRENCE_NOT_FOUND: "OCCURRENCE_NOT_FOUND",
  TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND",
  INVALID_QUANTITY: "INVALID_QUANTITY",
  ADDON_NOT_FOUND: "ADDON_NOT_FOUND",
  ADDON_UNAVAILABLE: "ADDON_UNAVAILABLE",
} as const;
 
type PricingError = keyof typeof PRICING_ERROR_CODES;
FunctionError CodeMessage KeyCondition
calculateTotalPriceOCCURRENCE_NOT_FOUNDerrors.pricing.occurrenceNotFoundInvalid occurrence ID
calculateTotalPriceTEMPLATE_NOT_FOUNDerrors.pricing.templateNotFoundTemplate not found
calculateTotalPriceINVALID_QUANTITYerrors.pricing.invalidQuantityQuantity < 1
calculateTotalPriceADDON_NOT_FOUNDerrors.pricing.addonNotFoundAdd-on not found
calculateTotalPriceADDON_UNAVAILABLEerrors.pricing.addonUnavailableAdd-on unavailable

Error handling in caller:

try {
  const breakdown = await calculateTotalPrice(ctx, params);
} catch (err) {
  if (err instanceof Error) {
    switch (err.message) {
      case PRICING_ERROR_CODES.OCCURRENCE_NOT_FOUND:
        // Handle missing occurrence
        break;
      case PRICING_ERROR_CODES.TEMPLATE_NOT_FOUND:
        // Handle missing template
        break;
      case PRICING_ERROR_CODES.INVALID_QUANTITY:
        // Handle invalid quantity
        break;
    }
  }
}

3. Convex Real-time Subscription Pattern

// Sticky cart — real-time price updates
// CORRECT: pass function reference, not call it
const breakdown = useQuery(
  api.pricing.getPriceBreakdown,
  occurrenceId && ticketType
    ? { occurrenceId, ticketType, quantity, addOns }
    : "skip",
);
 
// Checkout — recalculate on quantity change
// CORRECT: pass function reference, not call it
const updatedBreakdown = useQuery(api.pricing.getPriceBreakdown, {
  occurrenceId,
  ticketType,
  newQuantity,
  addOns,
});
 
// React component with useTransition for price changes
const [isPending, startTransition] = useTransition();
startTransition(() => {
  // quantity or addOns changed — Convex auto-updates breakdown
});

4. Mobile/Responsive Considerations

ComponentMobile Behavior
Sticky cartFull-width on mobile; collapses to shows-only total when scrolled
Surcharge breakdownStacked line items; day label abbreviated
Checkout summaryCard layout; full-width on mobile

5. PWA / Offline Behavior

Not applicable — pricing calculations require server-side computation and cannot be cached offline.

6. i18n / next-intl Requirements

{
  "booking": {
    "ticketTypes": {
      "dinnerTheatre": "Dinner Theatre",
      "showOnly": "Show Only"
    },
    "yourBooking": "Your Booking",
    "orderSummary": "Order Summary",
    "addOns": "Add-ons",
    "total": "Total",
    "surcharge": {
      "dayOfWeek": "{day} surcharge",
      "dayOfWeekShort": "{day} surcharge",
      "dayOfWeekTooltip": "Weekend and holiday pricing",
      "smallParty": "Small party surcharge",
      "smallPartyShort": "Small party",
      "smallPartyApplied": "Applied because your group is fewer than 15 guests",
      "perPerson": "× {count}"
    }
  },
  "common": {
    "currency": "VND",
    "days": {
      "short": {
        "0": "Sun",
        "1": "Mon",
        "2": "Tue",
        "3": "Wed",
        "4": "Thu",
        "5": "Fri",
        "6": "Sat"
      }
    }
  },
  "errors": {
    "pricing": {
      "occurrenceNotFound": "Show date not found",
      "templateNotFound": "Show not found",
      "invalidQuantity": "Quantity must be at least 1",
      "addonNotFound": "Add-on not found",
      "addonUnavailable": "Add-on is currently unavailable"
    }
  }
}

Day labels must be translated:

// Bad — hardcoded English
const dayLabel = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][dayOfWeek];
 
// Good — dayOfWeek returned from query (0-6), frontend translates via i18n
const dayLabel = t("common.days.short", { day: breakdown.dayOfWeek });
// en.json: { "common": { "days": { "short": { "0": "Sun", "1": "Mon", ... }}}}
// vi.json: { "common": { "days": { "short": { "0": "CN", "1": "T2", ... }}}}

7. Environment-Specific Configuration

# Server-only (never exposed to client):
CLERK_SECRET_KEY=           # Clerk secret key
 
# Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL=    # Convex deployment URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=  # Clerk publishable key
 
# Bundle discount configuration (future)
EARLY_BIRD_DAYS_THRESHOLD=14
EARLY_BIRD_DISCOUNT_PERCENT=10
GROUP_MIN_QUANTITY=8
GROUP_DISCOUNT_PERCENT=10
VIP_SURCHARGE_FLAT=500000

8. TDD Test Cases

E2E Tests (Playwright):

// e2e/pricing.spec.ts
 
test("PRC-E2E-1.1: Guest sees Saturday surcharge in sticky cart", async ({
  page,
}) => {
  // Given: Guest has selected a Saturday occurrence with 2 tickets
  // When: Guest views the sticky cart
  // Then: Cart shows Saturday surcharge line (+300,000 VND for 2 people)
  await page.goto("/en/booking?step=tickets&occurrenceId=sat-occ");
  const surchargeLine = page.locator('[data-testid="surcharge-dayOfWeek"]');
  await expect(surchargeLine).toContainText("300,000");
});
 
test("PRC-E2E-1.2: Small party surcharge shows for 2-person booking", async ({
  page,
}) => {
  // Given: Guest has selected 2 tickets
  // When: Guest views the sticky cart
  // Then: Small party surcharge line appears (+200,000 VND)
  await page.goto("/en/booking?step=tickets&occurrenceId=wed-occ&quantity=2");
  const surchargeLine = page.locator('[data-testid="surcharge-smallParty"]');
  await expect(surchargeLine).toContainText("200,000");
});
 
test("PRC-E2E-1.3: No small party surcharge for 15-person booking", async ({
  page,
}) => {
  // Given: Guest has selected 15 tickets
  // When: Guest views the sticky cart
  // Then: No small party surcharge line appears
  await page.goto("/en/booking?step=tickets&occurrenceId=wed-occ&quantity=15");
  const surchargeLine = page.locator('[data-testid="surcharge-smallParty"]');
  await expect(surchargeLine).not.toBeVisible();
});
 
test("PRC-E2E-1.4: Quantity change updates surcharge in real-time", async ({
  page,
}) => {
  // Given: Guest has 2 tickets in sticky cart
  // When: Guest changes quantity to 5
  // Then: Surcharge lines update to reflect new quantity
  await page.goto("/en/booking?step=tickets&occurrenceId=sat-occ&quantity=2");
  await page.getByTestId("quantity-input").fill("5");
  await page.waitForTimeout(500); // Allow Convex subscription to update
  const satSurcharge = page.locator('[data-testid="surcharge-dayOfWeek"]');
  await expect(satSurcharge).toContainText("750,000"); // 150,000 * 5
});
 
test("PRC-E2E-1.5: Add-ons shown as separate line items", async ({ page }) => {
  // Given: Guest has added Welcome Cocktail x2 to add-ons
  // When: Guest views sticky cart
  // Then: Add-ons appear as separate line item
  await page.goto("/en/booking?step=addons&occurrenceId=wed-occ");
  await page.getByTestId("addon-welcome-cocktail").click();
  await page.getByTestId("addon-quantity").fill("2");
  const addonLine = page.locator('[data-testid="addons-line"]');
  await expect(addonLine).toContainText("Welcome Cocktail");
});

Component Tests (Vitest + RTL):

// __tests__/components/sticky-cart.test.tsx
 
it("PRC-CT-1.1: Surcharge breakdown displays correctly for Saturday booking", async () => {
  // Given: Price breakdown for Saturday, 2 guests, 900,000 base price
  const mockBreakdown = {
    basePrice: 900000,
    quantity: 2,
    subtotal: 1800000,
    dayOfWeekSurcharge: 300000,
    smallPartySurcharge: 200000,
    addOnsTotal: 0,
    total: 2300000,
    dayOfWeek: 6,
  };
  render(<StickyCart breakdown={mockBreakdown} />);
  // Then: Shows ticket line, Sat surcharge, small party surcharge, total
  expect(screen.getByText("1,800,000")).toBeInTheDocument();
  expect(screen.getByText("+300,000")).toBeInTheDocument();
  expect(screen.getByText("+200,000")).toBeInTheDocument();
  expect(screen.getByText("2,300,000")).toBeInTheDocument();
});
 
it("PRC-CT-1.2: Weekday booking shows no day-of-week surcharge", async () => {
  // Given: Price breakdown for Wednesday (no surcharge), 3 guests
  const mockBreakdown = {
    basePrice: 900000,
    quantity: 3,
    subtotal: 2700000,
    dayOfWeekSurcharge: 0,
    smallPartySurcharge: 0,
    addOnsTotal: 0,
    total: 2700000,
    dayOfWeek: 3,
  };
  render(<StickyCart breakdown={mockBreakdown} />);
  // Then: No day-of-week surcharge line shown
  expect(screen.queryByTestId("surcharge-dayOfWeek")).not.toBeInTheDocument();
});
 
it("PRC-CT-1.3: 15-guest booking shows no small party surcharge", async () => {
  // Given: Price breakdown for 15 guests (threshold)
  const mockBreakdown = {
    basePrice: 900000,
    quantity: 15,
    subtotal: 13500000,
    dayOfWeekSurcharge: 0,
    smallPartySurcharge: 0,
    addOnsTotal: 0,
    total: 13500000,
    dayOfWeek: 3,
  };
  render(<StickyCart breakdown={mockBreakdown} />);
  // Then: No small party surcharge line
  expect(screen.queryByTestId("surcharge-smallParty")).not.toBeInTheDocument();
});
 
it("PRC-CT-1.4: Sold-out shows disabled proceed button", async () => {
  // Given: Occurrence that is sold out
  const mockBreakdown = { /* sold out breakdown */ };
  render(<StickyCart breakdown={mockBreakdown} isSoldOut={true} />);
  // Then: Proceed to checkout button is disabled
  expect(screen.getByTestId("proceed-btn")).toBeDisabled();
});

Backend Tests (Vitest):

// __tests__/convex/pricing.test.ts
 
it("PRC-BE-1.1: calculateDayOfWeekSurcharge returns 0 for Monday", () => {
  expect(calculateDayOfWeekSurcharge("2026-05-04", 2)).toBe(0);
});
 
it("PRC-BE-1.2: calculateDayOfWeekSurcharge returns 0 for Tuesday", () => {
  expect(calculateDayOfWeekSurcharge("2026-05-05", 2)).toBe(0);
});
 
it("PRC-BE-1.3: calculateDayOfWeekSurcharge returns 0 for Wednesday", () => {
  expect(calculateDayOfWeekSurcharge("2026-05-06", 2)).toBe(0);
});
 
it("PRC-BE-1.4: calculateDayOfWeekSurcharge returns 100,000 for Thursday (50K x 2)", () => {
  expect(calculateDayOfWeekSurcharge("2026-05-07", 2)).toBe(100000);
});
 
it("PRC-BE-1.5: calculateDayOfWeekSurcharge returns 200,000 for Friday (100K x 2)", () => {
  expect(calculateDayOfWeekSurcharge("2026-05-08", 2)).toBe(200000);
});
 
it("PRC-BE-1.6: calculateDayOfWeekSurcharge returns 300,000 for Saturday (150K x 2)", () => {
  expect(calculateDayOfWeekSurcharge("2026-05-09", 2)).toBe(300000);
});
 
it("PRC-BE-1.7: calculateDayOfWeekSurcharge returns 200,000 for Sunday (100K x 2)", () => {
  expect(calculateDayOfWeekSurcharge("2026-05-03", 2)).toBe(200000);
});
 
it("PRC-BE-1.8: calculateSmallPartySurcharge returns 0 for 15 guests (boundary)", () => {
  expect(calculateSmallPartySurcharge(15)).toBe(0);
});
 
it("PRC-BE-1.9: calculateSmallPartySurcharge returns 0 for 16+ guests", () => {
  expect(calculateSmallPartySurcharge(16)).toBe(0);
});
 
it("PRC-BE-1.10: calculateSmallPartySurcharge returns 1,400,000 for 14 guests", () => {
  expect(calculateSmallPartySurcharge(14)).toBe(1400000);
});
 
it("PRC-BE-1.11: calculateSmallPartySurcharge returns 100,000 for 1 guest (solo)", () => {
  expect(calculateSmallPartySurcharge(1)).toBe(100000);
});
 
it("PRC-BE-1.12: getDayOfWeek returns 0 for Sunday", () => {
  expect(getDayOfWeek("2026-05-03")).toBe(0);
});
 
it("PRC-BE-1.13: getDayOfWeek returns 6 for Saturday", () => {
  expect(getDayOfWeek("2026-05-09")).toBe(6);
});
 
it("PRC-BE-1.14: PRICING_ERROR_CODES contains OCCURRENCE_NOT_FOUND", () => {
  expect(PRICING_ERROR_CODES.OCCURRENCE_NOT_FOUND).toBe("OCCURRENCE_NOT_FOUND");
});
 
it("PRC-BE-1.15: getPriceBreakdown throws OCCURRENCE_NOT_FOUND for invalid ID", async () => {
  const ctx = createMockContext();
  await expect(
    ctx.runQuery(api.pricing.getPriceBreakdown, {
      occurrenceId: "nonexistent",
      ticketType: "DINNER_THEATRE",
      quantity: 2,
      addOns: [],
    }),
  ).rejects.toThrow("OCCURRENCE_NOT_FOUND");
});

9. Cross-Plan Dependencies

DependencyPlanShared Schema
Required bynotifications-crmreservation.subtotal, reservation.totalAmount for Zoho invoice
Depends onshow-systemshowTemplates.defaultDinnerPrice, showOccurrences.dinnerPriceOverride
Required bystaff-operationsOrder totals accumulate on reservation tab
Required bytable-pos-systemPOS charges added to reservation final bill
Used bybooking-flowPrice breakdown displayed in sticky cart and checkout
Zoho invoice usesnotifications-crmLine items constructed from pricing breakdown
Schema sharesshow-systemshowOccurrences.dinnerPriceOverride, showOccurrences.showOnlyPriceOverride

10. Performance Considerations

ScenarioAt Scale (1000 concurrent booking sessions)
Price breakdown querySimple DB reads + arithmetic; < 10ms
Real-time updatesConvex subscription on getPriceBreakdown; < 100ms latency
VNPay total calculationCalled once at checkout; server-side; no client impact

Business Summary

What this does: Implements the complete pricing engine for House of Legends bookings, including bundle tiers (Dinner Theatre vs Show Only), day-of-week surcharges (Thu-Sun premium pricing), and small party surcharges (groups under 15 guests). All pricing is calculated server-side in Convex to prevent client-side tampering.

Why it matters: Transparent pricing builds guest trust. Showing surcharges clearly (e.g., "Sat surcharge", "Small party surcharge") prevents checkout abandonment caused by surprise fees. Server-side calculation protects revenue by ensuring guests pay the correct amount regardless of browser manipulation. Real-time updates as guests adjust quantities create a smooth booking experience.

Time to implement: 2-4 days | Complexity: Medium

Dependencies: foundation-plan (schema setup), booking-flow-plan (pricing display integration)

Note: staffMutation/adminMutation are implemented in convex/functions/auth.ts — no longer a dependency gap.


Acceptance Criteria

  1. Price breakdown — sticky cart and checkout show: base price, day-of-week surcharge, small party surcharge (when applicable), add-ons total, grand total
  2. Surcharge calculation — day-of-week uses the show DATE (not booking date); small party uses actual quantity
  3. VNPay amountbuildPaymentUrl sends the full total (including all surcharges) to VNPay
  4. Real-time — sticky cart updates in real-time as quantity changes
  5. Threshold display — small party surcharge only shown when quantity < 15
  6. Day-of-week display — surcharge label shows day name (e.g., "Sat surcharge") not just "day-of-week surcharge"
  7. Surcharge tooltip — explanatory text on hover/focus for non-obvious surcharges

User Stories

IDAs a...I want to...So that...Priority
PRC-US01GuestSee base ticket price + seat row surcharge clearly broken down in sticky cartI understand exactly what I'm paying forMust
PRC-US02GuestSee day-of-week surcharge only on Friday, Saturday, Sunday, and ThursdayI know which days have premium pricingMust
PRC-US03GuestSee small party surcharge only when booking less than 15 guestsI understand why my total is higher for small groupsMust
PRC-US04GuestSee add-ons as separate line items in the cartI can verify add-on pricing before checkoutMust
PRC-US05GuestSee total update in real-time as I change quantity or select seatsI can make informed decisions about my bookingMust
PRC-US06GuestSee Saturday surcharge labeled as "Sat surcharge" not generic "day-of-week surcharge"The label clearly communicates which day has the surchargeMust
PRC-US07SystemSend correct total (including all surcharges) to VNPayPayment amount matches what guest sees in cartMust
PRC-US08AdminOverride prices per occurrence (dinnerPriceOverride, showOnlyPriceOverride)I can adjust pricing for special events without changing the templateShould
PRC-US09GuestUnderstand why Row A costs more than Row BFront row premium pricing is transparentShould

Test Scenarios

IDScenarioGivenWhenThen
PRC-TS01Saturday bookingGuest books for Saturday, 2 peopleSelect DINNER_THEATRE × 2, Row BTotal includes Sat surcharge (+150K×2) + Row B (+50K×2)
PRC-TS02Friday bookingGuest books for Friday, 3 peopleSelect DINNER_THEATRE × 3Total includes Fri surcharge (+100K×3)
PRC-TS03Thursday bookingGuest books for Thursday, 4 peopleSelect DINNER_THEATRE × 4Total includes Thu surcharge (+50K×4)
PRC-TS04Sunday bookingGuest books for Sunday, 2 peopleSelect DINNER_THEATRE × 2Total includes Sun surcharge (+100K×2)
PRC-TS05Weekday booking (Mon-Wed)Guest books for Monday, 2 peopleSelect DINNER_THEATRE × 2No day-of-week surcharge applied
PRC-TS06Small party — 14 guestsGuest books for Wednesday, 14 peopleSelect DINNER_THEATRE × 14Small party surcharge applied (+100K×14)
PRC-TS07Small party — 15 guestsGuest books for Wednesday, 15 peopleSelect DINNER_THEATRE × 15No small party surcharge (threshold met)
PRC-TS08Small party — 1 guestSolo guest books for SaturdaySelect DINNER_THEATRE × 1Small party surcharge applied (+100K×1) + Sat surcharge (+150K×1)
PRC-TS09Row A premium pricingGuest selects Row A (front row)Select DINNER_THEATRE × 2, Row ARow A surcharge added to base price
PRC-TS10Real-time quantity updateGuest has 2 tickets in cartChange quantity to 5Sticky cart total updates immediately, small party surcharge recalculates
PRC-TS11Real-time seat row changeGuest has Row B selectedChange to Row ARow surcharge updates, total recalculates
PRC-TS12Add-ons as line itemsGuest adds Welcome Cocktail × 2Add to cart with DINNER_THEATRE × 2Add-ons appear as separate line item, not bundled into ticket price
PRC-TS13VNPay total verificationGuest completes checkoutSubmit payment with Sat surcharge + small partyVNPay receives correct total including all surcharges
PRC-TS14Price override per occurrenceAdmin set dinnerPriceOverride for a specific showGuest books that occurrenceOverride price used instead of template defaultDinnerPrice
PRC-TS15Checkout form surcharge breakdownGuest is on checkout stepReview order summaryAll surcharges listed with labels: "Sat surcharge", "Small party surcharge"
PRC-TS16Invalid occurrence IDAPI called with fake occurrenceIdCall getPriceBreakdownThrows error with code "OCCURRENCE_NOT_FOUND"

Consistency Audit: package-bundle-pricing

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1Phase 2, StickyCartawait getTranslations() used in client componentChanged to useTranslations() hook (client components cannot use async getTranslations)
2Phase 1+2, useQuery callsuseQuery(api.fn(args)) — double-call patternChanged to useQuery(api.fn, args) — pass function reference
3Phase 1, calculateTotalPriceUnsafe as Record<string, unknown> casts on occurrence/template/addon fieldsReplaced with properly typed context interface — no type assertions needed

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1GlobalError codes not defined as const objectAdded PRICING_ERROR_CODES const object
2Globalconsole.log usageChanged to consola.debug with structured context

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
1Pricing calculations type safety[FIXED in plan] calculateTotalPrice now uses properly typed context interface. When Convex generates full dataModel types, replace the inline type with DataModel import.

Schema Consistency Check

  • DAY_OF_WEEK_SURCHARGES map keys (0-6) match getDayOfWeek() return values
  • SMALL_PARTY_THRESHOLD = 15 matches the spec (< 15 guests = surcharge)
  • getPriceBreakdown query args use proper Convex validators (v.id, v.union, etc.)
  • Pricing constants match TFB specification (Thu +50K, Fri +100K, Sat +150K, Sun +100K)