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>
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
/pricingvớ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 lý 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 có đ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-returnhoặ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ý
-
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
-
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
-
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
-
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
-
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ợ."
-
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:
- Tạo Modal Checkout - Kết nối trang pricing với việc tạo thanh toán
- Triển khai Trình xử lý Payment Return - Xử lý redirect từ payment gateway
- Thêm Tự động Tạo Subscription - Tự động tạo subscription sau khi thanh toán thành công
- 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.