一人公司:付费系统的设计与实现
天下熙熙,皆为利来;天下攘攘,皆为利往。 —— 司马迁《史记》
题记:今天是大年初七,也是新年假期的最后一天。希望大家都度过了一个轻松愉快的马年新春假期!程序君在这里祝大家新春快乐,万事顺遂,马到成功!这是「一人公司」系列的第九篇。在收官之作后,我再次忍不住提笔记录新的征途。前七篇我们搭建了从基础设施到产品应用的完整技术栈,第八篇做了收官总结,聊了商业模式和获客策略。但有一个关键拼图始终缺席:钱怎么收?
邀请制解决了「谁能进来」的问题,现在该解决「进来之后怎么付费」的问题了。一个 SaaS 产品如果不能收费,那它永远只是一个 side project。
注意:本篇使用了 stripe 的服务。我个人认为,stripe 是一家伟大的技术公司,其 API 设计非常考究,测试环境非常完备,是做 SaaS 服务的参考系。如果你做出海服务,或者在海外做互联网服务,涉及到付费,stripe 是迈出第一步的首选。
但使用 stripe 并不意味着付费就可以一帆风顺,尤其是我们这样的,「野心」很大,妄图做若干个业务(或者说可以快速 pivot)的「一人公司」。所以,我们需要请出 operon framework,构建一个独立的计费服务。
一、为什么需要独立的计费服务?
上一篇做 Ligand(邀请服务)时,我已经尝到了「独立服务」的甜头。同样的逻辑放到计费领域,理由更加充分:
第一,计费逻辑比你想的复杂十倍。 你以为付费就是「收钱 → 开通功能」?实际上你要处理:订阅升降级、计费周期轮转、信用额度扣减与退款、团队共享池、Stripe Webhook 的幂等处理、支付失败的优雅降级……把这些逻辑塞进每个应用里,是自找麻烦。
第二,Stripe 集成需要集中管控。 Stripe 的 Webhook 密钥、API Key、Price ID 这些敏感配置,散落在各个应用里是安全隐患。集中到一个服务,只有计费服务跟 Stripe 打交道,应用只跟计费服务打交道。攻击面收窄,安全性直线上升。
第三,第二个产品不用重新造轮子。 今天 Slides 要收费,明天我做第二个产品也要收费。如果计费逻辑在 Slides 里,新产品要么复制粘贴,要么从头写。有了独立计费服务,新产品注册一下就能用。
所以,答案和 Ligand 一样:独立服务,多租户,通过 API 集成。
二、ATP:多租户计费服务
我给这个服务起名 ATP(Adenosine Triphosphate,三磷酸腺苷),延续生物学命名传统。ATP 在细胞里是「能量货币」——几乎所有需要能量的细胞反应都靠 ATP 驱动。放在我们的语境里,信用额度就是应用的「能量货币」,用户每执行一次 AI 生成操作,就消耗一单位 ATP。名副其实。
设计原则
ATP 的核心设计理念是四个字:中心化结算。
第一,面向用户的付费应用永远不直接跟 Stripe 交互。 ATP 是唯一跟 Stripe 对话的服务。应用想创建 Checkout 会话?调 ATP 的 API。应用想查用户余额?调 ATP 的 API。这保证了所有支付逻辑在一个地方,改起来也在一个地方。
第二,信用额度是统一计量单位。 不管应用是卖图片生成、文档翻译还是代码分析,统统转换成「信用额度」。一次图片生成消耗 1 个额度,一次样式生成消耗 1 个额度。额度的成本表(CostTable)每个应用自行配置。统一计量,灵活定价。
第三,多租户但隔离。 每个应用是 ATP 的一个租户,有自己的订阅方案配置、额度包定义和 Stripe 产品映射。应用之间的数据完全隔离,但共享同一套计费引擎。
订阅层级
ATP 定义了三个层级,覆盖了典型 SaaS 产品的定价模型:
Free → Pro → Team
- Free(免费):每月赠送少量额度,功能受限,不能购买额度包
- Pro(专业版):每月更多额度,功能全开,可以按需购买额度包
- Team(团队版):最高额度,团队共享池,支持多人协作
每个层级的具体限制由应用自行定义:
struct TierLimits {
monthly_credits: u32, // 每月赠送额度
max_projects: Option<u32>, // 最大项目数
allowed_visibility: Vec<String>,// 允许的可见性设置
can_purchase_credits: bool, // 能否购买额度包
team_features: bool, // 是否开启团队功能
}
比如 Slides 可能配置 Free 层级每月 20 个额度,Pro 层级每月 500 个,Team 层级每月 2000 个。而另一个应用可能完全不同的数字。同一套引擎,千人千面。
给大家看看成品:

购买 credit:

管理/升级 plan:

三、信用额度系统:看似简单的会计学
信用额度(credits)是 ATP 的核心抽象。它是一个双入口记账系统:CreditBalance 记录当前快照,CreditLedger 记录每笔交易明细。这不是过度设计,这是任何涉及「钱」的系统的基本素养。
三种额度来源
用户的额度来自三个口袋:
订阅额度(Subscription Credits) 是月度配额。每个计费周期开始时,Stripe 的 invoice.paid Webhook 触发额度刷新。上个月用剩的不累积——用不完就没了,这是典型的「use it or lose it」模式。
购买额度(Purchased Credits) 是用户额外购买的额度包。这些额度永不过期,是用户的「存款」。
团队池(Team Pool) 是 Team 层级的专属能力。团队管理员购买的额度进入共享池,所有成员都能从中消费。
扣减优先级
当用户执行一次消耗操作(比如生成一张图),ATP 按固定优先级扣减:
订阅额度 → 购买额度 → 团队池
为什么这个顺序?因为订阅额度月末清零,不用白不用;购买额度永不过期,留着最划算;团队池是公共资源,最后再动。 这是对用户最有利的扣减策略。
核心扣减逻辑:
pub async fn debit(
db: &DynamoClient, email: &str, app_id: &str,
amount: u32, action: &str, related_id: Option<&str>,
) -> Result<DebitResult, AppError> {
let user_balance = credit_repo::get_user_balance(db, email, app_id).await?;
let user_total = user_balance.as_ref().map_or(0, |b| b.total());
// 1. 用户自己的额度够用,直接扣
if user_total >= amount as i32 {
return credit_repo::debit_user_credits(
db, email, app_id, amount, action, related_id
).await;
}
// 2. 不够,检查团队池
let binding = user_repo::get_binding(db, email, app_id).await?;
let team_id = binding.as_ref()
.and_then(|b| b.team_id.as_deref())
.ok_or_else(|| AppError::BadRequest(
format!("insufficient credits: have {user_total}, need {amount}")
))?;
let team_balance = credit_repo::get_team_balance(db, team_id, app_id).await?;
let team_available = team_balance.as_ref().map_or(0, |b| b.purchased_credits);
if user_total + team_available < amount as i32 {
return Err(AppError::BadRequest(format!(
"insufficient credits: have {} (user: {}, team: {}), need {}",
user_total + team_available, user_total, team_available, amount
)));
}
// 3. 先耗尽用户额度,不足部分从团队池补
let from_user = user_total.max(0) as u32;
let from_team = amount - from_user;
// ... 分别扣减,返回 DebitResult
}
DebitResult 会告诉调用方每笔扣减的来源明细:
pub struct DebitResult {
pub amount: u32, // 总扣减量
pub from_subscription: u32,// 来自订阅额度
pub from_purchased: u32, // 来自购买额度
pub from_team_pool: u32, // 来自团队池
pub tx_id: String, // 交易 ID
}