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:
bookingExpiresAtis set tonow + 10 minuteson PENDING creationbookedCounton 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:
bookingExpiresAtis cleared, QR code is generated - On timeout/cancel:
bookedCountis decremented (seat released) - QR code is generated on PAID confirmation — encodes booking details for scanning at venue
OnePay flow:
- Customer submits checkout → Convex mutation calls OnePay API to create order
- OnePay returns VA number + QR code → we display both to the customer
- OnePay processes payment → redirects customer back to our return URL with result
- OnePay detects transfer → sends webhook to our endpoint
- We update reservation to PAID, generate QR code
- 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 varsPhase 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
.envsetup
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.tsVerify calculateTotalPrice is exported and handles surcharges correctly.
- Step 2: Read existing
convex/functions/reservations.ts
cat convex/functions/reservations.ts- Step 3: Add
createOnePayOrderForReservationmutation 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
confirmPaymentmutation
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
paymentGatewayand 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:
- Display VA number and QR code (from
reservation.vaNumber,reservation.qrCode) - Show countdown timer until VA expires
- 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.ts—releaseExpiredmutation -
Step 1: Verify
releaseExpiredexists and handles OnePay reservations
The existing releaseExpired mutation should handle PENDING reservations that have expired. Key behavior:
- Queries reservations where
bookingExpiresAt < nowANDpaymentStatus === "PENDING" - Decrements
bookedCounton occurrence - Sets
paymentStatusto"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
onepayWebhookhandler includes idempotency checks
OnePay webhooks are retried with Fibonacci backoff (7 retries, 5 hours max). The handler:
- Checks if reservation is already PAID before processing
- Returns 200 success to stop retries after first successful processing
- 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];| Mutation | Error Code | Error Message |
|---|---|---|
reservations.createOnePayOrderForReservation | RESERVATION_NOT_FOUND | "Booking session expired" |
reservations.createOnePayOrderForReservation | NOT_PENDING | "Booking is no longer pending" |
reservations.createOnePayOrderForReservation | ONEPAY_API_ERROR | "Failed to create payment. Please try again." |
reservations.confirmPayment | ALREADY_PAID | "Booking already confirmed" |
reservations.confirmPayment | AMOUNT_MISMATCH | "Payment amount mismatch" |
reservations.releaseExpired | RELEASE_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-side4. 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:
- Show "Connection lost" overlay with retry button
- Continue displaying VA and QR code (stored in component state from Convex)
- On reconnect, Convex subscription will update to PAID status
- If expiry occurs offline, reservation will be cleaned up by
releaseExpiredscheduler - 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
| Variable | Description | Required | Location |
|---|---|---|---|
ONEPAY_BASE_URL | OnePay API endpoint | Yes | Server only |
ONEPAY_API_KEY | OnePay API key | Yes | Server only |
ONEPAY_BANK_ACCOUNT_XID | OnePay bank account XID | Yes | Server only |
ONEPAY_WEBHOOK_SECRET | OnePay webhook secret for verification | Yes | Server only |
NEXT_PUBLIC_BASE_URL | Public URL for webhook registration | Yes | Client+Server |
NEXT_PUBLIC_CONVEX_URL | Convex deployment URL | Yes | Client |
Critical: Server-only variables — ONEPAY_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
| Dependency | Plan | Shared Schema |
|---|---|---|
| Booking flow | 2026-05-03-booking-flow.md | Uses createOnePayOrderForReservation, confirmation polling |
| Seat selection | 2026-05-03-seat-selection.md | reservations.seatIds included in price calculation |
| Pricing | 16-package-bundle-pricing.md | calculateTotalPrice for total with surcharges |
| Admin backoffice | 03-admin-backoffice.md | Admin can manually trigger refund/cancel |
| Notifications | 17-notifications-crm.md | Post-payment email/SMS confirmation |
| QR generation | qr.ts library | QR 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
- OnePay order creation — checkout creates a OnePay order with correct amount and duration
- VA + QR display — customer sees Virtual Account number and QR code on confirmation page
- Exact amount transfer — OnePay VA is set to the exact booking total (anti-fraud)
- Webhook confirmation — OnePay webhook hits our endpoint when transfer is detected
- Confirmation page updates — Convex subscription detects PAID status and shows success
- Seat hold timer — VA and seat hold both expire at 10 minutes
- Idempotent webhooks — duplicate webhook calls don't double-process
- Failed/timeout handling — expired PENDING reservations have seats released
- QR code generation — QR code encodes booking details for venue scanning
- Status transitions — all states (PENDING → PAID/FAILED/CANCELLED, PAID → REFUNDED) handled correctly
Consistency Audit: payment-onepay
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| — | None | — | — |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | onepayWebhook handler | console.log usage | Replaced with consola from consola library |
| 2 | Throughout code | Hardcoded strings | All user-facing strings use useTranslations/getTranslations |
| 3 | API route | Missing Zod validation | Added CreateOrderRequestSchema.safeParse() validation |
| 4 | Throughout components | Missing error handling | Added typed error codes via PAYMENT_ERROR_CODES const object |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation/authenticatedQuery/authenticatedMutation not exported from convex/auth.ts | Admin 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).