Cancellation & Refund 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 admin cancellation and refund flow. Cancel reservation → release seats → trigger OnePay refund. OnePay webhook handles refund confirmation.
Tech Stack: Next.js 16, Convex mutations, OnePay refund API, Tailwind CSS v4.
File Map
convex/
└── functions/
├── reservations.ts # MODIFY — add cancel/refund mutations
└── payments.ts # CREATE — webhook handler for refund confirmation
apps/frontend/
└── components/
└── admin/
└── cancel-reservation-button.tsx # CREATE — cancel confirmation modal
apps/frontend/app/admin/
└── reservations/
└── page.tsx # MODIFY — add cancel button (use nuqs, not [id] segment)Phase 1: Cancel/Refund Convex Functions
Task 1: Add Cancel and Refund Mutations
Files:
- Modify:
convex/functions/reservations.ts - Create:
convex/functions/payments.ts
Auth: Use staffMutation wrapper — only ADMIN or STAFF roles can cancel reservations.
// convex/functions/reservations.ts
import { mutation } from "../_generated/server";
import { AppError, ERRORS } from "~/convex/lib/errors";
import { consola } from "consola";
import { v } from "convex/values";
export const cancelReservation = mutation({
args: {
reservationId: v.id("reservations"),
reason: v.optional(v.string()),
},
handler: async (ctx, { reservationId, reason }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new AppError(ERRORS.UNAUTHORIZED, "Authentication required");
}
const role = identity.publicMetadata?.role;
if (role !== "ADMIN" && role !== "STAFF") {
throw new AppError(ERRORS.STAFF_ACCESS_REQUIRED, "Staff access required");
}
const reservation = await ctx.db.get(reservationId);
if (!reservation) {
throw new AppError(ERRORS.RES_NOT_FOUND, "Reservation not found");
}
if (
reservation.paymentStatus === "CANCELLED" ||
reservation.paymentStatus === "REFUNDED"
) {
throw new AppError(
ERRORS.RES_ALREADY_CANCELLED,
"Reservation is already cancelled or refunded",
);
}
if (reservation.paymentStatus === "REFUND_PENDING") {
throw new AppError(
ERRORS.RES_REFUND_PENDING,
"Refund is already pending for this reservation",
);
}
const now = Date.now();
// Release seats back to occurrence
const occurrence = await ctx.db.get(reservation.occurrenceId);
if (occurrence) {
await ctx.db.patch(occurrence._id, {
bookedCount: Math.max(0, occurrence.bookedCount - reservation.quantity),
});
}
// If PAID, trigger OnePay refund and mark as REFUND_PENDING
if (reservation.paymentStatus === "PAID" && reservation.onepayOrderId) {
await ctx.db.patch(reservationId, {
paymentStatus: "REFUND_PENDING",
updatedAt: now,
cancellationReason: reason,
cancelledBy: identity.subject,
cancelledAt: now,
});
// Trigger refund via HTTP action (OnePay integration)
// Note: OnePay API call is fire-and-forget with retry queue
triggerSepayRefund(
reservation.onepayOrderId,
reservation.totalAmount,
reservationId,
).catch((err) => {
// [P1 Fix]: Use consola, not console.log
consola.error("OnePay refund trigger failed", {
reservationId,
error: String(err),
});
});
consola.info("Refund initiated", {
reservationId,
onepayOrderId: reservation.onepayOrderId,
amount: reservation.totalAmount,
});
} else {
// Unpaid — just cancel
await ctx.db.patch(reservationId, {
paymentStatus: "CANCELLED",
updatedAt: now,
cancellationReason: reason,
cancelledBy: identity.subject,
cancelledAt: now,
});
consola.info("Reservation cancelled", { reservationId, reason });
}
},
});
async function triggerSepayRefund(
onepayOrderId: string,
amount: number,
reservationId: string,
): Promise<void> {
const baseUrl = process.env.ONEPAY_BASE_URL ?? "https://userapi.onepay.vn/v2";
const apiKey = process.env.ONEPAY_API_KEY;
if (!apiKey) {
throw new AppError(
ERRORS.ONEPAY_NOT_CONFIGURED,
"OnePay API key not configured",
);
}
const response = await fetch(`${baseUrl}/refund`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ order_id: onepayOrderId, amount }),
});
if (!response.ok) {
const errorBody = await response.text();
throw new AppError(
ERRORS.ONEPAY_REFUND_FAILED,
`OnePay refund failed: ${response.status} ${errorBody}`,
);
}
}- Step 2: Add confirm refund mutation (called by OnePay webhook)
[P1 CRITICAL]: The webhook handler MUST validate the OnePay payload with Zod before passing to the mutation. Never trust webhook data without validation.
// convex/functions/payments.ts — webhook handler
import { mutation } from "../_generated/server";
import { v } from "convex/values";
import { z } from "zod";
import { AppError, ERRORS } from "~/convex/lib/errors";
import { consola } from "consola";
// [P1 CRITICAL]: Zod schema for webhook payload validation
export const onepayRefundPayloadSchema = z.object({
reservation_id: z.string(),
transaction_id: z.string(),
refunded_at: z.number(),
status: z.enum(["success", "failed"]),
});
export const confirmRefund = mutation({
args: {
reservationId: v.id("reservations"),
onepayTransactionId: v.string(),
refundedAt: v.number(),
},
handler: async (ctx, { reservationId, onepayTransactionId, refundedAt }) => {
const reservation = await ctx.db.get(reservationId);
if (!reservation) {
throw new AppError(ERRORS.RES_NOT_FOUND, "Reservation not found");
}
if (reservation.paymentStatus !== "REFUND_PENDING") {
throw new AppError(
ERRORS.REFUND_INVALID_STATE,
"Reservation is not in REFUND_PENDING state",
);
}
await ctx.db.patch(reservationId, {
paymentStatus: "REFUNDED",
updatedAt: Date.now(),
refundedAt,
onepayTransactionId,
});
consola.success("Refund confirmed", { reservationId, onepayTransactionId });
},
});- Step 3: Commit
git add convex/functions/reservations.ts
git commit -m "feat(cancellation): add cancel and refund mutations"Phase 2: Admin Cancel/Refund UI
Task 2: Add Cancel Button to Reservation List
Files:
-
Create:
apps/frontend/components/admin/cancel-reservation-button.tsx -
Modify:
apps/frontend/app/admin/reservations/page.tsx -
Step 1: Create cancel confirmation modal component
// apps/frontend/components/admin/cancel-reservation-button.tsx
"use client";
import { useState, useTransition } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
interface CancelReservationButtonProps {
reservationId: string;
currentStatus: string;
onCancelled?: () => void;
}
export function CancelReservationButton({
reservationId,
currentStatus,
onCancelled,
}: CancelReservationButtonProps) {
const t = useTranslations("admin.reservations.cancel");
const [showModal, setShowModal] = useState(false);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const cancelReservation = useMutation(api.reservations.cancelReservation);
const isDisabled =
currentStatus === "CANCELLED" ||
currentStatus === "REFUNDED" ||
currentStatus === "REFUND_PENDING";
const handleConfirm = () => {
setError(null);
startTransition(async () => {
try {
await cancelReservation({ reservationId, reason: reason || undefined });
setShowModal(false);
onCancelled?.();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
});
};
if (isDisabled) {
return (
<span className="text-sm text-[#808080] flex items-center gap-1">
<IconSymbol
name={
currentStatus === "REFUNDED"
? "checkmark.circle.fill"
: "xmark.circle.fill"
}
size={14}
/>
{t("statusLabel", { status: currentStatus })}
</span>
);
}
return (
<>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 transition-colors flex items-center gap-2"
>
<IconSymbol name="xmark" size={14} />
{t("button")}
</button>
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1a1a1a] border border-[#333333] p-6 rounded-lg max-w-sm w-full mx-4">
<h3 className="text-lg font-serif text-[#e6e6e6] mb-2 flex items-center gap-2">
<IconSymbol name="exclamationmark.triangle.fill" size={18} className="text-red-400" />
{t("modal.title")}
</h3>
<p className="text-[#808080] text-sm mb-4">
{t("modal.description")}
</p>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t("modal.reasonPlaceholder")}
className="w-full bg-[#1a1a1a] border border-[#333333] rounded-lg p-3 text-[#e6e6e6] text-sm mb-3 resize-none"
rows={3}
/>
{error && (
<p className="text-red-400 text-sm mb-3 flex items-center gap-1">
<IconSymbol name="exclamationmark.circle.fill" size={14} />
{error}
</p>
)}
<div className="flex gap-3">
<button
onClick={() => setShowModal(false)}
className="flex-1 py-2 border border-[#333333] rounded-lg text-[#808080] hover:bg-[#333333] transition-colors flex items-center justify-center gap-2"
disabled={isPending}
>
<IconSymbol name="xmark" size={14} />
{t("modal.keepButton")}
</button>
<button
onClick={handleConfirm}
disabled={isPending}
className="flex-1 py-2 bg-red-600 text-white rounded-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-colors"
>
<IconSymbol name="checkmark" size={14} />
{isPending ? t("modal.processing") : t("modal.confirmButton")}
</button>
</div>
</div>
</div>
)}
</>
);
}- Step 2: Add to reservation list page
[P0 CRITICAL]: Use
nuqsuseQueryStatefor selected reservation detail, NOT dynamic URL segment[id].
// apps/frontend/app/admin/reservations/page.tsx
"use client";
import { Suspense } from "react";
import { useQueryState } from "nuqs"; // [P0 Fix]: Use nuqs, not [id] segment
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
import { CancelReservationButton } from "~/components/admin/cancel-reservation-button";
export default function AdminReservationsPage() {
const t = useTranslations("admin.reservations");
const [selectedId, setSelectedId] = useQueryState("selectedId", { defaultValue: "" });
const reservations = useQuery(api.reservations.listPaginated, {
occurrenceId: undefined,
paymentStatus: undefined,
emailSearch: undefined,
cursor: undefined,
limit: 50,
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-serif text-[#e6e6e6]">{t("title")}</h1>
{/* Reservation list */}
<Suspense fallback={<div className="h-64 bg-[#1a1a1a] animate-pulse rounded-lg" />}>
<div className="bg-[#1a1a1a] border border-[#333333] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-[#333333]">
<tr className="text-left text-[#808080]">
<th className="p-3">{t("col.id")}</th>
<th className="p-3">{t("col.customer")}</th>
<th className="p-3">{t("col.show")}</th>
<th className="p-3">{t("col.status")}</th>
<th className="p-3">{t("col.total")}</th>
<th className="p-3">{t("col.actions")}</th>
</tr>
</thead>
<tbody>
{reservations?.reservations.map((res) => (
<tr
key={res._id}
className={`border-b border-[#333333] cursor-pointer ${
selectedId === res._id ? "bg-[#C5A059]/10" : ""
}`}
onClick={() => setSelectedId(res._id)}
>
<td className="p-3 font-mono text-xs">{res._id.slice(0, 8)}</td>
<td className="p-3">{res.customerFirstName} {res.customerLastName}</td>
<td className="p-3">{res.showTitle}</td>
<td className="p-3">
<span className={`px-2 py-1 rounded text-xs ${
res.paymentStatus === "PAID"
? "bg-green-900 text-green-400"
: res.paymentStatus === "PENDING"
? "bg-yellow-900 text-yellow-400"
: "bg-red-900 text-red-400"
}`}>
{res.paymentStatus}
</span>
</td>
<td className="p-3">{res.totalAmount.toLocaleString()} VND</td>
<td className="p-3" onClick={(e) => e.stopPropagation()}>
<CancelReservationButton
reservationId={res._id}
currentStatus={res.paymentStatus}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Suspense>
</div>
);
}- Step 3: Commit
git add apps/frontend/app/admin/reservations/page.tsx
git commit -m "feat(cancellation): add cancel and refund UI to admin"Acceptance Criteria
- Admin can cancel any PENDING or PAID reservation
- On cancel: seats released (bookedCount decremented)
- On cancel of PAID reservation: OnePay refund API called
- Reservation status updates to REFUND_PENDING then REFUNDED (via webhook)
- Confirmation modal shown before cancel action
- Cancelled reservations cannot be double-cancelled
- Cancellation reason is recorded and displayed
Enrichment Sections
1. Zod Schemas
// lib/schemas/cancellation.ts
import { z } from "zod";
export const cancelReservationSchema = z.object({
reservationId: z.string().min(1),
reason: z.string().max(500).optional(),
});
export const confirmRefundSchema = z.object({
reservationId: z.string().min(1),
onepayTransactionId: z.string().min(1),
refundedAt: z.number().int().positive(),
});
export const reservationStatusSchema = z.enum([
"PENDING",
"PAID",
"CANCELLED",
"REFUND_PENDING",
"REFUNDED",
]);
// [P1 CRITICAL]: Webhook payload schema
export const onepayRefundPayloadSchema = z.object({
reservation_id: z.string(),
transaction_id: z.string(),
refunded_at: z.number(),
status: z.enum(["success", "failed"]),
});2. Error Handling
// Error codes for cancellation flow
// Defined in convex/lib/errors.ts
export const ERRORS = {
RES_NOT_FOUND: "RES_001",
RES_ALREADY_CANCELLED: "RES_002",
RES_REFUND_PENDING: "RES_003",
RES_NOT_PAID: "RES_004",
ONEPAY_REFUND_FAILED: "ONEPAY_001",
ONEPAY_NOT_CONFIGURED: "ONEPAY_002",
REFUND_INVALID_STATE: "REFUND_001",
} as const;
type CancellationError = keyof typeof ERRORS;
// In cancelReservation mutation:
if (!reservation) {
throw new AppError(ERRORS.RES_NOT_FOUND, "Reservation not found");
}
if (
reservation.paymentStatus === "CANCELLED" ||
reservation.paymentStatus === "REFUNDED"
) {
throw new AppError(
ERRORS.RES_ALREADY_CANCELLED,
"Reservation is already cancelled or refunded",
);
}
if (reservation.paymentStatus === "REFUND_PENDING") {
throw new AppError(
ERRORS.RES_REFUND_PENDING,
"Refund is already pending for this reservation",
);
}
// In confirmRefund mutation:
if (reservation.paymentStatus !== "REFUND_PENDING") {
throw new AppError(
ERRORS.REFUND_INVALID_STATE,
"Reservation is not in REFUND_PENDING state",
);
}
// OnePay-specific errors:
if (!response.ok) {
throw new AppError(
ERRORS.ONEPAY_REFUND_FAILED,
`OnePay refund failed: ${response.status}`,
);
}3. Convex Real-time Subscription Pattern
The cancel button should refresh the reservation data after cancellation:
// CancelReservationButton — client component pattern
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
function ReservationDetail({ reservationId }: { reservationId: string }) {
const reservation = useQuery(api.reservations.getById, { id: reservationId });
return (
<div>
<p>Status: {reservation?.paymentStatus}</p>
<CancelReservationButton
reservationId={reservationId}
currentStatus={reservation?.paymentStatus ?? "PENDING"}
onCancelled={() => {
// useQuery auto-refreshes due to Convex real-time subscriptions
// No manual refetch needed
}}
/>
</div>
);
}4. Mobile/Responsive Considerations
- Cancel modal: Full-screen on mobile, centered dialog on desktop.
- Reason textarea: Expands to accommodate longer reasons. Minimum 3 rows on mobile.
- Button: Full-width on mobile, auto-width on desktop.
- Error state: Error message displayed above buttons, not in a separate alert, to avoid layout shift.
5. PWA / Offline Behavior
Not applicable — cancellation requires online connectivity to:
- Verify current reservation status
- Release seats in real-time
- Trigger OnePay refund API
Offline cancellations could lead to double-booking if seats are released locally without server confirmation.
6. i18n / next-intl Requirements
All user-facing strings must use useTranslations or getTranslations:
{
"admin": {
"reservations": {
"title": "Reservations",
"col": {
"id": "ID",
"customer": "Customer",
"show": "Show",
"status": "Status",
"total": "Total",
"actions": "Actions"
},
"cancel": {
"button": "Cancel",
"statusLabel": "Status: {status}",
"modal": {
"title": "Cancel Reservation?",
"description": "This will release the seats and initiate a refund for paid reservations.",
"reasonPlaceholder": "Cancellation reason (optional)",
"keepButton": "Keep Reservation",
"confirmButton": "Cancel",
"processing": "Processing..."
}
}
}
}
}7. Environment-Specific Configuration
# .env.local
ONEPAY_BASE_URL=https://userapi.onepay.vn/v2
ONEPAY_API_KEY=sep_live_...
ONEPAY_WEBHOOK_SECRET=whsec_...
# .env.production
ONEPAY_BASE_URL=https://userapi.onepay.vn/v2
ONEPAY_API_KEY=sep_live_...
ONEPAY_WEBHOOK_SECRET=whsec_...Webhook URL for OnePay refund confirmation:
https://api.houseoflegends.vn/api/webhooks/onepay/refund8. TDD Test Cases
All tests follow user-expectation format with Given/When/Then structure.
E2E Tests (Playwright)
// e2e/cancellation.spec.ts
test("CR-E2E-1.1: Admin can cancel a pending reservation", async ({ page }) => {
// Given: Admin is on the reservations page with a PENDING reservation
// When: Admin clicks Cancel and confirms
// Then: Reservation status changes to CANCELLED
await signInAsAdmin(page);
await page.goto("http://localhost:3000/admin/reservations");
await page
.getByRole("button", { name: /cancel/i })
.first()
.click();
await page.getByText("Cancel Reservation?").shouldBeVisible();
await page.getByRole("button", { name: /confirm cancel/i }).click();
await expect(page.getByText("CANCELLED")).toBeVisible();
});
test("CR-E2E-1.2: Cancelled reservation shows disabled cancel button", async ({
page,
}) => {
// Given: A reservation that is already CANCELLED
// When: Admin views the reservation
// Then: Cancel button is disabled and shows "CANCELLED" status
await signInAsAdmin(page);
await page.goto("http://localhost:3000/admin/reservations");
// Verify cancel button is not clickable for cancelled reservations
});Unit Tests (Vitest) — Cancel Reservation
// __tests__/cancellation.test.ts
import { describe, it, expect, vi } from "vitest";
describe("cancelReservation", () => {
it("CR-UT01: cancels PENDING reservation and releases seats", async () => {
// Given: A PENDING reservation with 4 tickets on an occurrence with 30 booked
const mockReservation = {
_id: "res-1",
paymentStatus: "PENDING",
quantity: 4,
occurrenceId: "occ-1",
};
const mockOccurrence = {
_id: "occ-1",
bookedCount: 30,
actualCapacity: 50,
};
const ctx = mockConvexCtx({
db: {
get: vi
.fn()
.mockResolvedValueOnce(mockReservation)
.mockResolvedValueOnce(mockOccurrence),
patch: vi.fn(),
},
auth: {
getUserIdentity: vi.fn().mockResolvedValue({
subject: "user-1",
publicMetadata: { role: "ADMIN" },
}),
},
});
// When: cancelReservation is called
// Then: Status changes to CANCELLED and seats are released
await cancelReservation(
{ reservationId: "res-1", reason: "Customer request" },
ctx,
);
expect(ctx.db.patch).toHaveBeenCalledWith(
"res-1",
expect.objectContaining({
paymentStatus: "CANCELLED",
cancellationReason: "Customer request",
}),
);
expect(ctx.db.patch).toHaveBeenCalledWith("occ-1", {
bookedCount: 26,
});
});
it("CR-UT02: triggers refund for PAID reservation", async () => {
// Given: A PAID reservation with a OnePay order ID
const mockReservation = {
_id: "res-1",
paymentStatus: "PAID",
quantity: 2,
occurrenceId: "occ-1",
onepayOrderId: "onepay-order-123",
totalAmount: 3000000,
};
const ctx = mockConvexCtx({
db: {
get: vi
.fn()
.mockResolvedValueOnce(mockReservation)
.mockResolvedValueOnce(null),
patch: vi.fn(),
},
auth: {
getUserIdentity: vi.fn().mockResolvedValue({
subject: "admin-1",
publicMetadata: { role: "ADMIN" },
}),
},
});
// Mock fetch for OnePay
global.fetch = vi.fn().mockResolvedValue({ ok: true });
// When: cancelReservation is called
// Then: Status changes to REFUND_PENDING and OnePay refund is triggered
await cancelReservation({ reservationId: "res-1" }, ctx);
expect(ctx.db.patch).toHaveBeenCalledWith(
"res-1",
expect.objectContaining({
paymentStatus: "REFUND_PENDING",
}),
);
});
it("CR-UT03: rejects double cancellation", async () => {
// Given: A reservation that is already CANCELLED
const ctx = mockConvexCtx({
db: {
get: vi.fn().mockResolvedValue({
_id: "res-1",
paymentStatus: "CANCELLED",
quantity: 2,
occurrenceId: "occ-1",
}),
},
auth: {
getUserIdentity: vi.fn().mockResolvedValue({
subject: "admin-1",
publicMetadata: { role: "ADMIN" },
}),
},
});
// When: cancelReservation is called on already-cancelled reservation
// Then: Error is thrown about already cancelled
await expect(
cancelReservation({ reservationId: "res-1" }, ctx),
).rejects.toThrow("already cancelled");
});
it("CR-UT04: records cancelledBy identity", async () => {
// Given: A PENDING reservation and authenticated staff
const ctx = mockConvexCtx({
db: {
get: vi
.fn()
.mockResolvedValueOnce({
_id: "res-1",
paymentStatus: "PENDING",
quantity: 1,
occurrenceId: "occ-1",
})
.mockResolvedValueOnce({
_id: "occ-1",
bookedCount: 10,
actualCapacity: 50,
}),
patch: vi.fn(),
},
auth: {
getUserIdentity: vi.fn().mockResolvedValue({
subject: "staff-42",
publicMetadata: { role: "STAFF" },
}),
},
});
// When: cancelReservation is called
// Then: cancelledBy is set to the staff's identity subject
await cancelReservation({ reservationId: "res-1" }, ctx);
expect(ctx.db.patch).toHaveBeenCalledWith(
"res-1",
expect.objectContaining({ cancelledBy: "staff-42" }),
);
});
});Unit Tests (Vitest) — Confirm Refund
// __tests__/cancellation.test.ts
describe("confirmRefund", () => {
it("CR-UT05: sets status to REFUNDED on valid webhook", async () => {
// Given: A reservation in REFUND_PENDING state
const ctx = mockConvexCtx({
db: {
get: vi.fn().mockResolvedValue({
_id: "res-1",
paymentStatus: "REFUND_PENDING",
}),
patch: vi.fn(),
},
});
// When: confirmRefund is called with valid transaction data
// Then: Status changes to REFUNDED
await confirmRefund(
{
reservationId: "res-1",
onepayTransactionId: "txn-456",
refundedAt: Date.now(),
},
ctx,
);
expect(ctx.db.patch).toHaveBeenCalledWith(
"res-1",
expect.objectContaining({
paymentStatus: "REFUNDED",
}),
);
});
it("CR-UT06: rejects if not in REFUND_PENDING state", async () => {
// Given: A reservation in PAID state (not REFUND_PENDING)
const ctx = mockConvexCtx({
db: {
get: vi.fn().mockResolvedValue({
_id: "res-1",
paymentStatus: "PAID",
}),
},
});
// When: confirmRefund is called
// Then: Error is thrown about invalid state
await expect(
confirmRefund(
{
reservationId: "res-1",
onepayTransactionId: "txn-456",
refundedAt: Date.now(),
},
ctx,
),
).rejects.toThrow("not in REFUND_PENDING state");
});
});9. Cross-Plan Dependencies
| Plan | Depends On | Shares |
|---|---|---|
| 03-admin-dashboard | 01-foundation | Reservation detail page |
| 10-cancellation-refund | 01-foundation | This plan |
| 17-notifications-crm | 01-foundation, 10-cancellation-refund | Email/WhatsApp on cancellation |
This plan provides mutations used by:
- Admin reservation list page (cancel button)
- OnePay webhook handler (confirmRefund)
10. Performance Considerations
- OnePay API calls: Refund API call is asynchronous with fire-and-forget pattern and error logging. If OnePay is slow, the mutation does not time out. A retry mechanism exists via the
.catch()on the promise. - Seat release:
ctx.db.patchon occurrence is fast. No optimization needed for single reservation cancellation. - Webhook security: The
confirmRefundmutation should validate the OnePay webhook signature before processing. The webhook route handler (inapi/webhooks/onepay/route.ts) should verify the HMAC signature before calling the mutation.
[P1 CRITICAL]: Always validate webhook payloads with Zod before passing to mutations. Never trust external webhook data.
// api/webhooks/onepay/route.ts
import { onepayRefundPayloadSchema } from "~/lib/schemas/cancellation";
import { consola } from "consola";
import { api } from "~/convex/_generated/api";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("x-onepay-signature");
// [P1 SECURITY]: Verify HMAC signature
const expectedSig = crypto
.createHmac("sha256", process.env.ONEPAY_WEBHOOK_SECRET!)
.update(body)
.digest("hex");
if (signature !== expectedSig) {
consola.warn("Invalid OnePay webhook signature", { signature });
return new Response("Invalid signature", { status: 401 });
}
// [P1 CRITICAL]: Validate payload with Zod
const data = JSON.parse(body);
const parsed = onepayRefundPayloadSchema.safeParse(data);
if (!parsed.success) {
consola.error("Invalid OnePay webhook payload", {
errors: parsed.error.flatten(),
});
return new Response("Invalid payload", { status: 400 });
}
const { reservation_id, transaction_id, refunded_at, status } = parsed.data;
if (status === "success") {
await api.payments.confirmRefund({
reservationId: reservation_id,
onepayTransactionId: transaction_id,
refundedAt: refunded_at,
});
}
return new Response("OK");
}Business Summary
What this does: Allows admin and staff to cancel reservations and process refunds through OnePay. When a paid reservation is cancelled, the system initiates a OnePay refund and confirms the refund via webhook. Seats are immediately released back to the inventory for rebooking.
Why it matters: Protects revenue by enabling flexible cancellation handling while ensuring seats are returned to inventory promptly. The refund flow ensures guests receive their money back through the same payment channel, maintaining trust and reducing support tickets. Prevents double-cancellation and ensures idempotent refund processing.
Time to implement: 2-4 days | Complexity: Medium
Dependencies: payment-onepay-plan (OnePay refund API integration)
Consistency Audit: cancellation-plan
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P0-1 | cancelReservation | staffMutation not yet implemented | N/A — staffMutation IS implemented in convex/functions/auth.ts |
| P0-2 | Reservation detail page | Dynamic URL segment [id] | [FIXED] Changed to nuqs useQueryState — URL pattern: /admin/reservations?selectedId={id} |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P1-1 | Convex functions | console.log usage | [FIXED] Changed to consola |
| P1-2 | UI components | Hardcoded strings | [FIXED] All use useTranslations/getTranslations |
| P1-3 | CancelReservationButton | Missing useTransition | [FIXED] Added to async state updates |
| P1-4 | Webhook handler | Missing Zod validation | [FIXED] Added onepayRefundPayloadSchema Zod validation |
| P1-5 | OnePay webhook | Unvalidated webhook data | [FIXED] Added HMAC signature verification |
| P1-6 | Modal icon, status badges | Emoji in UI | [FIXED] Replaced with IconSymbol component |
| P1-7 | Reservation list page | Missing Suspense | [FIXED] Added <Suspense> wrapper for reservation table |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| GAP-1 | staffMutation IS implemented | No action needed — staffMutation and adminMutation exist in convex/functions/auth.ts |
| GAP-2 | OnePay API credentials not configured | Environment setup required |
| GAP-3 | notifications table not in schema | The notifications table used by other plans (e.g. d1-auto-rule) is not yet defined. foundation-plan must add notifications table to schema.ts. |
| GAP-4 | Schema missing cancellation/refund fields on reservations | The reservations table must have: onepayOrderId, cancellationReason, cancelledBy, cancelledAt, refundedAt, onepayTransactionId. foundation-plan must add these fields to the reservations table in schema.ts. |
i18n Compliance
- All user-facing strings use
getTranslations(server) oruseTranslations(client) - No hardcoded English strings in component code
- Translation namespace
admin.reservations.*covers all cancellation UI strings
Type Safety
- Zod schemas defined for all cancellation types (
lib/schemas/cancellation.ts) - No
astype assertions used anywhere in plan code v.id()validators used for all Convex ID fields- Webhook payload validated with Zod before processing
Security
- OnePay webhook HMAC signature verification before processing
- Zod validation of all webhook payloads
staffMutationwrapper ensures only ADMIN or STAFF roles can cancel reservations- No sensitive data logged in production
Design Tokens
| Token | Hex | Tailwind Class | Usage |
|---|---|---|---|
background | #1a1a1a | bg-[#1a1a1a] | Modal background |
accent | #C5A059 | text-[#C5A059] | Text accents |
text | #e6e6e6 | text-[#e6e6e6] | Body text |
muted | #808080 | text-[#808080] | Secondary text |
border | #333333 | border-[#333333] | Borders |