Payments Specification

Status: Canonical Last Updated: 2026-05-11 Source: packages/backend/convex/domains/payments.ts, packages/backend/convex/http/onepay.ts
Doc Status: Excellent | ✓ All 6 checks passed

Overview

OnePay handles payment processing for the booking system. Guests are redirected to OnePay for payment, which returns with a verified response that updates the reservation status. OnePay supports both card payments and virtual account (bank transfer) options.

Payment Flow

StepDescription
1. User on checkout pageFills customer info, clicks “Pay Now”
2. Create pending reservationstatus: PENDING, paymentStatus: PENDING, bookingExpiresAt: now + 10 min
3. Call OnePay APICreate payment URL with amount, order info, redirect user to OnePay
4. User completes payment on OnePaySuccess — redirect to /booking/[id]/confirmation; Fail — redirect to /booking/[id]/checkout?error=…
5. Handle returnVerify OnePay signature, update reservation status, create payment record, send confirmation email

OnePay Integration

Configuration

Environment variables (via npx convex env set):
ONEPAY_MERCHANT_ID=your_merchant_id
ONEPAY_ACCESS_CODE=your_access_code
ONEPAY_SECRET_KEY=your_secret_key
ONEPAY_URL=https://mtf.onepay.vn

Payment URL Creation

// packages/backend/convex/lib/onepay/api.ts
createPaymentUrl({
  amount: number,
  orderId: string,
  returnUrl: string,
  transactionType: 'PAY',
})

Return URL Handling

// packages/backend/convex/http/onepayReturn.ts
// - Extract VPC response params
// - Verify secure hash
// - Update reservation paymentStatus
// - Redirect to confirmation or error

Payment Status

StatusDescriptionAction
PENDINGAwaiting paymentUser redirected to OnePay
PAIDPayment successfulConfirmation sent
FAILEDPayment failedShow error, allow retry
CANCELLEDUser cancelledRelease seats
REFUND_PENDINGRefund requestedAdmin processes
REFUNDEDRefund completedNotify customer

Virtual Account (Bank Transfer)

Flow

  1. User selects bank transfer
  2. System generates VA number
  3. User transfers to VA
  4. OnePay notifies on receipt
  5. Payment marked PAID

VA Number Display

Shown on checkout for bank transfer option.

Payment Tracking

payments Table

Stores all OnePay transactions:
  • vpcMerchTxnRef: Unique reference
  • vpcTransactionNo: OnePay transaction ID
  • amount: VND amount
  • status: PENDING | SUCCESS | FAILED
  • card: Card type (Visa, Mastercard)
  • cardNum: Last 4 digits

notificationLogs Table

Tracks delivery of confirmations:
  • EMAIL_CONFIRMATION
  • WHATSAPP_CONFIRMATION
  • EMAIL_ADMIN_NEW_BOOKING

Refund Flow

Implementation

The refund flow uses OnePay V2 API for asynchronous refund processing:
// packages/backend/convex/domains/reservations.ts
export const cancelReservation = staffMutation({
  args: CancelReservationArgsSchema,
  handler: async (ctx, { reservationId, reason }) => {
    // ...
    if (reservation.paymentStatus === "PAID" && reservation.onePayOrderId) {
      await ctx.db.patch(reservationId, {
        paymentStatus: "REFUND_PENDING",
      });
      await triggerSepayRefund(ctx, reservation);
      return { refunded: true };
    }
    // ...
  },
});

// triggerSepayRefund calls OnePay V2 refund API
async function triggerSepayRefund(
  ctx: MutationCtx,
  reservation: Doc<"reservations">,
): Promise<string> {
  const refundResult = await createOnePayRefund(
    reservation.onePayOrderId,
    reservation.totalAmount,
  );
  return refundResult.refundId;
}

Flow

  1. Admin opens reservation detail
  2. Clicks “Cancel & Refund”
  3. cancelReservation mutation sets status to REFUND_PENDING
  4. triggerSepayRefund calls createOnePayRefund (OnePay V2 API)
  5. OnePay processes refund asynchronously
  6. OnePay calls webhook with refund confirmation
  7. Webhook handler calls confirmRefund mutation
  8. Mutation sets status to REFUNDED
  9. Notification sent (email/cancellation)

OnePay Refund API

// packages/backend/convex/http/onepay.ts
export async function createOnePayRefund(
  orderId: string,
  amount: number,
): Promise<{
  refundId: string;
  status: string;
  refundedAmount: number;
}> {
  const response = await fetch(
    `${ONEPAY_API_BASE_URL}/v2/order/${orderId}/refunds`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Client-ID": ONEPAY_CLIENT_ID,
        "X-API-Key": ONEPAY_API_KEY,
      },
      body: JSON.stringify({
        amount,
        reason: "Customer cancellation",
      }),
    },
  );
  // ...
}

Pricing Display

All prices in VND. Format with:
// apps/frontend/lib/utils/format.ts
formatCurrency(amount: number): string
// Output: "500.000 đ"