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:
parent
9cf1c167e8
commit
701d790f97
@ -5,7 +5,7 @@ import useSWR from "swr";
|
|||||||
|
|
||||||
import { openStripePortal } from "@components/billing/stripe-client";
|
import { openStripePortal } from "@components/billing/stripe-client";
|
||||||
import Card from "../components/Card";
|
import Card from "../components/Card";
|
||||||
import { fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage";
|
import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from "../lib/fetchAccountUsage";
|
||||||
|
|
||||||
const fetcher = (url: string) =>
|
const fetcher = (url: string) =>
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
@ -50,6 +50,7 @@ export default function SubscriptionPanel() {
|
|||||||
fetcher,
|
fetcher,
|
||||||
);
|
);
|
||||||
const { data: usageSummary } = useSWR("account-usage-summary", fetchAccountUsageSummary);
|
const { data: usageSummary } = useSWR("account-usage-summary", fetchAccountUsageSummary);
|
||||||
|
const { data: billingSummary } = useSWR("account-billing-summary", fetchAccountBillingSummary);
|
||||||
const { data: accountPolicy } = useSWR("account-policy", fetchAccountPolicy);
|
const { data: accountPolicy } = useSWR("account-policy", fetchAccountPolicy);
|
||||||
const [submitting, setSubmitting] = useState<string | null>(null);
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
const [portalLoading, setPortalLoading] = useState(false);
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
@ -139,6 +140,9 @@ export default function SubscriptionPanel() {
|
|||||||
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
|
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
|
||||||
统计由 accounts.svc.plus 汇总,非本地客户端计数。
|
统计由 accounts.svc.plus 汇总,非本地客户端计数。
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
|
||||||
|
数据源:{usageSummary.sourceOfTruth || "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
|
<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)]">
|
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
|
||||||
@ -154,6 +158,10 @@ export default function SubscriptionPanel() {
|
|||||||
? `${usageSummary.remainingIncludedQuota.toLocaleString()} B`
|
? `${usageSummary.remainingIncludedQuota.toLocaleString()} B`
|
||||||
: "—"}
|
: "—"}
|
||||||
</p>
|
</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>
|
||||||
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
|
<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)]">
|
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">
|
||||||
@ -166,6 +174,47 @@ export default function SubscriptionPanel() {
|
|||||||
统计延迟约 {usageSummary.syncDelaySeconds ?? 0} 秒,策略组{" "}
|
统计延迟约 {usageSummary.syncDelaySeconds ?? 0} 秒,策略组{" "}
|
||||||
{accountPolicy?.eligibleNodeGroups?.join(", ") || "—"}
|
{accountPolicy?.eligibleNodeGroups?.join(", ") || "—"}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage'
|
import { fetchAccountBillingSummary, fetchAccountPolicy, fetchAccountUsageSummary } from './fetchAccountUsage'
|
||||||
|
|
||||||
describe('fetchAccountUsage', () => {
|
describe('fetchAccountUsage', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -9,7 +9,7 @@ describe('fetchAccountUsage', () => {
|
|||||||
|
|
||||||
it('loads the authoritative usage summary from the account api', async () => {
|
it('loads the authoritative usage summary from the account api', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
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,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}),
|
}),
|
||||||
@ -18,6 +18,7 @@ describe('fetchAccountUsage', () => {
|
|||||||
await expect(fetchAccountUsageSummary()).resolves.toEqual({
|
await expect(fetchAccountUsageSummary()).resolves.toEqual({
|
||||||
accountUuid: 'acct-1',
|
accountUuid: 'acct-1',
|
||||||
totalBytes: 384,
|
totalBytes: 384,
|
||||||
|
sourceOfTruth: 'postgresql',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -34,4 +35,28 @@ describe('fetchAccountUsage', () => {
|
|||||||
preferredStrategy: 'ewma',
|
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 = {
|
export type AccountUsageSummary = {
|
||||||
accountUuid: string
|
accountUuid: string
|
||||||
totalBytes: number
|
totalBytes: number
|
||||||
|
sourceOfTruth?: string
|
||||||
uplinkBytes?: number
|
uplinkBytes?: number
|
||||||
downlinkBytes?: number
|
downlinkBytes?: number
|
||||||
currentBalance?: number
|
currentBalance?: number
|
||||||
@ -14,6 +15,8 @@ export type AccountUsageSummary = {
|
|||||||
syncDelaySeconds?: number
|
syncDelaySeconds?: number
|
||||||
suspendState?: string
|
suspendState?: string
|
||||||
throttleState?: string
|
throttleState?: string
|
||||||
|
arrears?: boolean
|
||||||
|
billingProfile?: AccountBillingProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountPolicy = {
|
export type AccountPolicy = {
|
||||||
@ -24,6 +27,41 @@ export type AccountPolicy = {
|
|||||||
degradeMode?: string
|
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 {
|
function toError(payload: unknown, status: number): AccountUsageError {
|
||||||
const message =
|
const message =
|
||||||
payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string'
|
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> {
|
export function fetchAccountPolicy(): Promise<AccountPolicy> {
|
||||||
return requestJSON<AccountPolicy>('/api/account/policy')
|
return requestJSON<AccountPolicy>('/api/account/policy')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAccountBillingSummary(): Promise<AccountBillingSummary> {
|
||||||
|
return requestJSON<AccountBillingSummary>('/api/account/billing/summary')
|
||||||
|
}
|
||||||
|
|||||||
@ -18,11 +18,17 @@ type AgentNodesError = Error & {
|
|||||||
status?: number
|
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 {
|
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
|
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 payload.error
|
||||||
}
|
}
|
||||||
return `Request failed (${status})`
|
return `Request failed (${status})`
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user