Files
goodgo-platform/docs/audits/PRICING_CHECKOUT_AUDIT.md
Ho Ngoc Hai b93c28fa01 chore: organize docs — move 37 files from root into docs/ subfolders
Root now contains only essential files:
  README.md, CLAUDE.md, CHANGELOG.md, CONTRIBUTING.md

Reorganized into:
  docs/audits/       — all audit reports & checklists (71 files)
  docs/architecture/  — codebase overview, implementation plan
  docs/guides/        — auth guide, implementation checklist
  docs/load-testing/  — k6 load test guides & endpoints
  docs/security/      — payment & security reviews

Also removed 5 untracked debug/investigation files and
cleaned up playwright-report/ & test-results/ artifacts.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-04-13 12:09:14 +07:00

1319 lines
36 KiB
Markdown

# GoodGo Platform AI - Pricing, Subscriptions & Payment Systems Audit
**Date:** April 12, 2026
**Scope:** Comprehensive exploration of pricing pages, subscription plans, and payment checkout flows
**Status:** Frontend pricing page ACTIVE, Backend subscription/payment modules ACTIVE, checkout flow NOT IMPLEMENTED
---
## Executive Summary
The GoodGo Platform has a **complete backend infrastructure** for subscriptions and payments with NestJS CQRS architecture, but the **frontend pricing-to-checkout flow is incomplete**. The pricing page exists and loads plans from the API, but there is **no checkout/payment flow component** linking the pricing page to the payment API.
### Key Findings:
-**Pricing Page:** Fully functional at `/pricing` with 4 tiers (FREE, AGENT_PRO, INVESTOR, ENTERPRISE)
-**Subscription Backend:** Complete with CQRS commands, entities, repositories
-**Payment Gateway Integration:** VNPay, MoMo, ZaloPay implementations ready
-**Payment API Endpoints:** Full CRUD operations available
-**Missing:** Checkout page/modal connecting pricing → payment creation → redirect to payment gateway
-**Missing:** Payment success callback handling on frontend
-**Missing:** Subscription setup after successful payment
---
## 1. Frontend Pricing Page
### Location
```
/Users/velikho/Desktop/WORKING/goodgo-platform-ai/apps/web/app/[locale]/(public)/pricing/page.tsx
```
### Current Implementation
**Route:** `/(public)/pricing`
**Status:** ✅ Active and functional
#### Page Component Features:
```tsx
// Key Exports and Functionality
- PricingPage (default export)
- Uses useTranslations('pricing') for i18n
- Manages billingCycle state (monthly/yearly)
- Renders 4 plan cards
- Feature comparison table
- CTA sections
```
#### Plan Data Flow:
```
usePlans() hook (from use-subscription.ts)
subscriptionApi.getPlans()
GET /subscriptions/plans (backend)
Returns: PlanDto[] with pricing, features, limits
```
#### Fallback Plans (Hardcoded):
The page includes fallback plans when API unavailable:
- **FREE:** 0 VND, 3 listings, 5 saved searches
- **AGENT_PRO:** 499,000 VND/month, 50 listings, 30 searches (marked as "popular")
- **INVESTOR:** 999,000 VND/month, 20 listings, 100 searches
- **ENTERPRISE:** 4,990,000 VND/month, unlimited listings/searches
#### Key Components Used:
```tsx
- Badge, Button, Card
- Check, Crown, Rocket, Shield, X, Zap icons (lucide-react)
- useTranslations for 15+ translation keys
- formatVND for currency display
- cn() utility for conditional classes
```
#### Billing Cycle Toggle:
```tsx
// State management for monthly/yearly pricing
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
// Displays appropriate price based on cycle
const price = billingCycle === 'monthly'
? plan.priceMonthlyVND
: plan.priceYearlyVND;
```
#### Current CTAs:
All plans have a "Register" link:
```tsx
<Link href={'/register'}>
<Button>
{plan.tier === 'FREE' ? t('ctaFree') : t('ctaUpgrade')}
</Button>
</Link>
```
**ISSUE:** CTAs link to `/register` instead of starting checkout flow
---
## 2. Frontend API Integration
### Files
#### `/apps/web/lib/subscription-api.ts`
**Type Definitions:**
```typescript
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;
}
```
**API Methods:**
```typescript
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`
**Type Definitions:**
```typescript
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; // Direct payment gateway URL
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;
}
```
**API Methods:**
```typescript
paymentApi = {
createPayment(data): Promise<CreatePaymentResult>
getPaymentStatus(id): Promise<PaymentStatusDto>
getTransactions(params): Promise<TransactionListDto>
}
```
### Frontend Hooks
#### `/apps/web/lib/hooks/use-subscription.ts`
```typescript
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,
};
// React Query hooks
export function usePlans() // GET plans
export function useBillingHistory() // GET billing history
export function useQuota(metric: string) // GET quota check
```
#### `/apps/web/lib/hooks/use-payments.ts`
```typescript
export const paymentKeys = {
all: ['payments'] as const,
transactions: (params) => ['payments', 'transactions', params] as const,
status: (id: string) => ['payments', 'status', id] as const,
};
// React Query hooks
export function useTransactions(params) // GET user transactions
export function usePaymentStatus(id) // GET single payment status
```
---
## 3. Subscription Backend (NestJS CQRS)
### Module Location
```
/apps/api/src/modules/subscriptions/
```
### Architecture
**Module:** `subscriptions.module.ts`
**Pattern:** Hexagonal Architecture (DDD + CQRS)
#### Domain Layer
```
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)
```
**SubscriptionEntity Class:**
```typescript
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
}
```
#### Application Layer
**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 ← Returns PlanDto[]
├── check-quota/
│ ├── check-quota.query.ts
│ └── check-quota.handler.ts
└── get-billing-history/
├── get-billing-history.query.ts
└── get-billing-history.handler.ts
```
#### Infrastructure Layer
```
infrastructure/
├── repositories/prisma-subscription.repository.ts
└── event-handlers/listing-created-usage.handler.ts
```
#### Presentation Layer
```
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
```
### Subscription API Endpoints
#### Public Endpoints
```
GET /subscriptions/plans
→ Returns: PlanDto[]
GET /subscriptions/plans/:tier
→ Returns: PlanDto
```
#### Authenticated Endpoints
```
POST /subscriptions
Body: { planTier: PlanTier, billingCycle: 'monthly' | 'yearly' }
Returns: CreateSubscriptionResult
PUT /subscriptions/upgrade
Body: { newPlanTier: PlanTier }
Returns: UpgradeSubscriptionResult
DELETE /subscriptions
Body: { reason?: string }
Returns: CancelSubscriptionResult
POST /subscriptions/usage
Body: { metric: string, count: number }
Returns: MeterUsageResult
GET /subscriptions/quota/:metric
Returns: QuotaCheckResult
GET /subscriptions/billing?limit=20&offset=0
Returns: BillingHistoryDto
```
### CreateSubscriptionHandler Logic
```typescript
async execute(command): Promise<CreateSubscriptionResult> {
// 1. Check user doesn't already have active subscription
if (existing && (status === 'ACTIVE' || 'PAST_DUE')) {
throw ConflictException('User already has active subscription');
}
// 2. Fetch Plan from DB
const plan = prisma.plan.findFirst({
where: { tier: command.planTier, isActive: true }
});
// 3. Calculate period
const now = new Date();
const periodEnd = new Date(now);
if (yearly) periodEnd.setFullYear(+1);
else periodEnd.setMonth(+1);
// 4. Create domain entity
const subscription = SubscriptionEntity.createNew(
id, userId, planId, planTier, now, periodEnd
);
// 5. Save to repo
await subscriptionRepo.save(subscription);
// 6. Publish domain events
eventBus.publish(subscription.clearDomainEvents());
return {
subscriptionId,
planTier,
status: 'ACTIVE',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
};
}
```
---
## 4. Payment Backend (NestJS CQRS)
### Module Location
```
/apps/api/src/modules/payments/
```
### Architecture
#### Domain Layer
```
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
```
**PaymentEntity Class:**
```typescript
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
}
```
#### Application Layer
**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
│ └── (no DTO - webhook endpoint)
└── 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
```
#### Infrastructure Layer (Payment Gateways)
```
infrastructure/services/
├── payment-gateway.interface.ts ← IPaymentGateway interface
├── payment-gateway.factory.ts
├── vnpay.service.ts ← ✅ Implemented
├── momo.service.ts ← ✅ Implemented
└── zalopay.service.ts ← ✅ Implemented
```
**IPaymentGateway Interface:**
```typescript
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; // Direct to payment gateway
providerTxId: string; // Gateway transaction ID
}
export interface CallbackVerifyResult {
isValid: boolean;
orderId: string; // Our payment ID
providerTxId: string; // Gateway transaction ID
isSuccess: boolean; // Payment successful?
rawData: Record<string, unknown>;
}
```
### Payment Gateway Implementations
#### VNPay Service
```typescript
private readonly tmnCode: string; // Terminal ID
private readonly hashSecret: string; // HMAC key
async createPaymentUrl(params): Promise<{paymentUrl, providerTxId}> {
// Build VNPay params (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 uses cents
vnp_ReturnUrl: params.returnUrl,
vnp_IpAddr: params.ipAddress,
vnp_CreateDate: yyyyMMddHHmmss,
vnp_ExpireDate: yyyyMMddHHmmss + 15min,
};
// Sign with HMAC SHA-512
const signed = hmac('sha512', this.hashSecret).digest('hex');
vnpParams['vnp_SecureHash'] = signed;
// Return redirect URL
return {
paymentUrl: `https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?${params}`,
providerTxId: params.orderId,
};
}
verifyCallback(data: Record<string, string>): CallbackVerifyResult {
// VNPay returns: vnp_SecureHash, vnp_ResponseCode, vnp_TransactionNo, etc.
// Verify hash signature
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
```typescript
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();
// Build signature: 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 to 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
Similar structure with ZaloPay-specific endpoints and signature algorithm (HMAC SHA-256)
### Payment API Endpoints
#### Public Endpoints
```
POST /payments/callback/:provider
Query/Body: Provider callback data (VNPay via query, MoMo/ZaloPay via body)
Returns: HandleCallbackResult
Webhook for payment provider callbacks
```
#### Authenticated Endpoints
```
POST /payments
Body: CreatePaymentDto {
provider: PaymentProvider,
type: PaymentType,
amountVND: number,
description: string,
returnUrl: string,
transactionId?: string,
idempotencyKey?: string
}
Returns: CreatePaymentResult {
paymentId: string,
paymentUrl: string, ← Redirect user here
providerTxId: string
}
GET /payments
Query: { status?, limit?, offset? }
Returns: TransactionListDto
GET /payments/:id
Returns: PaymentStatusDto
POST /payments/:id/refund (Admin only)
Body: { reason: string }
Returns: RefundPaymentResult
```
### CreatePaymentHandler Logic
```typescript
async execute(command): Promise<CreatePaymentResult> {
// 1. Idempotency check (prevent duplicate payments)
if (command.idempotencyKey) {
const existing = await paymentRepo.findByIdempotencyKey(key);
if (existing && (status === 'PENDING' || 'PROCESSING')) {
throw ConflictException('Payment already exists');
}
}
// 2. Validate amount (1 VND to 100 billion VND)
const money = Money.create(command.amountVND);
if (money.isErr) throw ValidationException();
// 3. Create domain entity
const payment = PaymentEntity.createNew(
id, userId, provider, type, money, ...
);
// 4. Get payment gateway and create 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. Mark as PROCESSING and save
payment.markProcessing(providerTxId);
await paymentRepo.save(payment);
// 6. Publish domain events
eventBus.publish(payment.clearDomainEvents());
return {
paymentId,
paymentUrl, Client redirects to this
providerTxId,
};
}
```
---
## 5. Prisma Data Models
### Location
```
/prisma/schema.prisma (lines 451-514)
```
### Plan Model
```prisma
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 // JSON object with boolean/number features
isActive Boolean @default(true)
subscriptions Subscription[]
}
enum PlanTier {
FREE
AGENT_PRO
INVESTOR
ENTERPRISE
}
```
### Subscription Model
```prisma
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
}
```
### Payment Model
```prisma
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
}
```
### UsageRecord Model
```prisma
model UsageRecord {
id String @id @default(cuid())
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
metric String // e.g., "listings_created", "searches_performed"
count Int
periodStart DateTime
periodEnd DateTime
@@index([subscriptionId, metric])
}
```
---
## 6. Payments Dashboard
### Location
```
/apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx
```
### Current State
**Implemented:** View transaction history with filtering and pagination
#### Features:
- Transaction table with columns: Date, Type, Provider, Amount, Status, Transaction ID
- Status filter dropdown (ALL, PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED)
- Summary cards: Total transactions, Total completed amount, Pending count
- Pagination (20 items per page)
- Mobile-friendly card layout
#### Hooks Used:
```typescript
const { data: transactions, isLoading } = useTransactions({
status: statusFilter,
limit: 20,
offset: page * 20,
});
```
#### No Payment Creation UI
**Missing:** No button/flow to create new payment from this page
---
## 7. i18n Configuration
### Location
```
/apps/web/messages/
├── vi.json
└── en.json
```
### Pricing Translations (Vietnamese example)
```json
"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. Missing Components & Flows
### ❌ Critical Missing Pieces
#### 1. Checkout Page/Modal Component
```
Status: NOT IMPLEMENTED
Needed: Component to initiate payment for selected plan
Flow Should Be:
Pricing Page (Select plan + billing cycle)
[Missing] Checkout Modal/Page
↓ (Confirm plan details, display amount)
User clicks "Pay Now"
Create Payment → POST /payments
Get paymentUrl + paymentId
Redirect to payment gateway (VNPay/MoMo/ZaloPay)
User pays
[Missing] Success callback handler
[Missing] Create subscription → POST /subscriptions
```
#### 2. Payment Success Callback Handler
```
Status: NOT IMPLEMENTED
Location: Backend has POST /payments/callback/:provider
Frontend Needs: Page to handle redirect from payment gateway after payment
Flow:
Payment gateway redirects to returnUrl with callback params
Frontend callback handler page receives payment status
Query POST /payments/callback/:provider (verify)
If COMPLETED → Call POST /subscriptions (create subscription)
Redirect to success page or dashboard
```
#### 3. Subscription Setup After Payment
```
Status: NOT IMPLEMENTED
After successful payment:
1. Check payment status
2. If COMPLETED, create subscription automatically
3. Sync subscription data to client
4. Redirect to dashboard or subscription page
```
#### 4. Subscription Management UI
```
Status: Partially missing
Exists:
- Billing history view (in payments page)
- API endpoints for upgrade, cancel, meter usage
Missing:
- UI to upgrade/downgrade plan
- UI to cancel subscription
- Quota/usage display component
```
---
## 9. Proposed Checkout Flow Architecture
### Frontend Checkout Journey
```
1. User on /pricing page
2. Clicks plan "Upgrade" button
3. Open Checkout Modal/Page
- Show plan details
- Show total amount (monthly or yearly)
- Select payment provider (VNPay, MoMo, ZaloPay)
- Show terms & conditions checkbox
4. User clicks "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. Receive CreatePaymentResult:
- paymentId
- paymentUrl (from gateway)
- providerTxId
7. Redirect window.location = paymentUrl
8. User completes payment at gateway
9. Gateway redirects to returnUrl?paymentId=...&status=...
10. Frontend callback handler page:
- Extract paymentId from URL
- Poll GET /payments/{paymentId} until status = COMPLETED
- When completed, POST /subscriptions {
planTier: selectedPlanTier,
billingCycle: 'monthly' | 'yearly'
}
11. Show success message
12. Redirect to /dashboard/subscription or home
```
### Key Components Needed
```typescript
// 1. Checkout Modal/Page
<CheckoutModal
plan={selectedPlan}
billingCycle="monthly"
onClose={handleClose}
onSuccess={handlePaymentSuccess}
/>
// 2. Payment Provider Selector
<PaymentProviderSelect
providers={['VNPAY', 'MOMO', 'ZALOPAY']}
selected={selectedProvider}
onChange={setSelectedProvider}
/>
// 3. Payment Return Handler
// /payment-return page
// - Detects payment status from URL
// - Creates subscription
// - Redirects to dashboard
// 4. Subscription Status Component
<SubscriptionStatus
subscription={currentSubscription}
onUpgrade={handleUpgrade}
onCancel={handleCancel}
/>
```
---
## 10. Implementation Checklist
### Phase 1: Checkout Modal (Priority HIGH)
- [ ] Create `<CheckoutModal />` component
- [ ] Add payment provider selector (VNPay, MoMo, ZaloPay)
- [ ] Show plan summary and total amount
- [ ] Add terms & conditions checkbox
- [ ] Replace pricing page CTA to open checkout instead of going to register
### Phase 2: Payment Gateway Redirect (Priority HIGH)
- [ ] Implement payment creation mutation (useMutation + paymentApi.createPayment)
- [ ] Handle paymentUrl redirect
- [ ] Pass idempotencyKey to prevent duplicate payments
- [ ] Handle loading/error states
### Phase 3: Payment Return Handler (Priority HIGH)
- [ ] Create `/payment-return` or `/checkout/return` page
- [ ] Extract paymentId from URL params
- [ ] Implement polling mechanism for payment status
- [ ] Create subscription after successful payment
- [ ] Show success/failure UI
### Phase 4: Subscription Management (Priority MEDIUM)
- [ ] Create subscription detail page
- [ ] Show current plan tier and renewal date
- [ ] Add upgrade plan button (opens checkout modal)
- [ ] Add cancel subscription button with confirmation
- [ ] Display usage/quota information
### Phase 5: Webhook Handling (Priority MEDIUM)
- [ ] Ensure backend webhook handlers working
- [ ] Test VNPay callback
- [ ] Test MoMo callback
- [ ] Test ZaloPay callback
- [ ] Verify payment status updates in real-time
### Phase 6: Testing & Optimization (Priority MEDIUM)
- [ ] E2E test complete checkout flow
- [ ] Test with all 3 payment providers
- [ ] Handle edge cases (payment timeout, user closes window, etc.)
- [ ] Add error recovery flows
- [ ] Performance optimization (lazy load modals, etc.)
---
## 11. Environment Configuration
### Required Env Vars (Backend)
```
# VNPay
VNPAY_TMN_CODE=xxxxx
VNPAY_HASH_SECRET=xxxxx
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html (or 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 (or production)
# ZaloPay
ZALOPAY_APP_ID=xxxxx
ZALOPAY_KEY1=xxxxx
ZALOPAY_KEY2=xxxxx
ZALOPAY_ENDPOINT=https://sandbox.zalopay.com.vn (or production)
```
### Frontend Config
```typescript
// Return URL for all payment providers
const PAYMENT_RETURN_URL = `${process.env.NEXT_PUBLIC_APP_URL}/payment-return`;
// Payment polling config
const PAYMENT_POLL_INTERVAL = 2000; // 2 seconds
const PAYMENT_POLL_MAX_ATTEMPTS = 30; // 60 seconds total
```
---
## 12. Edge Cases & Error Handling
### Scenarios to Handle
1. **User closes payment gateway window**
- Payment remains PENDING in DB
- Show "Payment incomplete" message
- Offer retry option
2. **Payment timeout**
- Gateway doesn't complete within expected time
- Show "Payment timed out" message
- Allow retry
3. **Payment gateway error**
- Gateway returns error during createPaymentUrl
- Show provider-specific error message
- Allow retry with different provider
4. **Duplicate payment attempts**
- Use idempotencyKey to prevent duplicates
- Backend returns existing payment if key matches
5. **Subscription creation fails after payment**
- Payment COMPLETED but subscription creation fails
- Manual intervention needed (admin dashboard)
- Show message: "Payment successful but subscription not created. Please contact support."
6. **User navigates away from return page**
- Payment was successful but redirect didn't complete
- Subscription was created in background
- User should see subscription in dashboard on next login
---
## 13. Testing Strategy
### Unit Tests
```typescript
// usePaymentCheckout hook
describe('usePaymentCheckout', () => {
it('should create payment and return paymentUrl');
it('should handle payment provider errors');
it('should validate idempotency key');
});
// CheckoutModal component
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 Tests
```typescript
// Full checkout flow
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 Tests (Playwright)
```typescript
// Full user journey with test payment providers
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. Current State Summary Table
| Component | Location | Status | Notes |
|-----------|----------|--------|-------|
| **Pricing Page** | `/pricing` | ✅ Active | Displays 4 tiers with correct prices |
| **Plan API** | `GET /subscriptions/plans` | ✅ Active | Returns PlanDto[] |
| **usePlans Hook** | `lib/hooks/use-subscription.ts` | ✅ Active | Uses React Query |
| **Payment API** | `POST /payments` | ✅ Active | Creates payment & returns paymentUrl |
| **Payment Gateways** | 3x services (VNPay, MoMo, ZaloPay) | ✅ Implemented | Ready to use |
| **Subscription Creation** | `POST /subscriptions` | ✅ Active | Creates subscription after payment |
| **Checkout Modal** | - | ❌ Missing | Needed to link pricing → payment |
| **Payment Return Handler** | - | ❌ Missing | Needed to handle gateway redirect |
| **Subscription Management UI** | - | ⚠️ Partial | Exists for viewing, missing for modify |
| **Idempotency Support** | Payment model | ✅ Active | Built into schema |
| **Webhook Handlers** | `POST /payments/callback/:provider` | ✅ Active | Receives & verifies callbacks |
| **Payment Status Polling** | `GET /payments/:id` | ✅ Active | Can poll status on return |
| **Event-Driven Updates** | Domain events | ✅ Active | PaymentCompleted events published |
| **Notification Listeners** | 3x listeners | ✅ Active | payment-completed, payment-failed, payment-refunded |
---
## 15. Key Files Reference
### Frontend
```
/apps/web/
├── app/[locale]/(public)/pricing/page.tsx [Main pricing page]
├── lib/
│ ├── subscription-api.ts [Subscription API client]
│ ├── payment-api.ts [Payment API client]
│ └── hooks/
│ ├── use-subscription.ts [Subscription hooks]
│ └── use-payments.ts [Payment hooks]
└── app/[locale]/(dashboard)/dashboard/payments/page.tsx [Transaction history]
```
### 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 (lines 469-483)
├── model Subscription (lines 485-502)
├── model Payment (lines 424-449)
└── model UsageRecord (lines 504-514)
```
---
## Conclusion
The GoodGo Platform has **excellent backend infrastructure** for subscriptions and payments with a complete payment gateway integration (VNPay, MoMo, ZaloPay). The frontend pricing page is functional but **lacks the critical checkout flow** to convert viewers into paying customers.
### Immediate Next Steps:
1. **Create Checkout Modal** - Connect pricing page to payment creation
2. **Implement Payment Return Handler** - Handle payment gateway redirects
3. **Add Subscription Auto-Creation** - Automatically create subscription after successful payment
4. **Build Subscription Management UI** - Allow users to view and manage their subscriptions
The foundation is solid; only the user-facing checkout flow is missing.