plans
2026-05-03
2026 05 03 Cancellation Plan

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 nuqs useQueryState for 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

  1. Admin can cancel any PENDING or PAID reservation
  2. On cancel: seats released (bookedCount decremented)
  3. On cancel of PAID reservation: OnePay refund API called
  4. Reservation status updates to REFUND_PENDING then REFUNDED (via webhook)
  5. Confirmation modal shown before cancel action
  6. Cancelled reservations cannot be double-cancelled
  7. 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:

  1. Verify current reservation status
  2. Release seats in real-time
  3. 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/refund

8. 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

PlanDepends OnShares
03-admin-dashboard01-foundationReservation detail page
10-cancellation-refund01-foundationThis plan
17-notifications-crm01-foundation, 10-cancellation-refundEmail/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.patch on occurrence is fast. No optimization needed for single reservation cancellation.
  • Webhook security: The confirmRefund mutation should validate the OnePay webhook signature before processing. The webhook route handler (in api/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)

#LocationIssueFix Applied
P0-1cancelReservationstaffMutation not yet implementedN/A — staffMutation IS implemented in convex/functions/auth.ts
P0-2Reservation detail pageDynamic URL segment [id][FIXED] Changed to nuqs useQueryState — URL pattern: /admin/reservations?selectedId={id}

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
P1-1Convex functionsconsole.log usage[FIXED] Changed to consola
P1-2UI componentsHardcoded strings[FIXED] All use useTranslations/getTranslations
P1-3CancelReservationButtonMissing useTransition[FIXED] Added to async state updates
P1-4Webhook handlerMissing Zod validation[FIXED] Added onepayRefundPayloadSchema Zod validation
P1-5OnePay webhookUnvalidated webhook data[FIXED] Added HMAC signature verification
P1-6Modal icon, status badgesEmoji in UI[FIXED] Replaced with IconSymbol component
P1-7Reservation list pageMissing Suspense[FIXED] Added <Suspense> wrapper for reservation table

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

#IssueAction Required
GAP-1staffMutation IS implementedNo action needed — staffMutation and adminMutation exist in convex/functions/auth.ts
GAP-2OnePay API credentials not configuredEnvironment setup required
GAP-3notifications table not in schemaThe 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-4Schema missing cancellation/refund fields on reservationsThe 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) or useTranslations (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 as type 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
  • staffMutation wrapper ensures only ADMIN or STAFF roles can cancel reservations
  • No sensitive data logged in production

Design Tokens

TokenHexTailwind ClassUsage
background#1a1a1abg-[#1a1a1a]Modal background
accent#C5A059text-[#C5A059]Text accents
text#e6e6e6text-[#e6e6e6]Body text
muted#808080text-[#808080]Secondary text
border#333333border-[#333333]Borders