OnePay Payment Integration Migration 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: Migrate the OnePay payment gateway integration from PHP to the Convex/Next.js stack, enabling customers to complete bookings with real-time payment confirmation.
Architecture: OnePay uses a redirect-based payment flow where the customer is sent to OnePay's gateway, then redirected back to our system. We need: (1) a mutation to generate the payment URL, (2) HTTP endpoints to handle the return URL (customer redirect) and IPN (server-to-server callback), (3) a Convex schema to track payment state.
Tech Stack: Convex (backend functions + HTTP handlers), Next.js 16 (frontend), HMAC-SHA256 (hash verification)
File Structure
apps/backend/convex/
├── schema.ts # Reservations + Payments tables
├── functions/
│ └── reservations/
│ ├── createOnePayOrderForReservation.ts
│ ├── handleOnePayReturn.ts
│ ├── handleOnePayIPN.ts
│ └── updatePaymentStatus.ts
├── http/
│ ├── onepay/return_url.ts # Customer return handler
│ └── onepay/ipn.ts # Server callback handler
└── lib/
└── onepay-utils.ts # Hash generation, URL building
apps/frontend/
├── .env.example # Add ONEPAY_* vars
└── app/[locale]/booking/confirmation/page.tsx # Check if needs updateChecklist
- Task 1: Define Convex schema (reservations + payments tables)
- Task 2: Create OnePay utility functions (hash, URL generation)
- Task 3: Implement createOnePayOrderForReservation mutation
- Task 4: Implement handleOnePayReturn mutation
- Task 5: Implement handleOnePayIPN mutation
- Task 6: Create HTTP handler for return_url
- Task 7: Create HTTP handler for IPN
- Task 8: Add OnePay env vars to .env.example
- Task 9: Verify end-to-end flow works
Task 1: Define Convex Schema
Files:
-
Create:
apps/backend/convex/schema.ts -
Test:
apps/backend/convex/__tests__/schema.test.ts -
Step 1: Create schema with reservations and payments tables
// apps/backend/convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
reservations: defineTable({
// Customer info
customerName: v.string(),
customerEmail: v.string(),
customerPhone: v.string(),
customerNote: v.optional(v.string()),
// Booking details
occurrenceId: v.string(),
bundleId: v.string(),
guests: v.number(),
// Pricing
totalAmount: v.number(), // in VND
// Status
status: v.union([
v.literal("PENDING"),
v.literal("PAID_CONFIRMED"),
v.literal("CHECKED_IN"),
v.literal("CANCELLED"),
v.literal("REFUNDED"),
]),
// Payment tracking
paymentStatus: v.union([
v.literal("PENDING"),
v.literal("PAID"),
v.literal("FAILED"),
]),
paymentMethod: v.optional(v.string()),
paymentNotes: v.optional(v.string()),
// OnePay fields
vpcMerchTxnRef: v.optional(v.string()), // HOL-{reservationId}-{timestamp}
vpcTransactionNo: v.optional(v.string()),
// Timestamps
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_vpcMerchTxnRef", ["vpcMerchTxnRef"]),
payments: defineTable({
reservationId: v.id("reservations"),
vpcMerchTxnRef: v.string(),
vpcTransactionNo: v.optional(v.string()),
amount: v.number(),
currency: v.string(),
status: v.union([
v.literal("PENDING"),
v.literal("SUCCESS"),
v.literal("FAILED"),
]),
responseCode: v.optional(v.string()),
message: v.optional(v.string()),
card: v.optional(v.string()),
cardNum: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_vpcMerchTxnRef", ["vpcMerchTxnRef"]),
});- Step 2: Run codegen to generate types
Run: cd /Users/curlyz/usr/hol/apps/backend && npx convex dev --once
Expected: Schema compiled, types generated in _generated/
- Step 3: Verify generated types
Run: cat /Users/curlyz/usr/hol/apps/backend/convex/_generated/dataModel.d.ts
Expected: reservation, payment tables visible
Task 2: Create OnePay Utility Functions
Files:
-
Create:
apps/backend/convex/lib/onepay-utils.ts -
Test:
apps/backend/convex/lib/__tests__/onepay-utils.test.ts -
Step 1: Create onepay-utils.ts with hash generation
// apps/backend/convex/lib/onepay-utils.ts
import {
ONEPAY_MERCHANT_ID,
ONEPAY_ACCESS_CODE,
ONEPAY_HASH_CODE,
ONEPAY_PAYMENT_URL,
ONEPAY_VERSION,
ONEPAY_CURRENCY,
ONEPAY_RETURN_URL,
ONEPAY_IPN_URL,
} from "./env";
export interface OnePayPaymentParams {
reservationId: string;
bookingReference: string;
amount: number; // in VND
clientIP: string;
}
export interface OnePayPaymentResult {
paymentUrl: string;
vpcMerchTxnRef: string;
}
/**
* Generate string to hash from params (OnePay requirement)
* Only includes params with vpc_ or user_ prefix
*/
function generateStringToHash(params: Record<string, string | number>): string {
const entries = Object.entries(params)
.filter(([key]) => key.startsWith("vpc_") || key.startsWith("user_"))
.filter(([key]) => key !== "vpc_SecureHashType" && key !== "vpc_SecureHash")
.filter(([, value]) => String(value).length > 0)
.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([k, v]) => `${k}=${v}`).join("&");
}
/**
* Generate HMAC-SHA256 secure hash (uppercase hex)
*/
function generateSecureHash(
stringToHash: string,
merchantHashCode: string,
): string {
const merchantHex = Buffer.from(merchantHashCode, "hex");
const hash = require("crypto")
.createHmac("sha256", merchantHex)
.update(stringToHash)
.digest("hex");
return hash.toUpperCase();
}
/**
* Format amount for OnePay (multiply by 100)
*/
export function formatAmountForOnePay(amount: number): number {
return Math.round(amount * 100);
}
/**
* Parse amount from OnePay response (divide by 100)
*/
export function parseAmountFromOnePay(amount: number): number {
return amount / 100;
}
/**
* Generate OnePay payment URL
*/
export function generateOnePayPaymentUrl(
params: OnePayPaymentParams,
): OnePayPaymentResult {
const timestamp = Math.floor(Date.now() / 1000);
const vpcMerchTxnRef = `HOL-${params.reservationId}-${timestamp}`;
// Build payment params
const paymentParams: Record<string, string | number> = {
vpc_Version: ONEPAY_VERSION,
vpc_Currency: ONEPAY_CURRENCY,
vpc_Command: "pay",
vpc_AccessCode: ONEPAY_ACCESS_CODE,
vpc_Merchant: ONEPAY_MERCHANT_ID,
vpc_Locale: "vn",
vpc_ReturnURL: ONEPAY_RETURN_URL,
vpc_MerchTxnRef,
vpc_OrderInfo: `Booking ${params.bookingReference}`,
vpc_Amount: formatAmountForOnePay(params.amount),
vpc_TicketNo: params.clientIP,
AgainLink: process.env.NEXT_PUBLIC_APP_URL || "https://houseoflegends.vn",
Title: "House of Legends - Ticket Payment",
};
// Sort and generate hash
const sortedKeys = Object.keys(paymentParams).sort();
const sortedParams: Record<string, string | number> = {};
for (const key of sortedKeys) {
sortedParams[key] = paymentParams[key];
}
const stringToHash = generateStringToHash(sortedParams);
const secureHash = generateSecureHash(stringToHash, ONEPAY_HASH_CODE);
// Build URL
const queryParams = new URLSearchParams();
for (const [key, value] of Object.entries(sortedParams)) {
queryParams.set(key, String(value));
}
queryParams.set("vpc_SecureHash", secureHash);
return {
paymentUrl: `${ONEPAY_PAYMENT_URL}?${queryParams.toString()}`,
vpcMerchTxnRef,
};
}
/**
* Verify OnePay return hash
*/
export function verifyOnePayHash(
params: Record<string, string>,
receivedHash: string,
): boolean {
// Remove vpc_SecureHash from params for calculation
const paramsCopy = { ...params };
delete paramsCopy.vpc_SecureHash;
// Sort by key
const sortedKeys = Object.keys(paramsCopy).sort();
const sortedParams: Record<string, string> = {};
for (const key of sortedKeys) {
sortedParams[key] = paramsCopy[key];
}
const stringToHash = generateStringToHash(sortedParams);
const calculatedHash = generateSecureHash(stringToHash, ONEPAY_HASH_CODE);
return receivedHash.toUpperCase() === calculatedHash.toUpperCase();
}
/**
* Parse vpcMerchTxnRef to extract reservationId
* Format: HOL-{reservationId}-{timestamp}
*/
export function parseVpcMerchTxnRef(vpcMerchTxnRef: string): string | null {
const match = vpcMerchTxnRef.match(/^HOL-(.+)-\d+$/);
return match ? match[1] : null;
}- Step 2: Create env.ts with OnePay config
// apps/backend/convex/lib/env.ts
// OnePay configuration from environment
export const ONEPAY_MERCHANT_ID =
process.env.ONEPAY_MERCHANT_ID || "TESTONEPAY";
export const ONEPAY_ACCESS_CODE = process.env.ONEPAY_ACCESS_CODE || "6BEB2546";
export const ONEPAY_HASH_CODE =
process.env.ONEPAY_HASH_CODE || "6D0870CDE5F24F34F3915FB0045120DB";
export const ONEPAY_PAYMENT_URL =
process.env.ONEPAY_PAYMENT_URL || "https://mtf.onepay.vn/paygate/vpcpay.op";
export const ONEPAY_QUERY_URL =
process.env.ONEPAY_QUERYDR_URL ||
"https://mtf.onepay.vn/msp/api/v1/vpc/invoices/queries";
export const ONEPAY_RETURN_URL =
process.env.ONEPAY_RETURN_URL ||
"https://houseoflegends.vn/api/onepay/return_url";
export const ONEPAY_IPN_URL =
process.env.ONEPAY_IPN_URL || "https://houseoflegends.vn/api/onepay/ipn";
export const ONEPAY_VPC_USER = process.env.ONEPAY_VPC_USER || "op01";
export const ONEPAY_VPC_PASSWORD =
process.env.ONEPAY_VPC_PASSWORD || "op123456";
export const ONEPAY_VERSION = "2";
export const ONEPAY_CURRENCY = "VND";- Step 3: Write tests for hash functions
// apps/backend/convex/lib/__tests__/onepay-utils.test.ts
import { describe, it, expect } from "vitest";
import {
formatAmountForOnePay,
parseAmountFromOnePay,
generateOnePayPaymentUrl,
verifyOnePayHash,
parseVpcMerchTxnRef,
} from "../onepay-utils";
describe("formatAmountForOnePay", () => {
it("multiplies amount by 100", () => {
expect(formatAmountForOnePay(100000)).toBe(10000000);
expect(formatAmountForOnePay(250000)).toBe(25000000);
});
});
describe("parseAmountFromOnePay", () => {
it("divides amount by 100", () => {
expect(parseAmountFromOnePay(10000000)).toBe(100000);
expect(parseAmountFromOnePay(25000000)).toBe(250000);
});
});
describe("parseVpcMerchTxnRef", () => {
it("extracts reservationId from HOL-{id}-{timestamp}", () => {
expect(parseVpcMerchTxnRef("HOL-abc123-1715000000")).toBe("abc123");
expect(parseVpcMerchTxnRef("HOL-TEMP-12345-1715000000")).toBe("TEMP-12345");
});
it("returns null for invalid format", () => {
expect(parseVpcMerchTxnRef("INVALID")).toBeNull();
expect(parseVpcMerchTxnRef("HOL-")).toBeNull();
});
});- Step 4: Run tests
Run: cd /Users/curlyz/usr/hol/apps/backend && npx vitest run lib/__tests__/onepay-utils.test.ts
Expected: All tests pass
Task 3: Implement createOnePayOrderForReservation Mutation
Files:
-
Create:
apps/backend/convex/functions/reservations/createOnePayOrderForReservation.ts -
Test:
apps/backend/convex/__tests__/functions/reservations/createOnePayOrderForReservation.test.ts -
Step 1: Create the mutation
// apps/backend/convex/functions/reservations/createOnePayOrderForReservation.ts
import { mutation } from "../../_generated/server";
import { v } from "convex/values";
import { generateOnePayPaymentUrl } from "../../lib/onepay-utils";
export const createOnePayOrderForReservation = mutation({
args: {
reservationId: v.id("reservations"),
},
handler: async (ctx, { reservationId }) => {
// Get reservation
const reservation = await ctx.db.get(reservationId);
if (!reservation) {
throw new Error("Reservation not found");
}
if (reservation.paymentStatus === "PAID") {
throw new Error("Reservation already paid");
}
// Get client IP from auth context or use default
const identity = await ctx.auth.getUserIdentity();
const clientIP = identity?.ip || "127.0.0.1";
// Generate booking reference from reservation ID
const bookingReference = `HOL-${reservationId.slice(0, 8).toUpperCase()}`;
// Generate OnePay payment URL
const { paymentUrl, vpcMerchTxnRef } = generateOnePayPaymentUrl({
reservationId,
bookingReference,
amount: reservation.totalAmount,
clientIP,
});
// Update reservation with vpcMerchTxnRef
await ctx.db.patch(reservationId, {
vpcMerchTxnRef,
updatedAt: Date.now(),
});
return { paymentUrl };
},
});- Step 2: Write test
// apps/backend/convex/__tests__/functions/reservations/createOnePayOrderForReservation.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useFakeTimers } from "sinon";
import { createOnePayOrderForReservation } from "../../../functions/reservations/createOnePayOrderForReservation";
import { setupTestDB } from "../../helpers/setupTestDB";
describe("createOnePayOrderForReservation", () => {
let db: ReturnType<typeof setupTestDB>;
beforeEach(() => {
db = setupTestDB();
});
it("generates payment URL for valid reservation", async () => {
const reservationId = await db.createReservation({
customerName: "Test User",
customerEmail: "test@example.com",
customerPhone: "0123456789",
occurrenceId: "occ123",
bundleId: "bundle456",
guests: 2,
totalAmount: 250000,
status: "PENDING",
paymentStatus: "PENDING",
createdAt: Date.now(),
updatedAt: Date.now(),
});
const result = await createOnePayOrderForReservation({ reservationId });
expect(result.paymentUrl).toContain("mtf.onepay.vn");
expect(result.paymentUrl).toContain("vpc_MerchTxnRef");
});
it("throws for non-existent reservation", async () => {
await expect(
createOnePayOrderForReservation({ reservationId: "nonexistent" as any }),
).rejects.toThrow("Reservation not found");
});
it("throws for already paid reservation", async () => {
const reservationId = await db.createReservation({
paymentStatus: "PAID",
});
await expect(
createOnePayOrderForReservation({ reservationId }),
).rejects.toThrow("already paid");
});
});Task 4: Implement handleOnePayReturn Mutation
Files:
-
Create:
apps/backend/convex/functions/reservations/handleOnePayReturn.ts -
Test:
apps/backend/convex/__tests__/functions/reservations/handleOnePayReturn.test.ts -
Step 1: Create the mutation
// apps/backend/convex/functions/reservations/handleOnePayReturn.ts
import { mutation } from "../../_generated/server";
import { v } from "convex/values";
import {
verifyOnePayHash,
parseVpcMerchTxnRef,
parseAmountFromOnePay,
} from "../../lib/onepay-utils";
export const handleOnePayReturn = mutation({
args: {
// All query params from OnePay return URL
vpc_TxnResponseCode: v.string(),
vpc_Message: v.optional(v.string()),
vpc_MerchTxnRef: v.string(),
vpc_TransactionNo: v.optional(v.string()),
vpc_Amount: v.optional(v.number()),
vpc_OrderInfo: v.optional(v.string()),
vpc_Card: v.optional(v.string()),
vpc_CardNum: v.optional(v.string()),
vpc_SecureHash: v.string(),
},
handler: async (ctx, args) => {
// Verify hash
const isValid = verifyOnePayHash(args, args.vpc_SecureHash);
if (!isValid) {
throw new Error("Invalid secure hash");
}
// Extract reservationId from vpcMerchTxnRef
const reservationId = parseVpcMerchTxnRef(args.vpc_MerchTxnRef);
if (!reservationId) {
throw new Error("Invalid transaction reference");
}
// Find reservation by vpcMerchTxnRef
const reservation = await ctx.db
.query("reservations")
.withIndex("by_vpcMerchTxnRef", (q) =>
q.eq("vpcMerchTxnRef", args.vpc_MerchTxnRef),
)
.first();
if (!reservation) {
throw new Error("Reservation not found");
}
// Determine payment status
const isSuccess = args.vpc_TxnResponseCode === "0";
const paymentStatus = isSuccess ? "PAID" : "FAILED";
const paymentNotes = [
`ReturnURL: ${args.vpc_TxnResponseCode} - ${args.vpc_Message || ""}`,
`TransactionNo: ${args.vpc_TransactionNo || "N/A"}`,
`Card: ${args.vpc_Card || "N/A"}`,
].join("; ");
// Update reservation
await ctx.db.patch(reservation._id, {
paymentStatus,
paymentMethod: "OnePay",
paymentNotes,
vpcTransactionNo: args.vpc_TransactionNo,
status: isSuccess ? "PAID_CONFIRMED" : reservation.status,
updatedAt: Date.now(),
});
// Create payment record
await ctx.db.insert("payments", {
reservationId: reservation._id,
vpcMerchTxnRef: args.vpc_MerchTxnRef,
vpcTransactionNo: args.vpc_TransactionNo,
amount: args.vpc_Amount
? parseAmountFromOnePay(args.vpc_Amount)
: reservation.totalAmount,
currency: "VND",
status: isSuccess ? "SUCCESS" : "FAILED",
responseCode: args.vpc_TxnResponseCode,
message: args.vpc_Message,
card: args.vpc_Card,
cardNum: args.vpc_CardNum,
createdAt: Date.now(),
updatedAt: Date.now(),
});
return {
success: isSuccess,
reservationId: reservation._id,
message: isSuccess
? "Payment successful"
: `Payment failed: ${args.vpc_Message}`,
};
},
});Task 5: Implement handleOnePayIPN Mutation
Files:
-
Create:
apps/backend/convex/functions/reservations/handleOnePayIPN.ts -
Test:
apps/backend/convex/__tests__/functions/reservations/handleOnePayIPN.test.ts -
Step 1: Create the mutation (IPN is similar to return but for server-to-server)
// apps/backend/convex/functions/reservations/handleOnePayIPN.ts
import { mutation } from "../../_generated/server";
import { v } from "convex/values";
import {
verifyOnePayHash,
parseVpcMerchTxnRef,
parseAmountFromOnePay,
} from "../../lib/onepay-utils";
export const handleOnePayIPN = mutation({
args: {
vpc_TxnResponseCode: v.string(),
vpc_Message: v.optional(v.string()),
vpc_MerchTxnRef: v.string(),
vpc_TransactionNo: v.optional(v.string()),
vpc_Amount: v.optional(v.number()),
vpc_Card: v.optional(v.string()),
vpc_SecureHash: v.string(),
},
handler: async (ctx, args) => {
// Verify hash first (security critical)
const isValid = verifyOnePayHash(args, args.vpc_SecureHash);
if (!isValid) {
throw new Error("Invalid secure hash");
}
// Extract reservationId
const reservationId = parseVpcMerchTxnRef(args.vpc_MerchTxnRef);
if (!reservationId) {
throw new Error("Invalid transaction reference");
}
// Find reservation
const reservation = await ctx.db
.query("reservations")
.withIndex("by_vpcMerchTxnRef", (q) =>
q.eq("vpcMerchTxnRef", args.vpc_MerchTxnRef),
)
.first();
if (!reservation) {
throw new Error("Reservation not found");
}
// Skip if already processed successfully (idempotency)
if (
reservation.paymentStatus === "PAID" &&
args.vpc_TxnResponseCode === "0"
) {
return { success: true, alreadyProcessed: true };
}
// Determine status
const isSuccess = args.vpc_TxnResponseCode === "0";
const paymentStatus = isSuccess ? "PAID" : "FAILED";
const paymentNotes = [
`IPN: ${args.vpc_TxnResponseCode} - ${args.vpc_Message || ""}`,
`TransactionNo: ${args.vpc_TransactionNo || "N/A"}`,
`Card: ${args.vpc_Card || "N/A"}`,
].join("; ");
// Update reservation
await ctx.db.patch(reservation._id, {
paymentStatus,
paymentMethod: "OnePay",
paymentNotes,
vpcTransactionNo: args.vpc_TransactionNo,
status: isSuccess ? "PAID_CONFIRMED" : reservation.status,
updatedAt: Date.now(),
});
// Upsert payment record (IPN can arrive multiple times)
const existingPayment = await ctx.db
.query("payments")
.withIndex("by_vpcMerchTxnRef", (q) =>
q.eq("vpcMerchTxnRef", args.vpc_MerchTxnRef),
)
.first();
if (existingPayment) {
await ctx.db.patch(existingPayment._id, {
status: isSuccess ? "SUCCESS" : "FAILED",
responseCode: args.vpc_TxnResponseCode,
message: args.vpc_Message,
vpcTransactionNo: args.vpc_TransactionNo,
updatedAt: Date.now(),
});
} else {
await ctx.db.insert("payments", {
reservationId: reservation._id,
vpcMerchTxnRef: args.vpc_MerchTxnRef,
vpcTransactionNo: args.vpc_TransactionNo,
amount: args.vpc_Amount
? parseAmountFromOnePay(args.vpc_Amount)
: reservation.totalAmount,
currency: "VND",
status: isSuccess ? "SUCCESS" : "FAILED",
responseCode: args.vpc_TxnResponseCode,
message: args.vpc_Message,
card: args.vpc_Card,
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
return { success: isSuccess };
},
});Task 6: Create HTTP Handler for return_url
Files:
-
Create:
apps/backend/convex/http/onepay/return_url.ts -
Test:
apps/frontend/__tests__/api/onepay/return_url.test.ts -
Step 1: Create HTTP handler
// apps/backend/convex/http/onepay/return_url.ts
import { httpRouter } from "convex/server";
import { httpAction } from "../../_generated/server";
import {
parseVpcMerchTxnRef,
verifyOnePayHash,
parseAmountFromOnePay,
} from "../../lib/onepay-utils";
const http = httpRouter();
export const handleOnePayReturn = httpAction(async (ctx, request: Request) => {
const url = new URL(request.url);
// OnePay sends back query parameters
const params: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
params[key] = value;
});
// Verify hash
const receivedHash = params["vpc_SecureHash"];
if (!receivedHash) {
return new Response("Invalid request: missing hash", { status: 400 });
}
const isValid = verifyOnePayHash(params, receivedHash);
if (!isValid) {
return new Response("Invalid secure hash", { status: 400 });
}
// Extract reservation ID
const vpcMerchTxnRef = params["vpc_MerchTxnRef"];
const reservationId = parseVpcMerchTxnRef(vpcMerchTxnRef);
if (!reservationId) {
return new Response("Invalid transaction reference", { status: 400 });
}
// Find and update reservation
const reservation = await ctx.db
.query("reservations")
.withIndex("by_vpcMerchTxnRef", (q) =>
q.eq("vpcMerchTxnRef", vpcMerchTxnRef),
)
.first();
if (!reservation) {
return new Response("Reservation not found", { status: 404 });
}
// Determine payment status
const vpcTxnResponseCode = params["vpc_TxnResponseCode"];
const isSuccess = vpcTxnResponseCode === "0";
const paymentStatus = isSuccess ? "PAID" : "FAILED";
const status = isSuccess ? "PAID_CONFIRMED" : reservation.status;
// Update reservation
await ctx.db.patch(reservation._id, {
paymentStatus,
paymentMethod: "OnePay",
paymentNotes: `ReturnURL: ${vpcTxnResponseCode} - ${params["vpc_Message"] || ""}`,
vpcTransactionNo: params["vpc_TransactionNo"] || undefined,
status,
updatedAt: Date.now(),
});
// Create payment record
await ctx.db.insert("payments", {
reservationId: reservation._id,
vpcMerchTxnRef,
vpcTransactionNo: params["vpc_TransactionNo"] || undefined,
amount: params["vpc_Amount"]
? parseAmountFromOnePay(Number(params["vpc_Amount"]))
: reservation.totalAmount,
currency: "VND",
status: isSuccess ? "SUCCESS" : "FAILED",
responseCode: vpcTxnResponseCode,
message: params["vpc_Message"] || undefined,
card: params["vpc_Card"] || undefined,
cardNum: params["vpc_CardNum"] || undefined,
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Redirect to frontend confirmation page
const frontendUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/booking/confirmation?reservationId=${reservation._id}&status=${isSuccess ? "success" : "failed"}`;
return new Response(null, {
status: 302,
headers: { Location: frontendUrl },
});
});
http.define({
path: "/onepay/return_url",
method: "GET",
handler: handleOnePayReturn,
});
export default http;Task 7: Create HTTP Handler for IPN
Files:
-
Create:
apps/backend/convex/http/onepay/ipn.ts -
Test:
apps/frontend/__tests__/api/onepay/ipn.test.ts -
Step 1: Create IPN HTTP handler
// apps/backend/convex/http/onepay/ipn.ts
import { httpRouter } from "convex/server";
import { httpAction } from "../../_generated/server";
import {
parseVpcMerchTxnRef,
verifyOnePayHash,
parseAmountFromOnePay,
} from "../../lib/onepay-utils";
const http = httpRouter();
export const handleOnePayIPN = httpAction(async (ctx, request: Request) => {
// IPN can be GET or POST
let params: Record<string, string> = {};
if (request.method === "POST") {
const formData = await request.formData();
formData.forEach((value, key) => {
params[key] = String(value);
});
} else {
const url = new URL(request.url);
url.searchParams.forEach((value, key) => {
params[key] = value;
});
}
// Verify hash
const receivedHash = params["vpc_SecureHash"];
if (!receivedHash) {
return new Response("responsecode=0&desc=no-parameters", { status: 400 });
}
const isValid = verifyOnePayHash(params, receivedHash);
if (!isValid) {
return new Response("responsecode=0&desc=invalid-hash", { status: 400 });
}
// Extract reservation ID
const vpcMerchTxnRef = params["vpc_MerchTxnRef"];
const reservationId = parseVpcMerchTxnRef(vpcMerchTxnRef);
if (!reservationId) {
return new Response("responsecode=0&desc=invalid-reference", {
status: 400,
});
}
// Find reservation
const reservation = await ctx.db
.query("reservations")
.withIndex("by_vpcMerchTxnRef", (q) =>
q.eq("vpcMerchTxnRef", vpcMerchTxnRef),
)
.first();
if (!reservation) {
return new Response("responsecode=0&desc=reservation-not-found", {
status: 404,
});
}
// Check if already processed (idempotency)
if (
reservation.paymentStatus === "PAID" &&
params["vpc_TxnResponseCode"] === "0"
) {
return new Response("responsecode=1&desc=confirm-success", { status: 200 });
}
// Determine status
const vpcTxnResponseCode = params["vpc_TxnResponseCode"];
const isSuccess = vpcTxnResponseCode === "0";
const paymentStatus = isSuccess ? "PAID" : "FAILED";
const status = isSuccess ? "PAID_CONFIRMED" : reservation.status;
// Update reservation
await ctx.db.patch(reservation._id, {
paymentStatus,
paymentMethod: "OnePay",
paymentNotes: `IPN: ${vpcTxnResponseCode} - ${params["vpc_Message"] || ""}`,
vpcTransactionNo: params["vpc_TransactionNo"] || undefined,
status,
updatedAt: Date.now(),
});
// Upsert payment record
const existingPayment = await ctx.db
.query("payments")
.withIndex("by_vpcMerchTxnRef", (q) =>
q.eq("vpcMerchTxnRef", vpcMerchTxnRef),
)
.first();
if (existingPayment) {
await ctx.db.patch(existingPayment._id, {
status: isSuccess ? "SUCCESS" : "FAILED",
responseCode: vpcTxnResponseCode,
message: params["vpc_Message"] || undefined,
vpcTransactionNo: params["vpc_TransactionNo"] || undefined,
updatedAt: Date.now(),
});
} else {
await ctx.db.insert("payments", {
reservationId: reservation._id,
vpcMerchTxnRef,
vpcTransactionNo: params["vpc_TransactionNo"] || undefined,
amount: params["vpc_Amount"]
? parseAmountFromOnePay(Number(params["vpc_Amount"]))
: reservation.totalAmount,
currency: "VND",
status: isSuccess ? "SUCCESS" : "FAILED",
responseCode: vpcTxnResponseCode,
message: params["vpc_Message"] || undefined,
card: params["vpc_Card"] || undefined,
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
// CRITICAL: Return success format for OnePay
return new Response("responsecode=1&desc=confirm-success", { status: 200 });
});
http.define({
path: "/onepay/ipn",
method: "POST",
handler: handleOnePayIPN,
});
http.define({
path: "/onepay/ipn",
method: "GET",
handler: handleOnePayIPN,
});
export default http;Task 8: Add OnePay Environment Variables
Files:
-
Modify:
apps/frontend/.env.example -
Step 1: Add OnePay variables to .env.example
# OnePay Payment Gateway
ONEPAY_MERCHANT_ID=TESTONEPAY
ONEPAY_ACCESS_CODE=6BEB2546
ONEPAY_HASH_CODE=6D0870CDE5F24F34F3915FB0045120DB
ONEPAY_PAYMENT_URL=https://mtf.onepay.vn/paygate/vpcpay.op
ONEPAY_QUERYDR_URL=https://mtf.onepay.vn/msp/api/v1/vpc/invoices/queries
ONEPAY_RETURN_URL=https://houseoflegends.vn/api/onepay/return_url
ONEPAY_IPN_URL=https://houseoflegends.vn/api/onepay/ipn
ONEPAY_VPC_USER=op01
ONEPAY_VPC_PASSWORD=op123456
# Public URL for redirects
NEXT_PUBLIC_APP_URL=https://houseoflegends.vnTask 9: Verify End-to-End Flow
- Step 1: Run dev server
Run: cd /Users/curlyz/usr/hol && npm run dev
- Step 2: Test payment flow manually
- Go to
/bookingand complete steps 1-3 (tickets, addons, checkout) - On payment step, click OnePay card
- Should redirect to OnePay sandbox gateway
- Complete payment with test card
- Should redirect back to
/booking/confirmation?status=success
- Step 3: Verify in Convex dashboard
- Open Convex dashboard:
npx convex dashboard - Check
reservationstable - should have entry withpaymentStatus: "PAID" - Check
paymentstable - should have corresponding payment record
Migration Notes
What we're NOT migrating (yet)
- Email sending - The old system sends confirmation email after payment. This should be a separate task using Resend/SendGrid.
- Zoho sync - The old system syncs to Zoho Creator. This needs a separate Zoho integration task.
- Google Sheets logging - Was disabled in old system anyway.
Security Considerations
- Hash verification - Every callback must verify the
vpc_SecureHashbefore processing - IPN idempotency - IPN can arrive multiple times; check if already processed
- Amount validation - In production, verify callback amount matches reservation amount
Environment Setup
For production, ask Hamza for:
- Real OnePay merchant credentials
- Production OnePay URLs
- Public-facing domain for return/IPN URLs
Dependencies
{
"convex": "1.37.0",
"@tanstack/react-query": "^5.90.2",
"next": "^16.0.0"
}No additional npm packages required — using Node.js built-in crypto for HMAC-SHA256.