feat(billing): add CNY overage rate field and auto-detect currency from locale

- Add overage_rate_fen_per_m_token_cny column (migration 006)
- Plan entity and seed updated with CNY overage rates (Pro ¥58, Enterprise ¥36)
- upsertSeedPlans now updates existing plans (not insert-only)
- Plan controller exposes overageRateCnyPerMToken
- Frontend: currency auto-selects from i18n locale (zh→CNY, en→USD)
- Frontend: Intl.NumberFormat for proper currency formatting
- Currency toggle redesigned as pill selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 02:18:25 -08:00
parent af1cae9da8
commit 60cf49432e
6 changed files with 54 additions and 25 deletions

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
@ -15,6 +15,7 @@ interface Plan {
monthlyPriceCny: number;
includedTokensPerMonth: number;
overageRateUsdPerMToken: number;
overageRateCnyPerMToken: number;
maxServers: number;
maxUsers: number;
maxStandingOrders: number;
@ -27,6 +28,16 @@ interface Subscription {
status: string;
}
type Currency = 'USD' | 'CNY';
function formatCurrency(amount: number, currency: Currency): string {
return new Intl.NumberFormat(currency === 'CNY' ? 'zh-CN' : 'en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount);
}
function formatTokens(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
@ -36,8 +47,17 @@ function formatTokens(n: number) {
export default function PlansPage() {
const queryClient = useQueryClient();
const router = useRouter();
const { t } = useTranslation('billing');
const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD');
const { t, i18n } = useTranslation('billing');
// Default currency based on locale: zh → CNY, others → USD
const defaultCurrency: Currency = i18n.language === 'zh' ? 'CNY' : 'USD';
const [currency, setCurrency] = useState<Currency>(defaultCurrency);
// Sync currency when language changes
useEffect(() => {
setCurrency(i18n.language === 'zh' ? 'CNY' : 'USD');
}, [i18n.language]);
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const { data: plans = [] } = useQuery<Plan[]>({
@ -79,19 +99,20 @@ export default function PlansPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('plans.title')}</h1>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg text-sm">
{(['USD', 'CNY'] as Currency[]).map((c) => (
<button
onClick={() => setCurrency('USD')}
className={`px-3 py-1 rounded-full ${currency === 'USD' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
key={c}
onClick={() => setCurrency(c)}
className={`px-3 py-1 rounded-md transition-colors ${
currency === c
? 'bg-background shadow text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
}`}
>
USD
</button>
<button
onClick={() => setCurrency('CNY')}
className={`px-3 py-1 rounded-full ${currency === 'CNY' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
>
CNY
{c}
</button>
))}
</div>
</div>
@ -100,7 +121,7 @@ export default function PlansPage() {
const isCurrent = plan.name === currentPlanName;
const isSelected = plan.name === selectedPlan;
const price = currency === 'CNY' ? plan.monthlyPriceCny : plan.monthlyPriceUsd;
const symbol = currency === 'CNY' ? '¥' : '$';
const overageRate = currency === 'CNY' ? plan.overageRateCnyPerMToken : plan.overageRateUsdPerMToken;
const features = t(`plans.features.${plan.name}`, { returnObjects: true }) as string[];
const isEnterprise = plan.name === 'enterprise';
@ -133,7 +154,7 @@ export default function PlansPage() {
<div>
<h3 className="text-xl font-bold">{plan.displayName}</h3>
<div className="flex items-end gap-1 mt-2">
<span className="text-3xl font-bold">{symbol}{price.toFixed(2)}</span>
<span className="text-3xl font-bold">{formatCurrency(price, currency)}</span>
<span className="text-muted-foreground text-sm mb-1">{t('plans.perMonth')}</span>
</div>
{plan.trialDays > 0 && (
@ -146,13 +167,9 @@ export default function PlansPage() {
<Zap className="h-4 w-4 text-primary" />
{formatTokens(plan.includedTokensPerMonth)} {t('plans.tokensPerMonth')}
</div>
{plan.overageRateUsdPerMToken > 0 && (
{overageRate > 0 && (
<p className="text-xs text-muted-foreground pl-6">
{t('plans.overage', {
rate: currency === 'CNY'
? `¥${(plan.overageRateUsdPerMToken * 7.2).toFixed(2)}`
: `$${plan.overageRateUsdPerMToken.toFixed(2)}`,
})}
{t('plans.overage', { rate: formatCurrency(overageRate, currency) })}
</p>
)}
</div>

View File

@ -21,7 +21,10 @@ export class Plan {
includedTokens!: number; // tokens per month included in plan
@Column({ name: 'overage_rate_cents_per_m_token', type: 'int', default: 0 })
overageRateCentsPerMTokenUsd!: number; // price per 1M overage tokens in cents
overageRateCentsPerMTokenUsd!: number; // price per 1M overage tokens in USD cents
@Column({ name: 'overage_rate_fen_per_m_token_cny', type: 'int', default: 0 })
overageRateFenPerMTokenCny!: number; // price per 1M overage tokens in CNY fen
@Column({ name: 'max_servers', type: 'int', default: 5 })
maxServers!: number; // -1 = unlimited

View File

@ -31,6 +31,8 @@ export class PlanRepository {
const existing = await this.repo.findOne({ where: { name: p.name } });
if (!existing) {
await this.repo.save(this.repo.create(p));
} else {
await this.repo.save(Object.assign(existing, p));
}
}
}

View File

@ -9,6 +9,7 @@ const SEED_PLANS = [
monthlyPriceFenCny: 0,
includedTokens: 100_000,
overageRateCentsPerMTokenUsd: 0,
overageRateFenPerMTokenCny: 0,
maxServers: 5,
maxUsers: 3,
maxStandingOrders: 10,
@ -23,6 +24,7 @@ const SEED_PLANS = [
monthlyPriceFenCny: 34900, // ¥349.00
includedTokens: 1_000_000,
overageRateCentsPerMTokenUsd: 800, // $8.00 per MTok
overageRateFenPerMTokenCny: 5800, // ¥58.00 per MTok
maxServers: 50,
maxUsers: 20,
maxStandingOrders: 100,
@ -37,6 +39,7 @@ const SEED_PLANS = [
monthlyPriceFenCny: 139900, // ¥1399.00
includedTokens: 10_000_000,
overageRateCentsPerMTokenUsd: 500, // $5.00 per MTok
overageRateFenPerMTokenCny: 3600, // ¥36.00 per MTok
maxServers: -1,
maxUsers: -1,
maxStandingOrders: -1,

View File

@ -16,6 +16,7 @@ export class PlanController {
monthlyPriceCny: p.monthlyPriceFenCny / 100,
includedTokensPerMonth: p.includedTokens,
overageRateUsdPerMToken: p.overageRateCentsPerMTokenUsd / 100,
overageRateCnyPerMToken: p.overageRateFenPerMTokenCny / 100,
maxServers: p.maxServers,
maxUsers: p.maxUsers,
maxStandingOrders: p.maxStandingOrders,

View File

@ -0,0 +1,3 @@
-- Add CNY overage rate to billing_plans
ALTER TABLE billing_plans
ADD COLUMN IF NOT EXISTS overage_rate_fen_per_m_token_cny INTEGER NOT NULL DEFAULT 0;