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 <manbuzhe2009@qq.com>
This commit is contained in:
Haitao Pan 2026-04-09 14:05:47 +08:00 committed by GitHub
parent 9cf1c167e8
commit 701d790f97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 217 additions and 5 deletions

View File

@ -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<string | null>(null);
const [portalLoading, setPortalLoading] = useState(false);
@ -139,6 +140,9 @@ export default function SubscriptionPanel() {
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
accounts.svc.plus
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{usageSummary.sourceOfTruth || "—"}
</p>
</div>
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
@ -154,6 +158,10 @@ export default function SubscriptionPanel() {
? `${usageSummary.remainingIncludedQuota.toLocaleString()} B`
: "—"}
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{usageSummary.billingProfile?.packageName || billingSummary?.billingProfile?.packageName || "default"}
{usageSummary.billingProfile?.pricingRuleVersion || billingSummary?.billingProfile?.pricingRuleVersion || "—"}
</p>
</div>
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
@ -166,6 +174,47 @@ export default function SubscriptionPanel() {
{usageSummary.syncDelaySeconds ?? 0} {" "}
{accountPolicy?.eligibleNodeGroups?.join(", ") || "—"}
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{usageSummary.arrears ? "欠费" : "正常"} / {usageSummary.throttleState || "—"} / {usageSummary.suspendState || "—"}
</p>
</div>
</div>
) : null}
{billingSummary?.ledger?.length ? (
<div className="mt-4 rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-heading)]">Recent Billing Ledger</h3>
<p className="text-xs text-[var(--color-text-subtle)]">
accounts.svc.plus
</p>
</div>
<p className="text-xs text-[var(--color-text-subtle)]">
{billingSummary.sourceOfTruth || "—"}
</p>
</div>
<div className="mt-3 space-y-2">
{billingSummary.ledger.slice(0, 5).map((entry) => (
<div
key={entry.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[color:var(--color-surface-border)] px-3 py-2 text-sm"
>
<div>
<p className="font-medium text-[var(--color-text)]">{entry.entryType}</p>
<p className="text-xs text-[var(--color-text-subtle)]">
{entry.pricingRuleVersion || "—"} · {entry.bucketStart ? formatDate(entry.bucketStart) : "—"}
</p>
</div>
<div className="text-right">
<p className="font-medium text-[var(--color-text)]">{entry.ratedBytes.toLocaleString()} B</p>
<p className="text-xs text-[var(--color-text-subtle)]">
{typeof entry.amountDelta === "number" ? entry.amountDelta.toFixed(2) : "—"} / {" "}
{typeof entry.balanceAfter === "number" ? entry.balanceAfter.toFixed(2) : "—"}
</p>
</div>
</div>
))}
</div>
</div>
) : null}

View File

@ -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(<SubscriptionPanel />)
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()
})
})

View File

@ -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 }],
})
})
})

View File

@ -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<AccountUsageSummary> {
export function fetchAccountPolicy(): Promise<AccountPolicy> {
return requestJSON<AccountPolicy>('/api/account/policy')
}
export function fetchAccountBillingSummary(): Promise<AccountBillingSummary> {
return requestJSON<AccountBillingSummary>('/api/account/billing/summary')
}

View File

@ -18,11 +18,17 @@ type AgentNodesError = Error & {
status?: number
}
function isAgentNodeErrorPayload(
payload: AgentNodePayload | null,
): payload is Exclude<AgentNodePayload, VlessNode[]> {
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})`