feat(desktop): add billing frontend — plans, subscription, payment flow

Sprint 1: Desktop 计费闭环

- Add 7 billing types to saas-types.ts (BillingPlan, Subscription, UsageQuota, etc.)
- Add 6 billing API methods to saas-billing.ts (listPlans, getSubscription, createPayment, etc.)
- Extend saas-client.ts with interface merging for billing methods
- Extend saasStore with billing state/actions (plans, subscription, payment polling)
- Create PricingPage component with plan cards, usage bars, and checkout modal
- Add billing page entry in SettingsLayout (CreditCard icon + route)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-04 10:48:33 +08:00
parent be0a78a523
commit eac1d9449e
6 changed files with 670 additions and 3 deletions

View File

@@ -0,0 +1,393 @@
/**
* PricingPage — 订阅与计费页面
*
* 展示三层定价计划Free / Pro / Team
* 当前订阅状态和用量配额,
* 支付入口。
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSaaSStore } from '../../store/saasStore';
import {
CheckCircle,
Zap,
Users,
Rocket,
Loader2,
AlertCircle,
X,
} from 'lucide-react';
// === Constants ===
const PLAN_COLORS: Record<string, string> = {
free: '#8c8c8c',
pro: '#863bff',
team: '#47bfff',
};
const PLAN_ICONS: Record<string, React.ReactNode> = {
free: <Rocket className="w-6 h-6" />,
pro: <Zap className="w-6 h-6" />,
team: <Users className="w-6 h-6" />,
};
const PLAN_FEATURES: Record<string, string[]> = {
free: ['本地模型对话', '2 个 Hand (Quiz/Speech)', '3 个 Pipeline 模板', '基础记忆'],
pro: ['SaaS Relay 代理', '全部 9 个 Hands', '无限 Pipeline', '完整记忆系统', '优先模型', '14 天免费试用'],
team: ['Pro 全部功能', '多人管理', 'Admin 面板', 'API Key 统管', '优先支持', '团队共享模板'],
};
// === Sub-components ===
function UsageBar({ label, current, max }: { label: string; current: number; max: number | null }) {
const pct = max ? Math.min((current / max) * 100, 100) : 0;
const displayMax = max ? max.toLocaleString() : '∞';
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500';
return (
<div className="mb-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{label}</span>
<span>{current.toLocaleString()} / {displayMax}</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${Math.max(pct, 1)}%` }}
/>
</div>
</div>
);
}
function PlanCard({
plan,
isCurrent,
priceYuan,
onSelect,
}: {
plan: { id: string; name: string; display_name: string; description: string | null; price_cents: number; interval: string };
isCurrent: boolean;
priceYuan: string;
onSelect: () => void;
}) {
const color = PLAN_COLORS[plan.name] || '#666';
const features = PLAN_FEATURES[plan.name] || [];
return (
<div
className={`relative flex flex-col rounded-xl border-2 p-6 transition-all duration-200 hover:shadow-lg ${
isCurrent ? 'shadow-md' : 'border-gray-200'
}`}
style={isCurrent ? { borderColor: color } : {}}
>
{isCurrent && (
<div
className="absolute -top-0 right-4 px-3 py-1 text-xs font-medium text-white rounded-b-lg"
style={{ background: color }}
>
</div>
)}
<div className="text-center mb-5">
<div style={{ color }} className="mb-2 flex justify-center">
{PLAN_ICONS[plan.name]}
</div>
<h3 className="text-lg font-bold text-gray-900">{plan.display_name}</h3>
{plan.description && (
<p className="text-xs text-gray-500 mt-1">{plan.description}</p>
)}
</div>
<div className="text-center mb-5">
<span className="text-3xl font-bold" style={{ color }}>
¥{priceYuan}
</span>
<span className="text-gray-500 text-sm"> /{plan.interval === 'month' ? '月' : '年'}</span>
</div>
<ul className="space-y-2.5 text-sm flex-1 mb-6">
{features.map((f) => (
<li key={f} className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
<span className="text-gray-700">{f}</span>
</li>
))}
</ul>
<button
onClick={onSelect}
disabled={isCurrent}
className={`w-full py-2.5 rounded-lg text-sm font-medium transition-colors ${
isCurrent
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'text-white hover:opacity-90'
}`}
style={!isCurrent ? { background: color } : {}}
>
{isCurrent ? '当前计划' : '升级'}
</button>
</div>
);
}
// === Checkout Modal ===
function CheckoutModal({
plan,
onClose,
onConfirm,
isLoading,
payResult,
}: {
plan: { id: string; display_name: string; name: string; price_cents: number };
onClose: () => void;
onConfirm: (method: 'alipay' | 'wechat') => void;
isLoading: boolean;
payResult: { pay_url: string; amount_cents: number } | null;
}) {
const [method, setMethod] = useState<'alipay' | 'wechat'>('alipay');
const color = PLAN_COLORS[plan.name] || '#666';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900">
{plan.display_name}
</h3>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
{/* Body */}
<div className="p-5">
{payResult ? (
<div className="text-center py-6">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color }} />
<p className="font-medium text-gray-900 mb-1">...</p>
<p className="text-sm text-gray-500">
<br />
: ¥{(payResult.amount_cents / 100).toFixed(2)}
</p>
</div>
) : (
<>
<div className="text-center mb-6">
<span className="text-2xl font-bold" style={{ color }}>
¥{(plan.price_cents / 100).toFixed(0)}
</span>
<span className="text-gray-500 text-sm"> /</span>
</div>
<p className="text-sm font-medium text-gray-900 text-center mb-3"></p>
<div className="space-y-3">
<button
onClick={() => setMethod('alipay')}
className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-colors ${
method === 'alipay' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-bold"></div>
<div className="text-left">
<div className="font-medium text-sm"></div>
<div className="text-xs text-gray-400"></div>
</div>
</button>
<button
onClick={() => setMethod('wechat')}
className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-colors ${
method === 'wechat' ? 'border-green-500 bg-green-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="w-8 h-8 rounded-full bg-green-600 flex items-center justify-center text-white text-xs font-bold"></div>
<div className="text-left">
<div className="font-medium text-sm"></div>
<div className="text-xs text-gray-400"></div>
</div>
</button>
</div>
</>
)}
</div>
{/* Footer */}
{!payResult && (
<div className="p-5 border-t border-gray-100">
<button
onClick={() => onConfirm(method)}
disabled={isLoading}
className="w-full py-2.5 rounded-lg text-white text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
style={{ background: color }}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
'确认支付'
)}
</button>
</div>
)}
</div>
</div>
);
}
// === Main Page ===
export function PricingPage() {
const plans = useSaaSStore((s) => s.plans);
const subscription = useSaaSStore((s) => s.subscription);
const billingLoading = useSaaSStore((s) => s.billingLoading);
const billingError = useSaaSStore((s) => s.billingError);
const fetchBillingOverview = useSaaSStore((s) => s.fetchBillingOverview);
const createPaymentAction = useSaaSStore((s) => s.createPayment);
const pollPaymentStatus = useSaaSStore((s) => s.pollPaymentStatus);
const clearBillingError = useSaaSStore((s) => s.clearBillingError);
const [selectedPlan, setSelectedPlan] = useState<{
id: string; name: string; display_name: string; price_cents: number;
} | null>(null);
const [payResult, setPayResult] = useState<{
payment_id: string; pay_url: string; amount_cents: number;
} | null>(null);
const [paying, setPaying] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load billing data on mount
useEffect(() => {
fetchBillingOverview().catch(() => {});
}, [fetchBillingOverview]);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const currentPlanName = subscription?.plan?.name || 'free';
const usage = subscription?.usage;
const handleSelectPlan = useCallback((plan: typeof plans[number]) => {
if (plan.price_cents === 0) return;
setSelectedPlan(plan);
setPayResult(null);
}, []);
const handleConfirmPay = useCallback(async (method: 'alipay' | 'wechat') => {
if (!selectedPlan) return;
setPaying(true);
try {
const result = await createPaymentAction(selectedPlan.id, method);
if (result) {
setPayResult(result);
window.open(result.pay_url, '_blank', 'width=480,height=640');
// Start polling
pollRef.current = setInterval(async () => {
const status = await pollPaymentStatus(result.payment_id);
if (status?.status === 'succeeded') {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
setPayResult(null);
setSelectedPlan(null);
fetchBillingOverview().catch(() => {});
} else if (status?.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
clearBillingError();
setPayResult(null);
}
}, 3000);
}
} catch {
// Error already in store
} finally {
setPaying(false);
}
}, [selectedPlan, createPaymentAction, pollPaymentStatus, fetchBillingOverview, clearBillingError]);
const handleCloseCheckout = useCallback(() => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
setSelectedPlan(null);
setPayResult(null);
}, []);
return (
<div className="max-w-4xl">
<h1 className="text-xl font-bold text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6"></p>
{/* Error banner */}
{billingError && (
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 flex items-center gap-2 text-sm text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{billingError}</span>
<button onClick={clearBillingError} className="ml-auto p-1 hover:bg-red-100 rounded">
<X className="w-3 h-3" />
</button>
</div>
)}
{/* Current usage */}
{usage && (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mb-6">
<h2 className="text-sm font-semibold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<UsageBar label="中转请求" current={usage.relay_requests} max={usage.max_relay_requests} />
<UsageBar label="Hand 执行" current={usage.hand_executions} max={usage.max_hand_executions} />
<UsageBar label="Pipeline 运行" current={usage.pipeline_runs} max={usage.max_pipeline_runs} />
</div>
{subscription?.subscription && (
<p className="mt-3 text-xs text-gray-400">
: {new Date(subscription.subscription.current_period_start).toLocaleDateString()} {new Date(subscription.subscription.current_period_end).toLocaleDateString()}
</p>
)}
</div>
)}
{/* Plan cards */}
<h2 className="text-sm font-semibold text-gray-900 mb-4"></h2>
{billingLoading && !plans.length ? (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
isCurrent={plan.name === currentPlanName}
priceYuan={plan.price_cents === 0 ? '0' : (plan.price_cents / 100).toFixed(0)}
onSelect={() => handleSelectPlan(plan)}
/>
))}
</div>
)}
{/* Checkout modal */}
{selectedPlan && (
<CheckoutModal
plan={selectedPlan}
onClose={handleCloseCheckout}
onConfirm={handleConfirmPay}
isLoading={paying}
payResult={payResult}
/>
)}
</div>
);
}

View File

@@ -19,6 +19,7 @@ import {
Key,
Database,
Cloud,
CreditCard,
} from 'lucide-react';
import { silentErrorHandler } from '../../lib/error-utils';
import { General } from './General';
@@ -39,6 +40,7 @@ import { HeartbeatConfig } from '../HeartbeatConfig';
import { SecureStorage } from './SecureStorage';
import { VikingPanel } from '../VikingPanel';
import { SaaSSettings } from '../SaaS/SaaSSettings';
import { PricingPage } from '../SaaS/PricingPage';
interface SettingsLayoutProps {
onBack: () => void;
@@ -57,6 +59,7 @@ type SettingsPage =
| 'security'
| 'storage'
| 'saas'
| 'billing'
| 'viking'
| 'audit'
| 'tasks'
@@ -76,6 +79,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] =
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
{ id: 'storage', label: '安全存储', icon: <Key className="w-4 h-4" /> },
{ id: 'saas', label: 'SaaS 平台', icon: <Cloud className="w-4 h-4" /> },
{ id: 'billing', label: '订阅与计费', icon: <CreditCard className="w-4 h-4" /> },
{ id: 'viking', label: '语义记忆', icon: <Database className="w-4 h-4" /> },
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
@@ -102,6 +106,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
case 'privacy': return <Privacy />;
case 'storage': return <SecureStorage />;
case 'saas': return <SaaSSettings />;
case 'billing': return <PricingPage />;
case 'security': return (
<div className="space-y-6">
<div>

View File

@@ -1,10 +1,20 @@
/**
* SaaS Billing Methods — Mixin
*
* Installs billing-related methods (usage increment, quota check) onto
* SaaSClient.prototype. Uses the same mixin pattern as saas-telemetry.ts.
* Installs billing-related methods onto SaaSClient.prototype.
* Covers usage increment (fire-and-forget) and full billing CRUD
* (plans, subscription, usage, payments).
*/
import type {
BillingPlan,
SubscriptionInfo,
UsageQuota,
CreatePaymentRequest,
PaymentResult,
PaymentStatus,
} from './saas-types';
export interface UsageIncrementResult {
dimension: string;
incremented: number;
@@ -18,9 +28,13 @@ export interface UsageIncrementResult {
};
}
type RequestFn = <T>(method: string, path: string, body?: unknown) => Promise<T>;
export function installBillingMethods(ClientClass: { prototype: any }): void {
const proto = ClientClass.prototype;
// --- Usage Increment ---
/**
* Report a usage increment for a specific dimension.
*
@@ -52,9 +66,61 @@ export function installBillingMethods(ClientClass: { prototype: any }): void {
count: number = 1,
): void {
this.incrementUsageDimension(dimension, count).catch((err: unknown) => {
// Non-fatal: billing reporting failure must never block user operations
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Billing] Failed to report ${dimension} usage (+${count}): ${msg}`);
});
};
// --- Plans ---
/** List all active billing plans (public, no auth required) */
proto.listPlans = async function (
this: { request: RequestFn },
): Promise<BillingPlan[]> {
return this.request<BillingPlan[]>('GET', '/api/v1/billing/plans');
};
/** Get a single plan by ID */
proto.getPlan = async function (
this: { request: RequestFn },
planId: string,
): Promise<BillingPlan> {
return this.request<BillingPlan>('GET', `/api/v1/billing/plans/${planId}`);
};
// --- Subscription ---
/** Get current subscription info (plan + subscription + usage) */
proto.getSubscription = async function (
this: { request: RequestFn },
): Promise<SubscriptionInfo> {
return this.request<SubscriptionInfo>('GET', '/api/v1/billing/subscription');
};
// --- Usage ---
/** Get current month's usage quota */
proto.getUsage = async function (
this: { request: RequestFn },
): Promise<UsageQuota> {
return this.request<UsageQuota>('GET', '/api/v1/billing/usage');
};
// --- Payments ---
/** Create a payment order for a plan upgrade */
proto.createPayment = async function (
this: { request: RequestFn },
data: CreatePaymentRequest,
): Promise<PaymentResult> {
return this.request<PaymentResult>('POST', '/api/v1/billing/payments', data);
};
/** Check payment status by payment ID */
proto.getPaymentStatus = async function (
this: { request: RequestFn },
paymentId: string,
): Promise<PaymentStatus> {
return this.request<PaymentStatus>('GET', `/api/v1/billing/payments/${paymentId}`);
};
}

View File

@@ -115,6 +115,18 @@ import { installRelayMethods } from './saas-relay';
import { installPromptMethods } from './saas-prompt';
import { installTelemetryMethods } from './saas-telemetry';
import { installBillingMethods } from './saas-billing';
export type { UsageIncrementResult } from './saas-billing';
// Re-export billing types for convenience
export type {
BillingPlan,
Subscription,
UsageQuota,
SubscriptionInfo,
CreatePaymentRequest,
PaymentResult,
PaymentStatus,
} from './saas-types';
// === Client Implementation ===
@@ -448,6 +460,7 @@ installRelayMethods(SaaSClient);
installPromptMethods(SaaSClient);
installTelemetryMethods(SaaSClient);
installBillingMethods(SaaSClient);
export { installBillingMethods };
// === API Method Type Declarations ===
// These methods are installed at runtime by installXxxMethods() in saas-*.ts.
@@ -506,6 +519,12 @@ export interface SaaSClient {
// --- Billing (saas-billing.ts) ---
incrementUsageDimension(dimension: string, count?: number): Promise<import('./saas-billing').UsageIncrementResult>;
reportUsageFireAndForget(dimension: string, count?: number): void;
listPlans(): Promise<import('./saas-types').BillingPlan[]>;
getPlan(planId: string): Promise<import('./saas-types').BillingPlan>;
getSubscription(): Promise<import('./saas-types').SubscriptionInfo>;
getUsage(): Promise<import('./saas-types').UsageQuota>;
createPayment(data: import('./saas-types').CreatePaymentRequest): Promise<import('./saas-types').PaymentResult>;
getPaymentStatus(paymentId: string): Promise<import('./saas-types').PaymentStatus>;
}
// === Singleton ===

View File

@@ -468,6 +468,91 @@ export interface AssignTemplateRequest {
template_id: string;
}
// === Billing Types ===
/** Subscription plan definition */
export interface BillingPlan {
id: string;
name: string;
display_name: string;
description: string | null;
price_cents: number;
currency: string;
interval: string;
features: Record<string, unknown>;
limits: Record<string, unknown>;
is_default: boolean;
sort_order: number;
status: string;
created_at: string;
updated_at: string;
}
/** User subscription record */
export interface Subscription {
id: string;
account_id: string;
plan_id: string;
status: string;
current_period_start: string;
current_period_end: string;
trial_end: string | null;
canceled_at: string | null;
cancel_at_period_end: boolean;
created_at: string;
updated_at: string;
}
/** Monthly usage quota tracking */
export interface UsageQuota {
id: string;
account_id: string;
period_start: string;
period_end: string;
input_tokens: number;
output_tokens: number;
relay_requests: number;
hand_executions: number;
pipeline_runs: number;
max_input_tokens: number | null;
max_output_tokens: number | null;
max_relay_requests: number | null;
max_hand_executions: number | null;
max_pipeline_runs: number | null;
created_at: string;
updated_at: string;
}
/** Combined subscription info (plan + subscription + usage) */
export interface SubscriptionInfo {
plan: BillingPlan;
subscription: Subscription | null;
usage: UsageQuota;
}
/** Payment creation request */
export interface CreatePaymentRequest {
plan_id: string;
payment_method: 'alipay' | 'wechat';
}
/** Payment creation result */
export interface PaymentResult {
payment_id: string;
trade_no: string;
pay_url: string;
amount_cents: number;
}
/** Payment status query result */
export interface PaymentStatus {
id: string;
method: string;
amount_cents: number;
currency: string;
status: string;
}
/** Agent configuration derived from a template.
* capabilities are merged into tools (no separate field). */
export interface AgentConfigFromTemplate {

View File

@@ -28,6 +28,10 @@ import {
type SyncConfigRequest,
type AgentTemplateAvailable,
type AgentTemplateFull,
type BillingPlan,
type SubscriptionInfo,
type PaymentResult,
type PaymentStatus,
} from '../lib/saas-client';
import { createLogger } from '../lib/logger';
import {
@@ -80,6 +84,12 @@ export interface SaaSStateSlice {
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
// === Billing State ===
plans: BillingPlan[];
subscription: SubscriptionInfo | null;
billingLoading: boolean;
billingError: string | null;
}
export interface SaaSActionsSlice {
@@ -101,6 +111,13 @@ export interface SaaSActionsSlice {
unassignTemplate: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
// === Billing Actions ===
fetchPlans: () => Promise<void>;
fetchSubscription: () => Promise<void>;
fetchBillingOverview: () => Promise<void>;
createPayment: (planId: string, method: 'alipay' | 'wechat') => Promise<PaymentResult | null>;
pollPaymentStatus: (paymentId: string) => Promise<PaymentStatus | null>;
clearBillingError: () => void;
setupTotp: () => Promise<TotpSetupResponse>;
verifyTotp: (code: string) => Promise<void>;
disableTotp: (password: string) => Promise<void>;
@@ -157,6 +174,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
assignedTemplate: null,
_consecutiveFailures: 0,
// === Billing State ===
plans: [],
subscription: null,
billingLoading: false,
billingError: null,
// === Actions ===
login: async (saasUrl: string, username: string, password: string) => {
@@ -214,6 +237,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
log.warn('Failed to fetch assigned template after login:', err);
});
// Fetch billing data in background (non-blocking)
get().fetchBillingOverview().catch((err: unknown) => {
log.warn('Failed to fetch billing after login:', err);
});
// Fetch available models in background (non-blocking)
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after login:', err);
@@ -399,6 +427,10 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
availableModels: [],
availableTemplates: [],
assignedTemplate: null,
plans: [],
subscription: null,
billingLoading: false,
billingError: null,
error: null,
totpRequired: false,
totpSetupData: null,
@@ -724,6 +756,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
get().fetchAvailableModels().catch(() => {});
get().fetchAvailableTemplates().catch(() => {});
get().fetchAssignedTemplate().catch(() => {});
get().fetchBillingOverview().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
@@ -780,5 +813,71 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
cancelTotpSetup: () => {
set({ totpSetupData: null });
},
// === Billing Actions ===
fetchPlans: async () => {
try {
const plans = await saasClient.listPlans();
set({ plans });
} catch (err: unknown) {
log.warn('Failed to fetch plans:', err);
}
},
fetchSubscription: async () => {
try {
const sub = await saasClient.getSubscription();
set({ subscription: sub });
} catch (err: unknown) {
log.warn('Failed to fetch subscription:', err);
set({ subscription: null });
}
},
fetchBillingOverview: async () => {
set({ billingLoading: true, billingError: null });
try {
const [plans, sub] = await Promise.all([
saasClient.listPlans(),
saasClient.getSubscription(),
]);
set({ plans, subscription: sub, billingLoading: false });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ billingLoading: false, billingError: msg });
}
},
createPayment: async (planId: string, method: 'alipay' | 'wechat') => {
set({ billingLoading: true, billingError: null });
try {
const result = await saasClient.createPayment({ plan_id: planId, payment_method: method });
set({ billingLoading: false });
return result;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ billingLoading: false, billingError: msg });
return null;
}
},
pollPaymentStatus: async (paymentId: string) => {
try {
const status = await saasClient.getPaymentStatus(paymentId);
if (status.status === 'succeeded') {
// Refresh subscription after successful payment
await get().fetchSubscription();
}
return status;
} catch (err: unknown) {
log.warn('Failed to poll payment status:', err);
return null;
}
},
clearBillingError: () => {
set({ billingError: null });
},
};
});