@ codelitdev/ billing (英语)
CodeLit产品提供商中性计费引擎. 它运行 内部 您的 API 进程针对 PostgreSQL数据库. 这不是一个托管的收费服务.
版本 0.1.0-α.3 导弹工作流程、行动赠款、运营商服务和生命周期钩 实验. 在第二个消费者(当然Lit)证明同一合同之前没有v1分期付款.
目的
CodeLit产品通过付款机构销售自己的SaaS(组织,学校,后为其他总量). 如果没有一个共享的引擎,每个产品都倾向于发展自己的目录,取出,网络呼和调节——这些副本在容易出错的部分上存在分歧.
这个包使这些力学标准化:
- 无法更改的版本价格目录与供应商核对
- 离职、客户门户、取消和计划变更
- Webhook 校验、 耐用收件箱、 检索当前项目
- 订阅过渡、材料预测和对账租赁
- 供应商安全公共目录/商业状态读取模型
- 假的,多多,和柠檬斯克齐适配器 在一个合同后面
您的产品仍然拥有:
- 谁收帐(组织、学校、.)
- 计划名称、应享权利、配额、审判、优待、可用性
- 计划的改变是立即的还是延长的,如何按比例分配
- Auth, CSRF, 近代代号, HTTP 路由, UI
- Schema 迁移、回填、加密密钥、审计存储、提醒
软件包在安装、导入或处理启动时从未运行 DDL .
消费者准则
发动机为机械. 它不会从计划名称中推断出产品政策.
你决定,然后通过:
| 决定 | 你怎么告诉引擎 |
|---|---|
| 支付报价 | offers + 键 requiredOfferKeys 时间 createBilling; 准确设定, 不 "free" 内 planIds |
| 审判资格 | trialDays 退出(如果不符合资格,则为0)。 要求,HMAC,学校审判留在你的桌子上 |
| 升级与降级 | startPlanChange({ effectiveAt, prorationMode })。软件包不给Pro/Business排序 |
| 计划怎么解锁 | 你的权利解决者. commercialState() 只支付快照 |
| 实体生命周期 | getBillableEntityBillingBlockers 编号 : getPayerBillingResponsibilities;您选择了面向用户的错误 |
| 产品副作用 | afterProjection 编辑 引擎交易 (单位:千美元)store.getTransaction() ?? db(中文(简体) ) |
更改计划标记引擎所理解的:
effectiveAt数字 :immediately|next_billing_dateprorationMode数字 :prorated_immediately|do_not_bill
声明提供者 intervalChangesBillImmediately 当计费间隔改变时, 开始并开出新账, 所以 do_not_bill 跨间隔失败 plan_change_not_supported 在提供方被调用之前。 声明提供者 immediatePlanChangesOnly 无法安排更改, 因此 next_billing_date 失败的方式相同.
典型的SaaS拆分(SendLit做什么,不需要):升级 → 即时+ 按比例;降级 → 下一张发票 + 不收帐. 直到一个网约车/和解快照 实际显示新产品, commercialState().activePaidPlan 留在当前计划中 pendingPlanChange 已经设定。 您的UI应该禁用第二次更改, 并说“ 被确认” 与“ 预定” .
不许 :
- 将应用程序Free/OSS/trial输入生成
planIds - 拿着应用程序
db.transaction()穿过一个服务商呼叫,或巢db.transaction()在内部的根池上afterProjection - 处理退出方向,
payment.succeeded,或敬酒作为权利 - 重新解释路由中的供应商 SDK 错误; 映射
BillingWorkflowError.code仅限 - 读取或写入计费状态
billing.store;它是发动机的内部
安装
bun add @codelitdev/[email protected]
# or: bun add @codelitdev/billing@alpha
# Dodo is an optional peer of the Dodo adapter
bun add dodopayments
公共进口( 不导入) src/:
| 子路径 | 使用 |
|---|---|
@codelitdev/billing/config | defineBillingConfig |
@codelitdev/billing/drizzle | createDrizzleBillingStore |
@codelitdev/billing/workflows | createBilling (实验性) |
@codelitdev/billing/operations | 操作员检查/ 重试/ 取消 |
@codelitdev/billing/providers | 登记、假合同类型 |
@codelitdev/billing/providers/dodo | createDodoBillingProvider |
@codelitdev/billing/providers/lemonsqueezy | createLemonSqueezyBillingProvider |
@codelitdev/billing/core | 状况、金钱、错误、时钟 |
@codelitdev/billing/catalog | 提供验证/ 公共目录 |
@codelitdev/billing/testing | 供应商合同+工作流程利用 |
CLI: 爱丽丝
bun x codelit-billing generate --config billing.config.ts
bun x codelit-billing generate --check --config billing.config.ts
如何融为一体
Your API / UI
auth, grants, plan policy, entitlements
│
▼
createBilling ──► catalog + state machines
──► provider adapter (Dodo / fake)
──► Drizzle store ──► your Postgres
──► your audit + afterProjection hooks
在实践中重要的六边规则:
- 核心从不读
process.env或者墙壁时间。 你注射一个Clock和提供者选项. - 无法通过服务商 HTTP 呼叫进行数据库交易 。 持久退出 / webhook / 工作行弥合了这一差距.
- 一个经过验证的网球是唤醒。 只有在
retrieveSubscription和材料快照diff. - 云构成需要审计钩和回URL许可列表.
1. 说明计划a (billing.config.ts(中文(简体) )
与"Better Auth"相同的想法:软件包拥有Canonical模型;您检查生成的TypeScript,并通过细雨-kit拥有SQL.
import { defineBillingConfig } from "@codelitdev/billing/config";
export default defineBillingConfig({
dialect: "postgresql",
adapter: "drizzle",
output: "./src/db/schema/billing.generated.ts",
billableEntity: {
modelName: "organization",
tableImport: "./organizations",
tableExport: "organizations",
idColumn: "id",
idType: "uuid",
onDelete: "restrict",
},
payer: {
modelName: "user",
tableImport: "./auth",
tableExport: "user",
idColumn: "id",
idType: "text",
onDelete: "restrict",
},
planIds: ["pro", "business"],
requiredOfferKeys: [
"pro_month",
"pro_year",
"business_month",
"business_year",
],
additionalFields: {
checkoutAttempts: {
pendingTeamName: { type: "text", nullable: true },
},
},
});
planIds 为: 已支付 计划一直坚持在犬行。 免费计划或无卡试验留在您的桌子上 "free" 在这里.
然后说:
bun x codelit-billing generate --config apps/api/billing.config.ts
bun x drizzle-kit generate
bun x drizzle-kit migrate
CI 应该运行 generate --check 承诺 billing.generated.ts 无法漂移。 手编辑 SQL 用于回填( 例如种子一个 Free) billing_plan_states 行; 从不编辑生成的 TypeScript .
实例: examples/reference-product/, (中文(简体) ), examples/consumers/sendlit/。 。 。 .
2. 制作引擎
解开你自己的地窖 已键入多多选项 。 电线 Drizzle, 赠款,加密, 和产品效果.
当有多个产品共享一款多多业务时,多多将每个事件发送到每个webhook端点. 给每个产品自己的道多品牌, 创建自己的产品在这个品牌之下,并设定 brandId网络用户 brand_id 另一个品牌被存储为 ignored:它们被保存用于分解,但从未加工、再试验或隔离。 网络呼喊没有 brand_id 和以前一样处理。 见 ADR 0009 (韩语)。 。 。 .
要通过柠檬汁来销售, 做曲 createLemonSqueezyBillingProvider 从 @codelitdev/billing/providers/lemonsqueezy 与 apiKey,产品 storeId,和商店的 webhook webhookSecrets设置 checkoutProvider: "lemonsqueezy". Catalog 提供莱蒙 Squeezy 变体 ID 名称为 providerProductId。来自其他商店的 Webhooks 存储为 ignored. 试验来自变体,所以退出 trialDays; 立即适用计划修改,以及 do_not_bill 被拒绝更改到另一个间隔, 因为柠檬挤压的账单是一次性的。 关闭商店客户端设置中的计划更改: 引擎隔离一个没有开始的计划更改, 以及该订阅的后期webhooks.
import { systemClock } from "@codelitdev/billing/core";
import { createDrizzleBillingStore } from "@codelitdev/billing/drizzle";
import { createBilling } from "@codelitdev/billing/workflows";
import { createDodoBillingProvider } from "@codelitdev/billing/providers/dodo";
import { db } from "./db/client";
import * as billingSchema from "./db/billing.generated";
const clock = systemClock;
const store = createDrizzleBillingStore(db, {
schema: billingSchema,
clock,
planStateDefaults: { plan: "free", rampStage: 0, rampCleanStageDays: 0 },
checkoutApplicationFields: {
toColumns: (fields) => ({
pendingTeamName:
typeof fields.pendingTeamName === "string"
? fields.pendingTeamName
: null,
}),
fromRow: (row) => ({ pendingTeamName: row.pendingTeamName ?? null }),
},
});
export const billing = createBilling({
database: store,
providers: [
createDodoBillingProvider({
apiKey: process.env.DODO_PAYMENTS_API_KEY!,
environment: "test_mode", // or "live_mode"
webhookSecrets: [
{
version: "current",
secret: process.env.DODO_PAYMENTS_WEBHOOK_KEY_CURRENT!,
},
],
// Your product's Dodo brand. Other brands' webhooks are ignored.
brandId: process.env.DODO_BRAND_ID,
clock,
}),
],
clock,
authorization: yourGrantPort, // consume-once BillingAuthorizationPort
sensitiveValues: yourEncryptPort,
hooks: {
audit: yourAuditHook,
lifecycle: {
afterProjection: (input) =>
applyProductEffects(input, store.getTransaction() ?? db),
},
},
mode: "cloud", // "oss" forbids providers, offers, and catalog revision
checkoutProvider: "dodo",
requestedRevision: 1,
requiredOfferKeys: [
"pro_month",
"pro_year",
"business_month",
"business_year",
],
offers: yourOffers, // must match requiredOfferKeys exactly
returnUrlValidator: (url) =>
new URL(url).origin === new URL(process.env.WEB_CLIENT!).origin,
});
afterProjection 运行 内部 发动机交易 使用 store.getTransaction() ?? db 和做( 做) 没有 打开巢穴 db.transaction() 在根池客户端上—— 僵持状态 FOR UPDATE 作行相会. 如果您通过引擎客户端, 请在引擎客户端上写; 只有收到根时才有巢 db。 。 。 .
开放源码软件模式 : mode: "oss",空的供应商, checkoutProvider: "", (中文(简体) ), requestedRevision: null没有报价。 引擎拒绝退票; 您的产品会自动解锁付费能力 .
3. 启动时的目录
云启动验证配置形状 记录 请求的修订。 它不能阻止 Dodo 的普通API 准备状态 .
await billing.recordRequestedCatalog();
// later, from a worker / sweep — not on the hot health path
await billing.verifyRequestedCatalog();
只有在要求的修订得到核实时,才可进行离职 活动 修订。 待决或无效的请求集 checkoutAvailable: false 最后经核实的修订仍然是商业预测。 运算符放弃错误请求 abandonRequestedCatalog 返回配置后.
在创建提供者会话之前, 立即重新检查选中的产品 。 错配会冻结新的取出; 现有的订阅者会保存上次核实的映射 .
4. 人类收费行动
您的应用认证用户, 证明最近 认证 / CSRF , 不管你喜欢什么, 然后发布一个 单用途 BillingActionGrant。引擎消耗赠款 财务报告和审定财务报表 检查坚持付款人。 新的补助金不能将付款人换到现有的客户或订阅者.
const { checkoutUrl } = await billing.startCheckout({
grant,
entity: { kind: "organization", id: organizationId },
payer: { id: userId, email, name },
offerKey: "pro_month",
catalogRevision: displayedRevision, // the revision the UI showed
returnUrl,
trialDays: 14, // 0 if ineligible; the package does not decide eligibility
applicationFields: { pendingTeamName: "Acme Team" },
});
一个实体每次有一个开放式的离职,一个小时后失效. 再次开始相同报价的退票 。 如果同一付款人选择了另一个报价,例如每月一次,则该报价将取代开放式报价。 旧的退票变成了 abandoned,以及支持其过期时关闭页面的提供者。 如果它已经支付,它会变成 conflicted:它允许在没有其他情况下进入,否则第二次订阅对操作员进行隔离。 当另一个付款人的退票是开放的, startCheckout 失败 checkout_pending。 。 。 .
其他赠款行动: startPortal, (中文(简体) ), cancel,并修改计划:
await billing.startPlanChange({
grant,
entity: { kind: "organization", id: organizationId },
payer: { id: userId, email, name },
offerKey: "business_month",
catalogRevision: displayedRevision,
effectiveAt: "immediately", // or "next_billing_date"
prorationMode: "prorated_immediately", // or "do_not_bill"
});
提交用户看到的目录修订; 一个 stale 修订失败 catalog_changed 并可能包括目前的公共目录.
cancel, (中文(简体) ), resumeCancellation,以及 startPlanChange 更改后从提供者读取订阅, 所以 commercialState 反映在网络呼声到来之前. cancel 付款期结束时的注销。 订阅时将保留应享权利来源,直到 paidThroughAt,以及 commercialState 报告 cancelAtPeriodEnd: true 同时,显示结束日期并提议恢复. resumeCancellation 清除计划取消并使用 cancellation 赠款。 期限一结束,提供者的网页钩或 runDeadlineBatch 取消权利。 见 ADR 0010 (英语)。 。 。 .
const state = await billing.commercialState(organizationId);
if (state.cancelAtPeriodEnd) {
// "Pro stays active until {state.paidThroughAt}" and a resume button
}
await billing.resumeCancellation({ grant, entity, payer });
重新定向不是支付。 由webhook投影或对账产生的应享权利变化。 预定的计划更改不会重写订阅, 直至提供者的当前快照符合即将进行的尝试 .
5. 网络用户和工人
挂载每个提供者的网页 POST /webhooks/billing/<provider> 例如,在API上 /webhooks/billing/dodo。在每个产品中使用相同的路径,所以一个提供者的端点设置在产品中看起来是一样的.
保护 原始内容 躯体。 持久地插入事件后返回 2xx(包括复制件) 。 同步发射.
const ingested = await billing.ingestWebhook({
provider: "dodo",
raw: { body, headers },
});
res.status(ingested.duplicate ? 200 : 202).json({ accepted: true });
void billing.runWebhookInboxBatch({ workerId: `billing-${process.pid}` });
软件包不启动定时器 。 您的排程器应该打电话, 有独特的 workerId编号:
| 单位 | 任务 |
|---|---|
runWebhookInboxBatch | 经排水核实的事件(回收+项目) |
runReconciliationBatch | 排出卡住的退出, 计划更改, 和未调节的订阅( Q)discover: false ,然后运行队列任务 |
runDeadlineBatch | 到期未结结结账; 过期未结付应享权利 |
purgeExpiredSensitiveValues | 丢弃旧的加密取出 URL/ 重放有效载荷 |
verifyRequestedCatalog | 完成目录修订 |
算什么被卡住了 就可以被装扮成 createBilling({ reconciliation: { checkoutStaleAfterMs, planChangeStaleAfterMs, subscriptionStaleAfterMs } });默认时间为一分一小时. 每个未核对的订阅费用每个期间有一个供应商阅读.
多事件安全是数据库租赁,不是程序本地变种。 从不持有一个应用交易 通过一个提供者呼叫.
6. 阅读模型
普通路线不得调用提供者.
const catalog = await billing.publicCatalog();
// { revision, checkoutAvailable, offers: [{ key, plan, interval, amountMinor, currency, displayTrialDays }] }
// no provider product IDs
const commercial = await billing.commercialState(organizationId);
// activePaidPlan, subscriptionStatus, periods, pendingCheckout / pendingPlanChange, projectionVersion
// Inside your own transaction, for example when reserving quota:
await db.transaction(async (tx) => {
const locked = await billing.commercialState(organizationId, { transaction: tx });
// the Drizzle store locks the rows it read until tx ends
});
commercialState 是有偿访问的真相来源。 它会重新检查时钟,所以计划取消 停止计酬一次 paidThroughAt 在提供商最后活动之前 runDeadlineBatch. 决定从中解锁什么计划;不将付费状态复制到产品表中并重新确定.
以你的审判,自由计划,配额,和 canManageBilling。 。 。 . projectionVersion 是缓存密钥; 等同的 webhook 重放仅更新新鲜度, 不递增 .
关闭/ 删除/ 所有人降级前, 呼叫 getBillableEntityBillingBlockers 编号 : getPayerBillingResponsibilities 在与变异相同的交易中。 软件包报告阻塞器; 您决定了显示用户的错误 .
工作流失败 BillingWorkflowError 带有稳定代码( :catalog_changed, (中文(简体) ), payer_mismatch, (中文(简体) ), checkout_pending在应用程序中将其映射到 HTTP 。 不要坚持原始 SDK 消息 .
7. 运营商CLI
产品 CLI 调用 createOperations。每个变异需要 OperatorContext (单位:千美元)actorId边框 reason,可选票)并点击审计钩. 该包件不载运 Org/school CLI.
import { createOperations } from "@codelitdev/billing/operations";
const ops = createOperations({
billing,
clock,
requestedRevision: 1,
checkoutProvider: "dodo",
sensitiveValues,
});
await ops.health();
await ops.inspectWebhook(providerEventId);
await ops.retryWebhook(ctx, providerEventId, "preserve_attempts"); // also drains the inbox
await ops.reconcileEntity(ctx, billableEntityId); // requires a local entitlement row
await ops.projectProviderSubscription(ctx, {
providerSubscriptionId, // e.g. from the checkout return URL
checkoutAttemptId, // optional; defaults to snapshot metadata
});
await ops.adoptProviderSubscription(ctx, {
providerName: "lemonsqueezy",
providerSubscriptionId, // started with the provider before this package
entity: { kind: "organization", id: organizationId },
payer: { id: userId, email },
});
await ops.requestCancellation(ctx, subscriptionId);
const payload = await ops.inspectWebhookReplay(ctx, providerEventId);
adoptProviderSubscription 接管在引擎外创建的订阅,例如产品使用此包之前的订阅. 它将供应商的客户与付款人联系起来,并预测订阅情况;之后,续约、取消、恢复和门户正常工作。 订阅的产品必须在提供者的活性目录中,为同一个实体再次采用是安全的. 见 ADR 0014 (英语)。 。 。 .
reconcileEntity 无法创建付费但未经预测的离职单的订阅。 使用 projectProviderSubscription 提供商已经持有订阅身份证明,本地权益仍然缺失.
别 UPDATE billing_webhook_events SET status = 'pending' 手边。 重试或保留尝试计数器或启动 new_budget。 。 。 .
程序: docs/runbooks.md。 。 。 .
8. 测试
import {
runBillingProviderContract,
createWorkflowHarness,
} from "@codelitdev/billing/testing";
runBillingProviderContract(adapter)——每个适配器,包括CI中的多多与假和有门的沙盒测试对多多测试模式.createWorkflowHarness()——编目,结账,网约车订购,赠款,对账不设网络.
bun test 是决定性的。 活度多多沙盒测试停留在显式地洞门后.
此软件包不会做什么
- 产品路线、ts-rest、MCP或UI
- 授权、成员、公司社会责任基金或发行行动标志
- 安装/ 导入/ 启动时自动 SQL
- 共享计费数据库
- 用量、税费或发票
- 把用户从一个供应商移动到另一个供应商
- SendLit 名声、域名或“一个拥有的自由矿石”政策
版本
- 0.1.0-alpha.x (英语) ——实验工作流程. 公共核心/培训/提供者类型旨在保持稳定;工作流程/合同仍可改变。 安装
alpha标记, 不latest。 。 。 . - Schema-changing 软件包的放出会撞倒生成的 schema 版本. 升级:打击依赖性
generate* 审查《服务贸易总协定》 *drizzle-kit generate+ 应用回填 + 在产品中迁移 + 部署相容的API/工人. - 建筑和推出:
docs/architecture.md. 采掘业差异:docs/decisions/0001-extraction-discrepancies.md。 。 。 .
开发此 repo
bun install
bun test
bun run typecheck
bun run build
bun run test:packed # install the tarball via public exports only