From 701d790f97038bce1fa48bde158d4a4e94197a07 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 9 Apr 2026 14:05:47 +0800 Subject: [PATCH] feat(user-center): show authoritative billing usage details (#70) * feat(user-center): show authoritative billing usage details * fix(user-center): narrow agent node error payload typing --------- Co-authored-by: Haitao Pan --- .../user-center/account/SubscriptionPanel.tsx | 51 ++++++++++- .../__tests__/SubscriptionPanel.test.tsx | 90 +++++++++++++++++++ .../user-center/lib/fetchAccountUsage.test.ts | 29 +++++- .../user-center/lib/fetchAccountUsage.ts | 42 +++++++++ .../user-center/lib/fetchAgentNodes.ts | 10 ++- 5 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx diff --git a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx index 08ff8f4..94245cf 100644 --- a/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx +++ b/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx @@ -5,7 +5,7 @@ import useSWR from "swr"; import { openStripePortal } from "@components/billing/stripe-client"; import Card from "../components/Card"; -import { fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage"; +import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage"; const fetcher = (url: string) => fetch(url, { @@ -50,6 +50,7 @@ export default function SubscriptionPanel() { fetcher, ); const { data: usageSummary } = useSWR("account-usage-summary", fetchAccountUsageSummary); + const { data: billingSummary } = useSWR("account-billing-summary", fetchAccountBillingSummary); const { data: accountPolicy } = useSWR("account-policy", fetchAccountPolicy); const [submitting, setSubmitting] = useState(null); const [portalLoading, setPortalLoading] = useState(false); @@ -139,6 +140,9 @@ export default function SubscriptionPanel() {

统计由 accounts.svc.plus 汇总,非本地客户端计数。

+

+ 数据源:{usageSummary.sourceOfTruth || "—"} +

@@ -154,6 +158,10 @@ export default function SubscriptionPanel() { ? `${usageSummary.remainingIncludedQuota.toLocaleString()} B` : "—"}

+

+ 套餐 {usageSummary.billingProfile?.packageName || billingSummary?.billingProfile?.packageName || "default"}, + 规则 {usageSummary.billingProfile?.pricingRuleVersion || billingSummary?.billingProfile?.pricingRuleVersion || "—"} +

@@ -166,6 +174,47 @@ export default function SubscriptionPanel() { 统计延迟约 {usageSummary.syncDelaySeconds ?? 0} 秒,策略组{" "} {accountPolicy?.eligibleNodeGroups?.join(", ") || "—"}

+

+ 状态 {usageSummary.arrears ? "欠费" : "正常"} / {usageSummary.throttleState || "—"} / {usageSummary.suspendState || "—"} +

+
+ + ) : null} + + {billingSummary?.ledger?.length ? ( +
+
+
+

Recent Billing Ledger

+

+ 展示 accounts.svc.plus 返回的最新按量计费分录。 +

+
+

+ 数据源:{billingSummary.sourceOfTruth || "—"} +

+
+
+ {billingSummary.ledger.slice(0, 5).map((entry) => ( +
+
+

{entry.entryType}

+

+ {entry.pricingRuleVersion || "—"} · {entry.bucketStart ? formatDate(entry.bucketStart) : "—"} +

+
+
+

{entry.ratedBytes.toLocaleString()} B

+

+ {typeof entry.amountDelta === "number" ? entry.amountDelta.toFixed(2) : "—"} / 余额{" "} + {typeof entry.balanceAfter === "number" ? entry.balanceAfter.toFixed(2) : "—"} +

+
+
+ ))}
) : null} diff --git a/src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx b/src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx new file mode 100644 index 0000000..87b66d3 --- /dev/null +++ b/src/modules/extensions/builtin/user-center/account/__tests__/SubscriptionPanel.test.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import SubscriptionPanel from '../SubscriptionPanel' + +vi.mock('swr', () => ({ + default: vi.fn((key: string) => { + if (key === '/api/auth/subscriptions') { + return { + data: { subscriptions: [] }, + isLoading: false, + mutate: vi.fn(), + } + } + if (key === 'account-usage-summary') { + return { + data: { + totalBytes: 384, + currentBalance: 87.5, + remainingIncludedQuota: 2048, + syncDelaySeconds: 12, + arrears: false, + sourceOfTruth: 'postgresql', + billingProfile: { + packageName: 'starter', + pricingRuleVersion: 'pricing-v1', + }, + }, + } + } + if (key === 'account-billing-summary') { + return { + data: { + sourceOfTruth: 'postgresql', + billingProfile: { + packageName: 'starter', + pricingRuleVersion: 'pricing-v1', + }, + ledger: [ + { + id: 'ledger-1', + entryType: 'traffic_charge', + ratedBytes: 50, + amountDelta: -12.5, + balanceAfter: 75, + pricingRuleVersion: 'pricing-v1', + bucketStart: '2026-04-08T10:30:00Z', + }, + ], + }, + } + } + if (key === 'account-policy') { + return { + data: { + preferredStrategy: 'ewma', + eligibleNodeGroups: ['hk-premium'], + }, + } + } + return { data: undefined, isLoading: false, mutate: vi.fn() } + }), +})) + +vi.mock('@components/billing/stripe-client', () => ({ + openStripePortal: vi.fn(), +})) + +vi.mock('../../lib/fetchAccountUsage', () => ({ + fetchAccountUsageSummary: vi.fn(), + fetchAccountBillingSummary: vi.fn(), + fetchAccountPolicy: vi.fn(), +})) + +describe('SubscriptionPanel', () => { + it('renders accounts-backed source-of-truth usage metadata', () => { + render() + + expect(screen.getByText('Authoritative Usage')).toBeInTheDocument() + expect(screen.getByText('统计由 accounts.svc.plus 汇总,非本地客户端计数。')).toBeInTheDocument() + expect(screen.getAllByText('数据源:postgresql')).toHaveLength(2) + expect(screen.getByText('384 B')).toBeInTheDocument() + expect(screen.getByText(/统计延迟约 12 秒/)).toBeInTheDocument() + expect(screen.getByText(/策略组 hk-premium/)).toBeInTheDocument() + expect(screen.getByText((content) => content.includes('套餐') && content.includes('starter') && content.includes('pricing-v1'))).toBeInTheDocument() + expect(screen.getByText('Recent Billing Ledger')).toBeInTheDocument() + expect(screen.getByText(/traffic_charge/)).toBeInTheDocument() + }) +}) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts index 80b7480..86956ac 100644 --- a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts +++ b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage' +import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage' describe('fetchAccountUsage', () => { afterEach(() => { @@ -9,7 +9,7 @@ describe('fetchAccountUsage', () => { it('loads the authoritative usage summary from the account api', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce( - new Response(JSON.stringify({ accountUuid: 'acct-1', totalBytes: 384 }), { + new Response(JSON.stringify({ accountUuid: 'acct-1', totalBytes: 384, sourceOfTruth: 'postgresql' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }), @@ -18,6 +18,7 @@ describe('fetchAccountUsage', () => { await expect(fetchAccountUsageSummary()).resolves.toEqual({ accountUuid: 'acct-1', totalBytes: 384, + sourceOfTruth: 'postgresql', }) }) @@ -34,4 +35,28 @@ describe('fetchAccountUsage', () => { preferredStrategy: 'ewma', }) }) + + it('loads the authoritative billing summary from the account api', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + accountUuid: 'acct-1', + sourceOfTruth: 'postgresql', + billingProfile: { packageName: 'starter', pricingRuleVersion: 'pricing-v1' }, + ledger: [{ id: 'ledger-1', entryType: 'traffic_charge', ratedBytes: 50, amountDelta: -12.5, balanceAfter: 87.5 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + + await expect(fetchAccountBillingSummary()).resolves.toEqual({ + accountUuid: 'acct-1', + sourceOfTruth: 'postgresql', + billingProfile: { packageName: 'starter', pricingRuleVersion: 'pricing-v1' }, + ledger: [{ id: 'ledger-1', entryType: 'traffic_charge', ratedBytes: 50, amountDelta: -12.5, balanceAfter: 87.5 }], + }) + }) }) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts index 320a483..1f77392 100644 --- a/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts +++ b/src/modules/extensions/builtin/user-center/lib/fetchAccountUsage.ts @@ -7,6 +7,7 @@ type AccountUsageError = Error & { export type AccountUsageSummary = { accountUuid: string totalBytes: number + sourceOfTruth?: string uplinkBytes?: number downlinkBytes?: number currentBalance?: number @@ -14,6 +15,8 @@ export type AccountUsageSummary = { syncDelaySeconds?: number suspendState?: string throttleState?: string + arrears?: boolean + billingProfile?: AccountBillingProfile } export type AccountPolicy = { @@ -24,6 +27,41 @@ export type AccountPolicy = { degradeMode?: string } +export type AccountBillingProfile = { + packageName?: string + includedQuotaBytes?: number + basePricePerByte?: number + regionMultiplier?: number + lineMultiplier?: number + pricingRuleVersion?: string +} + +export type BillingLedgerEntry = { + id: string + entryType: string + ratedBytes: number + amountDelta: number + balanceAfter: number + pricingRuleVersion?: string + bucketStart?: string + bucketEnd?: string + createdAt?: string +} + +export type AccountBillingSummary = { + accountUuid: string + sourceOfTruth?: string + quotaState?: { + currentBalance?: number + remainingIncludedQuota?: number + arrears?: boolean + throttleState?: string + suspendState?: string + } + billingProfile?: AccountBillingProfile + ledger?: BillingLedgerEntry[] +} + function toError(payload: unknown, status: number): AccountUsageError { const message = payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string' @@ -59,3 +97,7 @@ export function fetchAccountUsageSummary(): Promise { export function fetchAccountPolicy(): Promise { return requestJSON('/api/account/policy') } + +export function fetchAccountBillingSummary(): Promise { + return requestJSON('/api/account/billing/summary') +} diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts index ea53927..d5b5d28 100644 --- a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts +++ b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts @@ -18,11 +18,17 @@ type AgentNodesError = Error & { status?: number } +function isAgentNodeErrorPayload( + payload: AgentNodePayload | null, +): payload is Exclude { + return !!payload && !Array.isArray(payload) +} + function extractMessage(payload: AgentNodePayload | null, status: number): string { - if (payload && typeof payload.message === 'string' && payload.message.trim().length > 0) { + if (isAgentNodeErrorPayload(payload) && typeof payload.message === 'string' && payload.message.trim().length > 0) { return payload.message } - if (payload && typeof payload.error === 'string' && payload.error.trim().length > 0) { + if (isAgentNodeErrorPayload(payload) && typeof payload.error === 'string' && payload.error.trim().length > 0) { return payload.error } return `Request failed (${status})`