Files
goodgo-platform/QUICK_REFERENCE.md
Ho Ngoc Hai db7147a95d feat: add pricing checkout flow, MFA type fixes, and Wave 13 audit docs
- Pricing page: enhanced with checkout modal integration, plan
  comparison table, and subscription funnel
- Payment return page: new VNPay/MoMo callback handler
- Subscription components: new checkout-modal with payment method
  selection (VNPay, MoMo, ZaloPay)
- API modules: type-safe PII encryption, improved error handling in
  MFA/auth/payments/analytics/search/notifications modules
- Audit docs: comprehensive Wave 13 platform assessment, pricing
  audit, production readiness checklist
- Updated PROJECT_TRACKER with Wave 13 status

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 20:17:11 +07:00

11 KiB

Quick Reference: Pricing/Subscription/Payment System

Files at a Glance

🎨 Frontend

File Purpose Status
apps/web/app/[locale]/(public)/pricing/page.tsx Main pricing page Complete
apps/web/lib/subscription-api.ts Subscription API client Complete
apps/web/lib/payment-api.ts Payment API client Complete
apps/web/lib/hooks/use-subscription.ts Subscription hooks Complete
apps/web/lib/hooks/use-payments.ts Payment hooks Complete
apps/web/app/.../dashboard/payments/page.tsx Payment history Complete

🔧 Backend

Directory Purpose Status
apps/api/src/modules/subscriptions/ Subscription CQRS module Complete
apps/api/src/modules/payments/ Payment CQRS module Complete
apps/api/src/modules/payments/infrastructure/services/ Payment gateways (VNPay, MoMo, ZaloPay) Complete

📦 Database

Model Fields Relationships
Plan id, tier (unique), name, prices, features, isActive 1→M Subscription
Subscription id, userId (unique), planId, status, periods, cancelledAt M←1 Plan, 1←1 User
Payment id, userId, provider, type, amountVND, status, providerTxId, idempotencyKey M←1 User
UsageRecord id, subscriptionId, metric, count, periods M←1 Subscription

Key API Endpoints

Plans (Public)

GET /subscriptions/plans
GET /subscriptions/plans/:tier

Subscriptions (Auth Required)

POST   /subscriptions                    # Create new
PUT    /subscriptions/upgrade            # Upgrade
DELETE /subscriptions                    # Cancel
GET    /subscriptions/quota/:metric      # Check quota
POST   /subscriptions/usage              # Record usage
GET    /subscriptions/billing            # View history

Payments (Auth + Webhook)

POST   /payments                         # Create payment → returns paymentUrl
POST   /payments/callback/:provider      # Webhook from gateway
GET    /payments/:id                     # Check status
GET    /payments                         # List transactions
POST   /payments/:id/refund              # Refund (admin)

Type Definitions

Frontend Types

// From subscription-api.ts
interface PlanDto {
  id: string;
  tier: string;                           // FREE, AGENT_PRO, INVESTOR, ENTERPRISE
  name: string;
  priceMonthlyVND: string;               // In VND
  priceYearlyVND: string;                // In VND
  maxListings: number;
  maxSavedSearches: number;
  features: Record<string, boolean | number | string>;
  isActive: boolean;
}

interface CreateSubscriptionResult {
  subscriptionId: string;
  planTier: string;
  status: string;                        // ACTIVE, PAST_DUE, CANCELLED, EXPIRED
  currentPeriodStart: string;            // ISO datetime
  currentPeriodEnd: string;              // ISO datetime
}

// From payment-api.ts
interface CreatePaymentPayload {
  provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' | 'BANK_TRANSFER';
  type: 'SUBSCRIPTION' | 'LISTING_FEE' | 'DEPOSIT' | 'FEATURED_LISTING';
  amountVND: number;                     // 1 to 100,000,000,000
  description: string;
  returnUrl: string;                     // Redirect after payment
  idempotencyKey?: string;               // Prevent duplicates
  transactionId?: string;                // External transaction ID
}

interface CreatePaymentResult {
  paymentId: string;
  paymentUrl: string;                    // Redirect user here
  providerTxId: string;
}

interface PaymentStatusDto {
  id: string;
  provider: string;
  type: string;
  amountVND: string;
  status: string;                        // PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED
  providerTxId: string | null;
  createdAt: string;
  updatedAt: string;
}

How to Use in Frontend

Get Plans

import { usePlans } from '@/lib/hooks/use-subscription';

export function MyComponent() {
  const { data: plans, isLoading } = usePlans();
  
  return (
    <div>
      {isLoading ? 'Loading...' : plans?.map(plan => <div>{plan.name}</div>)}
    </div>
  );
}

Create Payment

import { paymentApi } from '@/lib/payment-api';
import { useMutation } from '@tanstack/react-query';

const createPaymentMutation = useMutation({
  mutationFn: (payload) => paymentApi.createPayment(payload),
});

// When user clicks "Pay Now"
const handlePayment = async (planTier: string, provider: 'VNPAY' | 'MOMO' | 'ZALOPAY') => {
  const result = await createPaymentMutation.mutateAsync({
    provider,
    type: 'SUBSCRIPTION',
    amountVND: 499000,
    description: `Subscription to ${planTier}`,
    returnUrl: `${window.location.origin}/payment-return`,
    idempotencyKey: crypto.randomUUID(),
  });
  
  // Redirect to payment gateway
  window.location = result.paymentUrl;
};

Check Payment Status (on return page)

import { paymentApi } from '@/lib/payment-api';
import { useEffect, useState } from 'react';

export function PaymentReturnPage() {
  const searchParams = new URLSearchParams(window.location.search);
  const paymentId = searchParams.get('paymentId');
  const [status, setStatus] = useState<string>('loading');

  useEffect(() => {
    if (!paymentId) return;

    const poll = async () => {
      const payment = await paymentApi.getPaymentStatus(paymentId);
      
      if (payment.status === 'COMPLETED') {
        // Create subscription
        await subscriptionApi.createSubscription('AGENT_PRO', 'monthly');
        setStatus('success');
        // Redirect to dashboard
        window.location = '/dashboard';
      } else if (payment.status === 'FAILED') {
        setStatus('failed');
      } else {
        // Poll again in 2 seconds
        setTimeout(poll, 2000);
      }
    };

    poll();
  }, [paymentId]);

  return <div>{status === 'loading' ? 'Processing payment...' : status}</div>;
}

Create Subscription

import { subscriptionApi } from '@/lib/subscription-api';

const result = await subscriptionApi.createSubscription('AGENT_PRO', 'monthly');
console.log(result);
// {
//   subscriptionId: 'cuid...',
//   planTier: 'AGENT_PRO',
//   status: 'ACTIVE',
//   currentPeriodStart: '2024-04-12T...',
//   currentPeriodEnd: '2024-05-12T...'
// }

How to Use in Backend

Create Plan (Admin)

INSERT INTO "Plan" (id, tier, name, "priceMonthlyVND", "priceYearlyVND", "maxListings", features, "isActive")
VALUES (
  'cuid123',
  'AGENT_PRO',
  'Agent Pro',
  499000,
  4990000,
  50,
  '{"analytics": true, "aiValuation": true}',
  true
);

Create Payment (via API)

POST /payments
Authorization: Bearer <jwt_token>
Content-Type: application/json

{
  "provider": "VNPAY",
  "type": "SUBSCRIPTION",
  "amountVND": 499000,
  "description": "Agent Pro - Monthly",
  "returnUrl": "https://goodgo.vn/payment-return",
  "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000"
}

Response:
{
  "paymentId": "cuid456",
  "paymentUrl": "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?...",
  "providerTxId": "cuid456"
}

Handle Payment Callback (Webhook)

POST /payments/callback/vnpay?vnp_TxnRef=cuid456&vnp_ResponseCode=00&vnp_SecureHash=...

Response:
{
  "orderId": "cuid456",
  "isSuccess": true,
  "status": "COMPLETED"
}

Create Subscription (via API)

POST /subscriptions
Authorization: Bearer <jwt_token>
Content-Type: application/json

{
  "planTier": "AGENT_PRO",
  "billingCycle": "monthly"
}

Response:
{
  "subscriptionId": "cuid789",
  "planTier": "AGENT_PRO",
  "status": "ACTIVE",
  "currentPeriodStart": "2024-04-12T...",
  "currentPeriodEnd": "2024-05-12T..."
}

Pricing Structure

FREE (0 VND)
├── 3 listings
├── 5 saved searches
└── Basic features

AGENT_PRO (499,000 VND/month | 4,990,000/year = -17%)
├── 50 listings
├── 30 saved searches
├── Analytics
├── AI Valuation
├── Priority support
└── Lead management

INVESTOR (999,000 VND/month | 9,990,000/year = -17%)
├── 20 listings
├── 100 saved searches
├── Analytics
├── AI Valuation
├── Market reports
├── Price alerts
└── Portfolio tracking

ENTERPRISE (4,990,000 VND/month | 49,900,000/year = -17%)
├── Unlimited listings
├── Unlimited searches
├── All INVESTOR features
├── API access
├── White label
└── Dedicated support

Environment Variables

# Backend (.env)
VNPAY_TMN_CODE=your_tmn_code
VNPAY_HASH_SECRET=your_hash_secret
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction

MOMO_PARTNER_CODE=your_partner_code
MOMO_ACCESS_KEY=your_access_key
MOMO_SECRET_KEY=your_secret_key
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api

ZALOPAY_APP_ID=your_app_id
ZALOPAY_KEY1=your_key1
ZALOPAY_KEY2=your_key2
ZALOPAY_ENDPOINT=https://sandbox.zalopay.com.vn

# Frontend (.env.local)
NEXT_PUBLIC_APP_URL=https://goodgo.vn

Testing Credentials

VNPay Sandbox

Terminal: 0
Account: 0968323286
Password: 123456
Card: 9704198526191432198
OTP: 123456

MoMo Sandbox

Phone: 0987654321
Password: 123456
OTP: 123456

ZaloPay Sandbox

Phone: 0987654321
OTP: 123456

Common Errors

Error Cause Solution
ConflictException: User already has active subscription User trying to create 2nd subscription Check existing subscription first
ValidationException: Số tiền phải lớn hơn 0 Amount is 0 or negative Ensure amount > 0
NotFoundException: Plan not found Plan tier doesn't exist in DB Check plan is created and isActive=true
Payment gateway failed Payment gateway credentials wrong Verify ENV vars
Cannot complete payment in status X Payment already completed/failed Check idempotencyKey
Idempotency check failed Same idempotencyKey used twice Generate unique UUID each time

Debugging Checklist

  • Check payment provider credentials in .env
  • Verify idempotencyKey is unique per request
  • Ensure amountVND matches plan price
  • Check returnUrl is publicly accessible
  • Verify JWT token is valid when calling protected endpoints
  • Check payment status with GET /payments/:id
  • Review payment provider logs/dashboard
  • Test with sandbox credentials first
  • Verify callback signature matches gateway requirements
  • Check subscription was created after successful payment

  • Detailed Audit: PRICING_CHECKOUT_AUDIT.md
  • Summary: PRICING_AUDIT_SUMMARY.md
  • Pricing Page: apps/web/app/[locale]/(public)/pricing/page.tsx
  • Subscriptions Module: apps/api/src/modules/subscriptions/
  • Payments Module: apps/api/src/modules/payments/
  • Schema: prisma/schema.prisma (lines 451-514)