- Pricing page: enhanced with checkout modal integration, plan comparison table, and subscription funnel - Payment return page: new VNPay/MoMo callback handler - Subscription components: new checkout-modal with payment method selection (VNPay, MoMo, ZaloPay) - API modules: type-safe PII encryption, improved error handling in MFA/auth/payments/analytics/search/notifications modules - Audit docs: comprehensive Wave 13 platform assessment, pricing audit, production readiness checklist - Updated PROJECT_TRACKER with Wave 13 status Co-Authored-By: Paperclip <noreply@paperclip.ing>
11 KiB
11 KiB
Quick Reference: Pricing/Subscription/Payment System
Files at a Glance
🎨 Frontend
| File | Purpose | Status |
|---|---|---|
apps/web/app/[locale]/(public)/pricing/page.tsx |
Main pricing page | ✅ Complete |
apps/web/lib/subscription-api.ts |
Subscription API client | ✅ Complete |
apps/web/lib/payment-api.ts |
Payment API client | ✅ Complete |
apps/web/lib/hooks/use-subscription.ts |
Subscription hooks | ✅ Complete |
apps/web/lib/hooks/use-payments.ts |
Payment hooks | ✅ Complete |
apps/web/app/.../dashboard/payments/page.tsx |
Payment history | ✅ Complete |
🔧 Backend
| Directory | Purpose | Status |
|---|---|---|
apps/api/src/modules/subscriptions/ |
Subscription CQRS module | ✅ Complete |
apps/api/src/modules/payments/ |
Payment CQRS module | ✅ Complete |
apps/api/src/modules/payments/infrastructure/services/ |
Payment gateways (VNPay, MoMo, ZaloPay) | ✅ Complete |
📦 Database
| Model | Fields | Relationships |
|---|---|---|
Plan |
id, tier (unique), name, prices, features, isActive | 1→M Subscription |
Subscription |
id, userId (unique), planId, status, periods, cancelledAt | M←1 Plan, 1←1 User |
Payment |
id, userId, provider, type, amountVND, status, providerTxId, idempotencyKey | M←1 User |
UsageRecord |
id, subscriptionId, metric, count, periods | M←1 Subscription |
Key API Endpoints
Plans (Public)
GET /subscriptions/plans
GET /subscriptions/plans/:tier
Subscriptions (Auth Required)
POST /subscriptions # Create new
PUT /subscriptions/upgrade # Upgrade
DELETE /subscriptions # Cancel
GET /subscriptions/quota/:metric # Check quota
POST /subscriptions/usage # Record usage
GET /subscriptions/billing # View history
Payments (Auth + Webhook)
POST /payments # Create payment → returns paymentUrl
POST /payments/callback/:provider # Webhook from gateway
GET /payments/:id # Check status
GET /payments # List transactions
POST /payments/:id/refund # Refund (admin)
Type Definitions
Frontend Types
// From subscription-api.ts
interface PlanDto {
id: string;
tier: string; // FREE, AGENT_PRO, INVESTOR, ENTERPRISE
name: string;
priceMonthlyVND: string; // In VND
priceYearlyVND: string; // In VND
maxListings: number;
maxSavedSearches: number;
features: Record<string, boolean | number | string>;
isActive: boolean;
}
interface CreateSubscriptionResult {
subscriptionId: string;
planTier: string;
status: string; // ACTIVE, PAST_DUE, CANCELLED, EXPIRED
currentPeriodStart: string; // ISO datetime
currentPeriodEnd: string; // ISO datetime
}
// From payment-api.ts
interface CreatePaymentPayload {
provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' | 'BANK_TRANSFER';
type: 'SUBSCRIPTION' | 'LISTING_FEE' | 'DEPOSIT' | 'FEATURED_LISTING';
amountVND: number; // 1 to 100,000,000,000
description: string;
returnUrl: string; // Redirect after payment
idempotencyKey?: string; // Prevent duplicates
transactionId?: string; // External transaction ID
}
interface CreatePaymentResult {
paymentId: string;
paymentUrl: string; // Redirect user here
providerTxId: string;
}
interface PaymentStatusDto {
id: string;
provider: string;
type: string;
amountVND: string;
status: string; // PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED
providerTxId: string | null;
createdAt: string;
updatedAt: string;
}
How to Use in Frontend
Get Plans
import { usePlans } from '@/lib/hooks/use-subscription';
export function MyComponent() {
const { data: plans, isLoading } = usePlans();
return (
<div>
{isLoading ? 'Loading...' : plans?.map(plan => <div>{plan.name}</div>)}
</div>
);
}
Create Payment
import { paymentApi } from '@/lib/payment-api';
import { useMutation } from '@tanstack/react-query';
const createPaymentMutation = useMutation({
mutationFn: (payload) => paymentApi.createPayment(payload),
});
// When user clicks "Pay Now"
const handlePayment = async (planTier: string, provider: 'VNPAY' | 'MOMO' | 'ZALOPAY') => {
const result = await createPaymentMutation.mutateAsync({
provider,
type: 'SUBSCRIPTION',
amountVND: 499000,
description: `Subscription to ${planTier}`,
returnUrl: `${window.location.origin}/payment-return`,
idempotencyKey: crypto.randomUUID(),
});
// Redirect to payment gateway
window.location = result.paymentUrl;
};
Check Payment Status (on return page)
import { paymentApi } from '@/lib/payment-api';
import { useEffect, useState } from 'react';
export function PaymentReturnPage() {
const searchParams = new URLSearchParams(window.location.search);
const paymentId = searchParams.get('paymentId');
const [status, setStatus] = useState<string>('loading');
useEffect(() => {
if (!paymentId) return;
const poll = async () => {
const payment = await paymentApi.getPaymentStatus(paymentId);
if (payment.status === 'COMPLETED') {
// Create subscription
await subscriptionApi.createSubscription('AGENT_PRO', 'monthly');
setStatus('success');
// Redirect to dashboard
window.location = '/dashboard';
} else if (payment.status === 'FAILED') {
setStatus('failed');
} else {
// Poll again in 2 seconds
setTimeout(poll, 2000);
}
};
poll();
}, [paymentId]);
return <div>{status === 'loading' ? 'Processing payment...' : status}</div>;
}
Create Subscription
import { subscriptionApi } from '@/lib/subscription-api';
const result = await subscriptionApi.createSubscription('AGENT_PRO', 'monthly');
console.log(result);
// {
// subscriptionId: 'cuid...',
// planTier: 'AGENT_PRO',
// status: 'ACTIVE',
// currentPeriodStart: '2024-04-12T...',
// currentPeriodEnd: '2024-05-12T...'
// }
How to Use in Backend
Create Plan (Admin)
INSERT INTO "Plan" (id, tier, name, "priceMonthlyVND", "priceYearlyVND", "maxListings", features, "isActive")
VALUES (
'cuid123',
'AGENT_PRO',
'Agent Pro',
499000,
4990000,
50,
'{"analytics": true, "aiValuation": true}',
true
);
Create Payment (via API)
POST /payments
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"provider": "VNPAY",
"type": "SUBSCRIPTION",
"amountVND": 499000,
"description": "Agent Pro - Monthly",
"returnUrl": "https://goodgo.vn/payment-return",
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000"
}
Response:
{
"paymentId": "cuid456",
"paymentUrl": "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?...",
"providerTxId": "cuid456"
}
Handle Payment Callback (Webhook)
POST /payments/callback/vnpay?vnp_TxnRef=cuid456&vnp_ResponseCode=00&vnp_SecureHash=...
Response:
{
"orderId": "cuid456",
"isSuccess": true,
"status": "COMPLETED"
}
Create Subscription (via API)
POST /subscriptions
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"planTier": "AGENT_PRO",
"billingCycle": "monthly"
}
Response:
{
"subscriptionId": "cuid789",
"planTier": "AGENT_PRO",
"status": "ACTIVE",
"currentPeriodStart": "2024-04-12T...",
"currentPeriodEnd": "2024-05-12T..."
}
Pricing Structure
FREE (0 VND)
├── 3 listings
├── 5 saved searches
└── Basic features
AGENT_PRO (499,000 VND/month | 4,990,000/year = -17%)
├── 50 listings
├── 30 saved searches
├── Analytics
├── AI Valuation
├── Priority support
└── Lead management
INVESTOR (999,000 VND/month | 9,990,000/year = -17%)
├── 20 listings
├── 100 saved searches
├── Analytics
├── AI Valuation
├── Market reports
├── Price alerts
└── Portfolio tracking
ENTERPRISE (4,990,000 VND/month | 49,900,000/year = -17%)
├── Unlimited listings
├── Unlimited searches
├── All INVESTOR features
├── API access
├── White label
└── Dedicated support
Environment Variables
# Backend (.env)
VNPAY_TMN_CODE=your_tmn_code
VNPAY_HASH_SECRET=your_hash_secret
VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction
MOMO_PARTNER_CODE=your_partner_code
MOMO_ACCESS_KEY=your_access_key
MOMO_SECRET_KEY=your_secret_key
MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api
ZALOPAY_APP_ID=your_app_id
ZALOPAY_KEY1=your_key1
ZALOPAY_KEY2=your_key2
ZALOPAY_ENDPOINT=https://sandbox.zalopay.com.vn
# Frontend (.env.local)
NEXT_PUBLIC_APP_URL=https://goodgo.vn
Testing Credentials
VNPay Sandbox
Terminal: 0
Account: 0968323286
Password: 123456
Card: 9704198526191432198
OTP: 123456
MoMo Sandbox
Phone: 0987654321
Password: 123456
OTP: 123456
ZaloPay Sandbox
Phone: 0987654321
OTP: 123456
Common Errors
| Error | Cause | Solution |
|---|---|---|
ConflictException: User already has active subscription |
User trying to create 2nd subscription | Check existing subscription first |
ValidationException: Số tiền phải lớn hơn 0 |
Amount is 0 or negative | Ensure amount > 0 |
NotFoundException: Plan not found |
Plan tier doesn't exist in DB | Check plan is created and isActive=true |
Payment gateway failed |
Payment gateway credentials wrong | Verify ENV vars |
Cannot complete payment in status X |
Payment already completed/failed | Check idempotencyKey |
Idempotency check failed |
Same idempotencyKey used twice | Generate unique UUID each time |
Debugging Checklist
- Check payment provider credentials in .env
- Verify idempotencyKey is unique per request
- Ensure amountVND matches plan price
- Check returnUrl is publicly accessible
- Verify JWT token is valid when calling protected endpoints
- Check payment status with
GET /payments/:id - Review payment provider logs/dashboard
- Test with sandbox credentials first
- Verify callback signature matches gateway requirements
- Check subscription was created after successful payment
Links
- Detailed Audit:
PRICING_CHECKOUT_AUDIT.md - Summary:
PRICING_AUDIT_SUMMARY.md - Pricing Page:
apps/web/app/[locale]/(public)/pricing/page.tsx - Subscriptions Module:
apps/api/src/modules/subscriptions/ - Payments Module:
apps/api/src/modules/payments/ - Schema:
prisma/schema.prisma(lines 451-514)