feat(user-center): show authoritative billing usage details
This commit is contained in:
parent
9cf1c167e8
commit
47d132dfd7
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user