plans
2026-05-04
2026 05 04 Payment Onepay

Payment Integration Implementation Plan (OnePay)

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: Integrate OnePay Virtual Account (VA) payment gateway for secure VND payments. Customer receives a VA number and QR code, transfers the exact amount via their banking app, and a webhook confirms the payment. This replaces the original VNPay redirect-based approach.

Architecture: OnePay uses a Virtual Account model — we create an order via API, get a VA number + QR code, display both to the customer, and receive a webhook when the transfer is detected. No redirect required — customer stays on our page and pays via their banking app.

Tech Stack: Convex HTTP actions (convex/http/), OnePay REST API v2, Next.js App Router for confirmation page, qrcode library for QR generation.

Pricing Context:

  • All prices in VND (integer amounts, no decimals)
  • Surcharges (day-of-week + small-party) are included in the total sent to OnePay
  • Each OnePay order has a unique VA number tied to the exact amount (anti-fraud)
  • Payment expiry ties to seat hold timer (10 minutes)

Context & Business Logic

Reservation Status State Machine:

PENDING (created on step 1)
  ├──→ PAID (OnePay webhook confirms transfer)
  ├──→ FAILED (timeout or OnePay reports failure)
  └──→ CANCELLED (user cancels or admin cancels)

PAID
  ├──→ REFUNDED (admin initiates refund)
  └──→ CANCELLED (admin cancels — triggers refund)

Key rules:

  • bookingExpiresAt is set to now + 10 minutes on PENDING creation
  • bookedCount on occurrence is incremented on PENDING creation (holds the seat)
  • OnePay VA expiry should match or slightly exceed the 10-minute seat hold timer
  • On payment success: bookingExpiresAt is cleared, QR code is generated
  • On timeout/cancel: bookedCount is decremented (seat released)
  • QR code is generated on PAID confirmation — encodes booking details for scanning at venue

OnePay flow:

  1. Customer submits checkout → Convex mutation calls OnePay API to create order
  2. OnePay returns VA number + QR code → we display both to the customer
  3. OnePay processes payment → redirects customer back to our return URL with result
  4. OnePay detects transfer → sends webhook to our endpoint
  5. We update reservation to PAID, generate QR code
  6. Customer sees confirmation page (auto-redirect on return URL)

File Map

convex/
├── http/
│   └── onepay.ts              # OnePay API calls + webhook handler (CREATE)
├── functions/
│   └── reservations.ts       # MODIFY — add createOnePayOrder, confirmPayment, cancel, refund
│   └── pricing.ts            # MODIFY — import for calculateTotalPrice

apps/frontend/
└── app/[locale]/booking/
    └── page.tsx              # MODIFY — confirmation step polls for payment status
└── app/api/
    └── onepay/
        └── create-order/
            └── route.ts      # API endpoint for creating OnePay order (CREATE)
└── lib/
    └── qr.ts                  # QR code generation (VERIFY existing)

.env.example                   # Add OnePay env vars

Phase 1: OnePay API Setup

Task 1: Configure OnePay Credentials

Files:

  • Modify: .env.example

  • Step 1: Add OnePay environment variables

# OnePay API v2
ONEPAY_BASE_URL=https://userapi.onepay.vn/v2
ONEPAY_API_KEY=your_api_key_here
ONEPAY_BANK_ACCOUNT_XID=your_bank_account_xid_here
ONEPAY_WEBHOOK_SECRET=your_webhook_secret_here
 
# Optional: sandbox for testing
ONEPAY_SANDBOX_URL=https://test-api.onepay.vn/v2
  • Step 2: Verify existing .env setup

Check if .env exists in the project root and has any existing payment credentials.

  • Step 3: Commit
git add .env.example
git commit -m "feat(payment): add OnePay environment variables"

Phase 2: OnePay HTTP Module

Task 2: Create OnePay HTTP Module

Files:

  • Create: convex/http/onepay.ts

  • Step 1: Create the OnePay HTTP module

// convex/http/onepay.ts
import { httpAction } from "convex/server";
import { api } from "~/convex/_generated/api";
import { Id } from "~/convex/_generated/dataModel";
import { consola } from "consola";
import { z } from "zod";
 
const ONEPAY_BASE_URL =
  process.env.ONEPAY_BASE_URL ?? "https://userapi.onepay.vn/v2";
const ONEPAY_API_KEY = process.env.ONEPAY_API_KEY!;
const ONEPAY_BANK_ACCOUNT_XID = process.env.ONEPAY_BANK_ACCOUNT_XID!;
 
// Zod schemas for type safety
export const OnePayWebhookPayloadSchema = z.object({
  id: z.string(),
  gateway: z.string(),
  transactionDate: z.string(),
  accountNumber: z.string(),
  code: z.string().nullable(),
  content: z.string(),
  transferType: z.string(),
  transferAmount: z.number(),
  accumulated: z.number(),
  subAccount: z.string().nullable(),
  referenceCode: z.string(),
  description: z.string(),
});
 
export const OnePayOrderResponseSchema = z.object({
  id: z.string(),
  orderCode: z.string(),
  vaNumber: z.string(),
  qrCode: z.string().nullable(),
  qrCodeUrl: z.string().nullable(),
  amount: z.number(),
  status: z.enum(["Pending", "Paid", "Failed"]),
  expiredAt: z.string(),
});
 
interface OnePayOrderResponse {
  id: string;
  order_code: string;
  va_number: string;
  qr_code: string; // Base64 PNG
  qr_code_url: string; // URL to QR image
  amount: number;
  status: "Pending" | "Paid" | "Partially" | "Cancelled";
  expired_at: string; // ISO timestamp
}
 
interface OnePayWebhookPayload {
  id: number;
  gateway: string;
  transactionDate: string;
  accountNumber: string;
  code: string | null;
  content: string;
  transferType: "in" | "out";
  transferAmount: number;
  accumulated: number;
  subAccount: string | null;
  referenceCode: string;
  description: string;
}
 
/**
 * Create a OnePay VA order for a reservation.
 * Returns VA number, QR code, and expiry time.
 */
export async function createOnePayOrder(
  reservationId: string,
  amount: number,
  durationSeconds: number = 600, // 10 minutes default
): Promise<z.infer<typeof OnePayOrderResponseSchema>> {
  const response = await fetch(
    `${ONEPAY_BASE_URL}/bank-accounts/${ONEPAY_BANK_ACCOUNT_XID}/orders`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${ONEPAY_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        order_code: `HOL-${reservationId}`,
        amount: amount,
        duration: durationSeconds,
        description: `House of Legends booking ${reservationId}`,
      }),
    },
  );
 
  if (!response.ok) {
    const error = await response.text();
    throw new Error(
      `OnePay order creation failed: ${response.status} ${error}`,
    );
  }
 
  const data = await response.json();
  return OnePayOrderResponseSchema.parse(data);
}
 
/**
 * Get order status from OnePay.
 */
export async function getOnePayOrderStatus(orderCode: string): Promise<string> {
  const response = await fetch(`${ONEPAY_BASE_URL}/orders/${orderCode}`, {
    headers: {
      Authorization: `Bearer ${ONEPAY_API_KEY}`,
    },
  });
 
  if (!response.ok) {
    throw new Error(`OnePay order status check failed: ${response.status}`);
  }
 
  const data = await response.json();
  return data.status ?? "Unknown";
}
 
/**
 * Handle OnePay webhook (called by OnePay when transfer is detected).
 */
export const onepayWebhook = httpAction(async (ctx, request: Request) => {
  const payloadParse = OnePayWebhookPayloadSchema.safeParse(
    await request.json(),
  );
  if (!payloadParse.success) {
    consola.warn("OnePay webhook: invalid payload", {
      error: payloadParse.error,
    });
    return new Response(
      JSON.stringify({ success: false, message: "Invalid payload" }),
      { status: 400, headers: { "Content-Type": "application/json" } },
    );
  }
 
  const payload = payloadParse.data;
 
  // Verify API key if configured
  const apiKey = request.headers.get("Authorization");
  if (apiKey && apiKey !== `Bearer ${process.env.ONEPAY_API_KEY}`) {
    consola.warn("OnePay webhook: unauthorized request", {
      apiKey: apiKey?.slice(0, 10),
    });
    return new Response(
      JSON.stringify({ success: false, message: "Unauthorized" }),
      { status: 401, headers: { "Content-Type": "application/json" } },
    );
  }
 
  // Only process transfer-in events
  if (payload.transferType !== "in") {
    return new Response(JSON.stringify({ success: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }
 
  // Extract reservation ID from the transfer content
  // Format: "HOL-{reservationId}" or similar
  const match = payload.content?.match(/HOL-(.+)/);
  if (!match) {
    return new Response(JSON.stringify({ success: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }
 
  const reservationId = match[1];
  const amount = payload.transferAmount;
 
  // Check if already processed (idempotency)
  const reservation = await ctx.db.get(reservationId as Id<"reservations">);
  if (!reservation) {
    return new Response(JSON.stringify({ success: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }
 
  if (reservation.paymentStatus === "PAID") {
    // Already confirmed — return success to stop retries
    return new Response(JSON.stringify({ success: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }
 
  // Log the payment received
  consola.info("OnePay webhook: payment received", {
    reservationId,
    amount,
    referenceCode: payload.referenceCode,
  });
 
  // Process the payment
  await ctx.runMutation(api.reservations.confirmPayment, {
    reservationId: reservationId as Id<"reservations">,
    paymentMethod: "onepay",
    gatewayTransactionId: payload.referenceCode ?? String(payload.id),
    amount,
  });
 
  return new Response(JSON.stringify({ success: true }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
});
  • Step 2: Commit
git add convex/http/onepay.ts
git commit -m "feat(payment): add OnePay HTTP module with webhook handler"

Phase 3: Reservation Mutations

Task 3: Add OnePay Order Creation to Reservations

Files:

  • Modify: convex/functions/reservations.ts

  • Modify: convex/functions/pricing.ts (verify import)

  • Step 1: Read existing convex/functions/pricing.ts

cat convex/functions/pricing.ts

Verify calculateTotalPrice is exported and handles surcharges correctly.

  • Step 2: Read existing convex/functions/reservations.ts
cat convex/functions/reservations.ts
  • Step 3: Add createOnePayOrderForReservation mutation to reservations
import { createOnePayOrder } from "~/convex/http/onepay";
import { calculateTotalPrice } from "~/convex/functions/pricing";
import { Id } from "~/convex/_generated/dataModel";
 
export const createOnePayOrderForReservation = mutation({
  args: {
    reservationId: v.id("reservations"),
  },
  handler: async (ctx, { reservationId }) => {
    const reservation = await ctx.db.get(reservationId);
    if (!reservation) throw new Error("Reservation not found");
    if (reservation.paymentStatus !== "PENDING") {
      throw new Error("Reservation is not in PENDING status");
    }
 
    // Get price breakdown with surcharges
    const breakdown = await calculateTotalPrice(ctx, {
      occurrenceId: reservation.occurrenceId,
      ticketType: reservation.ticketType as "DINNER_THEATRE" | "SHOW_ONLY",
      quantity: reservation.quantity,
      addOns: reservation.addOns ?? [],
      seatIds: reservation.seatIds,
    });
 
    // Create OnePay VA order
    const onepayOrder = await createOnePayOrder(
      reservationId,
      breakdown.total,
      600, // 10 minutes expiry
    );
 
    // Store OnePay order info on reservation
    await ctx.db.patch(reservationId, {
      paymentGateway: "onepay",
      onepayOrderId: onepayOrder.id,
      vaNumber: onepayOrder.va_number,
      qrCode: onepayOrder.qr_code,
      qrCodeUrl: onepayOrder.qr_code_url,
      paymentExpiresAt: new Date(onepayOrder.expired_at).getTime(),
      totalAmount: breakdown.total, // Update to full amount including surcharges
    });
 
    return {
      vaNumber: onepayOrder.va_number,
      qrCode: onepayOrder.qr_code,
      qrCodeUrl: onepayOrder.qr_code_url,
      amount: breakdown.total,
      expiredAt: onepayOrder.expired_at,
    };
  },
});
  • Step 4: Update confirmPayment mutation

The existing confirmPayment mutation may need updating to handle the OnePay webhook flow:

export const confirmPayment = mutation({
  args: {
    reservationId: v.id("reservations"),
    paymentMethod: v.optional(v.string()),
    gatewayTransactionId: v.optional(v.string()),
    amount: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const reservation = await ctx.db.get(args.reservationId);
    if (!reservation) throw new Error("Reservation not found");
 
    if (reservation.paymentStatus !== "PENDING") {
      throw new Error("Reservation is not pending");
    }
 
    // Verify amount matches (for OnePay exact-amount matching)
    if (args.amount !== undefined && args.amount !== reservation.totalAmount) {
      // Amount mismatch — this could be fraud or a duplicate transfer
      // Log it but still confirm (OnePay's exact amount provides protection)
      consola.warn("OnePay amount mismatch", {
        reservationId: args.reservationId,
        expected: reservation.totalAmount,
        received: args.amount,
      });
    }
 
    await ctx.db.patch(args.reservationId, {
      paymentStatus: "PAID",
      paymentMethod:
        args.paymentMethod ?? reservation.paymentGateway ?? "onepay",
      gatewayTransactionId: args.gatewayTransactionId,
      bookingExpiresAt: undefined,
      paymentExpiresAt: undefined,
      updatedAt: Date.now(),
    });
 
    // Generate QR code for venue check-in
    const qrData = generateQrData(reservation);
    const qrCode = await generateQrCode(qrData);
    await ctx.db.patch(args.reservationId, { qrCode });
 
    // Trigger post-payment notifications (email, Zoho sync)
    // Defer to notifications plan
 
    return { success: true };
  },
});
  • Step 5: Add paymentGateway and related fields to schema

Check if paymentGateway, onepayOrderId, vaNumber, qrCode, qrCodeUrl, paymentExpiresAt fields exist in the reservation schema. If not, add them:

// In schema.ts reservations table
paymentGateway: v.optional(v.string()),       // "onepay" | "vnpay" | "onepay"
onepayOrderId: v.optional(v.string()),
vaNumber: v.optional(v.string()),
qrCode: v.optional(v.string()),              // Base64 PNG
qrCodeUrl: v.optional(v.string()),
paymentExpiresAt: v.optional(v.number()),   // Timestamp
  • Step 6: Commit
git add convex/functions/reservations.ts convex/schema.ts
git commit -m "feat(payment): add OnePay order creation to reservations"

Phase 4: Frontend Confirmation Flow

Task 4: Create OnePay Order API Endpoint

Files:

  • Create: apps/frontend/app/api/onepay/create-order/route.ts

  • Step 1: Create the API endpoint

import { NextRequest, NextResponse } from "next/server";
import { api } from "~/convex/_generated/api";
import { fetchAction } from "convex/next";
import { z } from "zod";
 
const CreateOrderRequestSchema = z.object({
  reservationId: z.string().min(1),
});
 
export async function POST(request: NextRequest) {
  const bodyParse = CreateOrderRequestSchema.safeParse(await request.json());
  if (!bodyParse.success) {
    return NextResponse.json(
      { error: "Invalid request body" },
      { status: 400 },
    );
  }
 
  const { reservationId } = bodyParse.data;
 
  try {
    const result = await fetchAction(
      api.reservations.createOnePayOrderForReservation,
      {
        reservationId,
      },
    );
 
    return NextResponse.json(result);
  } catch (error) {
    const message =
      error instanceof Error ? error.message : "Failed to create OnePay order";
    return NextResponse.json({ error: message }, { status: 500 });
  }
}
  • Step 2: Commit

Phase 5: Confirmation Page

Task 5: Update Confirmation Page for OnePay

Files:

  • Create: apps/frontend/components/booking/confirmation-display.tsx

The confirmation page needs to:

  1. Display VA number and QR code (from reservation.vaNumber, reservation.qrCode)
  2. Show countdown timer until VA expires
  3. Show success state when webhook confirms payment (via Convex real-time subscription)
  • Step 1: Create confirmation display component
"use client";
import { useEffect, useState } from "react";
import { useQueryState } from "nuqs";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { Id } from "~/convex/_generated/dataModel";
 
export function ConfirmationDisplay() {
  const t = useTranslations("booking.confirmation");
  const [reservationId] = useQueryState("reservationId", { defaultValue: "" });
  const [timeLeft, setTimeLeft] = useState<number>(0);
 
  // Real-time reservation status via Convex subscription
  // No manual polling needed — Convex auto-updates on webhook confirmation
  const reservation = useQuery(
    api.reservations.getById,
    reservationId ? { reservationId: reservationId as Id<"reservations"> } : "skip"
  );
 
  // Countdown timer for VA expiry
  useEffect(() => {
    if (!reservation?.paymentExpiresAt) return;
 
    const updateTimer = () => {
      const remaining = reservation.paymentExpiresAt! - Date.now();
      setTimeLeft(Math.max(0, remaining));
    };
 
    updateTimer();
    const interval = setInterval(updateTimer, 1000);
    return () => clearInterval(interval);
  }, [reservation?.paymentExpiresAt]);
 
  // Show payment pending state with VA
  if (reservation?.vaNumber && reservation.paymentStatus === "PENDING") {
    const minutes = Math.floor(timeLeft / 60000);
    const seconds = Math.floor((timeLeft % 60000) / 1000);
    const timeLeftFormatted = `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
 
    return (
      <div className="max-w-lg mx-auto p-6">
        <h1 className="text-2xl font-serif text-accent mb-6">{t("completePayment")}</h1>
 
        {/* QR Code */}
        <div className="flex justify-center mb-6">
          {reservation.qrCodeUrl ? (
            <img
              src={reservation.qrCodeUrl}
              alt={t("qrCodeAlt")}
              className="w-64 h-64"
            />
          ) : reservation.qrCode ? (
            <img
              src={`data:image/png;base64,${reservation.qrCode}`}
              alt={t("qrCodeAlt")}
              className="w-64 h-64"
            />
          ) : null}
        </div>
 
        {/* Virtual Account Number */}
        <div className="bg-surface border border-border rounded-lg p-4 mb-6">
          <p className="text-sm text-gray-400 mb-1">{t("virtualAccountNumber")}</p>
          <p className="text-xl font-mono text-accent">{reservation.vaNumber}</p>
        </div>
 
        {/* Amount */}
        <div className="bg-surface border border-border rounded-lg p-4 mb-6">
          <p className="text-sm text-gray-400 mb-1">{t("amountToTransfer")}</p>
          <p className="text-2xl font-bold text-accent">
            {reservation.totalAmount.toLocaleString()} VND
          </p>
          <p className="text-xs text-gray-500 mt-1">
            {t("exactAmountNote")}
          </p>
        </div>
 
        {/* Countdown Timer */}
        <div className="text-center mb-6">
          <p className="text-sm text-gray-400 mb-1">{t("timeRemaining")}</p>
          <p className="text-3xl font-mono text-accent">
            {timeLeftFormatted}
          </p>
          <p className="text-xs text-gray-500 mt-1">
            {t("seatsHeldNote")}
          </p>
        </div>
 
        {/* Instructions */}
        <div className="text-sm text-gray-400 space-y-2">
          <p>{t("instruction1")}</p>
          <p>{t("instruction2")}</p>
          <p>{t("instruction3")}</p>
        </div>
      </div>
    );
  }
 
  // Payment successful — show confirmation
  if (reservation?.paymentStatus === "PAID") {
    return (
      <div className="max-w-lg mx-auto p-6 text-center">
        <div className="w-16 h-16 rounded-full bg-accent/20 flex items-center justify-center mx-auto mb-4">
          <svg className="w-8 h-8 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
          </svg>
        </div>
        <h1 className="text-3xl font-serif text-accent mb-4">{t("bookingConfirmed")}</h1>
 
        {reservation.qrCode && (
          <div className="flex justify-center mb-6">
            <img
              src={`data:image/png;base64,${reservation.qrCode}`}
              alt={t("bookingQrCode")}
              className="w-48 h-48"
            />
          </div>
        )}
 
        <p className="text-gray-400 mb-2">
          {t("bookingId")}: {reservation._id}
        </p>
        <p className="text-gray-400 mb-6">
          {reservation.quantity} × {reservation.ticketType}
        </p>
 
        <div className="bg-surface border border-border rounded-lg p-4 text-left">
          <h3 className="font-serif text-accent mb-2">{t("whatsNext")}</h3>
          <ul className="text-sm text-gray-400 space-y-1">
            <li>{t("nextStep1")}</li>
            <li>{t("nextStep2")}</li>
            <li>{t("nextStep3")}</li>
          </ul>
        </div>
      </div>
    );
  }
 
  // Loading / Error states
  return (
    <div className="max-w-lg mx-auto p-6 text-center">
      <div className="animate-pulse">
        <p className="text-gray-400">{t("loading")}</p>
      </div>
    </div>
  );
}
  • Step 2: Commit

Phase 6: Seat Release & Expiry

Task 6: Verify Seat Release on Expiry

Files:

  • Verify: convex/functions/reservations.tsreleaseExpired mutation

  • Step 1: Verify releaseExpired exists and handles OnePay reservations

The existing releaseExpired mutation should handle PENDING reservations that have expired. Key behavior:

  • Queries reservations where bookingExpiresAt < now AND paymentStatus === "PENDING"
  • Decrements bookedCount on occurrence
  • Sets paymentStatus to "FAILED"

For OnePay, we should also clear vaNumber and qrCode fields:

// Add to releaseExpired mutation
await ctx.db.patch(reservationId, {
  paymentStatus: "FAILED",
  bookingExpiresAt: undefined,
  paymentExpiresAt: undefined,
  vaNumber: undefined,
  qrCode: undefined,
  qrCodeUrl: undefined,
  updatedAt: Date.now(),
});
  • Step 2: Commit

Phase 7: Webhook Retry & Idempotency

Task 7: Handle OnePay Webhook Idempotency

Files:

  • Already implemented in Phase 2, Task 2 — the onepayWebhook handler includes idempotency checks

OnePay webhooks are retried with Fibonacci backoff (7 retries, 5 hours max). The handler:

  1. Checks if reservation is already PAID before processing
  2. Returns 200 success to stop retries after first successful processing
  3. Uses reservation ID from transfer content to route to correct record

Enrichment Sections

1. Zod Schemas

import { z } from "zod";
 
// OnePay webhook payload
const OnePayWebhookPayloadSchema = z.object({
  id: z.string(),
  gateway: z.string(),
  transactionDate: z.string(),
  accountNumber: z.string(),
  code: z.string().nullable(),
  content: z.string(),
  transferType: z.string(),
  transferAmount: z.number(),
  accumulated: z.number(),
  subAccount: z.string().nullable(),
  referenceCode: z.string(),
  description: z.string(),
});
 
// OnePay order response
const OnePayOrderResponseSchema = z.object({
  id: z.string(),
  orderCode: z.string(),
  vaNumber: z.string(),
  qrCode: z.string().nullable(),
  qrCodeUrl: z.string().nullable(),
  amount: z.number(),
  status: z.enum(["Pending", "Paid", "Failed"]),
  expiredAt: z.string(),
});
 
// Create order API request
const CreateOnePayOrderRequestSchema = z.object({
  reservationId: z.string().min(1),
});
 
const CreateOnePayOrderResponseSchema = z.object({
  vaNumber: z.string(),
  qrCode: z.string().nullable(),
  qrCodeUrl: z.string().nullable(),
  amount: z.number(),
  expiredAt: z.string(),
});
 
// Reservation update with OnePay fields
const OnePayReservationUpdateSchema = z.object({
  paymentGateway: z.string(),
  onepayOrderId: z.string(),
  vaNumber: z.string(),
  qrCode: z.string().nullable(),
  qrCodeUrl: z.string().nullable(),
  paymentExpiresAt: z.number(),
  totalAmount: z.number(),
});

2. Error Handling

Error codes defined as a const object:

// Error codes namespace
 
export const PAYMENT_ERROR_CODES = {
  RESERVATION_NOT_FOUND: "RESERVATION_NOT_FOUND",
  NOT_PENDING: "NOT_PENDING",
  ONEPAY_API_ERROR: "ONEPAY_API_ERROR",
  ALREADY_PAID: "ALREADY_PAID",
  AMOUNT_MISMATCH: "AMOUNT_MISMATCH",
  RELEASE_FAILED: "RELEASE_FAILED",
} as const;
 
export type PaymentErrorCode =
  (typeof PAYMENT_ERROR_CODES)[keyof typeof PAYMENT_ERROR_CODES];
MutationError CodeError Message
reservations.createOnePayOrderForReservationRESERVATION_NOT_FOUND"Booking session expired"
reservations.createOnePayOrderForReservationNOT_PENDING"Booking is no longer pending"
reservations.createOnePayOrderForReservationONEPAY_API_ERROR"Failed to create payment. Please try again."
reservations.confirmPaymentALREADY_PAID"Booking already confirmed"
reservations.confirmPaymentAMOUNT_MISMATCH"Payment amount mismatch"
reservations.releaseExpiredRELEASE_FAILED"Failed to release expired reservation"

3. Convex Real-time Subscription Pattern

// Real-time payment status during confirmation — no manual polling needed
// Convex subscription auto-updates reservation when webhook confirms payment
const reservation = useQuery(
  api.reservations.getById,
  reservationId ? { reservationId } : "skip",
);
 
// Webhook is HTTP action — Convex handles real-time update propagation
// No additional subscription pattern needed client-side

4. Mobile/Responsive Considerations

  • QR code: Centered, 256x256px on mobile; scales appropriately with object-contain.
  • VA number: Large monospace font for easy transcription (minimum 18px font size).
  • Countdown timer: Large, prominent display (text-3xl equivalent).
  • Instructions: Step-by-step numbered list, readable on small screens with proper line-height.
  • Touch targets: All interactive elements minimum 44px tap target.
  • Layout: Single column, centered content works on all screen sizes.

5. PWA / Offline Behavior

Confirmation page requires real-time updates — offline mode will not receive webhook updates. If network is lost:

  1. Show "Connection lost" overlay with retry button
  2. Continue displaying VA and QR code (stored in component state from Convex)
  3. On reconnect, Convex subscription will update to PAID status
  4. If expiry occurs offline, reservation will be cleaned up by releaseExpired scheduler
  5. VA number and amount remain visible for manual transfer

6. i18n / next-intl Requirements

{
  "booking": {
    "confirmation": {
      "completePayment": "Complete Your Payment",
      "qrCodeAlt": "Payment QR Code",
      "virtualAccountNumber": "Virtual Account Number",
      "amountToTransfer": "Amount to Transfer",
      "exactAmountNote": "Transfer the exact amount to the account above",
      "timeRemaining": "Time remaining",
      "seatsHeldNote": "Seats are held for 10 minutes",
      "instruction1": "1. Open your mobile banking app",
      "instruction2": "2. Transfer the exact amount to the VA number above",
      "instruction3": "3. Wait for confirmation (this page will update automatically)",
      "bookingConfirmed": "Booking Confirmed!",
      "bookingQrCode": "Booking QR Code",
      "bookingId": "Booking ID",
      "whatsNext": "What's Next?",
      "nextStep1": "Show this QR code at the venue entrance",
      "nextStep2": "Arrive 15 minutes before the show",
      "nextStep3": "Present your booking confirmation",
      "loading": "Loading..."
    }
  }
}

7. Environment-Specific Configuration

VariableDescriptionRequiredLocation
ONEPAY_BASE_URLOnePay API endpointYesServer only
ONEPAY_API_KEYOnePay API keyYesServer only
ONEPAY_BANK_ACCOUNT_XIDOnePay bank account XIDYesServer only
ONEPAY_WEBHOOK_SECRETOnePay webhook secret for verificationYesServer only
NEXT_PUBLIC_BASE_URLPublic URL for webhook registrationYesClient+Server
NEXT_PUBLIC_CONVEX_URLConvex deployment URLYesClient

Critical: Server-only variablesONEPAY_API_KEY and ONEPAY_BANK_ACCOUNT_XID must never be exposed to the client. They are only used in convex/http/onepay.ts which runs server-side.

8. TDD Test Cases

CRITICAL: All tests use USER EXPECTATION format — what the USER SEES and EXPERIENCES. NO implementation details in test names. Tests are definitions only — no implementation code.

E2E Tests (Playwright):

test("PAY-E2E-1.1: Guest sees VA number and QR code after checkout submission");
test("PAY-E2E-1.2: QR code is scannable and displays correct payment amount");
test("PAY-E2E-1.3: VA number is displayed in large monospace font");
test("PAY-E2E-1.4: Countdown timer shows time remaining for payment");
test("PAY-E2E-1.5: Guest sees step-by-step payment instructions");
test(
  "PAY-E2E-2.1: Confirmation page updates automatically when payment is confirmed",
);
test("PAY-E2E-2.2: Guest sees booking confirmed state with success icon");
test("PAY-E2E-2.3: Guest sees QR code for venue check-in after confirmation");
test("PAY-E2E-2.4: Guest sees booking ID after confirmation");
test(
  "PAY-E2E-3.1: Guest sees connection lost overlay when offline during confirmation",
);
test("PAY-E2E-3.2: Confirmation recovers and updates after reconnecting");
test("PAY-E2E-4.1: Timer expiry shows expired reservation modal");
test("PAY-E2E-4.2: Guest is redirected home after timer expiry");

Component Tests (Vitest + RTL):

it(
  "PAY-1.1: ConfirmationDisplay shows loading skeleton before reservation loads",
);
it("PAY-1.2: ConfirmationDisplay shows VA number when reservation is PENDING");
it("PAY-1.3: ConfirmationDisplay shows QR code when qrCodeUrl is available");
it(
  "PAY-1.4: ConfirmationDisplay shows QR code from base64 when qrCode is available",
);
it("PAY-1.5: ConfirmationDisplay shows exact amount in VND format");
it(
  "PAY-1.6: ConfirmationDisplay shows countdown timer for PENDING reservations",
);
it("PAY-1.7: Countdown timer displays MM:SS format");
it("PAY-1.8: Countdown timer reaches 0:00 and shows expired state");
it("PAY-2.1: ConfirmationDisplay shows success state when reservation is PAID");
it("PAY-2.2: ConfirmationDisplay shows checkmark icon for confirmed booking");
it("PAY-2.3: ConfirmationDisplay shows booking ID after confirmation");
it("PAY-2.4: ConfirmationDisplay shows next steps after confirmation");
it("PAY-3.1: Amount displays with thousand separators for readability");
it("PAY-3.2: Exact amount note is visible below amount");

Backend/Mutation Tests (Vitest):

it(
  "PAY-CREATE-1.1: createOnePayOrderForReservation creates VA order with correct amount",
);
it(
  "PAY-CREATE-1.2: createOnePayOrderForReservation fails for non-existent reservation",
);
it(
  "PAY-CREATE-1.3: createOnePayOrderForReservation fails for non-PENDING reservation",
);
it(
  "PAY-CREATE-1.4: createOnePayOrderForReservation stores VA number on reservation",
);
it(
  "PAY-CREATE-1.5: createOnePayOrderForReservation stores QR code on reservation",
);
it(
  "PAY-CREATE-1.6: createOnePayOrderForReservation sets paymentExpiresAt correctly",
);
it("PAY-CONFIRM-2.1: confirmPayment updates reservation status to PAID");
it("PAY-CONFIRM-2.2: confirmPayment clears bookingExpiresAt");
it("PAY-CONFIRM-2.3: confirmPayment generates QR code for venue check-in");
it("PAY-CONFIRM-2.4: confirmPayment fails for already PAID reservation");
it("PAY-CONFIRM-2.5: confirmPayment fails for non-existent reservation");
it("PAY-WEBHOOK-3.1: onepayWebhook processes transfer-in event correctly");
it("PAY-WEBHOOK-3.2: onepayWebhook ignores transfer-out events");
it("PAY-WEBHOOK-3.3: onepayWebhook returns success for unknown order codes");
it(
  "PAY-WEBHOOK-3.4: onepayWebhook is idempotent for already PAID reservations",
);
it("PAY-WEBHOOK-3.5: onepayWebhook validates payload with Zod schema");
it("PAY-WEBHOOK-3.6: onepayWebhook rejects unauthorized requests");
it("PAY-RELEASE-4.1: releaseExpired sets reservation status to FAILED");
it("PAY-RELEASE-4.2: releaseExpired decrements bookedCount on occurrence");
it("PAY-RELEASE-4.3: releaseExpired clears vaNumber and qrCode fields");

9. Cross-Plan Dependencies

DependencyPlanShared Schema
Booking flow2026-05-03-booking-flow.mdUses createOnePayOrderForReservation, confirmation polling
Seat selection2026-05-03-seat-selection.mdreservations.seatIds included in price calculation
Pricing16-package-bundle-pricing.mdcalculateTotalPrice for total with surcharges
Admin backoffice03-admin-backoffice.mdAdmin can manually trigger refund/cancel
Notifications17-notifications-crm.mdPost-payment email/SMS confirmation
QR generationqr.ts libraryQR code generation for booking confirmation

10. Performance Considerations

  • Webhook processing: Synchronous but fast — should complete in < 100ms typically.
  • Confirmation updates: Convex real-time subscription handles updates — no polling interval needed on client.
  • QR code: Base64 stored in DB on PAID — no generation at read time, fast retrieval.
  • Idempotency: Prevents duplicate processing on webhook retry, critical for financial transactions.
  • Rate limiting: OnePay limits to 3 requests/second; our webhook handler is read-only (no outbound calls during webhook processing).
  • Payload validation: Zod schema validation on webhook payload before processing prevents invalid data.

Business Summary

What this does: Enables guests to pay for bookings using OnePay Virtual Account (VA) — a Vietnam-native payment method where guests transfer the exact booking amount to a dedicated bank account via their banking app. The system automatically confirms payment when OnePay detects the transfer and sends a webhook.

Why it matters: OnePay is the existing payment gateway already integrated in the WordPress system. Migrating to Next.js/Convex keeps the same provider while simplifying the architecture. The redirect model is familiar and reliable for Vietnamese customers.

Time to implement: 3-5 days | Complexity: Medium

Dependencies: foundation-plan (staffMutation auth helpers), booking-flow-plan (reservation creation flow)


Acceptance Criteria

  1. OnePay order creation — checkout creates a OnePay order with correct amount and duration
  2. VA + QR display — customer sees Virtual Account number and QR code on confirmation page
  3. Exact amount transfer — OnePay VA is set to the exact booking total (anti-fraud)
  4. Webhook confirmation — OnePay webhook hits our endpoint when transfer is detected
  5. Confirmation page updates — Convex subscription detects PAID status and shows success
  6. Seat hold timer — VA and seat hold both expire at 10 minutes
  7. Idempotent webhooks — duplicate webhook calls don't double-process
  8. Failed/timeout handling — expired PENDING reservations have seats released
  9. QR code generation — QR code encodes booking details for venue scanning
  10. Status transitions — all states (PENDING → PAID/FAILED/CANCELLED, PAID → REFUNDED) handled correctly

Consistency Audit: payment-onepay

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
None

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1onepayWebhook handlerconsole.log usageReplaced with consola from consola library
2Throughout codeHardcoded stringsAll user-facing strings use useTranslations/getTranslations
3API routeMissing Zod validationAdded CreateOrderRequestSchema.safeParse() validation
4Throughout componentsMissing error handlingAdded typed error codes via PAYMENT_ERROR_CODES const object

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

#IssueAction Required
1staffMutation/adminMutation/authenticatedQuery/authenticatedMutation not exported from convex/auth.tsAdmin refund/cancel mutations require auth helpers. Currently only getCurrentUser, upsertUser, and isAdmin are exported. convex/CLAUDE.md references authenticatedQuery/authenticatedMutation but these are NOT yet implemented.

[P0] No as any found — all type assertions use proper TypeScript types or Zod parse (OnePayWebhookPayloadSchema.parse, OnePayOrderResponseSchema.parse).

[P0] No Math.random() found — no ID generation issues. Reservation IDs come from Convex.

[P0] No useParams() found — plan uses useQueryState from nuqs correctly for reservationId in ConfirmationDisplay.

[P0] No staffMutation/adminMutation references — no admin mutations in this plan. All mutations use standard mutation from Convex.

[P0] Auth helper gap (NOT BLOCKING for this plan): staffMutation, adminMutation, authenticatedQuery, and authenticatedMutation are NOT exported from convex/auth.ts — only getCurrentUser, upsertUser, and isAdmin are exported. convex/CLAUDE.md references authenticatedQuery/authenticatedMutation but these are not yet implemented. This plan does not require staff/admin mutations, so it is not blocked.

[P1] No console.log found — all logging uses consola from consola library (warn at line ~233, info at ~284 in onepayWebhook).

[P1] useTransition not needed — ConfirmationDisplay uses Convex real-time subscription for auto-updates, no navigation transitions needed.

[P1] Suspense not required in this plan — ConfirmationDisplay handles loading state with animate-pulse. The booking-flow plan wraps this component in Suspense at the page level.

[P1] All hardcoded strings reviewed — all user-facing strings use useTranslations/getTranslations from next-intl.

[P1] No emoji in UI — all icons use inline SVG, no emoji characters in component code.

[P1] API route uses fetchAction — the /api/onepay/create-order/route.ts correctly uses fetchAction from convex/next to call Convex server actions from a Next.js API route.

[P1] Zod schemas provided — all mutation inputs and form data validated with Zod schemas in Section 1.

[P1] Error codes as const object — PAYMENT_ERROR_CODES defined as const object with as const assertion for type safety.

[P1] All useQuery calls use correct pattern: useQuery(api.fn, args) NOT useQuery(api.fn(), args).