Menu

订单和订阅管理

订单

数据结构要点

orders 表一行 = 一次钱的移动。关键字段:

字段说明
user_id付款人。用户注销后置 NULL,订单不消失(财务记录不随账号走)
organization_id团队购买时指向受益组织;个人订单为 NULL
plan_idconfig/pricing.ts 里的套餐 slug
order_typeone_time / subscription_initial / subscription_renewal / subscription_upgrade
statuspending / completed / partially_refunded / refunded / failed
credits_granted本单发放的积分
amount_total amount_subtotal amount_discount amount_tax amount_refunded一律整数分
provider + provider_order_id联合唯一,就是幂等键
provider_payment_idcharge / capture / payment intent id,退款打在它身上

provider_order_id 在各渠道的具体含义:

渠道与场景值
Stripe 一次性checkout session id
Stripe 订阅invoice id
Creem 一次性order / checkout id
Creem 订阅transaction id
PayPal 一次性capture id
PayPal 订阅sale id

关于 pending 与退款的两个设计

pending 只有一种来源:PayPal eCheck 等已接受但未结清的捕获。它是定时任务追踪的本地凭据,积分为 0,不计入收入。结清后原地提升为 completed(此刻才发积分),被拒则标记 failed(终态,零积分,仅留审计痕迹)。

退款改写原订单行,不新增行。 累计到 amount_refunded,并把 status 改成 partially_refunded 或 refunded。这样「一笔钱一行」的不变量始终成立,也让幂等变得简单。

查询

用户端(actions/orders/user.ts):

import { getMyOrders } from '@/actions/orders/user'
 
const result = await getMyOrders({ pageIndex: 0, pageSize: 10 })
// 套餐名按请求语言从 config/pricing 解析

管理端(actions/orders/admin.ts):

import { getAdminOrders } from '@/actions/orders/admin'
 
const result = await getAdminOrders({
  pageIndex: 0,
  pageSize: 20,
  userId,        // 可选,限定单个用户(用户详情页用)
  status,        // 可选
  orderType,     // 可选
  provider,      // 可选
  search,        // 可选,模糊匹配用户邮箱或套餐 id
})

退款

管理后台 /dashboard/admin/orders 的退款对话框调用 refundOrder:

import { refundOrder } from '@/actions/orders/admin'
 
await refundOrder({
  orderId,
  amountCents,   // 省略 = 全额退剩余部分;传值 = 部分退款
})

这个 action 只做一件事:调渠道的退款 API。本地订单状态和积分回收统统等退款 webhook 回来再写。

这么设计是为了让「后台发起的退款」和「直接在渠道后台发起的退款」走完全相同的一条代码路径 —— 不需要两套逻辑,也不会出现两边状态不一致。

前置校验:

  • pending 和 failed 的订单没有已结清的钱可退(渠道也会拒绝)
  • 没有 provider_payment_id 的订单退不了
  • 已全额退款的订单会被拒绝
  • 部分退款金额不能超过剩余可退金额

订阅

数据结构要点

字段说明
status归一化后的统一状态,见下
intervalmonth / year
current_period_start / current_period_end当前周期
cancel_at_period_end是否已预约期末取消
canceled_at / ended_at取消时间 / 实际结束时间
monthly_credits / remaining_grants / next_grant_at年付定投计划(见积分系统)
provider + provider_subscription_id联合唯一
organization_id团队订阅指向组织。删除组织时 RESTRICT —— 有过订阅的组织不允许硬删

状态

数据库里只存这一套归一化状态:

active | trialing | past_due | unpaid | canceled | incomplete | expired | paused

两个重要子集:

// 算「持有订阅」,权益不断
ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due', 'unpaid']
 
// 能发放积分
GRANTABLE_STATUSES = ['active', 'trialing']

催款期(past_due / unpaid)仍然保留权益,但不再发放新积分。

canceled 和 expired 是终态,永不复活 —— 重新订阅会产生新的订阅 ID 和新的行。

状态同步

订阅状态由 webhook 驱动同步,不需要轮询。syncSubscription 只管状态,不碰积分和定投计数(那是付款履约的事)。它还会拒绝乱序 webhook 造成的终态回写。

丢失的 webhook 由定时任务 reconcileOverdueRenewals 补救。

自助管理门户

用户端入口是 actions/billing/portal.ts:

import { createCustomerPortalSession } from '@/actions/billing/portal'
 
const result = await createCustomerPortalSession()
// result.data.url 直接跳转

它按当前工作区找到有效订阅,再按该订阅的 provider 分发:

provider去处
StripeBilling Portal(换卡、看发票、取消、变更套餐)
CreemCreem Customer Portal
PayPalPayPal 自动付款页(PayPal 没有托管门户)

没有有效订阅时,回落到用户的 Stripe 账单历史(换卡、看发票)。

团队订阅的门户限制

团队订阅挂在购买者的个人 customer 上。这意味着任何持有会话的成员理论上都能打开门户看到卡片信息、甚至取消订阅。

所以有一条服务端强制的限制:团队工作区下,只有订阅购买者本人能打开门户。这个校验在服务端,不是靠 UI 藏按钮。

变更套餐

Stripe 的套餐变更在 Billing Portal 里完成,需要先在 Stripe 后台配置好允许切换的产品。具体配置和积分如何计算见订阅变更。

优惠券控制台

管理后台 /dashboard/admin/coupons。

Stripe 是唯一存储 —— 本地不建表、不做迁移。一个优惠券 = Stripe Coupon(折扣、适用范围、计费时长)+ Stripe Promotion Code(对客码、次数上限、有效期、限制条件)。

码一旦创建就不可修改,这条约束塑造了整个 UI:

  • 唯一能改的是启用/停用开关
  • 想改内容只能「复制一份再改」
  • 创建时会实时校验码是否已被占用

创建表单、状态机、扫描上限等细节见优惠券控制台。客户侧的自动应用逻辑在 actions/billing/checkout.ts,读的是 config/pricing.ts 里配置的 promotionCode(见定价配置)。

注意

Stripe 的促销码只能限定到 Stripe 产品,所以这个功能只对 provider: 'stripe' 的套餐生效。Creem 用自己的 discountCode,PayPal 不支持。

页面一览

用户端

路由内容
/dashboard/billing两个积分桶、当前套餐、购买记录与积分流水(两张表独立分页)。团队工作区下顶部横幅引导去团队页
/dashboard/team订阅、共享积分池、成员与席位、团队积分流水四张卡片

/dashboard/subscription 和 /dashboard/credit-history 在 v4.0.0 已合并进 /dashboard/billing。

管理端

路由内容
/dashboard/admin/overview运营总览:净收入、MRR、新增注册、积分消耗、趋势图、待处理事项、最近订单
/dashboard/admin/orders订单列表(按用户/状态/类型/渠道筛选,搜索邮箱或套餐)+ 退款
/dashboard/admin/credits全局积分流水审计 + 按邮箱批量发放
/dashboard/admin/coupons优惠券控制台
/dashboard/admin/users用户列表 + 注册邮箱黑名单
/dashboard/admin/users/[userId]单用户 360 度视图:资料、积分余额、订阅、累计统计与历史,支持手工调整积分

Overview 页的口径

看数字之前先知道它怎么算的:

  • 金额一律整数分
  • 净收入 = 总额 − 退款;pending 和 failed 不计入收入
  • 本位币取定价配置中第一个启用套餐的币种(非本位币订单会出现在「待处理」里)
  • 新增注册排除匿名账号
  • 积分消耗 = usage − usage_refund
  • MRR = active + past_due 的订阅按配置的月度费率折算

时间窗由 ?range=7d|30d|90d 控制,按 UTC 自然日切分。五个区块各自 Suspense 并行加载,任一区块失败不拖垮整页。

常见问题

订单里的金额为什么是整数?

一律整数分,避免浮点误差。展示时除以 100(或按币种最小单位)。

用户注销后订单会消失吗?

不会。user_id 置 NULL,订单行保留。积分流水同理 —— 注销是去标识化,不是抹除,否则收入和消耗统计会追溯性缩水。

为什么退款后积分没有全部收回?

回收在 0 处截断。用户已经花掉的积分不会让余额变成负数。

能不能在代码里直接改余额?

不能。余额表只能通过 lib/credits/ledger.ts 变更,否则账本和余额会脱节。