plans
2026-05-05
2026 05 05 Onepay Migration

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 update

Checklist

  • 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.vn

Task 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
  1. Go to /booking and complete steps 1-3 (tickets, addons, checkout)
  2. On payment step, click OnePay card
  3. Should redirect to OnePay sandbox gateway
  4. Complete payment with test card
  5. Should redirect back to /booking/confirmation?status=success
  • Step 3: Verify in Convex dashboard
  1. Open Convex dashboard: npx convex dashboard
  2. Check reservations table - should have entry with paymentStatus: "PAID"
  3. Check payments table - should have corresponding payment record

Migration Notes

What we're NOT migrating (yet)

  1. Email sending - The old system sends confirmation email after payment. This should be a separate task using Resend/SendGrid.
  2. Zoho sync - The old system syncs to Zoho Creator. This needs a separate Zoho integration task.
  3. Google Sheets logging - Was disabled in old system anyway.

Security Considerations

  1. Hash verification - Every callback must verify the vpc_SecureHash before processing
  2. IPN idempotency - IPN can arrive multiple times; check if already processed
  3. 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.