# 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 ``` **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; 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 // GET /subscriptions/plans getPlanByTier(tier): Promise // GET /subscriptions/plans/:tier getBillingHistory(limit, offset): Promise checkQuota(metric): Promise createSubscription(planTier, billingCycle): Promise 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 getPaymentStatus(id): Promise getTransactions(params): Promise } ``` ### 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 { 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 cancel(): Result markExpired(): Result markPastDue(): Result 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 { // 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 { 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 markFailed(callbackData): Result markRefunded(): Result 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; verifyCallback(data: Record): CallbackVerifyResult; refund(params: RefundParams): Promise; } 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; } ``` ### 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): 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 { // 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 // 2. Payment Provider Selector // 3. Payment Return Handler // /payment-return page // - Detects payment status from URL // - Creates subscription // - Redirects to dashboard // 4. Subscription Status Component ``` --- ## 10. Implementation Checklist ### Phase 1: Checkout Modal (Priority HIGH) - [ ] Create `` 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('', () => { 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.