feat: comprehensive seed, Lucide icons, grouped dashboard nav, API fixes

- Rewrite prisma/seed.ts to populate all 27 models with realistic
  Vietnamese real estate data (8 users with login, 10 properties,
  10 listings, orders, payments, reviews, notifications, etc.)
- Replace all emoji icons with Lucide React SVG icons across frontend
  for consistent rendering, sizing, and accessibility
- Redesign dashboard nav: grouped sidebar with section headers,
  primary/secondary split on desktop, icon-only secondary items
- Replace language switcher flag emoji with Globe icon
- Replace SVG theme toggle with Lucide Moon/Sun icons
- Fix API startup: graceful fallback for Sentry profiling, Google OAuth,
  and Zalo OAuth when credentials are not configured
- Relax rate limiting in development mode (10k req/min)
- Fix listings API to include media[] array in search response
- Add optional chaining for property.media across frontend components
- Update OAuth strategy tests to match graceful fallback behavior

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-13 11:13:04 +07:00
parent db0fe8b9b7
commit a9fa214544
22 changed files with 876 additions and 744 deletions

View File

@@ -1,415 +1,192 @@
# Quick Reference: Pricing/Subscription/Payment System
# GoodGo Platform - Authentication Quick Reference
## Files at a Glance
## 🔑 Key Points 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)
### Password Hashing
```
GET /subscriptions/plans
GET /subscriptions/plans/:tier
Algorithm: bcrypt
Salt Rounds: 12 (env: BCRYPT_ROUNDS)
Min Length: 8 characters
Example: bcrypt.hash('password', 12)
```
### Subscriptions (Auth Required)
### Phone Numbers (Vietnamese)
```
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
Valid Formats: 0900000001, 84900000001, +84900000001
Normalized: +84900000001
Regex: /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/
File: apps/api/src/modules/shared/utils/vietnam-phone.validator.ts
```
### Payments (Auth + Webhook)
### Email
```
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)
Regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
Normalization: lowercase + trim
Storage: admin@goodgo.vn
```
### PII Encryption
```
Algorithm: AES-256-GCM
Key: 32 bytes (64 hex chars)
Encrypted: email, phone, kycData
Searchable: email → emailHash (HMAC-SHA256)
phone → phoneHash (HMAC-SHA256)
Env Var: FIELD_ENCRYPTION_KEY
```
### User Login
```
Username: phone (normalized)
Password: plain text
Lookup: by phoneHash (unique index)
Required: isActive = true, passwordHash ≠ null
Response: tokens (or MFA challenge)
```
### User Roles
```
BUYER - Search, inquire, offer (default)
SELLER - Create listings
AGENT - Professional agent
ADMIN - Full access
```
### MFA
```
TOTP: otplib (RFC 6238)
Period: 30 seconds
Digits: 6
Backup Codes: 10 × 8 chars (A-Z no OI, 2-9 no 01)
Hashing: HMAC-SHA256 (not bcrypt)
```
---
## Type Definitions
## 📋 Creating a Login-Capable Admin User
### Frontend Types
### 5-Step Process
**1. Normalize phone**
```typescript
// 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;
}
phone = '0900000001' '+84900000001'
```
---
## How to Use in Frontend
### Get Plans
**2. Derive HMAC key**
```typescript
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>
);
}
hmacKey = crypto.hkdfSync('sha256', Buffer.from(encryptionKey, 'hex'),
Buffer.alloc(0), Buffer.from('goodgo-field-hash', 'utf8'), 32)
```
### Create Payment
**3. Compute hashes**
```typescript
import { paymentApi } from '@/lib/payment-api';
import { useMutation } from '@tanstack/react-query';
phoneHash = crypto.createHmac('sha256', hmacKey).update('+84900000001').digest('hex')
emailHash = crypto.createHmac('sha256', hmacKey).update('admin@goodgo.vn').digest('hex')
```
const createPaymentMutation = useMutation({
mutationFn: (payload) => paymentApi.createPayment(payload),
**4. Hash password**
```typescript
passwordHash = await bcrypt.hash('AdminPassword123', 12)
```
**5. Create user**
```typescript
await prisma.user.create({
data: {
id: 'admin-seed-001',
phone: '+84900000001',
phoneHash,
email: 'admin@goodgo.vn',
emailHash,
passwordHash,
fullName: 'Admin',
role: 'ADMIN',
kycStatus: 'VERIFIED',
isActive: true,
totpEnabled: false,
totpBackupCodes: [],
},
});
// 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)
```typescript
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
```typescript
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)
```sql
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
## 🧪 Test Login
```bash
# 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
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{
"phone": "0900000001",
"password": "AdminPassword123"
}'
```
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
**Success Response:**
```json
{
"requiresMfa": false,
"tokens": {
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"expiresIn": 3600
}
}
```
---
## Testing Credentials
## ⚠️ Common Issues
### 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
```
| Issue | Fix |
|-------|-----|
| User can't login | Check: `passwordHash` ≠ null, `isActive` = true |
| "Invalid phone" | Phone must match regex (mobile only) |
| Hash mismatch | Verify `FIELD_ENCRYPTION_KEY` is consistent |
| MFA issue | Verify `MFA_BACKUP_CODE_SECRET` env var |
| PII not encrypted | Verify key is exactly 32 bytes (64 hex chars) |
---
## Common Errors
## 📁 Key Files
| 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 |
| File | Purpose |
|------|---------|
| `hashed-password.vo.ts` | bcrypt hashing |
| `vietnam-phone.validator.ts` | Phone validation |
| `field-encryption.ts` | AES-256-GCM encryption |
| `local.strategy.ts` | Login endpoint |
| `mfa.service.ts` | TOTP / backup codes |
| `user.entity.ts` | User domain model |
| `prisma-user.repository.ts` | User persistence |
| `seed.ts` | Seed script |
---
## Debugging Checklist
## 🔐 Checklist for Seed User
- [ ] 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
- [ ] Password ≥ 8 chars
- [ ] Phone matches regex
- [ ] Phone normalized: +84...
- [ ] Phone hashed: HMAC-SHA256
- [ ] Email lowercased
- [ ] Email hashed: HMAC-SHA256
- [ ] Password hashed: bcrypt (12 rounds)
- [ ] `isActive: true`
- [ ] `passwordHash` ≠ null
- [ ] `totpEnabled: false`
- [ ] `totpBackupCodes: []`
---
## Links
## 📚 Full Documentation Files
- 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)
1. **AUTHENTICATION_GUIDE.md** - Complete technical reference
2. **AUTH_IMPLEMENTATION_CHECKLIST.md** - Implementation checklist & troubleshooting
3. **SEED_GENERATION_SCRIPT.ts** - Ready-to-use seed script
4. **QUICK_REFERENCE.md** - This file
---
**Last Updated:** April 12, 2026
**Status:** ✅ Production-Ready