Files
goodgo-platform/docs/audits/PRICING_CHECKOUT_AUDIT.md
Ho Ngoc Hai d8b409a9ab
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 18s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m15s
Deploy / Build API Image (push) Failing after 28s
Deploy / Build Web Image (push) Failing after 16s
Deploy / Build AI Services Image (push) Failing after 17s
E2E Tests / Playwright E2E (push) Failing after 31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m46s
Security Scanning / Trivy Scan — Web Image (push) Failing after 1m7s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 53s
Security Scanning / Trivy Filesystem Scan (push) Failing after 35s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
docs: dịch 22 file Markdown còn lại sang tiếng Việt có dấu (TEC-2881)
Hoàn tất đợt cuối của nhiệm vụ chuyển toàn bộ tài liệu sang tiếng Việt.
Đã dịch 22 file `.md` còn sót (~9.7k dòng) — gồm RUNBOOK, audits,
docs/architecture, docs/load-testing, libs READMEs và các quick references.
Giữ nguyên code blocks, đường dẫn, identifier kỹ thuật, URL và biến môi trường.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 03:26:14 +07:00

38 KiB

GoodGo Platform AI - Audit Hệ thống Giá, Subscription & Thanh toán

Ngày: 12 tháng 4, 2026
Phạm vi: Khảo sát toàn diện các trang pricing, plan subscription và luồng checkout thanh toán
Trạng thái: Trang pricing frontend ACTIVE, các module subscription/payment backend ACTIVE, luồng checkout CHƯA ĐƯỢC TRIỂN KHAI


Tóm tắt Điều hành

GoodGo Platform có hạ tầng backend hoàn chỉnh cho subscription và thanh toán với kiến trúc NestJS CQRS, nhưng luồng pricing-đến-checkout ở frontend vẫn chưa hoàn thiện. Trang pricing đã tồn tại và load plan từ API, nhưng không có component checkout/payment flow kết nối trang pricing với API thanh toán.

Các phát hiện chính:

  • Trang Pricing: Hoạt động đầy đủ tại /pricing với 4 tier (FREE, AGENT_PRO, INVESTOR, ENTERPRISE)
  • Backend Subscription: Hoàn chỉnh với command CQRS, entity, repository
  • Tích hợp Payment Gateway: Triển khai VNPay, MoMo, ZaloPay đã sẵn sàng
  • API Endpoint Thanh toán: Các thao tác CRUD đầy đủ đã có
  • Thiếu: Trang/modal checkout kết nối pricing → tạo payment → redirect tới payment gateway
  • Thiếu: Xử lý callback thành công của thanh toán ở frontend
  • Thiếu: Thiết lập subscription sau khi thanh toán thành công

1. Trang Pricing Frontend

Vị trí

/Users/velikho/Desktop/WORKING/goodgo-platform-ai/apps/web/app/[locale]/(public)/pricing/page.tsx

Triển khai hiện tại

Route: /(public)/pricing
Trạng thái: Active và hoạt động

Tính năng Page Component:

// Các Export và Chức năng chính
- PricingPage (default export)
- Dùng useTranslations('pricing') cho i18n
- Quản  state billingCycle (monthly/yearly)
- Render 4 thẻ plan
- Bảng so sánh tính năng
- Các section CTA

Luồng dữ liệu Plan:

usePlans() hook (từ use-subscription.ts)
  ↓
subscriptionApi.getPlans() 
  ↓
GET /subscriptions/plans (backend)
  ↓
Trả về: PlanDto[] với thông tin giá, tính năng, giới hạn

Plan Fallback (Hardcoded):

Trang có sẵn các plan fallback khi API không khả dụng:

  • FREE: 0 VND, 3 tin đăng, 5 tìm kiếm đã lưu
  • AGENT_PRO: 499,000 VND/tháng, 50 tin đăng, 30 tìm kiếm (được đánh dấu "popular")
  • INVESTOR: 999,000 VND/tháng, 20 tin đăng, 100 tìm kiếm
  • ENTERPRISE: 4,990,000 VND/tháng, tin đăng/tìm kiếm không giới hạn

Các Component chính được sử dụng:

- Badge, Button, Card
- Icon Check, Crown, Rocket, Shield, X, Zap (lucide-react)
- useTranslations cho 15+ translation key
- formatVND để hiển thị tiền tệ
- Tiện ích cn() cho class  điều kiện

Toggle Chu kỳ Thanh toán:

// Quản lý state cho giá monthly/yearly
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');

// Hiển thị giá phù hợp dựa trên chu kỳ
const price = billingCycle === 'monthly' 
  ? plan.priceMonthlyVND 
  : plan.priceYearlyVND;

CTA hiện tại:

Tất cả các plan đều có link "Register":

<Link href={'/register'}>
  <Button>
    {plan.tier === 'FREE' ? t('ctaFree') : t('ctaUpgrade')}
  </Button>
</Link>

VẤN ĐỀ: Các CTA liên kết đến /register thay vì bắt đầu luồng checkout


2. Tích hợp API Frontend

Files

/apps/web/lib/subscription-api.ts

Định nghĩa Type:

export interface PlanDto {
  id: string;
  tier: string;
  name: string;
  priceMonthlyVND: string;
  priceYearlyVND: string;
  maxListings: number;
  maxSavedSearches: number;
  features: Record<string, boolean | number | string>;
  isActive: boolean;
}

export interface CreateSubscriptionResult {
  subscriptionId: string;
  planTier: string;
  status: string;
  currentPeriodStart: string;
  currentPeriodEnd: string;
}

export interface BillingHistoryDto {
  subscription: SubscriptionInfo | null;
  payments: Array<{ id, provider, type, amountVND, status, createdAt }>;
  total: number;
}

export interface QuotaCheckResult {
  metric: string;
  used: number;
  limit: number;
  remaining: number;
}

Phương thức API:

subscriptionApi = {
  getPlans(): Promise<PlanDto[]>              // GET /subscriptions/plans
  getPlanByTier(tier): Promise<PlanDto>       // GET /subscriptions/plans/:tier
  getBillingHistory(limit, offset): Promise<BillingHistoryDto>
  checkQuota(metric): Promise<QuotaCheckResult>
  createSubscription(planTier, billingCycle): Promise<CreateSubscriptionResult>
  upgradeSubscription(newPlanTier): Promise<{message}>
  cancelSubscription(reason): Promise<{message}>
}

/apps/web/lib/payment-api.ts

Định nghĩa Type:

export interface CreatePaymentPayload {
  provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' | 'BANK_TRANSFER';
  type: 'SUBSCRIPTION' | 'LISTING_FEE' | 'DEPOSIT' | 'FEATURED_LISTING';
  amountVND: number;
  description: string;
  returnUrl: string;
  idempotencyKey?: string;
}

export interface CreatePaymentResult {
  paymentId: string;
  paymentUrl: string;      // URL payment gateway trực tiếp
  providerTxId: string;
}

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

Phương thức API:

paymentApi = {
  createPayment(data): Promise<CreatePaymentResult>
  getPaymentStatus(id): Promise<PaymentStatusDto>
  getTransactions(params): Promise<TransactionListDto>
}

Frontend Hooks

/apps/web/lib/hooks/use-subscription.ts

export const subscriptionKeys = {
  all: ['subscription'] as const,
  plans: () => ['subscription', 'plans'] as const,
  billing: () => ['subscription', 'billing'] as const,
  quota: (metric: string) => ['subscription', 'quota', metric] as const,
};

// Hook React Query
export function usePlans()               // GET plans
export function useBillingHistory()      // GET lịch sử billing
export function useQuota(metric: string) // GET kiểm tra quota

/apps/web/lib/hooks/use-payments.ts

export const paymentKeys = {
  all: ['payments'] as const,
  transactions: (params) => ['payments', 'transactions', params] as const,
  status: (id: string) => ['payments', 'status', id] as const,
};

// Hook React Query
export function useTransactions(params)  // GET các giao dịch của user
export function usePaymentStatus(id)     // GET trạng thái của một payment

3. Subscription Backend (NestJS CQRS)

Vị trí Module

/apps/api/src/modules/subscriptions/

Kiến trúc

Module: subscriptions.module.ts
Pattern: Hexagonal Architecture (DDD + CQRS)

Tầng Domain

domain/
├── entities/subscription.entity.ts
├── events/
│   ├── subscription-created.event.ts
│   ├── subscription-upgraded.event.ts
│   ├── subscription-cancelled.event.ts
│   ├── subscription-renewed.event.ts
│   └── subscription-expired.event.ts
└── repositories/subscription.repository.ts (interface)

Class SubscriptionEntity:

export class SubscriptionEntity extends AggregateRoot<string> {
  private _userId: string;
  private _planId: string;
  private _planTier: PlanTier;
  private _status: SubscriptionStatus;  // ACTIVE, PAST_DUE, CANCELLED, EXPIRED
  private _currentPeriodStart: Date;
  private _currentPeriodEnd: Date;
  private _cancelledAt: Date | null;

  // Methods
  static createNew(userId, planId, planTier, periodStart, periodEnd)
  upgrade(newPlanId, newPlanTier): Result<void>
  cancel(): Result<void>
  markExpired(): Result<void>
  markPastDue(): Result<void>
  renewPeriod(newStart, newEnd): void
  isActive(): boolean
  isExpired(): boolean
}

Tầng Application

Commands:

application/commands/
├── create-subscription/
│   ├── create-subscription.command.ts
│   ├── create-subscription.handler.ts      ← CreateSubscriptionResult
│   └── create-subscription.dto.ts
├── upgrade-subscription/
│   ├── upgrade-subscription.command.ts
│   ├── upgrade-subscription.handler.ts
│   └── upgrade-subscription.dto.ts
├── cancel-subscription/
│   ├── cancel-subscription.command.ts
│   ├── cancel-subscription.handler.ts
│   └── cancel-subscription.dto.ts
└── meter-usage/
    ├── meter-usage.command.ts
    ├── meter-usage.handler.ts
    └── meter-usage.dto.ts

Queries:

application/queries/
├── get-plan/
│   ├── get-plan.query.ts
│   └── get-plan.handler.ts              ← Trả về PlanDto[]
├── check-quota/
│   ├── check-quota.query.ts
│   └── check-quota.handler.ts
└── get-billing-history/
    ├── get-billing-history.query.ts
    └── get-billing-history.handler.ts

Tầng Infrastructure

infrastructure/
├── repositories/prisma-subscription.repository.ts
└── event-handlers/listing-created-usage.handler.ts

Tầng Presentation

presentation/
├── controllers/subscriptions.controller.ts
├── dto/
│   ├── create-subscription.dto.ts
│   ├── upgrade-subscription.dto.ts
│   ├── cancel-subscription.dto.ts
│   ├── meter-usage.dto.ts
│   └── billing-history.dto.ts
└── guards/quota.guard.ts

Endpoint API Subscription

Endpoint Công khai

GET /subscriptions/plans
  → Trả về: PlanDto[]
  
GET /subscriptions/plans/:tier
  → Trả về: PlanDto

Endpoint Cần Authenticate

POST /subscriptions
  Body: { planTier: PlanTier, billingCycle: 'monthly' | 'yearly' }
  Trả về: CreateSubscriptionResult
  
PUT /subscriptions/upgrade
  Body: { newPlanTier: PlanTier }
  Trả về: UpgradeSubscriptionResult
  
DELETE /subscriptions
  Body: { reason?: string }
  Trả về: CancelSubscriptionResult
  
POST /subscriptions/usage
  Body: { metric: string, count: number }
  Trả về: MeterUsageResult
  
GET /subscriptions/quota/:metric
  Trả về: QuotaCheckResult
  
GET /subscriptions/billing?limit=20&offset=0
  Trả về: BillingHistoryDto

Logic của CreateSubscriptionHandler

async execute(command): Promise<CreateSubscriptionResult> {
  // 1. Kiểm tra user chưa có subscription đang hoạt động
  if (existing && (status === 'ACTIVE' || 'PAST_DUE')) {
    throw ConflictException('User already has active subscription');
  }

  // 2. Lấy Plan từ DB
  const plan = prisma.plan.findFirst({
    where: { tier: command.planTier, isActive: true }
  });

  // 3. Tính toán period
  const now = new Date();
  const periodEnd = new Date(now);
  if (yearly) periodEnd.setFullYear(+1);
  else periodEnd.setMonth(+1);

  // 4. Tạo domain entity
  const subscription = SubscriptionEntity.createNew(
    id, userId, planId, planTier, now, periodEnd
  );

  // 5. Lưu vào repo
  await subscriptionRepo.save(subscription);

  // 6. Publish domain event
  eventBus.publish(subscription.clearDomainEvents());

  return {
    subscriptionId,
    planTier,
    status: 'ACTIVE',
    currentPeriodStart: now,
    currentPeriodEnd: periodEnd,
  };
}

4. Backend Thanh toán (NestJS CQRS)

Vị trí Module

/apps/api/src/modules/payments/

Kiến trúc

Tầng Domain

domain/
├── entities/payment.entity.ts
├── events/
│   ├── payment-created.event.ts
│   ├── payment-completed.event.ts
│   ├── payment-failed.event.ts
│   └── payment-refunded.event.ts
├── repositories/payment.repository.ts
└── value-objects/money.vo.ts

Class PaymentEntity:

export class PaymentEntity extends AggregateRoot<string> {
  private _userId: string;
  private _transactionId: string | null;
  private _provider: PaymentProvider;       // VNPAY, MOMO, ZALOPAY, BANK_TRANSFER
  private _type: PaymentType;               // SUBSCRIPTION, LISTING_FEE, DEPOSIT, FEATURED_LISTING
  private _amount: Money;
  private _status: PaymentStatus;           // PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED
  private _providerTxId: string | null;
  private _callbackData: unknown;
  private _idempotencyKey: string | null;

  // Methods
  static createNew(id, userId, provider, type, amount, ...)
  markProcessing(providerTxId): void
  markCompleted(callbackData): Result<void>
  markFailed(callbackData): Result<void>
  markRefunded(): Result<void>
  emitCompleted(): void
  emitFailed(): void
}

Tầng Application

Commands:

application/commands/
├── create-payment/
│   ├── create-payment.command.ts
│   ├── create-payment.handler.ts          ← CreatePaymentResult
│   └── create-payment.dto.ts
├── handle-callback/
│   ├── handle-callback.command.ts
│   ├── handle-callback.handler.ts
│   └── (không có DTO - endpoint webhook)
└── refund-payment/
    ├── refund-payment.command.ts
    ├── refund-payment.handler.ts
    └── refund-payment.dto.ts

Queries:

application/queries/
├── get-payment-status/
│   ├── get-payment-status.query.ts
│   └── get-payment-status.handler.ts
└── list-transactions/
    ├── list-transactions.query.ts
    └── list-transactions.handler.ts

Tầng Infrastructure (Payment Gateway)

infrastructure/services/
├── payment-gateway.interface.ts           ← Interface IPaymentGateway
├── payment-gateway.factory.ts
├── vnpay.service.ts                      ← ✅ Đã triển khai
├── momo.service.ts                       ← ✅ Đã triển khai
└── zalopay.service.ts                    ← ✅ Đã triển khai

Interface IPaymentGateway:

export interface IPaymentGateway {
  readonly provider: PaymentProvider;
  createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult>;
  verifyCallback(data: Record<string, string>): CallbackVerifyResult;
  refund(params: RefundParams): Promise<RefundResult>;
}

export interface CreatePaymentUrlParams {
  orderId: string;
  amountVND: bigint;
  description: string;
  returnUrl: string;
  ipAddress: string;
}

export interface CreatePaymentUrlResult {
  paymentUrl: string;        // Trực tiếp đến payment gateway
  providerTxId: string;      // Gateway transaction ID
}

export interface CallbackVerifyResult {
  isValid: boolean;
  orderId: string;           // Payment ID của chúng ta
  providerTxId: string;      // Gateway transaction ID
  isSuccess: boolean;        // Thanh toán có thành công?
  rawData: Record<string, unknown>;
}

Triển khai Payment Gateway

VNPay Service

private readonly tmnCode: string;          // Terminal ID
private readonly hashSecret: string;       // HMAC key

async createPaymentUrl(params): Promise<{paymentUrl, providerTxId}> {
  // Xây dựng tham số VNPay (v2.1.0)
  const vnpParams = {
    vnp_Version: '2.1.0',
    vnp_Command: 'pay',
    vnp_TmnCode: this.tmnCode,
    vnp_Locale: 'vn',
    vnp_CurrCode: 'VND',
    vnp_TxnRef: params.orderId,
    vnp_OrderInfo: params.description,
    vnp_Amount: (params.amountVND * 100n).toString(),  // VNPay dùng cent
    vnp_ReturnUrl: params.returnUrl,
    vnp_IpAddr: params.ipAddress,
    vnp_CreateDate: yyyyMMddHHmmss,
    vnp_ExpireDate: yyyyMMddHHmmss + 15min,
  };

  // Ký bằng HMAC SHA-512
  const signed = hmac('sha512', this.hashSecret).digest('hex');
  vnpParams['vnp_SecureHash'] = signed;

  // Trả về URL redirect
  return {
    paymentUrl: `https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?${params}`,
    providerTxId: params.orderId,
  };
}

verifyCallback(data: Record<string, string>): CallbackVerifyResult {
  // VNPay trả về: vnp_SecureHash, vnp_ResponseCode, vnp_TransactionNo, v.v.
  // Xác minh chữ ký hash
  const secureHash = data['vnp_SecureHash'];
  const isValid = crypto.timingSafeEqual(hash1, hash2);
  const isSuccess = isValid && data['vnp_ResponseCode'] === '00';
  
  return {
    isValid,
    orderId: data['vnp_TxnRef'],
    providerTxId: data['vnp_TransactionNo'],
    isSuccess,
    rawData: data,
  };
}

MoMo Service

private readonly partnerCode: string;
private readonly accessKey: string;
private readonly secretKey: string;
private readonly endpoint: string;        // https://test-payment.momo.vn/v2/gateway/api

async createPaymentUrl(params): Promise<{paymentUrl, providerTxId}> {
  const requestId = crypto.randomUUID();
  
  // Xây dựng chữ ký: accessKey=...&amount=...&...&signature=
  const rawSignature = [
    `accessKey=${this.accessKey}`,
    `amount=${params.amountVND}`,
    `extraData=`,
    `ipnUrl=${params.returnUrl}`,
    `orderId=${params.orderId}`,
    `orderInfo=${params.description}`,
    `partnerCode=${this.partnerCode}`,
    `redirectUrl=${params.returnUrl}`,
    `requestId=${requestId}`,
    `requestType=payWithMethod`,
  ].join('&');

  const signature = hmac('sha256', this.secretKey).update(rawSignature).digest('hex');

  const body = {
    partnerCode: this.partnerCode,
    requestId,
    amount: params.amountVND,
    orderId: params.orderId,
    orderInfo: params.description,
    redirectUrl: params.returnUrl,
    ipnUrl: params.returnUrl,
    requestType: 'payWithMethod',
    signature,
  };

  // POST tới MoMo API
  const response = await fetch(`${this.endpoint}/create`, {
    method: 'POST',
    body: JSON.stringify(body),
  });

  const result = await response.json();  // { resultCode: 0, payUrl: "..." }

  return {
    paymentUrl: result.payUrl,
    providerTxId: params.orderId,
  };
}

ZaloPay Service

Cấu trúc tương tự với endpoint và thuật toán chữ ký (HMAC SHA-256) đặc thù của ZaloPay

Endpoint API Thanh toán

Endpoint Công khai

POST /payments/callback/:provider
  Query/Body: Dữ liệu callback của provider (VNPay qua query, MoMo/ZaloPay qua body)
  Trả về: HandleCallbackResult
  Webhook cho callback của payment provider

Endpoint Cần Authenticate

POST /payments
  Body: CreatePaymentDto {
    provider: PaymentProvider,
    type: PaymentType,
    amountVND: number,
    description: string,
    returnUrl: string,
    transactionId?: string,
    idempotencyKey?: string
  }
  Trả về: CreatePaymentResult {
    paymentId: string,
    paymentUrl: string,       ← Redirect user đến đây
    providerTxId: string
  }

GET /payments
  Query: { status?, limit?, offset? }
  Trả về: TransactionListDto

GET /payments/:id
  Trả về: PaymentStatusDto

POST /payments/:id/refund (Chỉ Admin)
  Body: { reason: string }
  Trả về: RefundPaymentResult

Logic của CreatePaymentHandler

async execute(command): Promise<CreatePaymentResult> {
  // 1. Kiểm tra idempotency (tránh thanh toán trùng)
  if (command.idempotencyKey) {
    const existing = await paymentRepo.findByIdempotencyKey(key);
    if (existing && (status === 'PENDING' || 'PROCESSING')) {
      throw ConflictException('Payment already exists');
    }
  }

  // 2. Xác thực số tiền (từ 1 VND đến 100 tỷ VND)
  const money = Money.create(command.amountVND);
  if (money.isErr) throw ValidationException();

  // 3. Tạo domain entity
  const payment = PaymentEntity.createNew(
    id, userId, provider, type, money, ...
  );

  // 4. Lấy payment gateway và tạo URL
  const gateway = gatewayFactory.getGateway(command.provider);
  const { paymentUrl, providerTxId } = await gateway.createPaymentUrl({
    orderId: paymentId,
    amountVND: command.amountVND,
    description: command.description,
    returnUrl: command.returnUrl,
    ipAddress: command.ipAddress,
  });

  // 5. Đánh dấu là PROCESSING và lưu
  payment.markProcessing(providerTxId);
  await paymentRepo.save(payment);

  // 6. Publish domain event
  eventBus.publish(payment.clearDomainEvents());

  return {
    paymentId,
    paymentUrl,                Client redirect tới URL này
    providerTxId,
  };
}

5. Các Model Dữ liệu Prisma

Vị trí

/prisma/schema.prisma (dòng 451-514)

Model Plan

model Plan {
  id                  String   @id @default(cuid())
  tier                PlanTier @unique
  name                String
  priceMonthlyVND     BigInt
  priceYearlyVND      BigInt
  maxListings         Int?
  maxSavedSearches    Int?
  maxAnalyticsQueries Int?
  maxMediaUploads     Int?
  features            Json      // Object JSON chứa các feature boolean/number
  isActive            Boolean   @default(true)

  subscriptions Subscription[]
}

enum PlanTier {
  FREE
  AGENT_PRO
  INVESTOR
  ENTERPRISE
}

Model Subscription

model Subscription {
  id                 String             @id @default(cuid())
  userId             String             @unique
  user               User               @relation(fields: [userId], references: [id], onDelete: Cascade)
  planId             String
  plan               Plan               @relation(fields: [planId], references: [id], onDelete: Restrict)
  status             SubscriptionStatus @default(ACTIVE)
  currentPeriodStart DateTime
  currentPeriodEnd   DateTime
  cancelledAt        DateTime?
  createdAt          DateTime           @default(now())
  updatedAt          DateTime           @updatedAt

  usageRecords UsageRecord[]

  @@index([planId])
  @@index([status])
}

enum SubscriptionStatus {
  ACTIVE
  PAST_DUE
  CANCELLED
  EXPIRED
}

Model Payment

model Payment {
  id             String          @id @default(cuid())
  userId         String
  user           User            @relation(fields: [userId], references: [id], onDelete: Restrict)
  transactionId  String?
  transaction    Transaction?    @relation(fields: [transactionId], references: [id], onDelete: SetNull)
  provider       PaymentProvider
  type           PaymentType
  amountVND      BigInt
  status         PaymentStatus   @default(PENDING)
  providerTxId   String?
  callbackData   Json?
  idempotencyKey String?
  createdAt      DateTime        @default(now())
  updatedAt      DateTime        @updatedAt

  @@unique([userId, provider, idempotencyKey], name: "Payment_idempotency_unique")
  @@index([userId])
  @@index([transactionId])
  @@index([status])
  @@index([providerTxId])
  @@index([createdAt])
  @@index([userId, status, createdAt(sort: Desc)])
  @@index([userId, type, createdAt(sort: Desc)])
}

enum PaymentProvider {
  VNPAY
  MOMO
  ZALOPAY
  BANK_TRANSFER
}

enum PaymentStatus {
  PENDING
  PROCESSING
  COMPLETED
  FAILED
  REFUNDED
}

enum PaymentType {
  SUBSCRIPTION
  LISTING_FEE
  DEPOSIT
  FEATURED_LISTING
}

Model UsageRecord

model UsageRecord {
  id             String       @id @default(cuid())
  subscriptionId String
  subscription   Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
  metric         String       // ví dụ: "listings_created", "searches_performed"
  count          Int
  periodStart    DateTime
  periodEnd      DateTime

  @@index([subscriptionId, metric])
}

6. Dashboard Thanh toán

Vị trí

/apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx

Trạng thái hiện tại

Đã triển khai: Xem lịch sử giao dịch với filter và phân trang

Tính năng:

  • Bảng giao dịch với các cột: Ngày, Loại, Provider, Số tiền, Trạng thái, Transaction ID
  • Dropdown filter trạng thái (ALL, PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED)
  • Thẻ tóm tắt: Tổng số giao dịch, Tổng số tiền đã hoàn tất, Số lượng đang chờ
  • Phân trang (20 mục/trang)
  • Layout thẻ thân thiện với mobile

Hook được sử dụng:

const { data: transactions, isLoading } = useTransactions({
  status: statusFilter,
  limit: 20,
  offset: page * 20,
});

Không có UI Tạo Thanh toán

Thiếu: Không có nút/luồng để tạo thanh toán mới từ trang này


7. Cấu hình i18n

Vị trí

/apps/web/messages/
├── vi.json
└── en.json

Translation cho Pricing (ví dụ tiếng Việt)

"pricing": {
  "badge": "Bảng giá dịch vụ",
  "title": "Chọn gói dịch vụ phù hợp",
  "subtitle": "Từ cá nhân đến doanh nghiệp...",
  "monthly": "Theo tháng",
  "yearly": "Theo năm",
  "yearlyDiscount": "-17%",
  "perMonth": "tháng",
  "perYear": "năm",
  "loading": "Đang tải gói dịch vụ...",
  "popular": "Phổ biến nhất",
  "unlimited": "Không giới hạn",
  "listingsCount": "tin đăng",
  "savedSearchesCount": "tìm kiếm đã lưu",
  "photosPerListing": "ảnh/tin đăng",
  "tiers": {
    "FREE": "Miễn phí",
    "AGENT_PRO": "Môi giới Pro",
    "INVESTOR": "Nhà đầu tư",
    "ENTERPRISE": "Doanh nghiệp"
  },
  "tierDescriptions": {...},
  "features": {...},
  "ctaFree": "Bắt đầu miễn phí",
  "ctaUpgrade": "Nâng cấp ngay",
  "ctaEnterprise": "Liên hệ Sales",
  "ctaTitle": "Bạn có bất động sản muốn đăng?",
  "ctaDescription": "...",
  "ctaRegister": "Đăng ký miễn phí",
  "ctaLearnMore": "Tìm hiểu thêm",
  "comparisonTitle": "So sánh các gói dịch vụ",
  "comparisonSubtitle": "...",
  "feature": "Tính năng"
}

8. Các Component & Luồng còn thiếu

Các mảnh quan trọng còn thiếu

1. Component Trang/Modal Checkout

Trạng thái: CHƯA TRIỂN KHAI
Cần: Component để khởi tạo thanh toán cho plan đã chọn

Luồng nên là:
  Trang Pricing (chọn plan + chu kỳ billing)
    ↓
  [Thiếu] Modal/Page Checkout
    ↓ (Xác nhận chi tiết plan, hiển thị số tiền)
  User nhấp "Pay Now"
    ↓
  Tạo Payment → POST /payments
    ↓
  Nhận paymentUrl + paymentId
    ↓
  Redirect tới payment gateway (VNPay/MoMo/ZaloPay)
    ↓
  User thanh toán
    ↓
  [Thiếu] Xử lý callback thành công
    ↓
  [Thiếu] Tạo subscription → POST /subscriptions

2. Xử lý Callback Thành công của Thanh toán

Trạng thái: CHƯA TRIỂN KHAI
Vị trí: Backend có POST /payments/callback/:provider
Frontend cần: Trang để xử lý redirect từ payment gateway sau khi thanh toán

Luồng:
  Payment gateway redirect tới returnUrl với tham số callback
    ↓
  Trang xử lý callback ở frontend nhận trạng thái thanh toán
    ↓
  Gọi POST /payments/callback/:provider (xác minh)
    ↓
  Nếu COMPLETED → Gọi POST /subscriptions (tạo subscription)
    ↓
  Redirect tới trang success hoặc dashboard

3. Thiết lập Subscription sau khi Thanh toán

Trạng thái: CHƯA TRIỂN KHAI
Sau khi thanh toán thành công:
  1. Kiểm tra trạng thái thanh toán
  2. Nếu COMPLETED, tự động tạo subscription
  3. Đồng bộ dữ liệu subscription về client
  4. Redirect tới dashboard hoặc trang subscription

4. UI Quản lý Subscription

Trạng thái: Thiếu một phần
Đã có:
  - View lịch sử billing (trong trang payments)
  - API endpoint cho upgrade, cancel, meter usage
Thiếu:
  - UI để upgrade/downgrade plan
  - UI để hủy subscription
  - Component hiển thị quota/usage

9. Kiến trúc Luồng Checkout Đề xuất

Hành trình Checkout ở Frontend

1. Người dùng ở trang /pricing
   ↓
2. Nhấp nút "Upgrade" của plan
   ↓
3. Mở Modal/Page Checkout
   - Hiển thị chi tiết plan
   - Hiển thị tổng số tiền (theo tháng hoặc theo năm)
   - Chọn payment provider (VNPay, MoMo, ZaloPay)
   - Hiển thị checkbox điều khoản & điều kiện
   ↓
4. Người dùng nhấp "Pay Now"
   ↓
5. POST /payments {
      provider: 'VNPAY',
      type: 'SUBSCRIPTION',
      amountVND: plan.priceMonthlyVND,
      description: `Subscription to ${plan.name}`,
      returnUrl: 'https://goodgo.vn/payment-return',
      idempotencyKey: UUID
    }
   ↓
6. Nhận CreatePaymentResult:
   - paymentId
   - paymentUrl (từ gateway)
   - providerTxId
   ↓
7. Redirect window.location = paymentUrl
   ↓
8. Người dùng hoàn tất thanh toán tại gateway
   ↓
9. Gateway redirect tới returnUrl?paymentId=...&status=...
   ↓
10. Trang xử lý callback ở frontend:
    - Trích xuất paymentId từ URL
    - Poll GET /payments/{paymentId} cho đến khi status = COMPLETED
    - Khi hoàn tất, POST /subscriptions {
        planTier: selectedPlanTier,
        billingCycle: 'monthly' | 'yearly'
      }
    ↓
11. Hiển thị thông báo thành công
    ↓
12. Redirect tới /dashboard/subscription hoặc trang chủ

Các Component cần thiết

// 1. Modal/Page Checkout
<CheckoutModal
  plan={selectedPlan}
  billingCycle="monthly"
  onClose={handleClose}
  onSuccess={handlePaymentSuccess}
/>

// 2. Selector Payment Provider
<PaymentProviderSelect
  providers={['VNPAY', 'MOMO', 'ZALOPAY']}
  selected={selectedProvider}
  onChange={setSelectedProvider}
/>

// 3. Trình xử lý Payment Return
// Trang /payment-return
// - Phát hiện trạng thái thanh toán từ URL
// - Tạo subscription
// - Redirect tới dashboard

// 4. Component Trạng thái Subscription
<SubscriptionStatus
  subscription={currentSubscription}
  onUpgrade={handleUpgrade}
  onCancel={handleCancel}
/>

10. Checklist Triển khai

Phase 1: Modal Checkout (Ưu tiên CAO)

  • Tạo component <CheckoutModal />
  • Thêm selector payment provider (VNPay, MoMo, ZaloPay)
  • Hiển thị tóm tắt plan và tổng số tiền
  • Thêm checkbox điều khoản & điều kiện
  • Thay thế CTA trang pricing để mở checkout thay vì chuyển tới register

Phase 2: Redirect Payment Gateway (Ưu tiên CAO)

  • Triển khai mutation tạo thanh toán (useMutation + paymentApi.createPayment)
  • Xử lý redirect paymentUrl
  • Truyền idempotencyKey để tránh thanh toán trùng
  • Xử lý trạng thái loading/error

Phase 3: Trình xử lý Payment Return (Ưu tiên CAO)

  • Tạo trang /payment-return hoặc /checkout/return
  • Trích xuất paymentId từ tham số URL
  • Triển khai cơ chế polling cho trạng thái thanh toán
  • Tạo subscription sau khi thanh toán thành công
  • Hiển thị UI thành công/thất bại

Phase 4: Quản lý Subscription (Ưu tiên TRUNG BÌNH)

  • Tạo trang chi tiết subscription
  • Hiển thị tier plan hiện tại và ngày renewal
  • Thêm nút upgrade plan (mở modal checkout)
  • Thêm nút cancel subscription với xác nhận
  • Hiển thị thông tin usage/quota

Phase 5: Xử lý Webhook (Ưu tiên TRUNG BÌNH)

  • Đảm bảo webhook handler backend đang hoạt động
  • Test callback VNPay
  • Test callback MoMo
  • Test callback ZaloPay
  • Xác minh trạng thái thanh toán cập nhật theo thời gian thực

Phase 6: Testing & Tối ưu (Ưu tiên TRUNG BÌNH)

  • E2E test toàn bộ luồng checkout
  • Test với cả 3 payment provider
  • Xử lý edge case (timeout thanh toán, user đóng cửa sổ, v.v.)
  • Thêm luồng khôi phục lỗi
  • Tối ưu hiệu năng (lazy load modal, v.v.)

11. Cấu hình Môi trường

Biến Env Cần thiết (Backend)

# VNPay
VNPAY_TMN_CODE=xxxxx
VNPAY_HASH_SECRET=xxxxx
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html (hoặc production)
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction

# MoMo
MOMO_PARTNER_CODE=xxxxx
MOMO_ACCESS_KEY=xxxxx
MOMO_SECRET_KEY=xxxxx
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api (hoặc production)

# ZaloPay
ZALOPAY_APP_ID=xxxxx
ZALOPAY_KEY1=xxxxx
ZALOPAY_KEY2=xxxxx
ZALOPAY_ENDPOINT=https://sandbox.zalopay.com.vn (hoặc production)

Cấu hình Frontend

// Return URL cho tất cả các payment provider
const PAYMENT_RETURN_URL = `${process.env.NEXT_PUBLIC_APP_URL}/payment-return`;

// Cấu hình polling thanh toán
const PAYMENT_POLL_INTERVAL = 2000;   // 2 giây
const PAYMENT_POLL_MAX_ATTEMPTS = 30; // tổng cộng 60 giây

12. Edge Case & Xử lý Lỗi

Các kịch bản cần xử lý

  1. Người dùng đóng cửa sổ payment gateway

    • Thanh toán vẫn ở trạng thái PENDING trong DB
    • Hiển thị thông báo "Thanh toán chưa hoàn tất"
    • Cho phép thử lại
  2. Timeout thanh toán

    • Gateway không hoàn tất trong thời gian dự kiến
    • Hiển thị thông báo "Thanh toán đã hết thời gian"
    • Cho phép thử lại
  3. Lỗi payment gateway

    • Gateway trả về lỗi trong quá trình createPaymentUrl
    • Hiển thị thông báo lỗi đặc thù của provider
    • Cho phép thử lại với provider khác
  4. Lần thử thanh toán trùng lặp

    • Sử dụng idempotencyKey để tránh trùng lặp
    • Backend trả về thanh toán hiện có nếu key khớp
  5. Tạo subscription thất bại sau khi thanh toán

    • Thanh toán COMPLETED nhưng tạo subscription thất bại
    • Cần can thiệp thủ công (dashboard admin)
    • Hiển thị thông báo: "Thanh toán thành công nhưng subscription chưa được tạo. Vui lòng liên hệ hỗ trợ."
  6. Người dùng rời khỏi trang return

    • Thanh toán thành công nhưng redirect không hoàn tất
    • Subscription đã được tạo ở background
    • Người dùng sẽ thấy subscription trong dashboard vào lần đăng nhập tiếp theo

13. Chiến lược Testing

Unit Test

// hook usePaymentCheckout
describe('usePaymentCheckout', () => {
  it('should create payment and return paymentUrl');
  it('should handle payment provider errors');
  it('should validate idempotency key');
});

// Component CheckoutModal
describe('<CheckoutModal />', () => {
  it('should render plan details');
  it('should show payment providers');
  it('should create payment on submit');
  it('should disable submit while loading');
});

Integration Test

// Luồng checkout đầy đủ
describe('Checkout Flow', () => {
  it('should go from pricing page to successful subscription');
  it('should handle payment provider callbacks correctly');
  it('should create subscription after successful payment');
});

E2E Test (Playwright)

// Hành trình người dùng đầy đủ với payment provider test
describe('E2E: Subscription Checkout', () => {
  it('should complete checkout with VNPay');
  it('should complete checkout with MoMo');
  it('should complete checkout with ZaloPay');
  it('should handle payment failures gracefully');
});

14. Bảng Tóm tắt Trạng thái Hiện tại

Component Vị trí Trạng thái Ghi chú
Trang Pricing /pricing Active Hiển thị 4 tier với giá chính xác
Plan API GET /subscriptions/plans Active Trả về PlanDto[]
Hook usePlans lib/hooks/use-subscription.ts Active Dùng React Query
Payment API POST /payments Active Tạo payment & trả về paymentUrl
Payment Gateway 3 service (VNPay, MoMo, ZaloPay) Đã triển khai Sẵn sàng sử dụng
Tạo Subscription POST /subscriptions Active Tạo subscription sau khi thanh toán
Modal Checkout - Thiếu Cần để kết nối pricing → payment
Trình xử lý Payment Return - Thiếu Cần để xử lý redirect từ gateway
UI Quản lý Subscription - ⚠️ Một phần Đã có để xem, thiếu để chỉnh sửa
Hỗ trợ Idempotency Model Payment Active Đã tích hợp vào schema
Webhook Handler POST /payments/callback/:provider Active Nhận & xác minh callback
Polling Trạng thái Thanh toán GET /payments/:id Active Có thể poll trạng thái khi return
Cập nhật Hướng Sự kiện Domain event Active Sự kiện PaymentCompleted được publish
Listener Thông báo 3 listener Active payment-completed, payment-failed, payment-refunded

15. Tham chiếu File chính

Frontend

/apps/web/
├── app/[locale]/(public)/pricing/page.tsx          [Trang pricing chính]
├── lib/
│   ├── subscription-api.ts                         [Client API Subscription]
│   ├── payment-api.ts                              [Client API Payment]
│   └── hooks/
│       ├── use-subscription.ts                     [Hook Subscription]
│       └── use-payments.ts                         [Hook Payment]
└── app/[locale]/(dashboard)/dashboard/payments/page.tsx [Lịch sử giao dịch]

Backend

/apps/api/src/modules/
├── subscriptions/
│   ├── presentation/controllers/subscriptions.controller.ts
│   ├── application/commands/create-subscription/
│   ├── domain/entities/subscription.entity.ts
│   └── infrastructure/repositories/prisma-subscription.repository.ts
└── payments/
    ├── presentation/controllers/payments.controller.ts
    ├── application/commands/create-payment/
    ├── domain/entities/payment.entity.ts
    ├── infrastructure/services/
    │   ├── vnpay.service.ts
    │   ├── momo.service.ts
    │   ├── zalopay.service.ts
    │   └── payment-gateway.factory.ts
    └── infrastructure/repositories/prisma-payment.repository.ts

Database

/prisma/schema.prisma
├── model Plan (dòng 469-483)
├── model Subscription (dòng 485-502)
├── model Payment (dòng 424-449)
└── model UsageRecord (dòng 504-514)

Kết luận

GoodGo Platform có hạ tầng backend xuất sắc cho subscription và thanh toán với tích hợp payment gateway hoàn chỉnh (VNPay, MoMo, ZaloPay). Trang pricing frontend hoạt động nhưng thiếu luồng checkout quan trọng để chuyển đổi người xem thành khách hàng trả phí.

Các bước tiếp theo cần làm ngay:

  1. Tạo Modal Checkout - Kết nối trang pricing với việc tạo thanh toán
  2. Triển khai Trình xử lý Payment Return - Xử lý redirect từ payment gateway
  3. Thêm Tự động Tạo Subscription - Tự động tạo subscription sau khi thanh toán thành công
  4. Xây dựng UI Quản lý Subscription - Cho phép người dùng xem và quản lý subscription của họ

Nền tảng đã vững chắc; chỉ còn thiếu luồng checkout hướng tới người dùng.