flowglad-usage-tracking

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
<!-- @flowglad/skill sources_reviewed: 2026-01-21T12:00:00Z source_files: - platform/docs/features/usage.mdx - platform/docs/sdks/feature-access-usage.mdx -->
<!-- @flowglad/skill sources_reviewed: 2026-01-21T12:00:00Z source_files: - platform/docs/features/usage.mdx - platform/docs/sdks/feature-access-usage.mdx -->

Usage Tracking

使用量跟踪

Abstract

概述

This skill covers implementing usage-based billing with Flowglad, including recording usage events for metered billing, checking usage balances, and displaying usage information to users. Proper implementation ensures accurate billing and prevents users from bypassing usage charges.

本方案涵盖如何使用Flowglad实现基于使用量的计费,包括为计量计费记录使用事件、检查使用余额以及向用户展示使用信息。正确的实现方式可确保计费准确,避免用户规避使用费用。

Table of Contents

目录

1. Recording Usage Events

1. 记录使用事件

Impact: CRITICAL
Flowglad supports recording usage events from both client-side and server-side code. Each approach has different APIs and trade-offs.
影响级别: CRITICAL
Flowglad支持从客户端和服务器端代码记录使用事件。两种方式对应不同的API和取舍。

1.1 Client-Side Recording

1.1 客户端记录

Impact: CRITICAL (simplest approach for many use cases)
Use
useBilling().createUsageEvent
for client-side usage tracking. The client SDK provides smart defaults that simplify implementation.
Client-side smart defaults:
  • amount
    defaults to
    1
  • transactionId
    is auto-generated for idempotency
  • subscriptionId
    is auto-inferred from current subscription
Basic client-side usage:
tsx
'use client'

import { useBilling } from '@flowglad/nextjs'

function RecordUsageButton({ usageMeterSlug }: { usageMeterSlug: string }) {
  const billing = useBilling()

  const handleClick = async () => {
    if (!billing.createUsageEvent) return

    const result = await billing.createUsageEvent({
      usageMeterSlug,
      // amount defaults to 1
      // transactionId auto-generated
      // subscriptionId auto-inferred
    })

    if ('error' in result) {
      console.error('Failed to record usage:', result.error)
      return
    }

    console.log('Usage recorded:', result.usageEvent.id)
  }

  return <button onClick={handleClick}>Use Feature</button>
}
With explicit values:
tsx
const result = await billing.createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 5, // Override default of 1
  // transactionId and subscriptionId still auto-handled
})
Important: Client-side usage events do not automatically refresh billing data. Call
billing.reload()
after recording if you need to update displayed balances.
tsx
await billing.createUsageEvent({ usageMeterSlug: 'generations' })
await billing.reload() // Refresh to show updated balance
影响级别: CRITICAL(多数场景下最简单的实现方式)
使用
useBilling().createUsageEvent
进行客户端使用量跟踪。客户端SDK提供了智能默认值,可简化实现流程。
客户端智能默认值:
  • amount
    默认值为
    1
  • transactionId
    会自动生成以保证幂等性
  • subscriptionId
    会从当前订阅中自动推断
客户端基础用法:
tsx
'use client'

import { useBilling } from '@flowglad/nextjs'

function RecordUsageButton({ usageMeterSlug }: { usageMeterSlug: string }) {
  const billing = useBilling()

  const handleClick = async () => {
    if (!billing.createUsageEvent) return

    const result = await billing.createUsageEvent({
      usageMeterSlug,
      // amount默认值为1
      // transactionId自动生成
      // subscriptionId自动推断
    })

    if ('error' in result) {
      console.error('记录使用量失败:', result.error)
      return
    }

    console.log('使用量已记录:', result.usageEvent.id)
  }

  return <button onClick={handleClick}>使用功能</button>
}
显式指定参数的用法:
tsx
const result = await billing.createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 5, // 覆盖默认值1
  // transactionId和subscriptionId仍自动处理
})
注意: 客户端使用事件不会自动刷新计费数据。如果需要更新展示的余额,记录完成后需调用
billing.reload()
tsx
await billing.createUsageEvent({ usageMeterSlug: 'generations' })
await billing.reload() // 刷新数据以展示更新后的余额

1.2 Server-Side Recording

1.2 服务器端记录

Impact: CRITICAL (required for atomic operations)
Use
flowglad(userId).createUsageEvent
for server-side usage tracking. Server-side requires explicit values for all parameters.
Server-side required parameters:
  • subscriptionId
    - must be provided explicitly
  • transactionId
    - must be provided explicitly
  • amount
    - must be provided explicitly
Basic server-side usage:
typescript
// API route - app/api/generate/route.ts
import { flowglad } from '@/lib/flowglad'
import { auth } from '@/lib/auth'

export async function POST(req: Request) {
  const session = await auth()
  if (!session?.user?.id) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Get billing to find subscriptionId
  const billing = await flowglad(session.user.id).getBilling()
  const subscriptionId = billing.currentSubscription?.id

  if (!subscriptionId) {
    return Response.json({ error: 'No active subscription' }, { status: 402 })
  }

  // Perform the operation
  const result = await generateContent()

  // Record usage with explicit parameters
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'generations',
    amount: 1,
    subscriptionId,
    transactionId: `gen_${result.id}`,
  })

  return Response.json(result)
}
影响级别: CRITICAL(原子操作必需)
使用
flowglad(userId).createUsageEvent
进行服务器端使用量跟踪。服务器端需要显式指定所有参数。
服务器端必填参数:
  • subscriptionId
    - 必须显式提供
  • transactionId
    - 必须显式提供
  • amount
    - 必须显式提供
服务器端基础用法:
typescript
// API路由 - app/api/generate/route.ts
import { flowglad } from '@/lib/flowglad'
import { auth } from '@/lib/auth'

export async function POST(req: Request) {
  const session = await auth()
  if (!session?.user?.id) {
    return Response.json({ error: '未授权' }, { status: 401 })
  }

  // 获取计费信息以找到subscriptionId
  const billing = await flowglad(session.user.id).getBilling()
  const subscriptionId = billing.currentSubscription?.id

  if (!subscriptionId) {
    return Response.json({ error: '无有效订阅' }, { status: 402 })
  }

  // 执行操作
  const result = await generateContent()

  // 显式指定参数记录使用量
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'generations',
    amount: 1,
    subscriptionId,
    transactionId: `gen_${result.id}`,
  })

  return Response.json(result)
}

1.3 Choosing Client vs Server

1.3 选择客户端还是服务器端

Impact: CRITICAL (architectural decision)
Both approaches are valid. Choose based on your needs:
Use CaseRecommended ApproachWhy
Simple button click trackingClient-sideSmart defaults make it easy
Feature usage countersClient-sideNo server round-trip needed
AI generation / expensive operationsServer-sideTrack atomically with operation
API endpoint meteringServer-sideAlready on server
Operations that cost you moneyServer-sideEnsure tracking happens
Quick prototypingClient-sideFewer files to create
Pattern: Atomic server-side tracking
When an operation costs you money (e.g., calling OpenAI), track usage atomically with the operation:
typescript
export async function POST(req: Request) {
  const session = await auth()
  const billing = await flowglad(session.user.id).getBilling()

  // 1. Check balance first
  const balance = billing.checkUsageBalance('generations')
  if (!balance || balance.availableBalance <= 0) {
    return Response.json({ error: 'No credits' }, { status: 402 })
  }

  // 2. Perform expensive operation
  const result = await openai.images.generate({ prompt })

  // 3. Record usage (atomic with operation)
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'generations',
    amount: 1,
    subscriptionId: billing.currentSubscription!.id,
    transactionId: `gen_${result.data[0].url}`,
  })

  return Response.json(result)
}
Pattern: Simple client-side tracking
For tracking feature usage where bypassing isn't a concern:
tsx
function FeatureButton() {
  const billing = useBilling()

  const handleUse = async () => {
    // Track usage - smart defaults handle the rest
    await billing.createUsageEvent({ usageMeterSlug: 'feature-uses' })
    await billing.reload()
    // Do the feature thing
  }

  return <button onClick={handleUse}>Use Feature</button>
}

影响级别: CRITICAL(架构决策)
两种方式均有效,可根据需求选择:
使用场景推荐方式原因
简单按钮点击跟踪客户端智能默认值让实现更简单
功能使用计数器客户端无需服务器往返
AI生成/昂贵操作服务器端与操作原子性跟踪
API端点计量服务器端本身就在服务器端处理
会产生成本的操作服务器端确保跟踪一定会执行
快速原型开发客户端需创建的文件更少
模式: 原子性服务器端跟踪
当操作会产生成本时(例如调用OpenAI),需与操作原子性地跟踪使用量:
typescript
export async function POST(req: Request) {
  const session = await auth()
  const billing = await flowglad(session.user.id).getBilling()

  // 1. 先检查余额
  const balance = billing.checkUsageBalance('generations')
  if (!balance || balance.availableBalance <= 0) {
    return Response.json({ error: '无可用额度' }, { status: 402 })
  }

  // 2. 执行昂贵操作
  const result = await openai.images.generate({ prompt })

  // 3. 记录使用量(与操作原子性绑定)
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'generations',
    amount: 1,
    subscriptionId: billing.currentSubscription!.id,
    transactionId: `gen_${result.data[0].url}`,
  })

  return Response.json(result)
}
模式: 简单客户端跟踪
对于无需担心用户绕过跟踪的功能使用场景:
tsx
function FeatureButton() {
  const billing = useBilling()

  const handleUse = async () => {
    // 跟踪使用量 - 智能默认值处理其余逻辑
    await billing.createUsageEvent({ usageMeterSlug: 'feature-uses' })
    await billing.reload()
    // 执行功能逻辑
  }

  return <button onClick={handleUse}>使用功能</button>
}

2. Usage Meter Resolution

2. 使用计量器解析

Impact: HIGH
When creating usage events, you can identify the usage price by slug or ID. Understanding how these resolve helps you structure your billing correctly.
影响级别: HIGH
创建使用事件时,可通过slug或ID指定使用定价。理解这些参数的解析逻辑有助于正确构建计费系统。

2.1 Using usageMeterSlug vs priceSlug

2.1 使用usageMeterSlug与priceSlug

Impact: HIGH (determines which price is charged)
You can identify usage with exactly one of:
  • priceSlug
    or
    priceId
    - targets a specific price directly
  • usageMeterSlug
    or
    usageMeterId
    - resolves to the meter's default price
Using priceSlug (explicit):
typescript
await createUsageEvent({
  priceSlug: 'api-calls-standard', // Specific price
  amount: 1,
  // ...
})
Using usageMeterSlug (resolves to default):
typescript
await createUsageEvent({
  usageMeterSlug: 'api-calls', // Resolves to meter's default price
  amount: 1,
  // ...
})
When using
usageMeterSlug
, the system uses the meter's configured default price. If no custom default is set, it uses the auto-generated no-charge price.
影响级别: HIGH(决定将使用哪个定价计费)
你可以通过以下任意一种方式指定使用项:
  • priceSlug
    priceId
    - 直接指向特定定价
  • usageMeterSlug
    usageMeterId
    - 解析为计量器的默认定价
使用priceSlug(显式指定):
typescript
await createUsageEvent({
  priceSlug: 'api-calls-standard', // 特定定价
  amount: 1,
  // ...
})
使用usageMeterSlug(解析为默认定价):
typescript
await createUsageEvent({
  usageMeterSlug: 'api-calls', // 解析为计量器的默认定价
  amount: 1,
  // ...
})
使用
usageMeterSlug
时,系统会使用计量器配置的默认定价。如果未设置自定义默认值,则会使用自动生成的免费定价。

2.2 Default No-Charge Prices

2.2 默认免费定价

Impact: HIGH (understanding automatic pricing)
Every usage meter automatically has a no-charge price with:
  • Slug pattern:
    {usagemeterslug}_no_charge
  • Unit price:
    $0.00
    (always free)
  • Cannot be archived or deleted
This means you can start tracking usage immediately without creating a price first:
typescript
// Works even if no custom price exists for 'api-calls' meter
await createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 1,
  // Resolves to 'api-calls_no_charge' if no other default is set
})
When you need paid usage:
  1. Create a usage price in your Flowglad dashboard (e.g.,
    api-calls-standard
    at $0.001/call)
  2. Set it as the default price for the meter, OR
  3. Reference it directly with
    priceSlug: 'api-calls-standard'
Checking which price was used:
The response from
createUsageEvent
always includes the resolved
priceId
:
typescript
const result = await createUsageEvent({ usageMeterSlug: 'api-calls', amount: 1 })

if (!('error' in result)) {
  console.log('Charged to price:', result.usageEvent.priceId)
}

影响级别: HIGH(理解自动定价逻辑)
每个使用计量器都会自动生成一个免费定价,包含以下属性:
  • Slug格式:
    {usagemeterslug}_no_charge
  • 单价:
    $0.00
    (始终免费)
  • 无法归档或删除
这意味着你无需先创建定价即可立即开始跟踪使用量:
typescript
// 即使'api-calls'计量器没有自定义定价,也能正常工作
await createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 1,
  // 如果未设置其他默认值,会解析为'api-calls_no_charge'
})
当需要付费使用时:
  1. 在Flowglad控制台中创建使用定价(例如,
    api-calls-standard
    ,单价$0.001/次调用)
  2. 将其设置为计量器的默认定价,或者
  3. 直接使用
    priceSlug: 'api-calls-standard'
    引用该定价
查看实际使用的定价:
createUsageEvent
的响应中始终会包含解析后的
priceId
:
typescript
const result = await createUsageEvent({ usageMeterSlug: 'api-calls', amount: 1 })

if (!('error' in result)) {
  console.log('计费使用的定价:', result.usageEvent.priceId)
}

3. Idempotency with transactionId

3. 通过transactionId实现幂等性

Impact: HIGH
Network failures and retries can cause duplicate usage events. Always include a
transactionId
to ensure each logical operation is only billed once.
影响级别: HIGH
网络故障和重试可能导致重复的使用事件。始终包含
transactionId
以确保每个逻辑操作仅计费一次。

3.1 Preventing Double-Charging

3.1 避免重复计费

Impact: HIGH (prevents billing disputes and customer trust issues)
Without idempotency, a network timeout followed by a retry could charge the user twice for the same operation.
Incorrect: no idempotency key
typescript
// API route
export async function POST(req: Request) {
  const session = await auth()
  const result = await generateImage(prompt)

  // If this request times out and retries, user gets double-charged!
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
  })

  return Response.json(result)
}
Correct: always include transactionId
typescript
// API route
export async function POST(req: Request) {
  const session = await auth()
  const result = await generateImage(prompt)

  // Safe for retries - same transactionId = same event
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
    transactionId: `img_${result.id}`, // Unique per logical operation
  })

  return Response.json(result)
}
影响级别: HIGH(避免计费纠纷和用户信任问题)
如果没有幂等性保障,网络超时后重试可能会导致用户为同一操作被计费两次。
错误示例: 无幂等键
typescript
// API路由
export async function POST(req: Request) {
  const session = await auth()
  const result = await generateImage(prompt)

  // 如果请求超时并重试,用户会被重复计费!
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
  })

  return Response.json(result)
}
正确示例: 始终包含transactionId
typescript
// API路由
export async function POST(req: Request) {
  const session = await auth()
  const result = await generateImage(prompt)

  // 重试安全 - 相同的transactionId对应同一个事件
  await flowglad(session.user.id).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
    transactionId: `img_${result.id}`, // 每个逻辑操作唯一
  })

  return Response.json(result)
}

3.2 Generating Unique Transaction IDs

3.2 生成唯一交易ID

Impact: HIGH (ensures uniqueness across all operations)
Transaction IDs must be unique per logical operation, not per request. Use deterministic IDs based on the operation's output or a combination of user, timestamp, and operation details.
Incorrect: using random IDs
typescript
// Random IDs don't prevent duplicates on retry
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 1,
  transactionId: crypto.randomUUID(), // New ID on every retry!
})
Correct: use deterministic IDs based on the operation
typescript
// Option 1: Use the result's ID
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'generations',
  amount: 1,
  transactionId: `gen_${result.id}`,
})

// Option 2: Use request ID from incoming request header
// IMPORTANT: Only use if your client sends a stable x-request-id on retries
const requestId = req.headers.get('x-request-id')
if (!requestId) {
  return Response.json({ error: 'x-request-id header required' }, { status: 400 })
}
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 1,
  transactionId: `req_${requestId}`,
})

// Option 3: Hash of operation parameters for deterministic operations
import { createHash } from 'crypto'

function hashOperationParams(params: Record<string, unknown>): string {
  return createHash('sha256')
    .update(JSON.stringify(params))
    .digest('hex')
    .slice(0, 16)
}

const operationHash = hashOperationParams({ userId, prompt })
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'queries',
  amount: 1,
  transactionId: `query_${operationHash}`,
})

影响级别: HIGH(确保所有操作的唯一性)
交易ID必须针对每个逻辑操作唯一,而非每个请求。可基于操作的输出,或结合用户、时间戳和操作详情生成确定性ID。
错误示例: 使用随机ID
typescript
// 随机ID无法防止重试时的重复计费
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 1,
  transactionId: crypto.randomUUID(), // 每次重试都会生成新ID!
})
正确示例: 基于操作生成确定性ID
typescript
// 选项1: 使用操作结果的ID
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'generations',
  amount: 1,
  transactionId: `gen_${result.id}`,
})

// 选项2: 使用请求头中的x-request-id
// 重要: 仅当客户端在重试时发送稳定的x-request-id时使用
const requestId = req.headers.get('x-request-id')
if (!requestId) {
  return Response.json({ error: '缺少x-request-id请求头' }, { status: 400 })
}
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'api-calls',
  amount: 1,
  transactionId: `req_${requestId}`,
})

// 选项3: 对确定性操作的参数进行哈希
import { createHash } from 'crypto'

function hashOperationParams(params: Record<string, unknown>): string {
  return createHash('sha256')
    .update(JSON.stringify(params))
    .digest('hex')
    .slice(0, 16)
}

const operationHash = hashOperationParams({ userId, prompt })
await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'queries',
  amount: 1,
  transactionId: `query_${operationHash}`,
})

4. Pre-Check Balance Before Expensive Operations

4. 昂贵操作前预检查余额

Impact: MEDIUM
For operations that consume significant resources or cost money (API calls to AI services, image generation, etc.), check the user's balance before starting the operation.
影响级别: MEDIUM
对于消耗大量资源或产生成本的操作(如调用AI服务API、图片生成等),需在开始操作前检查用户的余额。

4.1 Check Before Consume Pattern

4.1 先检查后使用模式

Impact: MEDIUM (prevents wasted compute and poor user experience)
Running an expensive operation only to discover the user has no credits wastes resources and frustrates users.
Incorrect: runs expensive operation, then fails on billing
typescript
async function generateImage(userId: string, prompt: string) {
  // Spends $0.10 on AI generation
  const image = await openai.images.generate({
    model: 'dall-e-3',
    prompt,
  })

  // Then discovers user has no credits - too late, we already paid OpenAI!
  const billing = await flowglad(userId).getBilling()
  const balance = billing.checkUsageBalance('image-generations')

  if (balance.availableBalance <= 0) {
    throw new Error('No credits') // User got nothing, we lost money
  }

  await flowglad(userId).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
  })

  return image
}
Correct: check balance first
typescript
async function generateImage(userId: string, prompt: string) {
  // Check balance BEFORE the expensive operation
  const billing = await flowglad(userId).getBilling()
  const balance = billing.checkUsageBalance('image-generations')

  if (balance.availableBalance <= 0) {
    throw new InsufficientCreditsError('No credits remaining. Please upgrade.')
  }

  // Now safe to proceed
  const image = await openai.images.generate({
    model: 'dall-e-3',
    prompt,
  })

  // Use a stable identifier from the operation result
  // The image URL or a hash of the image data provides idempotency
  const imageId = image.data[0].url?.split('/').pop()?.split('.')[0] ||
    createHash('sha256').update(prompt + userId).digest('hex').slice(0, 16)

  await flowglad(userId).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
    transactionId: `img_${imageId}`,
  })

  return image
}
影响级别: MEDIUM(避免资源浪费和糟糕的用户体验)
执行昂贵操作后才发现用户没有可用额度,不仅会浪费资源,还会让用户感到不满。
错误示例: 先执行昂贵操作,后处理计费失败
typescript
async function generateImage(userId: string, prompt: string) {
  // 花费$0.10调用AI生成图片
  const image = await openai.images.generate({
    model: 'dall-e-3',
    prompt,
  })

  // 之后才发现用户没有可用额度 - 为时已晚,我们已经向OpenAI付费了!
  const billing = await flowglad(userId).getBilling()
  const balance = billing.checkUsageBalance('image-generations')

  if (balance.availableBalance <= 0) {
    throw new Error('无可用额度') // 用户未获得任何服务,我们却产生了成本
  }

  await flowglad(userId).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
  })

  return image
}
正确示例: 先检查余额
typescript
async function generateImage(userId: string, prompt: string) {
  // 执行昂贵操作前先检查余额
  const billing = await flowglad(userId).getBilling()
  const balance = billing.checkUsageBalance('image-generations')

  if (balance.availableBalance <= 0) {
    throw new InsufficientCreditsError('无可用额度,请升级套餐。')
  }

  // 现在可以安全执行操作
  const image = await openai.images.generate({
    model: 'dall-e-3',
    prompt,
  })

  // 使用操作结果中的稳定标识符
  // 图片URL或图片数据的哈希值可确保幂等性
  const imageId = image.data[0].url?.split('/').pop()?.split('.')[0] ||
    createHash('sha256').update(prompt + userId).digest('hex').slice(0, 16)

  await flowglad(userId).createUsageEvent({
    usageMeterSlug: 'image-generations',
    amount: 1,
    transactionId: `img_${imageId}`,
  })

  return image
}

4.2 Handling Insufficient Balance

4.2 处理余额不足

Impact: MEDIUM (clear error handling improves user experience)
Incorrect: generic error message
typescript
if (balance.availableBalance <= 0) {
  throw new Error('Operation failed')
}
Correct: specific error with upgrade path
typescript
class InsufficientCreditsError extends Error {
  constructor(
    public meterSlug: string,
    public availableBalance: number,
    public required: number
  ) {
    super(
      `Insufficient credits for ${meterSlug}. ` +
      `Available: ${availableBalance}, Required: ${required}`
    )
    this.name = 'InsufficientCreditsError'
  }
}

// In API route - throwing the error
if (balance.availableBalance < requiredAmount) {
  throw new InsufficientCreditsError(
    'image-generations',
    balance.availableBalance,
    requiredAmount
  )
}

// Catching and handling the error in your API route
export async function POST(req: Request) {
  try {
    const result = await generateImage(userId, prompt)
    return Response.json(result)
  } catch (error) {
    if (error instanceof InsufficientCreditsError) {
      return Response.json(
        {
          error: 'insufficient_credits',
          message: error.message,
          availableBalance: error.availableBalance,
          required: error.required,
          upgradeUrl: '/pricing',
        },
        { status: 402 } // Payment Required
      )
    }
    throw error // Re-throw unexpected errors
  }
}

影响级别: MEDIUM(清晰的错误处理可提升用户体验)
错误示例: 通用错误提示
typescript
if (balance.availableBalance <= 0) {
  throw new Error('操作失败')
}
正确示例: 包含升级路径的具体错误
typescript
class InsufficientCreditsError extends Error {
  constructor(
    public meterSlug: string,
    public availableBalance: number,
    public required: number
  ) {
    super(
      `${meterSlug}可用额度不足。 ` +
      `当前可用: ${availableBalance}, 所需: ${required}`
    )
    this.name = 'InsufficientCreditsError'
  }
}

// 在API路由中抛出错误
if (balance.availableBalance < requiredAmount) {
  throw new InsufficientCreditsError(
    'image-generations',
    balance.availableBalance,
    requiredAmount
  )
}

// 在API路由中捕获并处理错误
export async function POST(req: Request) {
  try {
    const result = await generateImage(userId, prompt)
    return Response.json(result)
  } catch (error) {
    if (error instanceof InsufficientCreditsError) {
      return Response.json(
        {
          error: 'insufficient_credits',
          message: error.message,
          availableBalance: error.availableBalance,
          required: error.required,
          upgradeUrl: '/pricing',
        },
        { status: 402 } // 需要付费
      )
    }
    throw error // 重新抛出未预期的错误
  }
}

5. Display Patterns for Usage

5. 使用量展示模式

Impact: MEDIUM
Users need visibility into their usage. Display current balance, usage history, and limits clearly.
影响级别: MEDIUM
用户需要清晰了解自己的使用情况。需明确展示当前余额、使用历史和额度限制。

5.1 Progress Bars and Counters

5.1 进度条与计数器

Impact: MEDIUM (transparency builds trust)
Incorrect: shows usage without context
tsx
function UsageDisplay() {
  const { checkUsageBalance } = useBilling()
  const balance = checkUsageBalance('api-calls')

  // Just showing a number is confusing
  return <div>Usage: {balance.usedBalance}</div>
}
Correct: shows usage with limit and visual progress
tsx
import { useBilling } from '@flowglad/nextjs'

function UsageDisplay() {
  const { loaded, checkUsageBalance } = useBilling()

  if (!loaded) {
    return <UsageSkeleton />
  }

  const balance = checkUsageBalance('api-calls')

  // Handle unlimited plans (balanceLimit is null)
  // For unlimited plans, show usage count without percentage
  const hasLimit = balance.balanceLimit != null
  const percentUsed = hasLimit
    ? (balance.usedBalance / balance.balanceLimit!) * 100
    : 0

  // For unlimited plans, skip the progress bar entirely
  if (!hasLimit) {
    return (
      <div className="text-sm">
        <span>API Calls: {balance.usedBalance.toLocaleString()}</span>
        <span className="text-gray-500 ml-1">(Unlimited)</span>
      </div>
    )
  }

  return (
    <div className="space-y-2">
      <div className="flex justify-between text-sm">
        <span>API Calls</span>
        <span>
          {balance.usedBalance.toLocaleString()} / {balance.balanceLimit?.toLocaleString() ?? 'Unlimited'}
        </span>
      </div>
      <div className="h-2 bg-gray-200 rounded-full overflow-hidden">
        <div
          className={`h-full transition-all ${
            percentUsed > 90 ? 'bg-red-500' : percentUsed > 75 ? 'bg-yellow-500' : 'bg-blue-500'
          }`}
          style={{ width: `${Math.min(percentUsed, 100)}%` }}
        />
      </div>
      {percentUsed > 90 && (
        <p className="text-sm text-red-600">
          You're approaching your limit. Consider upgrading.
        </p>
      )}
    </div>
  )
}
影响级别: MEDIUM(透明性可建立用户信任)
错误示例: 仅展示使用量无上下文
tsx
function UsageDisplay() {
  const { checkUsageBalance } = useBilling()
  const balance = checkUsageBalance('api-calls')

  // 仅展示数字会让用户困惑
  return <div>使用量: {balance.usedBalance}</div>
}
正确示例: 结合额度限制和可视化进度展示
tsx
import { useBilling } from '@flowglad/nextjs'

function UsageDisplay() {
  const { loaded, checkUsageBalance } = useBilling()

  if (!loaded) {
    return <UsageSkeleton />
  }

  const balance = checkUsageBalance('api-calls')

  // 处理无限制套餐(balanceLimit为null)
  // 无限制套餐仅展示使用量,不展示百分比
  const hasLimit = balance.balanceLimit != null
  const percentUsed = hasLimit
    ? (balance.usedBalance / balance.balanceLimit!) * 100
    : 0

  // 无限制套餐完全跳过进度条
  if (!hasLimit) {
    return (
      <div className="text-sm">
        <span>API调用次数: {balance.usedBalance.toLocaleString()}</span>
        <span className="text-gray-500 ml-1">(无限制)</span>
      </div>
    )
  }

  return (
    <div className="space-y-2">
      <div className="flex justify-between text-sm">
        <span>API调用次数</span>
        <span>
          {balance.usedBalance.toLocaleString()} / {balance.balanceLimit?.toLocaleString() ?? '无限制'}
        </span>
      </div>
      <div className="h-2 bg-gray-200 rounded-full overflow-hidden">
        <div
          className={`h-full transition-all ${
            percentUsed > 90 ? 'bg-red-500' : percentUsed > 75 ? 'bg-yellow-500' : 'bg-blue-500'
          }`}
          style={{ width: `${Math.min(percentUsed, 100)}%` }}
        />
      </div>
      {percentUsed > 90 && (
        <p className="text-sm text-red-600">
          你即将达到额度限制,考虑升级套餐。
        </p>
      )}
    </div>
  )
}

5.2 Real-Time Balance Display

5.2 实时余额展示

Impact: MEDIUM (accurate display after mutations)
Incorrect: stale balance after usage
tsx
function Dashboard() {
  const { checkUsageBalance } = useBilling()

  async function handleGenerate() {
    await fetch('/api/generate', { method: 'POST' })
    // Balance display is now stale - shows old value
  }

  const balance = checkUsageBalance('generations')

  return (
    <div>
      <p>Remaining: {balance.availableBalance}</p>
      <button onClick={handleGenerate}>Generate</button>
    </div>
  )
}
Correct: reload billing after usage
tsx
function Dashboard() {
  const { checkUsageBalance, reload, loaded } = useBilling()
  const [isGenerating, setIsGenerating] = useState(false)

  async function handleGenerate() {
    setIsGenerating(true)
    try {
      await fetch('/api/generate', { method: 'POST' })
      // Refresh billing data to show updated balance
      await reload()
    } finally {
      setIsGenerating(false)
    }
  }

  if (!loaded) {
    return <LoadingSkeleton />
  }

  const balance = checkUsageBalance('generations')

  return (
    <div>
      <p>Remaining: {balance.availableBalance}</p>
      <button onClick={handleGenerate} disabled={isGenerating}>
        {isGenerating ? 'Generating...' : 'Generate'}
      </button>
    </div>
  )
}

影响级别: MEDIUM(变更后展示准确数据)
错误示例: 使用量变更后余额展示过期
tsx
function Dashboard() {
  const { checkUsageBalance } = useBilling()

  async function handleGenerate() {
    await fetch('/api/generate', { method: 'POST' })
    // 余额展示现在已过期 - 显示的是旧数据
  }

  const balance = checkUsageBalance('generations')

  return (
    <div>
      <p>剩余额度: {balance.availableBalance}</p>
      <button onClick={handleGenerate}>生成内容</button>
    </div>
  )
}
正确示例: 使用量变更后重新加载计费数据
tsx
function Dashboard() {
  const { checkUsageBalance, reload, loaded } = useBilling()
  const [isGenerating, setIsGenerating] = useState(false)

  async function handleGenerate() {
    setIsGenerating(true)
    try {
      await fetch('/api/generate', { method: 'POST' })
      // 重新加载计费数据以展示更新后的余额
      await reload()
    } finally {
      setIsGenerating(false)
    }
  }

  if (!loaded) {
    return <LoadingSkeleton />
  }

  const balance = checkUsageBalance('generations')

  return (
    <div>
      <p>剩余额度: {balance.availableBalance}</p>
      <button onClick={handleGenerate} disabled={isGenerating}>
        {isGenerating ? '生成中...' : '生成内容'}
      </button>
    </div>
  )
}

6. Handling Exhausted Balance

6. 处理余额耗尽

Impact: MEDIUM
When users run out of credits, provide a clear path to continue using the product.
影响级别: MEDIUM
当用户额度耗尽时,需提供清晰的继续使用路径。

6.1 Graceful Degradation

6.1 优雅降级

Impact: MEDIUM (maintains usability when credits exhausted)
Incorrect: hard block with no explanation
tsx
function FeatureComponent() {
  const { checkUsageBalance } = useBilling()
  const balance = checkUsageBalance('generations')

  if (balance.availableBalance <= 0) {
    return null // Feature just disappears
  }

  return <GenerateForm />
}
Correct: explain the situation and offer solutions
tsx
function FeatureComponent() {
  const { loaded, checkUsageBalance, createCheckoutSession } = useBilling()

  if (!loaded) {
    return <LoadingSkeleton />
  }

  const balance = checkUsageBalance('generations')

  if (balance.availableBalance <= 0) {
    return (
      <div className="p-6 border rounded-lg bg-gray-50">
        <h3 className="font-semibold text-lg">Out of generations</h3>
        <p className="text-gray-600 mt-2">
          You've used all {balance.balanceLimit} generations this month.
          Upgrade to continue creating.
        </p>
        <div className="mt-4 flex gap-3">
          <button
            onClick={() =>
              createCheckoutSession({
                priceSlug: 'pro-monthly',
                successUrl: `${window.location.origin}/dashboard?upgraded=true`,
                cancelUrl: window.location.href,
                autoRedirect: true,
              })
            }
            className="px-4 py-2 bg-blue-600 text-white rounded-lg"
          >
            Upgrade to Pro
          </button>
          <button
            onClick={() => window.location.href = '/pricing'}
            className="px-4 py-2 border rounded-lg"
          >
            View Plans
          </button>
        </div>
      </div>
    )
  }

  return <GenerateForm />
}
影响级别: MEDIUM(额度耗尽时仍保持产品可用性)
错误示例: 无提示直接隐藏功能
tsx
function FeatureComponent() {
  const { checkUsageBalance } = useBilling()
  const balance = checkUsageBalance('generations')

  if (balance.availableBalance <= 0) {
    return null // 功能直接消失
  }

  return <GenerateForm />
}
正确示例: 说明情况并提供解决方案
tsx
function FeatureComponent() {
  const { loaded, checkUsageBalance, createCheckoutSession } = useBilling()

  if (!loaded) {
    return <LoadingSkeleton />
  }

  const balance = checkUsageBalance('generations')

  if (balance.availableBalance <= 0) {
    return (
      <div className="p-6 border rounded-lg bg-gray-50">
        <h3 className="font-semibold text-lg">生成额度已耗尽</h3>
        <p className="text-gray-600 mt-2">
          你已使用完本月的{balance.balanceLimit}次生成额度。
          升级套餐即可继续使用。
        </p>
        <div className="mt-4 flex gap-3">
          <button
            onClick={() =>
              createCheckoutSession({
                priceSlug: 'pro-monthly',
                successUrl: `${window.location.origin}/dashboard?upgraded=true`,
                cancelUrl: window.location.href,
                autoRedirect: true,
              })
            }
            className="px-4 py-2 bg-blue-600 text-white rounded-lg"
          >
            升级到专业版
          </button>
          <button
            onClick={() => window.location.href = '/pricing'}
            className="px-4 py-2 border rounded-lg"
          >
            查看套餐
          </button>
        </div>
      </div>
    )
  }

  return <GenerateForm />
}

6.2 Upgrade Prompts

6.2 升级提示

Impact: MEDIUM (converts free users at the right moment)
Incorrect: shows upgrade prompt at random times
tsx
// Showing upgrade randomly is annoying
function Dashboard() {
  const showUpgrade = Math.random() > 0.7

  return (
    <div>
      {showUpgrade && <UpgradePrompt />}
      <MainContent />
    </div>
  )
}
Correct: show upgrade when contextually relevant
tsx
function Dashboard() {
  const { loaded, checkUsageBalance } = useBilling()

  if (!loaded) {
    return <LoadingSkeleton />
  }

  const balance = checkUsageBalance('generations')
  const percentUsed = balance.balanceLimit
    ? (balance.usedBalance / balance.balanceLimit) * 100
    : 0

  // Show upgrade prompts at meaningful thresholds
  const showUpgrade = percentUsed >= 80

  return (
    <div>
      {showUpgrade && (
        <div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
          <p className="text-blue-800">
            {percentUsed >= 100
              ? "You've used all your generations this month."
              : `You've used ${Math.round(percentUsed)}% of your generations.`}
            {' '}
            <a href="/pricing" className="underline font-medium">
              Upgrade for unlimited access
            </a>
          </p>
        </div>
      )}
      <MainContent />
    </div>
  )
}

影响级别: MEDIUM(在合适时机转化免费用户)
错误示例: 随机展示升级提示
tsx
// 随机展示升级提示会让用户反感
function Dashboard() {
  const showUpgrade = Math.random() > 0.7

  return (
    <div>
      {showUpgrade && <UpgradePrompt />}
      <MainContent />
    </div>
  )
}
正确示例: 在上下文相关的时机展示
tsx
function Dashboard() {
  const { loaded, checkUsageBalance } = useBilling()

  if (!loaded) {
    return <LoadingSkeleton />
  }

  const balance = checkUsageBalance('generations')
  const percentUsed = balance.balanceLimit
    ? (balance.usedBalance / balance.balanceLimit) * 100
    : 0

  // 在关键阈值展示升级提示
  const showUpgrade = percentUsed >= 80

  return (
    <div>
      {showUpgrade && (
        <div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
          <p className="text-blue-800">
            {percentUsed >= 100
              ? "你已使用完本月的所有生成额度。"
              : `你已使用${Math.round(percentUsed)}%的生成额度。`}
            {' '}
            <a href="/pricing" className="underline font-medium">
              升级获取无限制访问权限
            </a>
          </p>
        </div>
      )}
      <MainContent />
    </div>
  )
}

Quick Reference

快速参考

Recording Usage (Client-Side)

记录使用量(客户端)

tsx
const billing = useBilling()

// With smart defaults (amount: 1, auto transactionId, auto subscriptionId)
await billing.createUsageEvent({ usageMeterSlug: 'your-meter-slug' })

// With explicit amount
await billing.createUsageEvent({ usageMeterSlug: 'your-meter-slug', amount: 5 })

// Don't forget to reload if showing balance
await billing.reload()
tsx
const billing = useBilling()

// 使用智能默认值(amount:1,自动生成transactionId,自动推断subscriptionId)
await billing.createUsageEvent({ usageMeterSlug: 'your-meter-slug' })

// 显式指定使用量
await billing.createUsageEvent({ usageMeterSlug: 'your-meter-slug', amount: 5 })

// 如果需要展示余额,不要忘记重新加载
await billing.reload()

Recording Usage (Server-Side)

记录使用量(服务器端)

typescript
import { flowglad } from '@/lib/flowglad'

const billing = await flowglad(userId).getBilling()

await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'your-meter-slug', // or priceSlug for specific price
  amount: 1,
  subscriptionId: billing.currentSubscription!.id,
  transactionId: `unique_${operationId}`,
})
typescript
import { flowglad } from '@/lib/flowglad'

const billing = await flowglad(userId).getBilling()

await flowglad(userId).createUsageEvent({
  usageMeterSlug: 'your-meter-slug', // 或使用priceSlug指定特定定价
  amount: 1,
  subscriptionId: billing.currentSubscription!.id,
  transactionId: `unique_${operationId}`,
})

Checking Balance (Client or Server)

检查余额(客户端或服务器端)

typescript
// Client-side
const { checkUsageBalance } = useBilling()
const balance = checkUsageBalance('your-meter-slug')
// balance.availableBalance, balance.usedBalance, balance.balanceLimit

// Server-side
const billing = await flowglad(userId).getBilling()
const balance = billing.checkUsageBalance('your-meter-slug')
typescript
// 客户端
const { checkUsageBalance } = useBilling()
const balance = checkUsageBalance('your-meter-slug')
// balance.availableBalance, balance.usedBalance, balance.balanceLimit

// 服务器端
const billing = await flowglad(userId).getBilling()
const balance = billing.checkUsageBalance('your-meter-slug')

Usage Meter Resolution

使用计量器解析

typescript
// Using usageMeterSlug - resolves to meter's default price
await createUsageEvent({ usageMeterSlug: 'api-calls', ... })

// Using priceSlug - targets specific price directly
await createUsageEvent({ priceSlug: 'api-calls-standard', ... })

// Every meter has auto-generated no-charge price: {slug}_no_charge
typescript
// 使用usageMeterSlug - 解析为计量器的默认定价
await createUsageEvent({ usageMeterSlug: 'api-calls', ... })

// 使用priceSlug - 直接指向特定定价
await createUsageEvent({ priceSlug: 'api-calls-standard', ... })

// 每个计量器都有自动生成的免费定价: {slug}_no_charge

HTTP Status Codes for Usage Errors

使用量错误对应的HTTP状态码

StatusUse Case
401 Unauthorized
User not authenticated
402 Payment Required
Insufficient credits/balance
403 Forbidden
User authenticated but lacks access to this feature
429 Too Many Requests
Rate limited (separate from usage billing)
状态码使用场景
401 Unauthorized
用户未认证
402 Payment Required
额度/余额不足
403 Forbidden
用户已认证但无该功能访问权限
429 Too Many Requests
请求速率限制(与计费无关)