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 Type | Base Price | Includes |
|---|---|---|
| DINNER_THEATRE | defaultDinnerPrice from show template | Show + dinner |
| SHOW_ONLY | defaultShowOnlyPrice from show template (if enabled) | Show only |
Per-occurrence overrides: dinnerPriceOverride, showOnlyPriceOverride on showOccurrences.
Day-of-Week Surcharge (per person)
| Day | Surcharge (VND) |
|---|---|
| Monday | 0 |
| Tuesday | 0 |
| Wednesday | 0 |
| Thursday | +50,000 |
| Friday | +100,000 |
| Saturday | +150,000 |
| Sunday | +100,000 |
Small Party Surcharge (per person)
| Condition | Surcharge (VND) |
|---|---|
| < 15 guests | +100,000 |
| >= 15 guests | 0 |
Total Calculation
basePrice = occurrence.dinnerPriceOverride ?? template.defaultDinnerPrice
subtotal = basePrice × quantity
dayOfWeekSurcharge = daySurchargeMap[dayOfWeek(date)] × quantity
smallPartySurcharge = (quantity < 15 ? 100000 : 0) × quantity
total = subtotal + dayOfWeekSurcharge + smallPartySurchargeContext & Key Constraints
P0 RULE — No dynamic URL segments: The booking flow uses nuqs URL state. No
/booking/[occurrenceId]/ticketsdynamic routes — use/booking?step=tickets&occurrenceId=xxx.
P0 RULE — Use
staffMutation/adminMutation: These helpers ARE implemented inconvex/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)NOTuseQuery(api.pricing.getPriceBreakdown(args)).
P1 RULE — Structured error codes: Do NOT throw plain
Errorobjects. 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
consolainstead ofconsole.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 VNDFile 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 PayPhase 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
getPriceBreakdownquery
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.ts—buildPaymentUrlmutation -
Step 1: Check
buildPaymentUrlincludes 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
totalAmountas base subtotal at step 1, then update it with surcharges before VNPay redirect - Option 2: Store
totalAmountas 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
buildPaymentUrlmutation does not exist inreservations.ts. Currently payment URL is constructed inapps/frontend/app/actions/payment.tsas 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 withcalculateTotalPrice.
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;| Function | Error Code | Message Key | Condition |
|---|---|---|---|
calculateTotalPrice | OCCURRENCE_NOT_FOUND | errors.pricing.occurrenceNotFound | Invalid occurrence ID |
calculateTotalPrice | TEMPLATE_NOT_FOUND | errors.pricing.templateNotFound | Template not found |
calculateTotalPrice | INVALID_QUANTITY | errors.pricing.invalidQuantity | Quantity < 1 |
calculateTotalPrice | ADDON_NOT_FOUND | errors.pricing.addonNotFound | Add-on not found |
calculateTotalPrice | ADDON_UNAVAILABLE | errors.pricing.addonUnavailable | Add-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
| Component | Mobile Behavior |
|---|---|
| Sticky cart | Full-width on mobile; collapses to shows-only total when scrolled |
| Surcharge breakdown | Stacked line items; day label abbreviated |
| Checkout summary | Card 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=5000008. 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
| Dependency | Plan | Shared Schema |
|---|---|---|
| Required by | notifications-crm | reservation.subtotal, reservation.totalAmount for Zoho invoice |
| Depends on | show-system | showTemplates.defaultDinnerPrice, showOccurrences.dinnerPriceOverride |
| Required by | staff-operations | Order totals accumulate on reservation tab |
| Required by | table-pos-system | POS charges added to reservation final bill |
| Used by | booking-flow | Price breakdown displayed in sticky cart and checkout |
| Zoho invoice uses | notifications-crm | Line items constructed from pricing breakdown |
| Schema shares | show-system | showOccurrences.dinnerPriceOverride, showOccurrences.showOnlyPriceOverride |
10. Performance Considerations
| Scenario | At Scale (1000 concurrent booking sessions) |
|---|---|
| Price breakdown query | Simple DB reads + arithmetic; < 10ms |
| Real-time updates | Convex subscription on getPriceBreakdown; < 100ms latency |
| VNPay total calculation | Called 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/adminMutationare implemented inconvex/functions/auth.ts— no longer a dependency gap.
Acceptance Criteria
- Price breakdown — sticky cart and checkout show: base price, day-of-week surcharge, small party surcharge (when applicable), add-ons total, grand total
- Surcharge calculation — day-of-week uses the show DATE (not booking date); small party uses actual quantity
- VNPay amount —
buildPaymentUrlsends the full total (including all surcharges) to VNPay - Real-time — sticky cart updates in real-time as quantity changes
- Threshold display — small party surcharge only shown when quantity < 15
- Day-of-week display — surcharge label shows day name (e.g., "Sat surcharge") not just "day-of-week surcharge"
- Surcharge tooltip — explanatory text on hover/focus for non-obvious surcharges
User Stories
| ID | As a... | I want to... | So that... | Priority |
|---|---|---|---|---|
| PRC-US01 | Guest | See base ticket price + seat row surcharge clearly broken down in sticky cart | I understand exactly what I'm paying for | Must |
| PRC-US02 | Guest | See day-of-week surcharge only on Friday, Saturday, Sunday, and Thursday | I know which days have premium pricing | Must |
| PRC-US03 | Guest | See small party surcharge only when booking less than 15 guests | I understand why my total is higher for small groups | Must |
| PRC-US04 | Guest | See add-ons as separate line items in the cart | I can verify add-on pricing before checkout | Must |
| PRC-US05 | Guest | See total update in real-time as I change quantity or select seats | I can make informed decisions about my booking | Must |
| PRC-US06 | Guest | See Saturday surcharge labeled as "Sat surcharge" not generic "day-of-week surcharge" | The label clearly communicates which day has the surcharge | Must |
| PRC-US07 | System | Send correct total (including all surcharges) to VNPay | Payment amount matches what guest sees in cart | Must |
| PRC-US08 | Admin | Override prices per occurrence (dinnerPriceOverride, showOnlyPriceOverride) | I can adjust pricing for special events without changing the template | Should |
| PRC-US09 | Guest | Understand why Row A costs more than Row B | Front row premium pricing is transparent | Should |
Test Scenarios
| ID | Scenario | Given | When | Then |
|---|---|---|---|---|
| PRC-TS01 | Saturday booking | Guest books for Saturday, 2 people | Select DINNER_THEATRE × 2, Row B | Total includes Sat surcharge (+150K×2) + Row B (+50K×2) |
| PRC-TS02 | Friday booking | Guest books for Friday, 3 people | Select DINNER_THEATRE × 3 | Total includes Fri surcharge (+100K×3) |
| PRC-TS03 | Thursday booking | Guest books for Thursday, 4 people | Select DINNER_THEATRE × 4 | Total includes Thu surcharge (+50K×4) |
| PRC-TS04 | Sunday booking | Guest books for Sunday, 2 people | Select DINNER_THEATRE × 2 | Total includes Sun surcharge (+100K×2) |
| PRC-TS05 | Weekday booking (Mon-Wed) | Guest books for Monday, 2 people | Select DINNER_THEATRE × 2 | No day-of-week surcharge applied |
| PRC-TS06 | Small party — 14 guests | Guest books for Wednesday, 14 people | Select DINNER_THEATRE × 14 | Small party surcharge applied (+100K×14) |
| PRC-TS07 | Small party — 15 guests | Guest books for Wednesday, 15 people | Select DINNER_THEATRE × 15 | No small party surcharge (threshold met) |
| PRC-TS08 | Small party — 1 guest | Solo guest books for Saturday | Select DINNER_THEATRE × 1 | Small party surcharge applied (+100K×1) + Sat surcharge (+150K×1) |
| PRC-TS09 | Row A premium pricing | Guest selects Row A (front row) | Select DINNER_THEATRE × 2, Row A | Row A surcharge added to base price |
| PRC-TS10 | Real-time quantity update | Guest has 2 tickets in cart | Change quantity to 5 | Sticky cart total updates immediately, small party surcharge recalculates |
| PRC-TS11 | Real-time seat row change | Guest has Row B selected | Change to Row A | Row surcharge updates, total recalculates |
| PRC-TS12 | Add-ons as line items | Guest adds Welcome Cocktail × 2 | Add to cart with DINNER_THEATRE × 2 | Add-ons appear as separate line item, not bundled into ticket price |
| PRC-TS13 | VNPay total verification | Guest completes checkout | Submit payment with Sat surcharge + small party | VNPay receives correct total including all surcharges |
| PRC-TS14 | Price override per occurrence | Admin set dinnerPriceOverride for a specific show | Guest books that occurrence | Override price used instead of template defaultDinnerPrice |
| PRC-TS15 | Checkout form surcharge breakdown | Guest is on checkout step | Review order summary | All surcharges listed with labels: "Sat surcharge", "Small party surcharge" |
| PRC-TS16 | Invalid occurrence ID | API called with fake occurrenceId | Call getPriceBreakdown | Throws error with code "OCCURRENCE_NOT_FOUND" |
Consistency Audit: package-bundle-pricing
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Phase 2, StickyCart | await getTranslations() used in client component | Changed to useTranslations() hook (client components cannot use async getTranslations) |
| 2 | Phase 1+2, useQuery calls | useQuery(api.fn(args)) — double-call pattern | Changed to useQuery(api.fn, args) — pass function reference |
| 3 | Phase 1, calculateTotalPrice | Unsafe as Record<string, unknown> casts on occurrence/template/addon fields | Replaced with properly typed context interface — no type assertions needed |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Global | Error codes not defined as const object | Added PRICING_ERROR_CODES const object |
| 2 | Global | console.log usage | Changed to consola.debug with structured context |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | Pricing 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_SURCHARGESmap keys (0-6) matchgetDayOfWeek()return valuesSMALL_PARTY_THRESHOLD = 15matches the spec (< 15 guests = surcharge)getPriceBreakdownquery args use proper Convex validators (v.id,v.union, etc.)- Pricing constants match TFB specification (Thu +50K, Fri +100K, Sat +150K, Sun +100K)