forked from wangziqi/gongxue-base
feat: automate usage overage billing
This commit is contained in:
@@ -71,6 +71,11 @@ WORKER_PLATFORM_BILLING_BATCH_SIZE=50
|
|||||||
WORKER_PLATFORM_BILLING_DAYS_AHEAD=45
|
WORKER_PLATFORM_BILLING_DAYS_AHEAD=45
|
||||||
WORKER_PLATFORM_BILLING_DUE_DAYS=15
|
WORKER_PLATFORM_BILLING_DUE_DAYS=15
|
||||||
WORKER_PLATFORM_BILLING_ID=platform-billing-1
|
WORKER_PLATFORM_BILLING_ID=platform-billing-1
|
||||||
|
# 平台 SaaS 用量超额自动开票默认处理上一个自然月,也可用 YYYY-MM 补跑指定月份。
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE=100
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_MONTH=
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS=15
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_ID=platform-usage-overage-1
|
||||||
# 逾期催缴只标记 overdue、写 tenant_invoice_reminders 和审计,不自动停用租户。
|
# 逾期催缴只标记 overdue、写 tenant_invoice_reminders 和审计,不自动停用租户。
|
||||||
WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
|
WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
|
||||||
WORKER_PLATFORM_DUNNING_ID=platform-dunning-1
|
WORKER_PLATFORM_DUNNING_ID=platform-dunning-1
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -15,7 +15,7 @@
|
|||||||
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、学生批量 CRM 推送、审计日志。
|
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、学生批量 CRM 推送、审计日志。
|
||||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
|
||||||
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、平台用量自动采集 worker、用量记录、SaaS 套餐额度判定、用量超额账单候选预览/dry-run/生成、公共题库授权。
|
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、平台用量自动采集 worker、用量记录、SaaS 套餐额度判定、用量超额账单候选预览/dry-run/生成和自动开票 worker、公共题库授权。
|
||||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细;worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status` 按 `platform:question_bank:ops` 查看跨租户同步运营摘要。
|
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细;worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status` 按 `platform:question_bank:ops` 查看跨租户同步运营摘要。
|
||||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
| 会员/订单/优惠券/激活码 | √ 可联调 | 下单、订单详情/状态轮询、优惠券规则/核销、激活码、权益、退款状态机和对账地基已完成 |
|
| 会员/订单/优惠券/激活码 | √ 可联调 | 下单、订单详情/状态轮询、优惠券规则/核销、激活码、权益、退款状态机和对账地基已完成 |
|
||||||
| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云/腾讯云短信、微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产账号和回调域名上云后联调 |
|
| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云/腾讯云短信、微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产账号和回调域名上云后联调 |
|
||||||
| 租户后台运营 | √ 可联调 | 学生/班级、内容导入导出、营销、优惠券、积分、勋章、CRM、分佣、财务运营、主题和角色模板已具备第一版 |
|
| 租户后台运营 | √ 可联调 | 学生/班级、内容导入导出、营销、优惠券、积分、勋章、CRM、分佣、财务运营、主题和角色模板已具备第一版 |
|
||||||
| 平台 SaaS 账务 | √ 可联调 | 套餐、订阅、订阅账单、自动计费、用量采集、超额账单、收款、逾期催缴、外部通知和审计已具备 |
|
| 平台 SaaS 账务 | √ 可联调 | 套餐、订阅、订阅账单、自动计费、用量采集、超额账单 API/worker、收款、逾期催缴、外部通知和审计已具备 |
|
||||||
| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 |
|
| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 |
|
||||||
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
|
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
|
||||||
| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 |
|
| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 |
|
||||||
@@ -221,6 +221,21 @@ WORKER_PLATFORM_BILLING_DUE_DAYS=15
|
|||||||
WORKER_PLATFORM_BILLING_ID=platform-billing-prod-1
|
WORKER_PLATFORM_BILLING_ID=platform-billing-prod-1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
单次运行平台 SaaS 用量超额自动开票 worker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --workspace @tiku-saas/worker run platform-usage-overage:once
|
||||||
|
```
|
||||||
|
|
||||||
|
生产定时任务建议每月 1 日低峰先用 `WORKER_PLATFORM_USAGE_MONTH=上月 YYYY-MM` 运行 `platform-usage` 采集完整用量快照,再运行 `node dist/apps/worker/src/index.js --once --job platform-usage-overage` 自动生成上一个自然月的 `usage_overage` 账单。该 worker 复用后端统一超额计算服务,只读取 `tenant_usage_records`、SaaS 套餐 `included_quotas/overage_prices` 和订阅 metadata 覆盖,使用唯一索引和账单查重防重复开票。
|
||||||
|
|
||||||
|
```text
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE=100
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_MONTH=
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS=15
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_ID=platform-usage-overage-prod-1
|
||||||
|
```
|
||||||
|
|
||||||
单次运行平台 SaaS 逾期催缴 worker:
|
单次运行平台 SaaS 逾期催缴 worker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -624,6 +639,6 @@ git diff --check
|
|||||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
|
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
|
||||||
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
||||||
4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。
|
4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。
|
||||||
5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、平台超额账单定时生成 worker/失败告警、积分活动风控和连续签到奖励深化。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。
|
5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、平台超额账单失败告警、积分活动风控和连续签到奖励深化。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。
|
||||||
|
|
||||||
旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。
|
旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ import {
|
|||||||
requiredString,
|
requiredString,
|
||||||
requirePlatformAdmin,
|
requirePlatformAdmin,
|
||||||
} from '../../core/request.js';
|
} from '../../core/request.js';
|
||||||
import { query, queryOne, transaction } from '../../core/db.js';
|
import { pool, query, queryOne, transaction } from '../../core/db.js';
|
||||||
import {
|
import {
|
||||||
centsFrom,
|
centsFrom,
|
||||||
createInvoiceNo,
|
createInvoiceNo,
|
||||||
invoiceSubtotal,
|
createInvoiceRecordWithClient,
|
||||||
|
type CreateInvoiceInput,
|
||||||
normalizeHost,
|
normalizeHost,
|
||||||
normalizeInvoiceItems,
|
normalizeInvoiceItems,
|
||||||
normalizeSlug,
|
normalizeSlug,
|
||||||
processOverduePlatformInvoices,
|
processOverduePlatformInvoices,
|
||||||
|
processUsageOverageInvoices,
|
||||||
quantityFrom,
|
quantityFrom,
|
||||||
recalculateInvoiceTotals,
|
recalculateInvoiceTotals,
|
||||||
|
usageOverageCandidateQuery as loadUsageOverageInvoiceCandidates,
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|
||||||
function jsonBodyValue(value: unknown) {
|
function jsonBodyValue(value: unknown) {
|
||||||
@@ -349,12 +352,12 @@ function tenantInvoiceStatusFrom(value: string) {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageOverageInvoiceStatusFrom(value: string) {
|
function usageOverageInvoiceStatusFrom(value: string): 'draft' | 'issued' {
|
||||||
const status = tenantInvoiceStatusFrom(value);
|
const status = tenantInvoiceStatusFrom(value);
|
||||||
if (!['draft', 'issued'].includes(status)) {
|
if (!['draft', 'issued'].includes(status)) {
|
||||||
throw new HttpError(400, 'usage overage invoices can only be draft or issued', 'INVALID_INVOICE_STATUS');
|
throw new HttpError(400, 'usage overage invoices can only be draft or issued', 'INVALID_INVOICE_STATUS');
|
||||||
}
|
}
|
||||||
return status;
|
return status as 'draft' | 'issued';
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateTextFrom(value: unknown, key: string, required = true) {
|
function dateTextFrom(value: unknown, key: string, required = true) {
|
||||||
@@ -2424,95 +2427,6 @@ export async function tenantInvoicesRoute(ctx: RequestContext) {
|
|||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateInvoiceInput {
|
|
||||||
tenantId: string;
|
|
||||||
invoiceNo?: string;
|
|
||||||
invoiceType?: string;
|
|
||||||
status?: string;
|
|
||||||
currency?: string;
|
|
||||||
discountCents?: number;
|
|
||||||
taxCents?: number;
|
|
||||||
billingPeriodStart?: string | null;
|
|
||||||
billingPeriodEnd?: string | null;
|
|
||||||
dueDate?: string | null;
|
|
||||||
note?: string | null;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
items: ReturnType<typeof normalizeInvoiceItems>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInvoiceRecordWithClient(client: pg.PoolClient, input: CreateInvoiceInput) {
|
|
||||||
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
|
|
||||||
|
|
||||||
const discountCents = centsFrom(input.discountCents, 0);
|
|
||||||
const taxCents = centsFrom(input.taxCents, 0);
|
|
||||||
const subtotalCents = invoiceSubtotal(input.items);
|
|
||||||
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
|
|
||||||
const invoiceNo = input.invoiceNo || createInvoiceNo();
|
|
||||||
|
|
||||||
const invoiceResult = await client.query(
|
|
||||||
`
|
|
||||||
insert into public.tenant_invoices (
|
|
||||||
tenant_id, invoice_no, invoice_type, status, currency,
|
|
||||||
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
|
|
||||||
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
|
|
||||||
)
|
|
||||||
values (
|
|
||||||
$1, $2, $3, $4, $5,
|
|
||||||
$6, $7, $8, $9, 0, $9,
|
|
||||||
$10::date, $11::date, $12::date,
|
|
||||||
case when $4 = 'draft' then null else now() end,
|
|
||||||
$13, $14::jsonb
|
|
||||||
)
|
|
||||||
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
|
|
||||||
invoice_type as "invoiceType", status, total_cents as "totalCents",
|
|
||||||
balance_cents as "balanceCents", due_date as "dueDate",
|
|
||||||
created_at as "createdAt"
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
input.tenantId,
|
|
||||||
invoiceNo,
|
|
||||||
input.invoiceType || 'subscription',
|
|
||||||
input.status || 'issued',
|
|
||||||
input.currency || 'CNY',
|
|
||||||
subtotalCents,
|
|
||||||
discountCents,
|
|
||||||
taxCents,
|
|
||||||
totalCents,
|
|
||||||
input.billingPeriodStart || null,
|
|
||||||
input.billingPeriodEnd || null,
|
|
||||||
input.dueDate || null,
|
|
||||||
input.note || null,
|
|
||||||
JSON.stringify(input.metadata || {}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoice = invoiceResult.rows[0];
|
|
||||||
for (const itemInput of input.items) {
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
insert into public.tenant_invoice_items (
|
|
||||||
tenant_id, invoice_id, item_type, description,
|
|
||||||
quantity, unit_amount_cents, amount_cents, metadata
|
|
||||||
)
|
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
input.tenantId,
|
|
||||||
invoice.id,
|
|
||||||
itemInput.itemType,
|
|
||||||
itemInput.description,
|
|
||||||
itemInput.quantity,
|
|
||||||
itemInput.unitAmountCents,
|
|
||||||
Math.round(itemInput.quantity * itemInput.unitAmountCents),
|
|
||||||
JSON.stringify(itemInput.metadata || {}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await recalculateInvoiceTotals(client, invoice.id);
|
|
||||||
return invoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInvoiceRecord(input: CreateInvoiceInput) {
|
async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||||
return transaction(async client => createInvoiceRecordWithClient(client, input));
|
return transaction(async client => createInvoiceRecordWithClient(client, input));
|
||||||
}
|
}
|
||||||
@@ -2762,372 +2676,6 @@ export async function tenantUsageRoute(ctx: RequestContext) {
|
|||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageMetricSnapshot {
|
|
||||||
value: number;
|
|
||||||
recordId: string;
|
|
||||||
source: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageOverageItem {
|
|
||||||
itemType: string;
|
|
||||||
description: string;
|
|
||||||
quantity: number;
|
|
||||||
unitAmountCents: number;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageOverageCandidate {
|
|
||||||
tenantId: string;
|
|
||||||
tenantSlug: string;
|
|
||||||
tenantName: string;
|
|
||||||
billingStatus: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
planCode: string;
|
|
||||||
planName: string | null;
|
|
||||||
subscriptionStatus: string;
|
|
||||||
billingCycle: string | null;
|
|
||||||
periodStart: string;
|
|
||||||
periodEnd: string;
|
|
||||||
existingInvoiceId: string | null;
|
|
||||||
existingInvoiceNo: string | null;
|
|
||||||
existingInvoiceStatus: string | null;
|
|
||||||
hasExistingInvoice: boolean;
|
|
||||||
wouldCreate: boolean;
|
|
||||||
totalCents: number;
|
|
||||||
items: UsageOverageItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const USAGE_METRIC_ALIASES: Record<string, string[]> = {
|
|
||||||
students: ['students', 'studentCount'],
|
|
||||||
active_students: ['active_students', 'activeStudents', 'activeStudentCount'],
|
|
||||||
questions: ['questions', 'questionCount'],
|
|
||||||
assets: ['assets', 'assetCount'],
|
|
||||||
storage_gb: ['storage_gb', 'storageGb', 'storageGB', 'storage'],
|
|
||||||
videos: ['videos', 'videoCount'],
|
|
||||||
video_plays: ['video_plays', 'videoPlays', 'videoPlayCount'],
|
|
||||||
video_quota_consumed: ['video_quota_consumed', 'videoQuotaConsumed', 'videoQuota'],
|
|
||||||
paid_orders: ['paid_orders', 'paidOrders', 'paidOrderCount'],
|
|
||||||
paid_order_amount_cents: ['paid_order_amount_cents', 'paidOrderAmountCents', 'paidOrderGmvCents'],
|
|
||||||
active_entitlements: ['active_entitlements', 'activeEntitlements', 'activeEntitlementCount'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const USAGE_METRIC_LABELS: Record<string, string> = {
|
|
||||||
students: '学生数',
|
|
||||||
active_students: '活跃学生数',
|
|
||||||
questions: '题目数量',
|
|
||||||
assets: '资源数量',
|
|
||||||
storage_gb: '存储容量 GB',
|
|
||||||
videos: '视频数量',
|
|
||||||
video_plays: '视频播放次数',
|
|
||||||
video_quota_consumed: '视频次数消耗',
|
|
||||||
paid_orders: '已支付订单数',
|
|
||||||
paid_order_amount_cents: '已支付订单金额',
|
|
||||||
active_entitlements: '有效权益数',
|
|
||||||
};
|
|
||||||
|
|
||||||
function numberOrNull(value: unknown) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function positiveIntegerOrNull(value: unknown) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
||||||
return Math.max(1, Math.trunc(parsed));
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricAliases(metricKey: string) {
|
|
||||||
return [...new Set([metricKey, ...(USAGE_METRIC_ALIASES[metricKey] || [])])];
|
|
||||||
}
|
|
||||||
|
|
||||||
function camelMetricKey(metricKey: string) {
|
|
||||||
return metricKey.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function snakeMetricKey(metricKey: string) {
|
|
||||||
return metricKey.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nestedMetricSpec(source: Record<string, unknown>, metricKey: string) {
|
|
||||||
for (const key of metricAliases(metricKey)) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(source, key)) return source[key];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstMetricValue(sources: Array<Record<string, unknown> | null>, metricKey: string, suffixes: string[]) {
|
|
||||||
const camel = camelMetricKey(metricKey);
|
|
||||||
const snake = snakeMetricKey(metricKey);
|
|
||||||
const directKeys = metricAliases(metricKey);
|
|
||||||
const generatedKeys = suffixes.flatMap(suffix => [
|
|
||||||
`${camel}${suffix}`,
|
|
||||||
`${snake}_${suffix.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`).replace(/^_/, '')}`,
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
if (!source) continue;
|
|
||||||
for (const key of [...directKeys, ...generatedKeys]) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(source, key)) return source[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function quotaForMetric(metricKey: string, planQuotas: Record<string, unknown>, subscriptionMetadata: Record<string, unknown>) {
|
|
||||||
const metadataQuotaSources = [
|
|
||||||
objectOrNull(subscriptionMetadata.includedQuotas),
|
|
||||||
objectOrNull(subscriptionMetadata.quotas),
|
|
||||||
objectOrNull(subscriptionMetadata.quotaOverrides),
|
|
||||||
];
|
|
||||||
const sources = [...metadataQuotaSources, planQuotas];
|
|
||||||
const direct = firstMetricValue(sources, metricKey, ['Included', 'Quota', 'Limit']);
|
|
||||||
if (direct && typeof direct === 'object' && !Array.isArray(direct)) {
|
|
||||||
const spec = direct as Record<string, unknown>;
|
|
||||||
return numberOrNull(spec.included ?? spec.includedQuota ?? spec.quota ?? spec.limit ?? spec.value);
|
|
||||||
}
|
|
||||||
const numeric = numberOrNull(direct);
|
|
||||||
if (numeric !== null) return numeric;
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
if (!source) continue;
|
|
||||||
const nested = objectOrNull(nestedMetricSpec(source, metricKey));
|
|
||||||
if (nested) {
|
|
||||||
const value = numberOrNull(nested.included ?? nested.includedQuota ?? nested.quota ?? nested.limit ?? nested.value);
|
|
||||||
if (value !== null) return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function priceForMetric(metricKey: string, planPrices: Record<string, unknown>, subscriptionMetadata: Record<string, unknown>) {
|
|
||||||
const metadataPriceSources = [
|
|
||||||
objectOrNull(subscriptionMetadata.overagePrices),
|
|
||||||
objectOrNull(subscriptionMetadata.overagePriceOverrides),
|
|
||||||
objectOrNull(subscriptionMetadata.prices),
|
|
||||||
];
|
|
||||||
const sources = [...metadataPriceSources, planPrices];
|
|
||||||
const suffixes = [
|
|
||||||
'UnitAmountCents',
|
|
||||||
'AmountCents',
|
|
||||||
'PriceCents',
|
|
||||||
'OverageCents',
|
|
||||||
'PerUnitCents',
|
|
||||||
'ExtraCents',
|
|
||||||
'ExtraPerMonthCents',
|
|
||||||
'ExtraPerYearCents',
|
|
||||||
'PerMonthCents',
|
|
||||||
'PerYearCents',
|
|
||||||
];
|
|
||||||
const direct = firstMetricValue(sources, metricKey, suffixes);
|
|
||||||
const directObject = objectOrNull(direct);
|
|
||||||
let unitAmountCents = directObject
|
|
||||||
? positiveIntegerOrNull(directObject.unitAmountCents ?? directObject.amountCents ?? directObject.priceCents ?? directObject.overageCents ?? directObject.cents ?? directObject.perUnitCents)
|
|
||||||
: positiveIntegerOrNull(direct);
|
|
||||||
let unitSize = directObject ? numberOrNull(directObject.unitSize ?? directObject.step ?? directObject.per ?? directObject.quantityUnit) : null;
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
if (!source) continue;
|
|
||||||
const nested = objectOrNull(nestedMetricSpec(source, metricKey));
|
|
||||||
if (!nested) continue;
|
|
||||||
unitAmountCents = unitAmountCents ?? positiveIntegerOrNull(nested.unitAmountCents ?? nested.amountCents ?? nested.priceCents ?? nested.overageCents ?? nested.cents ?? nested.perUnitCents);
|
|
||||||
unitSize = unitSize ?? numberOrNull(nested.unitSize ?? nested.step ?? nested.per ?? nested.quantityUnit);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!unitAmountCents) return null;
|
|
||||||
return {
|
|
||||||
unitAmountCents,
|
|
||||||
unitSize: unitSize && unitSize > 0 ? unitSize : 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function usageSnapshotMap(value: unknown) {
|
|
||||||
const usage = objectOrNull(value) || {};
|
|
||||||
const output: Record<string, UsageMetricSnapshot> = {};
|
|
||||||
for (const [metricKey, rawSnapshot] of Object.entries(usage)) {
|
|
||||||
const snapshot = objectOrNull(rawSnapshot);
|
|
||||||
if (!snapshot) continue;
|
|
||||||
const metricValue = numberOrNull(snapshot.value);
|
|
||||||
if (metricValue === null) continue;
|
|
||||||
output[metricKey] = {
|
|
||||||
value: metricValue,
|
|
||||||
recordId: String(snapshot.recordId || ''),
|
|
||||||
source: typeof snapshot.source === 'string' ? snapshot.source : null,
|
|
||||||
createdAt: typeof snapshot.createdAt === 'string' ? snapshot.createdAt : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUsageOverageItems(row: {
|
|
||||||
planCode: string;
|
|
||||||
planName: string | null;
|
|
||||||
includedQuotas: Record<string, unknown> | null;
|
|
||||||
overagePrices: Record<string, unknown> | null;
|
|
||||||
subscriptionMetadata: Record<string, unknown> | null;
|
|
||||||
usageSnapshots: unknown;
|
|
||||||
}) {
|
|
||||||
const planQuotas = row.includedQuotas || {};
|
|
||||||
const planPrices = row.overagePrices || {};
|
|
||||||
const subscriptionMetadata = row.subscriptionMetadata || {};
|
|
||||||
const usage = usageSnapshotMap(row.usageSnapshots);
|
|
||||||
const items: UsageOverageItem[] = [];
|
|
||||||
|
|
||||||
for (const [metricKey, snapshot] of Object.entries(usage)) {
|
|
||||||
const includedQuota = quotaForMetric(metricKey, planQuotas, subscriptionMetadata);
|
|
||||||
const price = priceForMetric(metricKey, planPrices, subscriptionMetadata);
|
|
||||||
if (includedQuota === null || !price) continue;
|
|
||||||
const overageValue = snapshot.value - includedQuota;
|
|
||||||
if (overageValue <= 0) continue;
|
|
||||||
|
|
||||||
const billableUnits = Math.ceil(overageValue / price.unitSize);
|
|
||||||
if (billableUnits <= 0) continue;
|
|
||||||
const label = USAGE_METRIC_LABELS[metricKey] || metricKey;
|
|
||||||
items.push({
|
|
||||||
itemType: 'usage_overage',
|
|
||||||
description: `${row.planName || row.planCode} ${label}超额 ${Number(overageValue.toFixed(4))}`,
|
|
||||||
quantity: billableUnits,
|
|
||||||
unitAmountCents: price.unitAmountCents,
|
|
||||||
metadata: {
|
|
||||||
metricKey,
|
|
||||||
metricLabel: label,
|
|
||||||
metricValue: snapshot.value,
|
|
||||||
includedQuota,
|
|
||||||
overageValue: Number(overageValue.toFixed(4)),
|
|
||||||
billableUnits,
|
|
||||||
unitSize: price.unitSize,
|
|
||||||
unitAmountCents: price.unitAmountCents,
|
|
||||||
usageRecordId: snapshot.recordId || null,
|
|
||||||
usageSource: snapshot.source,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function usageOverageCandidateQuery(params: {
|
|
||||||
tenantIds: string[];
|
|
||||||
periodStart: string;
|
|
||||||
periodEnd: string;
|
|
||||||
includeExisting: boolean;
|
|
||||||
includeZero: boolean;
|
|
||||||
limit: number;
|
|
||||||
}) {
|
|
||||||
const rows = await query<{
|
|
||||||
tenantId: string;
|
|
||||||
tenantSlug: string;
|
|
||||||
tenantName: string;
|
|
||||||
billingStatus: string;
|
|
||||||
subscriptionId: string;
|
|
||||||
planCode: string;
|
|
||||||
planName: string | null;
|
|
||||||
subscriptionStatus: string;
|
|
||||||
billingCycle: string | null;
|
|
||||||
includedQuotas: Record<string, unknown> | null;
|
|
||||||
overagePrices: Record<string, unknown> | null;
|
|
||||||
subscriptionMetadata: Record<string, unknown> | null;
|
|
||||||
usageSnapshots: unknown;
|
|
||||||
existingInvoiceId: string | null;
|
|
||||||
existingInvoiceNo: string | null;
|
|
||||||
existingInvoiceStatus: string | null;
|
|
||||||
}>(
|
|
||||||
`
|
|
||||||
select t.id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
|
||||||
t.billing_status as "billingStatus",
|
|
||||||
s.id as "subscriptionId", s.plan_code as "planCode",
|
|
||||||
p.name as "planName", s.status as "subscriptionStatus",
|
|
||||||
s.billing_cycle as "billingCycle",
|
|
||||||
p.included_quotas as "includedQuotas",
|
|
||||||
p.overage_prices as "overagePrices",
|
|
||||||
s.metadata as "subscriptionMetadata",
|
|
||||||
coalesce(usage_snapshots.metrics, '{}'::jsonb) as "usageSnapshots",
|
|
||||||
existing.id as "existingInvoiceId",
|
|
||||||
existing.invoice_no as "existingInvoiceNo",
|
|
||||||
existing.status as "existingInvoiceStatus"
|
|
||||||
from public.tenants t
|
|
||||||
join lateral (
|
|
||||||
select id, tenant_id, plan_code, status, billing_cycle, metadata, created_at, expires_at
|
|
||||||
from public.tenant_subscriptions
|
|
||||||
where tenant_id = t.id
|
|
||||||
and status in ('trial', 'active', 'past_due')
|
|
||||||
order by case status when 'active' then 0 when 'trial' then 1 else 2 end,
|
|
||||||
expires_at desc nulls last,
|
|
||||||
created_at desc
|
|
||||||
limit 1
|
|
||||||
) s on true
|
|
||||||
join public.platform_saas_plans p on p.code = s.plan_code
|
|
||||||
left join lateral (
|
|
||||||
select jsonb_object_agg(metric_key, jsonb_build_object(
|
|
||||||
'value', metric_value,
|
|
||||||
'recordId', id,
|
|
||||||
'source', metadata->>'source',
|
|
||||||
'createdAt', created_at
|
|
||||||
)) as metrics
|
|
||||||
from (
|
|
||||||
select distinct on (u.metric_key)
|
|
||||||
u.id, u.metric_key, u.metric_value, u.metadata, u.created_at
|
|
||||||
from public.tenant_usage_records u
|
|
||||||
where u.tenant_id = t.id
|
|
||||||
and u.period_start = $2::date
|
|
||||||
and u.period_end = $3::date
|
|
||||||
order by u.metric_key,
|
|
||||||
case when u.metadata->>'source' = 'platform_usage_worker' then 0 else 1 end,
|
|
||||||
u.created_at desc
|
|
||||||
) latest
|
|
||||||
) usage_snapshots on true
|
|
||||||
left join lateral (
|
|
||||||
select id, invoice_no, status
|
|
||||||
from public.tenant_invoices i
|
|
||||||
where i.tenant_id = t.id
|
|
||||||
and i.invoice_type = 'usage_overage'
|
|
||||||
and i.status <> 'void'
|
|
||||||
and i.billing_period_start = $2::date
|
|
||||||
and i.billing_period_end = $3::date
|
|
||||||
and i.metadata->>'source' = 'usage_overage_auto'
|
|
||||||
order by i.created_at desc
|
|
||||||
limit 1
|
|
||||||
) existing on true
|
|
||||||
where t.status = 'active'
|
|
||||||
and ($1::uuid[] = '{}'::uuid[] or t.id = any($1::uuid[]))
|
|
||||||
and ($4::boolean = true or existing.id is null)
|
|
||||||
order by t.created_at asc
|
|
||||||
limit $5
|
|
||||||
`,
|
|
||||||
[params.tenantIds, params.periodStart, params.periodEnd, params.includeExisting, params.limit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const candidates: UsageOverageCandidate[] = rows.map(row => {
|
|
||||||
const items = buildUsageOverageItems(row);
|
|
||||||
return {
|
|
||||||
tenantId: row.tenantId,
|
|
||||||
tenantSlug: row.tenantSlug,
|
|
||||||
tenantName: row.tenantName,
|
|
||||||
billingStatus: row.billingStatus,
|
|
||||||
subscriptionId: row.subscriptionId,
|
|
||||||
planCode: row.planCode,
|
|
||||||
planName: row.planName,
|
|
||||||
subscriptionStatus: row.subscriptionStatus,
|
|
||||||
billingCycle: row.billingCycle,
|
|
||||||
periodStart: params.periodStart,
|
|
||||||
periodEnd: params.periodEnd,
|
|
||||||
existingInvoiceId: row.existingInvoiceId,
|
|
||||||
existingInvoiceNo: row.existingInvoiceNo,
|
|
||||||
existingInvoiceStatus: row.existingInvoiceStatus,
|
|
||||||
hasExistingInvoice: Boolean(row.existingInvoiceId),
|
|
||||||
wouldCreate: items.length > 0 && !row.existingInvoiceId,
|
|
||||||
totalCents: invoiceSubtotal(items),
|
|
||||||
items,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return candidates.filter(item => params.includeZero || item.items.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function usageOverageInvoiceCandidatesRoute(ctx: RequestContext) {
|
export async function usageOverageInvoiceCandidatesRoute(ctx: RequestContext) {
|
||||||
await requirePlatformAdmin(ctx, 'platform:billing:read');
|
await requirePlatformAdmin(ctx, 'platform:billing:read');
|
||||||
|
|
||||||
@@ -3138,7 +2686,7 @@ export async function usageOverageInvoiceCandidatesRoute(ctx: RequestContext) {
|
|||||||
const includeExisting = listQuery(ctx, 'includeExisting') === 'true';
|
const includeExisting = listQuery(ctx, 'includeExisting') === 'true';
|
||||||
const includeZero = listQuery(ctx, 'includeZero') === 'true';
|
const includeZero = listQuery(ctx, 'includeZero') === 'true';
|
||||||
const limit = intParam(ctx, 'limit', 100, 500);
|
const limit = intParam(ctx, 'limit', 100, 500);
|
||||||
const items = await usageOverageCandidateQuery({ tenantIds, periodStart, periodEnd, includeExisting, includeZero, limit });
|
const items = await loadUsageOverageInvoiceCandidates(pool, { tenantIds, periodStart, periodEnd, includeExisting, includeZero, limit });
|
||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
@@ -3460,155 +3008,20 @@ export async function createTenantInvoicesFromUsageOverageRoute(ctx: RequestCont
|
|||||||
const dryRun = booleanFrom(body.dryRun, false);
|
const dryRun = booleanFrom(body.dryRun, false);
|
||||||
const limit = Math.min(Math.max(tenantIds.length || 0, 100), 500);
|
const limit = Math.min(Math.max(tenantIds.length || 0, 100), 500);
|
||||||
|
|
||||||
const candidates = await usageOverageCandidateQuery({
|
const session = currentSessionFromContext(ctx);
|
||||||
|
const result = await transaction(async client => processUsageOverageInvoices(client, {
|
||||||
tenantIds,
|
tenantIds,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
includeExisting: true,
|
|
||||||
includeZero: false,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!candidates.length) {
|
|
||||||
return { item: { dryRun, createdCount: 0, skippedCount: 0, totalCents: 0, items: [], skipped: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
const wouldCreate = candidates.filter(item => !item.hasExistingInvoice);
|
|
||||||
const existing = candidates.filter(item => item.hasExistingInvoice);
|
|
||||||
return {
|
|
||||||
item: {
|
|
||||||
dryRun: true,
|
|
||||||
createdCount: 0,
|
|
||||||
skippedCount: existing.length,
|
|
||||||
totalCents: wouldCreate.reduce((sum, item) => sum + item.totalCents, 0),
|
|
||||||
items: candidates.map(item => ({ ...item, wouldCreate: !item.hasExistingInvoice })),
|
|
||||||
skipped: existing.map(item => ({
|
|
||||||
tenantId: item.tenantId,
|
|
||||||
subscriptionId: item.subscriptionId,
|
|
||||||
reason: 'USAGE_OVERAGE_INVOICE_EXISTS',
|
|
||||||
invoiceId: item.existingInvoiceId,
|
|
||||||
invoiceNo: item.existingInvoiceNo,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await transaction(async client => {
|
|
||||||
const created: Array<UsageOverageCandidate & { invoice: Record<string, unknown> }> = [];
|
|
||||||
const skipped: unknown[] = [];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const lock = await client.query(
|
|
||||||
`
|
|
||||||
select id
|
|
||||||
from public.tenant_subscriptions
|
|
||||||
where tenant_id = $1
|
|
||||||
and id = $2
|
|
||||||
for update
|
|
||||||
`,
|
|
||||||
[candidate.tenantId, candidate.subscriptionId],
|
|
||||||
);
|
|
||||||
if (!lock.rows[0]) {
|
|
||||||
skipped.push({ tenantId: candidate.tenantId, subscriptionId: candidate.subscriptionId, reason: 'SUBSCRIPTION_NOT_FOUND' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await client.query<{ id: string; invoiceNo: string; status: string }>(
|
|
||||||
`
|
|
||||||
select id, invoice_no as "invoiceNo", status
|
|
||||||
from public.tenant_invoices
|
|
||||||
where tenant_id = $1
|
|
||||||
and invoice_type = 'usage_overage'
|
|
||||||
and status <> 'void'
|
|
||||||
and billing_period_start = $2::date
|
|
||||||
and billing_period_end = $3::date
|
|
||||||
and metadata->>'source' = 'usage_overage_auto'
|
|
||||||
order by created_at desc
|
|
||||||
limit 1
|
|
||||||
`,
|
|
||||||
[candidate.tenantId, periodStart, periodEnd],
|
|
||||||
);
|
|
||||||
if (existing.rows[0]) {
|
|
||||||
skipped.push({
|
|
||||||
tenantId: candidate.tenantId,
|
|
||||||
subscriptionId: candidate.subscriptionId,
|
|
||||||
reason: 'USAGE_OVERAGE_INVOICE_EXISTS',
|
|
||||||
invoiceId: existing.rows[0].id,
|
|
||||||
invoiceNo: existing.rows[0].invoiceNo,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidate.items.length) {
|
|
||||||
skipped.push({ tenantId: candidate.tenantId, subscriptionId: candidate.subscriptionId, reason: 'NO_USAGE_OVERAGE' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoice = await createInvoiceRecordWithClient(client, {
|
|
||||||
tenantId: candidate.tenantId,
|
|
||||||
invoiceType: 'usage_overage',
|
|
||||||
status,
|
|
||||||
dueDate,
|
dueDate,
|
||||||
billingPeriodStart: periodStart,
|
status,
|
||||||
billingPeriodEnd: periodEnd,
|
|
||||||
note,
|
note,
|
||||||
metadata: {
|
dryRun,
|
||||||
source: 'usage_overage_auto',
|
limit,
|
||||||
periodStart,
|
actorUserId: session?.id || null,
|
||||||
periodEnd,
|
ipAddress: requestIp(ctx),
|
||||||
subscriptionId: candidate.subscriptionId,
|
userAgent: getHeader(ctx.req, 'user-agent') || null,
|
||||||
planCode: candidate.planCode,
|
}));
|
||||||
tenantSlug: candidate.tenantSlug,
|
|
||||||
},
|
|
||||||
items: candidate.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
metadata: {
|
|
||||||
...item.metadata,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
subscriptionId: candidate.subscriptionId,
|
|
||||||
planCode: candidate.planCode,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
await recordPlatformAudit(client, ctx, 'platform.invoice.usage_overage_created', 'tenant_invoice', invoice.id, {
|
|
||||||
tenantId: candidate.tenantId,
|
|
||||||
subscriptionId: candidate.subscriptionId,
|
|
||||||
planCode: candidate.planCode,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
totalCents: candidate.totalCents,
|
|
||||||
metrics: candidate.items.map(item => item.metadata.metricKey),
|
|
||||||
invoiceNo: invoice.invoiceNo,
|
|
||||||
}, candidate.tenantId);
|
|
||||||
|
|
||||||
created.push({ ...candidate, invoice });
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCents = created.reduce((sum, item) => sum + Number(item.totalCents || 0), 0);
|
|
||||||
|
|
||||||
await recordPlatformAudit(client, ctx, 'platform.invoice.usage_overage_batch_created', 'tenant_invoice_batch', null, {
|
|
||||||
createdCount: created.length,
|
|
||||||
skippedCount: skipped.length,
|
|
||||||
totalCents,
|
|
||||||
tenantIds,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
dueDate,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
dryRun: false,
|
|
||||||
createdCount: created.length,
|
|
||||||
skippedCount: skipped.length,
|
|
||||||
totalCents,
|
|
||||||
items: created,
|
|
||||||
skipped,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { item: result };
|
return { item: result };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type pg from 'pg';
|
import type pg from 'pg';
|
||||||
|
import { HttpError } from '../../core/errors.js';
|
||||||
|
|
||||||
export function createInvoiceNo(prefix = 'BILL') {
|
export function createInvoiceNo(prefix = 'BILL') {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -45,6 +46,22 @@ export interface InvoiceItemInput {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceInput {
|
||||||
|
tenantId: string;
|
||||||
|
invoiceNo?: string;
|
||||||
|
invoiceType?: string;
|
||||||
|
status?: string;
|
||||||
|
currency?: string;
|
||||||
|
discountCents?: number;
|
||||||
|
taxCents?: number;
|
||||||
|
billingPeriodStart?: string | null;
|
||||||
|
billingPeriodEnd?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
items: InvoiceItemInput[];
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] {
|
export function normalizeInvoiceItems(items: unknown): InvoiceItemInput[] {
|
||||||
if (!Array.isArray(items)) return [];
|
if (!Array.isArray(items)) return [];
|
||||||
|
|
||||||
@@ -116,6 +133,683 @@ export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId:
|
|||||||
return updateResult.rows[0];
|
return updateResult.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createInvoiceRecordWithClient(client: pg.PoolClient, input: CreateInvoiceInput) {
|
||||||
|
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
|
||||||
|
|
||||||
|
const discountCents = centsFrom(input.discountCents, 0);
|
||||||
|
const taxCents = centsFrom(input.taxCents, 0);
|
||||||
|
const subtotalCents = invoiceSubtotal(input.items);
|
||||||
|
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
|
||||||
|
const invoiceNo = input.invoiceNo || createInvoiceNo();
|
||||||
|
|
||||||
|
const invoiceResult = await client.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_invoices (
|
||||||
|
tenant_id, invoice_no, invoice_type, status, currency,
|
||||||
|
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
|
||||||
|
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6, $7, $8, $9, 0, $9,
|
||||||
|
$10::date, $11::date, $12::date,
|
||||||
|
case when $4 = 'draft' then null else now() end,
|
||||||
|
$13, $14::jsonb
|
||||||
|
)
|
||||||
|
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
|
||||||
|
invoice_type as "invoiceType", status, total_cents as "totalCents",
|
||||||
|
balance_cents as "balanceCents", due_date as "dueDate",
|
||||||
|
created_at as "createdAt"
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.tenantId,
|
||||||
|
invoiceNo,
|
||||||
|
input.invoiceType || 'subscription',
|
||||||
|
input.status || 'issued',
|
||||||
|
input.currency || 'CNY',
|
||||||
|
subtotalCents,
|
||||||
|
discountCents,
|
||||||
|
taxCents,
|
||||||
|
totalCents,
|
||||||
|
input.billingPeriodStart || null,
|
||||||
|
input.billingPeriodEnd || null,
|
||||||
|
input.dueDate || null,
|
||||||
|
input.note || null,
|
||||||
|
JSON.stringify(input.metadata || {}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoice = invoiceResult.rows[0];
|
||||||
|
for (const itemInput of input.items) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_invoice_items (
|
||||||
|
tenant_id, invoice_id, item_type, description,
|
||||||
|
quantity, unit_amount_cents, amount_cents, metadata
|
||||||
|
)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.tenantId,
|
||||||
|
invoice.id,
|
||||||
|
itemInput.itemType,
|
||||||
|
itemInput.description,
|
||||||
|
itemInput.quantity,
|
||||||
|
itemInput.unitAmountCents,
|
||||||
|
Math.round(itemInput.quantity * itemInput.unitAmountCents),
|
||||||
|
JSON.stringify(itemInput.metadata || {}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await recalculateInvoiceTotals(client, invoice.id);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageMetricSnapshot {
|
||||||
|
value: number;
|
||||||
|
recordId: string;
|
||||||
|
source: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageOverageItem {
|
||||||
|
itemType: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitAmountCents: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageOverageCandidate {
|
||||||
|
tenantId: string;
|
||||||
|
tenantSlug: string;
|
||||||
|
tenantName: string;
|
||||||
|
billingStatus: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
planCode: string;
|
||||||
|
planName: string | null;
|
||||||
|
subscriptionStatus: string;
|
||||||
|
billingCycle: string | null;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
existingInvoiceId: string | null;
|
||||||
|
existingInvoiceNo: string | null;
|
||||||
|
existingInvoiceStatus: string | null;
|
||||||
|
hasExistingInvoice: boolean;
|
||||||
|
wouldCreate: boolean;
|
||||||
|
totalCents: number;
|
||||||
|
items: UsageOverageItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageOverageInvoiceBatchOptions {
|
||||||
|
tenantIds?: string[];
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
dueDate?: string | null;
|
||||||
|
status?: 'draft' | 'issued';
|
||||||
|
note?: string | null;
|
||||||
|
dryRun?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
workerId?: string | null;
|
||||||
|
actorUserId?: string | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE_METRIC_ALIASES: Record<string, string[]> = {
|
||||||
|
students: ['students', 'studentCount'],
|
||||||
|
active_students: ['active_students', 'activeStudents', 'activeStudentCount'],
|
||||||
|
questions: ['questions', 'questionCount'],
|
||||||
|
assets: ['assets', 'assetCount'],
|
||||||
|
storage_gb: ['storage_gb', 'storageGb', 'storageGB', 'storage'],
|
||||||
|
videos: ['videos', 'videoCount'],
|
||||||
|
video_plays: ['video_plays', 'videoPlays', 'videoPlayCount'],
|
||||||
|
video_quota_consumed: ['video_quota_consumed', 'videoQuotaConsumed', 'videoQuota'],
|
||||||
|
paid_orders: ['paid_orders', 'paidOrders', 'paidOrderCount'],
|
||||||
|
paid_order_amount_cents: ['paid_order_amount_cents', 'paidOrderAmountCents', 'paidOrderGmvCents'],
|
||||||
|
active_entitlements: ['active_entitlements', 'activeEntitlements', 'activeEntitlementCount'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const USAGE_METRIC_LABELS: Record<string, string> = {
|
||||||
|
students: '学生数',
|
||||||
|
active_students: '活跃学生数',
|
||||||
|
questions: '题目数量',
|
||||||
|
assets: '资源数量',
|
||||||
|
storage_gb: '存储容量 GB',
|
||||||
|
videos: '视频数量',
|
||||||
|
video_plays: '视频播放次数',
|
||||||
|
video_quota_consumed: '视频次数消耗',
|
||||||
|
paid_orders: '已支付订单数',
|
||||||
|
paid_order_amount_cents: '已支付订单金额',
|
||||||
|
active_entitlements: '有效权益数',
|
||||||
|
};
|
||||||
|
|
||||||
|
function numberOrNull(value: unknown) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveIntegerOrNull(value: unknown) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||||
|
return Math.max(1, Math.trunc(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricAliases(metricKey: string) {
|
||||||
|
return [...new Set([metricKey, ...(USAGE_METRIC_ALIASES[metricKey] || [])])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function camelMetricKey(metricKey: string) {
|
||||||
|
return metricKey.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function snakeMetricKey(metricKey: string) {
|
||||||
|
return metricKey.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nestedMetricSpec(source: Record<string, unknown>, metricKey: string) {
|
||||||
|
for (const key of metricAliases(metricKey)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) return source[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMetricValue(sources: Array<Record<string, unknown> | null>, metricKey: string, suffixes: string[]) {
|
||||||
|
const camel = camelMetricKey(metricKey);
|
||||||
|
const snake = snakeMetricKey(metricKey);
|
||||||
|
const directKeys = metricAliases(metricKey);
|
||||||
|
const generatedKeys = suffixes.flatMap(suffix => [
|
||||||
|
`${camel}${suffix}`,
|
||||||
|
`${snake}_${suffix.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`).replace(/^_/, '')}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
for (const key of [...directKeys, ...generatedKeys]) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) return source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaForMetric(metricKey: string, planQuotas: Record<string, unknown>, subscriptionMetadata: Record<string, unknown>) {
|
||||||
|
const metadataQuotaSources = [
|
||||||
|
objectOrNull(subscriptionMetadata.includedQuotas),
|
||||||
|
objectOrNull(subscriptionMetadata.quotas),
|
||||||
|
objectOrNull(subscriptionMetadata.quotaOverrides),
|
||||||
|
];
|
||||||
|
const sources = [...metadataQuotaSources, planQuotas];
|
||||||
|
const direct = firstMetricValue(sources, metricKey, ['Included', 'Quota', 'Limit']);
|
||||||
|
if (direct && typeof direct === 'object' && !Array.isArray(direct)) {
|
||||||
|
const spec = direct as Record<string, unknown>;
|
||||||
|
return numberOrNull(spec.included ?? spec.includedQuota ?? spec.quota ?? spec.limit ?? spec.value);
|
||||||
|
}
|
||||||
|
const numeric = numberOrNull(direct);
|
||||||
|
if (numeric !== null) return numeric;
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
const nested = objectOrNull(nestedMetricSpec(source, metricKey));
|
||||||
|
if (nested) {
|
||||||
|
const value = numberOrNull(nested.included ?? nested.includedQuota ?? nested.quota ?? nested.limit ?? nested.value);
|
||||||
|
if (value !== null) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceForMetric(metricKey: string, planPrices: Record<string, unknown>, subscriptionMetadata: Record<string, unknown>) {
|
||||||
|
const metadataPriceSources = [
|
||||||
|
objectOrNull(subscriptionMetadata.overagePrices),
|
||||||
|
objectOrNull(subscriptionMetadata.overagePriceOverrides),
|
||||||
|
objectOrNull(subscriptionMetadata.prices),
|
||||||
|
];
|
||||||
|
const sources = [...metadataPriceSources, planPrices];
|
||||||
|
const suffixes = [
|
||||||
|
'UnitAmountCents',
|
||||||
|
'AmountCents',
|
||||||
|
'PriceCents',
|
||||||
|
'OverageCents',
|
||||||
|
'PerUnitCents',
|
||||||
|
'ExtraCents',
|
||||||
|
'ExtraPerMonthCents',
|
||||||
|
'ExtraPerYearCents',
|
||||||
|
'PerMonthCents',
|
||||||
|
'PerYearCents',
|
||||||
|
];
|
||||||
|
const direct = firstMetricValue(sources, metricKey, suffixes);
|
||||||
|
const directObject = objectOrNull(direct);
|
||||||
|
let unitAmountCents = directObject
|
||||||
|
? positiveIntegerOrNull(directObject.unitAmountCents ?? directObject.amountCents ?? directObject.priceCents ?? directObject.overageCents ?? directObject.cents ?? directObject.perUnitCents)
|
||||||
|
: positiveIntegerOrNull(direct);
|
||||||
|
let unitSize = directObject ? numberOrNull(directObject.unitSize ?? directObject.step ?? directObject.per ?? directObject.quantityUnit) : null;
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
const nested = objectOrNull(nestedMetricSpec(source, metricKey));
|
||||||
|
if (!nested) continue;
|
||||||
|
unitAmountCents = unitAmountCents ?? positiveIntegerOrNull(nested.unitAmountCents ?? nested.amountCents ?? nested.priceCents ?? nested.overageCents ?? nested.cents ?? nested.perUnitCents);
|
||||||
|
unitSize = unitSize ?? numberOrNull(nested.unitSize ?? nested.step ?? nested.per ?? nested.quantityUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unitAmountCents) return null;
|
||||||
|
return {
|
||||||
|
unitAmountCents,
|
||||||
|
unitSize: unitSize && unitSize > 0 ? unitSize : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageSnapshotMap(value: unknown) {
|
||||||
|
const usage = objectOrNull(value) || {};
|
||||||
|
const output: Record<string, UsageMetricSnapshot> = {};
|
||||||
|
for (const [metricKey, rawSnapshot] of Object.entries(usage)) {
|
||||||
|
const snapshot = objectOrNull(rawSnapshot);
|
||||||
|
if (!snapshot) continue;
|
||||||
|
const metricValue = numberOrNull(snapshot.value);
|
||||||
|
if (metricValue === null) continue;
|
||||||
|
output[metricKey] = {
|
||||||
|
value: metricValue,
|
||||||
|
recordId: String(snapshot.recordId || ''),
|
||||||
|
source: typeof snapshot.source === 'string' ? snapshot.source : null,
|
||||||
|
createdAt: typeof snapshot.createdAt === 'string' ? snapshot.createdAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsageOverageItems(row: {
|
||||||
|
planCode: string;
|
||||||
|
planName: string | null;
|
||||||
|
includedQuotas: Record<string, unknown> | null;
|
||||||
|
overagePrices: Record<string, unknown> | null;
|
||||||
|
subscriptionMetadata: Record<string, unknown> | null;
|
||||||
|
usageSnapshots: unknown;
|
||||||
|
}) {
|
||||||
|
const planQuotas = row.includedQuotas || {};
|
||||||
|
const planPrices = row.overagePrices || {};
|
||||||
|
const subscriptionMetadata = row.subscriptionMetadata || {};
|
||||||
|
const usage = usageSnapshotMap(row.usageSnapshots);
|
||||||
|
const items: UsageOverageItem[] = [];
|
||||||
|
|
||||||
|
for (const [metricKey, snapshot] of Object.entries(usage)) {
|
||||||
|
const includedQuota = quotaForMetric(metricKey, planQuotas, subscriptionMetadata);
|
||||||
|
const price = priceForMetric(metricKey, planPrices, subscriptionMetadata);
|
||||||
|
if (includedQuota === null || !price) continue;
|
||||||
|
const overageValue = snapshot.value - includedQuota;
|
||||||
|
if (overageValue <= 0) continue;
|
||||||
|
|
||||||
|
const billableUnits = Math.ceil(overageValue / price.unitSize);
|
||||||
|
if (billableUnits <= 0) continue;
|
||||||
|
const label = USAGE_METRIC_LABELS[metricKey] || metricKey;
|
||||||
|
items.push({
|
||||||
|
itemType: 'usage_overage',
|
||||||
|
description: `${row.planName || row.planCode} ${label}超额 ${Number(overageValue.toFixed(4))}`,
|
||||||
|
quantity: billableUnits,
|
||||||
|
unitAmountCents: price.unitAmountCents,
|
||||||
|
metadata: {
|
||||||
|
metricKey,
|
||||||
|
metricLabel: label,
|
||||||
|
metricValue: snapshot.value,
|
||||||
|
includedQuota,
|
||||||
|
overageValue: Number(overageValue.toFixed(4)),
|
||||||
|
billableUnits,
|
||||||
|
unitSize: price.unitSize,
|
||||||
|
unitAmountCents: price.unitAmountCents,
|
||||||
|
usageRecordId: snapshot.recordId || null,
|
||||||
|
usageSource: snapshot.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampUsageOverageLimit(value: unknown, fallback = 100) {
|
||||||
|
const parsed = Number(value ?? fallback);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||||
|
return Math.min(Math.trunc(parsed), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertUsageOverageAudit(
|
||||||
|
client: pg.PoolClient,
|
||||||
|
input: {
|
||||||
|
tenantId: string | null;
|
||||||
|
actorUserId?: string | null;
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
insert into public.audit_logs (
|
||||||
|
tenant_id, actor_user_id, action, target_type, target_id,
|
||||||
|
details, ip_address, user_agent
|
||||||
|
)
|
||||||
|
values ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7, $8)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.tenantId,
|
||||||
|
input.actorUserId || null,
|
||||||
|
input.action,
|
||||||
|
input.targetType,
|
||||||
|
input.targetId,
|
||||||
|
JSON.stringify(input.details || {}),
|
||||||
|
'ipAddress' in input ? input.ipAddress || null : null,
|
||||||
|
'userAgent' in input ? input.userAgent || null : null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function usageOverageCandidateQuery(
|
||||||
|
db: pg.Pool | pg.PoolClient,
|
||||||
|
params: {
|
||||||
|
tenantIds?: string[];
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
includeExisting?: boolean;
|
||||||
|
includeZero?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const tenantIds = params.tenantIds || [];
|
||||||
|
const includeExisting = Boolean(params.includeExisting);
|
||||||
|
const includeZero = Boolean(params.includeZero);
|
||||||
|
const limit = clampUsageOverageLimit(params.limit, 100);
|
||||||
|
const rows = await db.query<{
|
||||||
|
tenantId: string;
|
||||||
|
tenantSlug: string;
|
||||||
|
tenantName: string;
|
||||||
|
billingStatus: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
planCode: string;
|
||||||
|
planName: string | null;
|
||||||
|
subscriptionStatus: string;
|
||||||
|
billingCycle: string | null;
|
||||||
|
includedQuotas: Record<string, unknown> | null;
|
||||||
|
overagePrices: Record<string, unknown> | null;
|
||||||
|
subscriptionMetadata: Record<string, unknown> | null;
|
||||||
|
usageSnapshots: unknown;
|
||||||
|
existingInvoiceId: string | null;
|
||||||
|
existingInvoiceNo: string | null;
|
||||||
|
existingInvoiceStatus: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select t.id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||||
|
t.billing_status as "billingStatus",
|
||||||
|
s.id as "subscriptionId", s.plan_code as "planCode",
|
||||||
|
p.name as "planName", s.status as "subscriptionStatus",
|
||||||
|
s.billing_cycle as "billingCycle",
|
||||||
|
p.included_quotas as "includedQuotas",
|
||||||
|
p.overage_prices as "overagePrices",
|
||||||
|
s.metadata as "subscriptionMetadata",
|
||||||
|
coalesce(usage_snapshots.metrics, '{}'::jsonb) as "usageSnapshots",
|
||||||
|
existing.id as "existingInvoiceId",
|
||||||
|
existing.invoice_no as "existingInvoiceNo",
|
||||||
|
existing.status as "existingInvoiceStatus"
|
||||||
|
from public.tenants t
|
||||||
|
join lateral (
|
||||||
|
select id, tenant_id, plan_code, status, billing_cycle, metadata, created_at, expires_at
|
||||||
|
from public.tenant_subscriptions
|
||||||
|
where tenant_id = t.id
|
||||||
|
and status in ('trial', 'active', 'past_due')
|
||||||
|
order by case status when 'active' then 0 when 'trial' then 1 else 2 end,
|
||||||
|
expires_at desc nulls last,
|
||||||
|
created_at desc
|
||||||
|
limit 1
|
||||||
|
) s on true
|
||||||
|
join public.platform_saas_plans p on p.code = s.plan_code
|
||||||
|
left join lateral (
|
||||||
|
select jsonb_object_agg(metric_key, jsonb_build_object(
|
||||||
|
'value', metric_value,
|
||||||
|
'recordId', id,
|
||||||
|
'source', metadata->>'source',
|
||||||
|
'createdAt', created_at
|
||||||
|
)) as metrics
|
||||||
|
from (
|
||||||
|
select distinct on (u.metric_key)
|
||||||
|
u.id, u.metric_key, u.metric_value, u.metadata, u.created_at
|
||||||
|
from public.tenant_usage_records u
|
||||||
|
where u.tenant_id = t.id
|
||||||
|
and u.period_start = $2::date
|
||||||
|
and u.period_end = $3::date
|
||||||
|
order by u.metric_key,
|
||||||
|
case when u.metadata->>'source' = 'platform_usage_worker' then 0 else 1 end,
|
||||||
|
u.created_at desc
|
||||||
|
) latest
|
||||||
|
) usage_snapshots on true
|
||||||
|
left join lateral (
|
||||||
|
select id, invoice_no, status
|
||||||
|
from public.tenant_invoices i
|
||||||
|
where i.tenant_id = t.id
|
||||||
|
and i.invoice_type = 'usage_overage'
|
||||||
|
and i.status <> 'void'
|
||||||
|
and i.billing_period_start = $2::date
|
||||||
|
and i.billing_period_end = $3::date
|
||||||
|
and i.metadata->>'source' = 'usage_overage_auto'
|
||||||
|
order by i.created_at desc
|
||||||
|
limit 1
|
||||||
|
) existing on true
|
||||||
|
where t.status = 'active'
|
||||||
|
and ($1::uuid[] = '{}'::uuid[] or t.id = any($1::uuid[]))
|
||||||
|
and ($4::boolean = true or existing.id is null)
|
||||||
|
order by t.created_at asc
|
||||||
|
limit $5
|
||||||
|
`,
|
||||||
|
[tenantIds, params.periodStart, params.periodEnd, includeExisting, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates: UsageOverageCandidate[] = rows.rows.map(row => {
|
||||||
|
const items = buildUsageOverageItems(row);
|
||||||
|
return {
|
||||||
|
tenantId: row.tenantId,
|
||||||
|
tenantSlug: row.tenantSlug,
|
||||||
|
tenantName: row.tenantName,
|
||||||
|
billingStatus: row.billingStatus,
|
||||||
|
subscriptionId: row.subscriptionId,
|
||||||
|
planCode: row.planCode,
|
||||||
|
planName: row.planName,
|
||||||
|
subscriptionStatus: row.subscriptionStatus,
|
||||||
|
billingCycle: row.billingCycle,
|
||||||
|
periodStart: params.periodStart,
|
||||||
|
periodEnd: params.periodEnd,
|
||||||
|
existingInvoiceId: row.existingInvoiceId,
|
||||||
|
existingInvoiceNo: row.existingInvoiceNo,
|
||||||
|
existingInvoiceStatus: row.existingInvoiceStatus,
|
||||||
|
hasExistingInvoice: Boolean(row.existingInvoiceId),
|
||||||
|
wouldCreate: items.length > 0 && !row.existingInvoiceId,
|
||||||
|
totalCents: invoiceSubtotal(items),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates.filter(item => includeZero || item.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processUsageOverageInvoices(
|
||||||
|
client: pg.PoolClient,
|
||||||
|
options: UsageOverageInvoiceBatchOptions,
|
||||||
|
) {
|
||||||
|
const tenantIds = options.tenantIds || [];
|
||||||
|
const dryRun = Boolean(options.dryRun);
|
||||||
|
const status = options.status || 'issued';
|
||||||
|
const limit = clampUsageOverageLimit(options.limit, Math.min(Math.max(tenantIds.length || 0, 100), 500));
|
||||||
|
const candidates = await usageOverageCandidateQuery(client, {
|
||||||
|
tenantIds,
|
||||||
|
periodStart: options.periodStart,
|
||||||
|
periodEnd: options.periodEnd,
|
||||||
|
includeExisting: true,
|
||||||
|
includeZero: false,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!candidates.length) {
|
||||||
|
return { dryRun, createdCount: 0, skippedCount: 0, totalCents: 0, items: [], skipped: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
const wouldCreate = candidates.filter(item => !item.hasExistingInvoice);
|
||||||
|
const existing = candidates.filter(item => item.hasExistingInvoice);
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
createdCount: 0,
|
||||||
|
skippedCount: existing.length,
|
||||||
|
totalCents: wouldCreate.reduce((sum, item) => sum + item.totalCents, 0),
|
||||||
|
items: candidates.map(item => ({ ...item, wouldCreate: !item.hasExistingInvoice })),
|
||||||
|
skipped: existing.map(item => ({
|
||||||
|
tenantId: item.tenantId,
|
||||||
|
subscriptionId: item.subscriptionId,
|
||||||
|
reason: 'USAGE_OVERAGE_INVOICE_EXISTS',
|
||||||
|
invoiceId: item.existingInvoiceId,
|
||||||
|
invoiceNo: item.existingInvoiceNo,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: Array<UsageOverageCandidate & { invoice: Record<string, unknown> }> = [];
|
||||||
|
const skipped: unknown[] = [];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const lock = await client.query(
|
||||||
|
`
|
||||||
|
select id
|
||||||
|
from public.tenant_subscriptions
|
||||||
|
where tenant_id = $1
|
||||||
|
and id = $2
|
||||||
|
for update
|
||||||
|
`,
|
||||||
|
[candidate.tenantId, candidate.subscriptionId],
|
||||||
|
);
|
||||||
|
if (!lock.rows[0]) {
|
||||||
|
skipped.push({ tenantId: candidate.tenantId, subscriptionId: candidate.subscriptionId, reason: 'SUBSCRIPTION_NOT_FOUND' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await client.query<{ id: string; invoiceNo: string; status: string }>(
|
||||||
|
`
|
||||||
|
select id, invoice_no as "invoiceNo", status
|
||||||
|
from public.tenant_invoices
|
||||||
|
where tenant_id = $1
|
||||||
|
and invoice_type = 'usage_overage'
|
||||||
|
and status <> 'void'
|
||||||
|
and billing_period_start = $2::date
|
||||||
|
and billing_period_end = $3::date
|
||||||
|
and metadata->>'source' = 'usage_overage_auto'
|
||||||
|
order by created_at desc
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[candidate.tenantId, options.periodStart, options.periodEnd],
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
skipped.push({
|
||||||
|
tenantId: candidate.tenantId,
|
||||||
|
subscriptionId: candidate.subscriptionId,
|
||||||
|
reason: 'USAGE_OVERAGE_INVOICE_EXISTS',
|
||||||
|
invoiceId: existing.rows[0].id,
|
||||||
|
invoiceNo: existing.rows[0].invoiceNo,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate.items.length) {
|
||||||
|
skipped.push({ tenantId: candidate.tenantId, subscriptionId: candidate.subscriptionId, reason: 'NO_USAGE_OVERAGE' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await createInvoiceRecordWithClient(client, {
|
||||||
|
tenantId: candidate.tenantId,
|
||||||
|
invoiceType: 'usage_overage',
|
||||||
|
status,
|
||||||
|
dueDate: options.dueDate || null,
|
||||||
|
billingPeriodStart: options.periodStart,
|
||||||
|
billingPeriodEnd: options.periodEnd,
|
||||||
|
note: options.note || null,
|
||||||
|
metadata: {
|
||||||
|
source: 'usage_overage_auto',
|
||||||
|
periodStart: options.periodStart,
|
||||||
|
periodEnd: options.periodEnd,
|
||||||
|
subscriptionId: candidate.subscriptionId,
|
||||||
|
planCode: candidate.planCode,
|
||||||
|
tenantSlug: candidate.tenantSlug,
|
||||||
|
workerId: options.workerId || null,
|
||||||
|
},
|
||||||
|
items: candidate.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
metadata: {
|
||||||
|
...item.metadata,
|
||||||
|
periodStart: options.periodStart,
|
||||||
|
periodEnd: options.periodEnd,
|
||||||
|
subscriptionId: candidate.subscriptionId,
|
||||||
|
planCode: candidate.planCode,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await insertUsageOverageAudit(client, {
|
||||||
|
tenantId: candidate.tenantId,
|
||||||
|
actorUserId: options.actorUserId || null,
|
||||||
|
ipAddress: options.ipAddress || null,
|
||||||
|
userAgent: options.userAgent || null,
|
||||||
|
action: 'platform.invoice.usage_overage_created',
|
||||||
|
targetType: 'tenant_invoice',
|
||||||
|
targetId: String(invoice.id),
|
||||||
|
details: {
|
||||||
|
tenantId: candidate.tenantId,
|
||||||
|
subscriptionId: candidate.subscriptionId,
|
||||||
|
planCode: candidate.planCode,
|
||||||
|
periodStart: options.periodStart,
|
||||||
|
periodEnd: options.periodEnd,
|
||||||
|
totalCents: candidate.totalCents,
|
||||||
|
metrics: candidate.items.map(item => item.metadata.metricKey),
|
||||||
|
invoiceNo: invoice.invoiceNo,
|
||||||
|
workerId: options.workerId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
created.push({ ...candidate, invoice });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCents = created.reduce((sum, item) => sum + Number(item.totalCents || 0), 0);
|
||||||
|
|
||||||
|
await insertUsageOverageAudit(client, {
|
||||||
|
tenantId: null,
|
||||||
|
actorUserId: options.actorUserId || null,
|
||||||
|
ipAddress: options.ipAddress || null,
|
||||||
|
userAgent: options.userAgent || null,
|
||||||
|
action: 'platform.invoice.usage_overage_batch_created',
|
||||||
|
targetType: 'tenant_invoice_batch',
|
||||||
|
targetId: null,
|
||||||
|
details: {
|
||||||
|
createdCount: created.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
totalCents,
|
||||||
|
tenantIds,
|
||||||
|
periodStart: options.periodStart,
|
||||||
|
periodEnd: options.periodEnd,
|
||||||
|
dueDate: options.dueDate || null,
|
||||||
|
status,
|
||||||
|
workerId: options.workerId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dryRun: false,
|
||||||
|
createdCount: created.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
totalCents,
|
||||||
|
items: created,
|
||||||
|
skipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface ProcessOverduePlatformInvoicesOptions {
|
interface ProcessOverduePlatformInvoicesOptions {
|
||||||
actorUserId?: string | null;
|
actorUserId?: string | null;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"provider-bills:once": "tsx src/index.ts --once --job provider-bills",
|
"provider-bills:once": "tsx src/index.ts --once --job provider-bills",
|
||||||
"platform-billing:once": "tsx src/index.ts --once --job platform-billing",
|
"platform-billing:once": "tsx src/index.ts --once --job platform-billing",
|
||||||
"platform-usage:once": "tsx src/index.ts --once --job platform-usage",
|
"platform-usage:once": "tsx src/index.ts --once --job platform-usage",
|
||||||
|
"platform-usage-overage:once": "tsx src/index.ts --once --job platform-usage-overage",
|
||||||
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
|
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
|
||||||
"platform-dunning-notifications:once": "tsx src/index.ts --once --job platform-dunning-notifications",
|
"platform-dunning-notifications:once": "tsx src/index.ts --once --job platform-dunning-notifications",
|
||||||
"platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts",
|
"platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts",
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export interface WorkerConfig {
|
|||||||
platformUsageBatchSize: number;
|
platformUsageBatchSize: number;
|
||||||
platformUsageWorkerId: string;
|
platformUsageWorkerId: string;
|
||||||
platformUsageMonth: string;
|
platformUsageMonth: string;
|
||||||
|
platformUsageOverageBatchSize: number;
|
||||||
|
platformUsageOverageWorkerId: string;
|
||||||
|
platformUsageOverageMonth: string;
|
||||||
|
platformUsageOverageDueDays: number;
|
||||||
platformDunningBatchSize: number;
|
platformDunningBatchSize: number;
|
||||||
platformDunningWorkerId: string;
|
platformDunningWorkerId: string;
|
||||||
platformDunningNotificationBatchSize: number;
|
platformDunningNotificationBatchSize: number;
|
||||||
@@ -204,6 +208,10 @@ const loadedConfig: WorkerConfig = {
|
|||||||
platformUsageBatchSize: envNumber('WORKER_PLATFORM_USAGE_BATCH_SIZE', 100),
|
platformUsageBatchSize: envNumber('WORKER_PLATFORM_USAGE_BATCH_SIZE', 100),
|
||||||
platformUsageWorkerId: envString('WORKER_PLATFORM_USAGE_ID', `platform-usage-${process.pid}`),
|
platformUsageWorkerId: envString('WORKER_PLATFORM_USAGE_ID', `platform-usage-${process.pid}`),
|
||||||
platformUsageMonth: envString('WORKER_PLATFORM_USAGE_MONTH', ''),
|
platformUsageMonth: envString('WORKER_PLATFORM_USAGE_MONTH', ''),
|
||||||
|
platformUsageOverageBatchSize: envNumber('WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE', 100),
|
||||||
|
platformUsageOverageWorkerId: envString('WORKER_PLATFORM_USAGE_OVERAGE_ID', `platform-usage-overage-${process.pid}`),
|
||||||
|
platformUsageOverageMonth: envString('WORKER_PLATFORM_USAGE_OVERAGE_MONTH', ''),
|
||||||
|
platformUsageOverageDueDays: envNumber('WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS', 15),
|
||||||
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
|
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
|
||||||
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
|
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
|
||||||
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),
|
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ async function runOnce() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (job === 'platform-usage-overage') {
|
||||||
|
const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js');
|
||||||
|
const result = await processPlatformUsageOverageBatch();
|
||||||
|
console.log(
|
||||||
|
`[worker] platform-usage-overage batch processed=${result.processed}`
|
||||||
|
+ ` created=${result.created} skipped=${result.skipped} totalCents=${result.totalCents}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (job === 'platform-dunning') {
|
if (job === 'platform-dunning') {
|
||||||
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
|
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
|
||||||
const result = await processPlatformDunningBatch();
|
const result = await processPlatformDunningBatch();
|
||||||
|
|||||||
97
apps/worker/src/jobs/platform-usage-overage.ts
Normal file
97
apps/worker/src/jobs/platform-usage-overage.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { processUsageOverageInvoices } from '../../../api/src/features/platform-admin/service.js';
|
||||||
|
|
||||||
|
export interface PlatformUsageOverageWorkerResult {
|
||||||
|
processed: number;
|
||||||
|
created: number;
|
||||||
|
skipped: number;
|
||||||
|
totalCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_RE = /^\d{4}-\d{2}$/;
|
||||||
|
|
||||||
|
function positiveInteger(value: number, fallback: number, max: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||||
|
return Math.min(Math.trunc(value), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeInteger(value: number, fallback: number, max: number) {
|
||||||
|
if (!Number.isFinite(value) || value < 0) return fallback;
|
||||||
|
return Math.min(Math.trunc(value), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shanghaiYearMonth(value = new Date()) {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousMonth(monthText: string) {
|
||||||
|
const [yearText, monthNumberText] = monthText.split('-');
|
||||||
|
const date = new Date(Date.UTC(Number(yearText), Number(monthNumberText) - 2, 1));
|
||||||
|
return date.toISOString().slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetMonth(value?: string) {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
if (normalized) return normalized;
|
||||||
|
return previousMonth(shanghaiYearMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthPeriod(monthText: string) {
|
||||||
|
const normalized = monthText.trim();
|
||||||
|
if (!MONTH_RE.test(normalized)) {
|
||||||
|
throw new Error(`Invalid platform usage overage month: ${monthText}. Expected YYYY-MM.`);
|
||||||
|
}
|
||||||
|
const [yearText, monthNumberText] = normalized.split('-');
|
||||||
|
const year = Number(yearText);
|
||||||
|
const monthNumber = Number(monthNumberText);
|
||||||
|
const periodStart = `${yearText}-${monthNumberText}-01`;
|
||||||
|
const periodEnd = new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
|
||||||
|
return { periodStart, periodEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dueDateText(days: number) {
|
||||||
|
const now = new Date();
|
||||||
|
now.setUTCDate(now.getUTCDate() + nonNegativeInteger(days, 15, 365));
|
||||||
|
return now.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processPlatformUsageOverageBatch(options: {
|
||||||
|
limit?: number;
|
||||||
|
month?: string;
|
||||||
|
dueDays?: number;
|
||||||
|
} = {}): Promise<PlatformUsageOverageWorkerResult> {
|
||||||
|
const limit = positiveInteger(options.limit ?? config.platformUsageOverageBatchSize, 100, 500);
|
||||||
|
const month = targetMonth(options.month || config.platformUsageOverageMonth);
|
||||||
|
const { periodStart, periodEnd } = monthPeriod(month);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const result = await processUsageOverageInvoices(client, {
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
dueDate: dueDateText(options.dueDays ?? config.platformUsageOverageDueDays),
|
||||||
|
status: 'issued',
|
||||||
|
note: `平台自动生成 ${periodStart} 至 ${periodEnd} 用量超额服务费账单`,
|
||||||
|
dryRun: false,
|
||||||
|
limit,
|
||||||
|
workerId: config.platformUsageOverageWorkerId,
|
||||||
|
});
|
||||||
|
await client.query('commit');
|
||||||
|
return {
|
||||||
|
processed: Number(result.createdCount || 0) + Number(result.skippedCount || 0),
|
||||||
|
created: Number(result.createdCount || 0),
|
||||||
|
skipped: Number(result.skippedCount || 0),
|
||||||
|
totalCents: Number(result.totalCents || 0),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback').catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
||||||
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`、`students/followups/report`、`students/supervision/preview`、`students/supervision/generate`、`students/supervision/rules`、`students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;跟进报表支持 7/30/90 天或自定义日期范围、状态/类型/优先级/负责人/班级聚合、逾期待办、CRM 推送队列摘要和每日趋势;学习督导由后端读取答题、错题、单词待复习和未完成练习数据,按阈值预览候选并幂等生成 `learning` 跟进任务,也可保存手动/每日/每周规则交给 worker 定时生成,要求 `students:supervision:read/write`;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write`、`students:read`、`students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 和 `primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
|
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`、`students/followups/report`、`students/supervision/preview`、`students/supervision/generate`、`students/supervision/rules`、`students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;跟进报表支持 7/30/90 天或自定义日期范围、状态/类型/优先级/负责人/班级聚合、逾期待办、CRM 推送队列摘要和每日趋势;学习督导由后端读取答题、错题、单词待复习和未完成练习数据,按阈值预览候选并幂等生成 `learning` 跟进任务,也可保存手动/每日/每周规则交给 worker 定时生成,要求 `students:supervision:read/write`;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write`、`students:read`、`students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 和 `primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
|
||||||
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
||||||
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、用量超额账单候选预览/dry-run/生成、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量台账和平台用量自动采集 worker;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:usage:read/write`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-usage` 会生成月度 SaaS 用量快照并保留手工调整记录;平台超额账单只使用后端权威用量快照和套餐/订阅 metadata,前端不得自行计算服务费;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、用量采集、用量超额开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
|
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、用量超额账单候选预览/dry-run/生成/自动开票 worker、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量台账和平台用量自动采集 worker;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:usage:read/write`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-usage` 会生成月度 SaaS 用量快照并保留手工调整记录;平台超额账单只使用后端权威用量快照和套餐/订阅 metadata,前端不得自行计算服务费;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、用量采集、用量超额开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
|
||||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库,并可限制授权地区和科目。平台保存 grant 时会校验 `allowedRegionIds`、`allowedSubjectIds` 属于源平台题库租户,且已发布题目的科目必须被授权科目覆盖 |
|
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库,并可限制授权地区和科目。平台保存 grant 时会校验 `allowedRegionIds`、`allowedSubjectIds` 属于源平台题库租户,且已发布题目的科目必须被授权科目覆盖 |
|
||||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`/api/platform-admin/question-bank-sync-status`;租户只能看到自己 `question_bank_grants`、有效 `tenant_subscriptions`、`platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess` 同时允许的题库。基础版默认 `limited_regions` 且需要地区 allowlist,专业版默认 `national`;采纳、同步和冲突处理都会重新校验当前授权,越权 grant 返回 `QUESTION_BANK_GRANT_NOT_AVAILABLE`。采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新、冲突和 worker 失败会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会写入逐条审计,冲突全部处理后相关通知自动 resolved,失败通知会在后续同步恢复成功后自动 resolved;平台运营接口需 `platform:question_bank:ops`,只返回跨租户同步摘要和通知数量,不返回题目正文/答案/解析 |
|
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`/api/platform-admin/question-bank-sync-status`;租户只能看到自己 `question_bank_grants`、有效 `tenant_subscriptions`、`platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess` 同时允许的题库。基础版默认 `limited_regions` 且需要地区 allowlist,专业版默认 `national`;采纳、同步和冲突处理都会重新校验当前授权,越权 grant 返回 `QUESTION_BANK_GRANT_NOT_AVAILABLE`。采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新、冲突和 worker 失败会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会写入逐条审计,冲突全部处理后相关通知自动 resolved,失败通知会在后续同步恢复成功后自动 resolved;平台运营接口需 `platform:question_bank:ops`,只返回跨租户同步摘要和通知数量,不返回题目正文/答案/解析 |
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
|
||||||
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量台账、platform-usage 月度自动采集 worker、套餐额度判定、用量超额账单候选预览/dry-run/生成、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 |
|
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量台账、platform-usage 月度自动采集 worker、套餐额度判定、用量超额账单候选预览/dry-run/生成/自动开票 worker、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 |
|
||||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、积分风控只读报表、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限、学生运营跟进、学习督导规则模板和 `student-supervision` worker;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注、跟进任务、学习督导预览/生成和保存每日规则第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台、积分风控摘要和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、学习督导触达联动/效果归因、真实打款/导出/凭证和完整权限菜单 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、积分风控只读报表、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限、学生运营跟进、学习督导规则模板和 `student-supervision` worker;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注、跟进任务、学习督导预览/生成和保存每日规则第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台、积分风控摘要和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、学习督导触达联动/效果归因、真实打款/导出/凭证和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 |
|
||||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 |
|
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 |
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
- 资金对账已支持手工/API 账单导入比对、微信/支付宝官方账单下载任务、异常查询和差错工单处理;继续补真实生产账单格式验收、财务复核报表和异常订单运营台。
|
- 资金对账已支持手工/API 账单导入比对、微信/支付宝官方账单下载任务、异常查询和差错工单处理;继续补真实生产账单格式验收、财务复核报表和异常订单运营台。
|
||||||
- XPay 或其它实际支付网关 adapter。
|
- XPay 或其它实际支付网关 adapter。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
||||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;公共题库可见、采纳、同步、冲突处理会统一校验 SaaS 套餐、有效订阅、订阅 metadata、grant 地区和科目范围。平台用量采集已覆盖存储、学生数、题量和视频播放等基础指标,超额服务费由后端按套餐额度生成 `usage_overage` 账单;继续补生产定时调度、失败告警和更完整运营消息。
|
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;公共题库可见、采纳、同步、冲突处理会统一校验 SaaS 套餐、有效订阅、订阅 metadata、grant 地区和科目范围。平台用量采集已覆盖存储、学生数、题量和视频播放等基础指标,超额服务费由后端按套餐额度生成 `usage_overage` 账单,`platform-usage-overage` worker 可按上月账期自动开票;继续补失败告警和更完整运营消息。
|
||||||
- 导入模板、字段映射、导入任务详情和复检 API 已可用;Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。
|
- 导入模板、字段映射、导入任务详情和复检 API 已可用;Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。
|
||||||
- 视频深度防盗链、转码级水印和播放统计。
|
- 视频深度防盗链、转码级水印和播放统计。
|
||||||
- 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。
|
- 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
||||||
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
||||||
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
||||||
- `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量、用量超额账单。
|
- `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量、用量超额账单和自动开票 worker。
|
||||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理和运营报表、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理和运营报表、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
||||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
||||||
- `tenant`:域名/租户解析。
|
- `tenant`:域名/租户解析。
|
||||||
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
||||||
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON,返回 base64 内容、sha256、行数和筛选条件,并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules`、`GET /api/platform-admin/audit-alerts` 和 `POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略,API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET/PUT /api/platform-admin/audit-notification-channels` 和 `GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥进入 `app_private.platform_secrets`,API 只回显 `secretRef` 和 webhook host/path;`GET /api/platform-admin/invoices/subscription-candidates` 和 `POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计;`GET /api/platform-admin/invoices/usage-overage-candidates` 和 `POST /api/platform-admin/invoices/from-usage-overage` 支持用量超额候选预览、dry-run、正式生成 `usage_overage` 账单、重复开票保护和平台审计;`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue`、`GET /api/platform-admin/invoices/reminders` 和 `apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计;`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、用量超额开票、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志,API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
|
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON,返回 base64 内容、sha256、行数和筛选条件,并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules`、`GET /api/platform-admin/audit-alerts` 和 `POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略,API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET/PUT /api/platform-admin/audit-notification-channels` 和 `GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥进入 `app_private.platform_secrets`,API 只回显 `secretRef` 和 webhook host/path;`GET /api/platform-admin/invoices/subscription-candidates` 和 `POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计;`GET /api/platform-admin/invoices/usage-overage-candidates` 和 `POST /api/platform-admin/invoices/from-usage-overage` 支持用量超额候选预览、dry-run、正式生成 `usage_overage` 账单、重复开票保护和平台审计;`apps/worker --job platform-usage-overage` 可按上一个自然月或指定 `WORKER_PLATFORM_USAGE_OVERAGE_MONTH=YYYY-MM` 自动生成超额服务费账单,复用同一套后端计算服务;`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue`、`GET /api/platform-admin/invoices/reminders` 和 `apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计;`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、用量超额开票、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志,API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
|
||||||
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
||||||
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups`、`tenant_student_supervision_rules` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态、督导规则模板和审计;批量学生 upsert、批量分班、禁用/恢复、学生批量 CRM 推送、跟进效果统计和学习督导自动化已接入权限校验、范围校验和集成测试。`GET /api/tenant-admin/students/followups/report` 支持 7/30/90 天或自定义日期范围、负责人/类型/班级筛选、状态/类型/优先级/负责人/班级聚合、每日趋势、逾期待办和 CRM 队列摘要,并会沿用教师班级范围与字段脱敏规则。`GET /api/tenant-admin/students/supervision/preview` 和 `POST /api/tenant-admin/students/supervision/generate` 支持按未学习、错题积压、低正确率、单词待复习和超期未完成练习生成风险候选,并用 `metadata.autoSupervision.idempotencyKey` 幂等生成 `learning` 跟进任务;`GET/PUT /api/tenant-admin/students/supervision/rules` 支持租户保存手动/每日/每周督导策略,`apps/worker --job student-supervision` 会按启用规则定时生成跟进任务并写回 `last_result/next_run_at`。
|
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups`、`tenant_student_supervision_rules` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态、督导规则模板和审计;批量学生 upsert、批量分班、禁用/恢复、学生批量 CRM 推送、跟进效果统计和学习督导自动化已接入权限校验、范围校验和集成测试。`GET /api/tenant-admin/students/followups/report` 支持 7/30/90 天或自定义日期范围、负责人/类型/班级筛选、状态/类型/优先级/负责人/班级聚合、每日趋势、逾期待办和 CRM 队列摘要,并会沿用教师班级范围与字段脱敏规则。`GET /api/tenant-admin/students/supervision/preview` 和 `POST /api/tenant-admin/students/supervision/generate` 支持按未学习、错题积压、低正确率、单词待复习和超期未完成练习生成风险候选,并用 `metadata.autoSupervision.idempotencyKey` 幂等生成 `learning` 跟进任务;`GET/PUT /api/tenant-admin/students/supervision/rules` 支持租户保存手动/每日/每周督导策略,`apps/worker --job student-supervision` 会按启用规则定时生成跟进任务并写回 `last_result/next_run_at`。
|
||||||
@@ -79,6 +79,8 @@ POST /api/platform-admin/invoices
|
|||||||
GET /api/platform-admin/invoices/subscription-candidates
|
GET /api/platform-admin/invoices/subscription-candidates
|
||||||
POST /api/platform-admin/invoices/from-subscription
|
POST /api/platform-admin/invoices/from-subscription
|
||||||
POST /api/platform-admin/invoices/from-subscriptions-batch
|
POST /api/platform-admin/invoices/from-subscriptions-batch
|
||||||
|
GET /api/platform-admin/invoices/usage-overage-candidates
|
||||||
|
POST /api/platform-admin/invoices/from-usage-overage
|
||||||
POST /api/platform-admin/invoices/payments/manual-confirm
|
POST /api/platform-admin/invoices/payments/manual-confirm
|
||||||
GET /api/platform-admin/usage
|
GET /api/platform-admin/usage
|
||||||
POST /api/platform-admin/usage
|
POST /api/platform-admin/usage
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
|
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、月度用量自动采集 worker、套餐存储/学生数/题量/视频播放额度判定、用量超额账单候选预览/dry-run/生成、公共题库披露策略、公共题库地区/科目授权和基础版单地区/专业版全国访问边界 | 平台侧主题模板库、平台在线收款、平台审计报表增强、超额账单定时生成 worker 和失败告警 |
|
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、月度用量自动采集 worker、套餐存储/学生数/题量/视频播放额度判定、用量超额账单候选预览/dry-run/生成/自动开票 worker、公共题库披露策略、公共题库地区/科目授权和基础版单地区/专业版全国访问边界 | 平台侧主题模板库、平台在线收款、平台审计报表增强、超额账单失败告警 |
|
||||||
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
||||||
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
|
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
|
||||||
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询 API、按 SaaS 套餐和订阅 metadata 控制公共题库地区/科目/题库范围 | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询 API、按 SaaS 套餐和订阅 metadata 控制公共题库地区/科目/题库范围 | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、跟进看板、学习督导候选预览/生成、督导规则保存和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、跟进看板、学习督导候选预览/生成、督导规则保存和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览/dry-run/生成、逾期预览、内部催缴生成、催缴记录查看和用量台账查看。平台用量由 `platform-usage` worker 从后端权威表采集,超额费用由后端按套餐额度统一计算,前端不要自行统计答题记录、资源大小、订单或视频播放后计算服务费。
|
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览/dry-run/生成/自动开票 worker、逾期预览、内部催缴生成、催缴记录查看和用量台账查看。平台用量由 `platform-usage` worker 从后端权威表采集,超额费用由后端按套餐额度统一计算,前端不要自行统计答题记录、资源大小、订单或视频播放后计算服务费。
|
||||||
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习/学习概览、商城收银台、订单详情和售后入口已经有第一版页面。
|
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习/学习概览、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||||
- 可以按新后端主模型接入内容导航:
|
- 可以按新后端主模型接入内容导航:
|
||||||
- `content_entries`
|
- `content_entries`
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约、积分风控只读报表和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换、积分风控摘要和用户通知查看第一版;营销自动化、外部订阅消息/短信和更完整活动效果看板继续补 |
|
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约、积分风控只读报表和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换、积分风控摘要和用户通知查看第一版;营销自动化、外部订阅消息/短信和更完整活动效果看板继续补 |
|
||||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
||||||
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、用量超额账单候选预览/dry-run/生成、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量台账、平台用量自动采集 worker、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、用量超额账单候选/dry-run/生成/重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-usage` 覆盖月度用量自动采集、手工调整不覆盖和幂等,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台账务和平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 |
|
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、用量超额账单候选预览/dry-run/生成/自动开票 worker、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量台账、平台用量自动采集 worker、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、用量超额账单候选/dry-run/生成/重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-usage` 覆盖月度用量自动采集、手工调整不覆盖和幂等,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台账务和平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 |
|
||||||
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 |
|
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 |
|
||||||
| 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run`、`pb:import:validate`、`test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 |
|
| 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run`、`pb:import:validate`、`test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 |
|
||||||
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
|
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
- 微信/支付宝官方账单下载地基已完成:`commerce_bill_download_jobs`、`POST /api/commerce/reconciliation/provider-bills/request`、`GET /api/commerce/reconciliation/provider-bills/jobs` 和 `apps/worker --job provider-bills` 已接入,worker 负责后端签名申请下载 URL、hash 校验、JSON/CSV/ZIP 账单解析、复用 `provider_download` 对账导入、任务状态回写和密钥脱敏。
|
- 微信/支付宝官方账单下载地基已完成:`commerce_bill_download_jobs`、`POST /api/commerce/reconciliation/provider-bills/request`、`GET /api/commerce/reconciliation/provider-bills/jobs` 和 `apps/worker --job provider-bills` 已接入,worker 负责后端签名申请下载 URL、hash 校验、JSON/CSV/ZIP 账单解析、复用 `provider_download` 对账导入、任务状态回写和密钥脱敏。
|
||||||
- 平台 SaaS 自动计费 worker 已完成:`apps/worker --job platform-billing` 会按 `WORKER_PLATFORM_BILLING_DAYS_AHEAD` 查找即将到期且未开票的订阅,生成 `tenant_invoices/tenant_invoice_items`,使用订阅行锁和账单查重防重复,写入 `platform.invoice.subscription_auto_created` 审计;`npm run test:worker:platform-billing` 覆盖自动开票、明细、审计和二次运行幂等。
|
- 平台 SaaS 自动计费 worker 已完成:`apps/worker --job platform-billing` 会按 `WORKER_PLATFORM_BILLING_DAYS_AHEAD` 查找即将到期且未开票的订阅,生成 `tenant_invoices/tenant_invoice_items`,使用订阅行锁和账单查重防重复,写入 `platform.invoice.subscription_auto_created` 审计;`npm run test:worker:platform-billing` 覆盖自动开票、明细、审计和二次运行幂等。
|
||||||
- 平台 SaaS 用量自动采集 worker 已完成:`apps/worker --job platform-usage` 默认按上海时区当前月采集,也可用 `WORKER_PLATFORM_USAGE_MONTH=YYYY-MM` 补跑指定月份;当前会生成 `students`、`active_students`、`questions`、`assets`、`storage_gb`、`videos`、`video_plays`、`video_quota_consumed`、`paid_orders`、`paid_order_amount_cents`、`active_entitlements` 11 类指标,写入 `tenant_usage_records` 和 `platform.usage.worker_collected` 审计;手工调整用量不被 worker 覆盖,`npm run test:worker:platform-usage` 覆盖幂等和指标口径。
|
- 平台 SaaS 用量自动采集 worker 已完成:`apps/worker --job platform-usage` 默认按上海时区当前月采集,也可用 `WORKER_PLATFORM_USAGE_MONTH=YYYY-MM` 补跑指定月份;当前会生成 `students`、`active_students`、`questions`、`assets`、`storage_gb`、`videos`、`video_plays`、`video_quota_consumed`、`paid_orders`、`paid_order_amount_cents`、`active_entitlements` 11 类指标,写入 `tenant_usage_records` 和 `platform.usage.worker_collected` 审计;手工调整用量不被 worker 覆盖,`npm run test:worker:platform-usage` 覆盖幂等和指标口径。
|
||||||
- 平台 SaaS 超额账单已完成第一版:`GET /api/platform-admin/invoices/usage-overage-candidates` 可按账期预览超出套餐额度的租户,`POST /api/platform-admin/invoices/from-usage-overage` 支持 dry-run 或正式生成 `usage_overage` 账单;后端支持套餐 `included_quotas/overage_prices`、订阅 metadata 覆盖、重复开票保护、审计和 Taro 平台账务中心第一版操作台。
|
- 平台 SaaS 超额账单已完成 API 和 worker:`GET /api/platform-admin/invoices/usage-overage-candidates` 可按账期预览超出套餐额度的租户,`POST /api/platform-admin/invoices/from-usage-overage` 支持 dry-run 或正式生成 `usage_overage` 账单;`apps/worker --job platform-usage-overage` 默认按上一个自然月自动开票,也可用 `WORKER_PLATFORM_USAGE_OVERAGE_MONTH=YYYY-MM` 补跑指定月份。后端支持套餐 `included_quotas/overage_prices`、订阅 metadata 覆盖、重复开票保护、审计和 Taro 平台账务中心第一版操作台;`npm run test:worker:platform-usage-overage` 覆盖账单明细、审计和重复运行幂等。
|
||||||
- 平台 SaaS 逾期催缴 worker 已完成:`apps/worker --job platform-dunning` 会扫描已过 `due_date` 且未结清的服务费账单,标记 `tenant_invoices.status=overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写审计;`POST /api/platform-admin/invoices/process-overdue` 支持平台后台 dry-run/执行,`GET /api/platform-admin/invoices/reminders` 支持查看催缴台账;`npm run test:worker:platform-dunning` 覆盖逾期标记、催缴幂等和审计。
|
- 平台 SaaS 逾期催缴 worker 已完成:`apps/worker --job platform-dunning` 会扫描已过 `due_date` 且未结清的服务费账单,标记 `tenant_invoices.status=overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写审计;`POST /api/platform-admin/invoices/process-overdue` 支持平台后台 dry-run/执行,`GET /api/platform-admin/invoices/reminders` 支持查看催缴台账;`npm run test:worker:platform-dunning` 覆盖逾期标记、催缴幂等和审计。
|
||||||
- 平台 SaaS 催缴外部通知第一版已完成:`platform_dunning_notification_channels/events`、`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已接入;支持 generic/钉钉/飞书/企微 webhook、按催缴类型/渠道/级别/租户筛选、发送重试、幂等、防重复、联系方式掩码、payload 脱敏和生产 readiness 阻断 localhost/不安全 webhook。
|
- 平台 SaaS 催缴外部通知第一版已完成:`platform_dunning_notification_channels/events`、`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已接入;支持 generic/钉钉/飞书/企微 webhook、按催缴类型/渠道/级别/租户筛选、发送重试、幂等、防重复、联系方式掩码、payload 脱敏和生产 readiness 阻断 localhost/不安全 webhook。
|
||||||
- 平台审计告警 worker 已完成:`apps/worker --job platform-audit-alerts` 会扫描 `platform.%` 审计日志,根据 `platform_audit_alert_rules` 把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台操作生成内部告警;API 已支持 `/api/platform-admin/audit-alert-rules`、`/api/platform-admin/audit-alerts`、`/api/platform-admin/audit-alerts/status`,Taro 平台工作台可查看开放告警并确认/解决;`npm run test:worker:platform-audit-alerts` 覆盖规则匹配、幂等和敏感 details 脱敏。
|
- 平台审计告警 worker 已完成:`apps/worker --job platform-audit-alerts` 会扫描 `platform.%` 审计日志,根据 `platform_audit_alert_rules` 把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台操作生成内部告警;API 已支持 `/api/platform-admin/audit-alert-rules`、`/api/platform-admin/audit-alerts`、`/api/platform-admin/audit-alerts/status`,Taro 平台工作台可查看开放告警并确认/解决;`npm run test:worker:platform-audit-alerts` 覆盖规则匹配、幂等和敏感 details 脱敏。
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
||||||
- 已完成 `public-banks` worker 自动同步、失败记录、审计、同步通知、冲突查询 API 和单条/批量冲突处理 API。
|
- 已完成 `public-banks` worker 自动同步、失败记录、审计、同步通知、冲突查询 API 和单条/批量冲突处理 API。
|
||||||
- 已完成基础 SaaS 套餐访问边界:`starter_yearly` 默认 `limited_regions` + 地区 allowlist,`pro_yearly` 默认 `national`;租户订阅 metadata 可进一步限制 allowedRegionIds、allowedSubjectIds、allowedQuestionBankIds。
|
- 已完成基础 SaaS 套餐访问边界:`starter_yearly` 默认 `limited_regions` + 地区 allowlist,`pro_yearly` 默认 `national`;租户订阅 metadata 可进一步限制 allowedRegionIds、allowedSubjectIds、allowedQuestionBankIds。
|
||||||
- 存储、学生数、题量、视频播放量等月度用量已由 `platform-usage` worker 自动采集;SaaS 套餐额度判定、超额价格计算和 `usage_overage` 账单手动触发生成已完成第一版。后续继续补定时生成 worker、失败告警、套餐配置 UI 和平台在线收款。
|
- 存储、学生数、题量、视频播放量等月度用量已由 `platform-usage` worker 自动采集;SaaS 套餐额度判定、超额价格计算、`usage_overage` 账单手动触发生成和 `platform-usage-overage` 自动开票 worker 已完成。后续继续补失败告警、套餐配置 UI 和平台在线收款。
|
||||||
- 继续补生产定时调度、失败告警和更完整运营后台消息。
|
- 继续补生产定时调度、失败告警和更完整运营后台消息。
|
||||||
|
|
||||||
6. 视频会员控制
|
6. 视频会员控制
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
|
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
|
||||||
"test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js",
|
"test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js",
|
||||||
"test:worker:platform-usage": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-usage-worker-integration-test.js",
|
"test:worker:platform-usage": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-usage-worker-integration-test.js",
|
||||||
|
"test:worker:platform-usage-overage": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-usage-overage-worker-integration-test.js",
|
||||||
"test:worker:platform-dunning": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-worker-integration-test.js",
|
"test:worker:platform-dunning": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-worker-integration-test.js",
|
||||||
"test:worker:platform-dunning-notifications": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-notification-worker-integration-test.js",
|
"test:worker:platform-dunning-notifications": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-notification-worker-integration-test.js",
|
||||||
"test:worker:platform-audit-alerts": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js",
|
"test:worker:platform-audit-alerts": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js",
|
||||||
|
|||||||
217
scripts/platform-usage-overage-worker-integration-test.js
Normal file
217
scripts/platform-usage-overage-worker-integration-test.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||||
|
|
||||||
|
const ids = {
|
||||||
|
tenant: '00000000-0000-0000-0000-00000000c901',
|
||||||
|
noOverageTenant: '00000000-0000-0000-0000-00000000c902',
|
||||||
|
subscription: '00000000-0000-0000-0000-00000000c903',
|
||||||
|
noOverageSubscription: '00000000-0000-0000-0000-00000000c904',
|
||||||
|
};
|
||||||
|
|
||||||
|
function runWorkerOnce() {
|
||||||
|
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-usage-overage'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE: '20',
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_MONTH: '2026-07',
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS: '21',
|
||||||
|
WORKER_PLATFORM_USAGE_OVERAGE_ID: 'platform-usage-overage-integration-test',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on('data', chunk => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('exit', code => {
|
||||||
|
try {
|
||||||
|
assert.equal(code, 0, `worker should exit 0\n${output}`);
|
||||||
|
assert.match(output, /platform-usage-overage batch processed=\d+/, 'worker output should include usage overage summary');
|
||||||
|
resolve(output);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateOnly(value) {
|
||||||
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||||
|
return String(value || '').slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(pool) {
|
||||||
|
await pool.query('delete from public.audit_logs where tenant_id = any($1::uuid[]) or action = $2', [
|
||||||
|
[ids.tenant, ids.noOverageTenant],
|
||||||
|
'platform.invoice.usage_overage_batch_created',
|
||||||
|
]);
|
||||||
|
await pool.query('delete from public.tenant_invoice_payments where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||||
|
await pool.query('delete from public.tenant_invoice_items where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||||
|
await pool.query('delete from public.tenant_invoices where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||||
|
await pool.query('delete from public.tenant_usage_records where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||||
|
await pool.query('delete from public.tenant_subscriptions where tenant_id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||||
|
await pool.query('delete from public.tenants where id = any($1::uuid[])', [[ids.tenant, ids.noOverageTenant]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFixture(pool) {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
|
||||||
|
values
|
||||||
|
($1, 'platform-usage-overage-worker', '平台超额账单测试租户', '平台超额账单测试有限公司', 'active', 'saas', 'active', '{"source":"platform-usage-overage-worker-test"}'::jsonb),
|
||||||
|
($2, 'platform-usage-overage-none', '平台无超额测试租户', '平台无超额测试有限公司', 'active', 'saas', 'active', '{"source":"platform-usage-overage-worker-test"}'::jsonb)
|
||||||
|
`,
|
||||||
|
[ids.tenant, ids.noOverageTenant],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_subscriptions (
|
||||||
|
id, tenant_id, plan_code, status, starts_at, expires_at,
|
||||||
|
billing_cycle, amount_cents, metadata
|
||||||
|
)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
$1, $3, 'starter_yearly', 'active',
|
||||||
|
'2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00',
|
||||||
|
'yearly', 980000,
|
||||||
|
'{"includedQuotas":{"students":100,"storageGb":2},"overagePrices":{"students":{"unitAmountCents":200,"unitSize":10},"storage_gb":{"unitAmountCents":1200,"unitSize":1}},"source":"platform-usage-overage-worker-test"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$2, $4, 'starter_yearly', 'active',
|
||||||
|
'2026-01-01 00:00:00+00', '2026-12-31 23:59:59+00',
|
||||||
|
'yearly', 980000,
|
||||||
|
'{"includedQuotas":{"students":100,"storageGb":2},"overagePrices":{"students":{"unitAmountCents":200,"unitSize":10},"storage_gb":{"unitAmountCents":1200,"unitSize":1}},"source":"platform-usage-overage-worker-test"}'::jsonb
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[ids.subscription, ids.noOverageSubscription, ids.tenant, ids.noOverageTenant],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_usage_records (
|
||||||
|
tenant_id, metric_key, metric_value, period_start, period_end, metadata, created_at
|
||||||
|
)
|
||||||
|
values
|
||||||
|
($1, 'students', 125, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:00+00'),
|
||||||
|
($1, 'storage_gb', 3.5, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:01+00'),
|
||||||
|
($1, 'questions', 4000, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:02+00'),
|
||||||
|
($2, 'students', 90, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:00+00'),
|
||||||
|
($2, 'storage_gb', 1.75, '2026-07-01', '2026-07-31', '{"source":"platform_usage_worker"}'::jsonb, '2026-08-01 00:00:01+00')
|
||||||
|
`,
|
||||||
|
[ids.tenant, ids.noOverageTenant],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||||
|
try {
|
||||||
|
await cleanup(pool);
|
||||||
|
await createFixture(pool);
|
||||||
|
|
||||||
|
const firstOutput = await runWorkerOnce();
|
||||||
|
assert.match(firstOutput, /created=1/, 'first worker run should create one usage overage invoice');
|
||||||
|
assert.match(firstOutput, /skipped=0/, 'first worker run should not skip existing overage invoices');
|
||||||
|
assert.match(firstOutput, /totalCents=3000/, 'worker should report the generated overage amount');
|
||||||
|
|
||||||
|
const invoices = await pool.query(
|
||||||
|
`
|
||||||
|
select id, invoice_no, invoice_type, status, total_cents, balance_cents,
|
||||||
|
billing_period_start::text as billing_period_start,
|
||||||
|
billing_period_end::text as billing_period_end,
|
||||||
|
due_date::text as due_date,
|
||||||
|
note, metadata
|
||||||
|
from public.tenant_invoices
|
||||||
|
where tenant_id = $1 and invoice_type = 'usage_overage'
|
||||||
|
`,
|
||||||
|
[ids.tenant],
|
||||||
|
);
|
||||||
|
assert.equal(invoices.rowCount, 1, 'worker should create exactly one usage overage invoice');
|
||||||
|
const invoice = invoices.rows[0];
|
||||||
|
assert.equal(invoice.status, 'issued', 'worker overage invoice should be issued');
|
||||||
|
assert.equal(Number(invoice.total_cents), 3000, 'worker invoice should include all overage items');
|
||||||
|
assert.equal(Number(invoice.balance_cents), 3000, 'worker invoice should start unpaid');
|
||||||
|
assert.equal(dateOnly(invoice.billing_period_start), '2026-07-01', 'invoice period start should match usage month');
|
||||||
|
assert.equal(dateOnly(invoice.billing_period_end), '2026-07-31', 'invoice period end should match usage month');
|
||||||
|
assert.equal(invoice.metadata?.source, 'usage_overage_auto', 'invoice metadata should record auto source');
|
||||||
|
assert.equal(invoice.metadata?.workerId, 'platform-usage-overage-integration-test', 'invoice metadata should record worker id');
|
||||||
|
|
||||||
|
const items = await pool.query(
|
||||||
|
`
|
||||||
|
select item_type, description, quantity, unit_amount_cents, amount_cents, metadata
|
||||||
|
from public.tenant_invoice_items
|
||||||
|
where tenant_id = $1 and invoice_id = $2
|
||||||
|
order by metadata->>'metricKey'
|
||||||
|
`,
|
||||||
|
[ids.tenant, invoice.id],
|
||||||
|
);
|
||||||
|
assert.equal(items.rowCount, 2, 'worker invoice should include two overage items');
|
||||||
|
const studentItem = items.rows.find(item => item.metadata?.metricKey === 'students');
|
||||||
|
const storageItem = items.rows.find(item => item.metadata?.metricKey === 'storage_gb');
|
||||||
|
assert.ok(studentItem, 'student overage item should exist');
|
||||||
|
assert.ok(storageItem, 'storage overage item should exist');
|
||||||
|
assert.equal(Number(studentItem.quantity), 3, '125 students over quota 100 should bill three 10-student units');
|
||||||
|
assert.equal(Number(studentItem.amount_cents), 600, 'student overage amount should match unit pricing');
|
||||||
|
assert.equal(Number(storageItem.quantity), 2, '3.5GB over quota 2GB should bill two 1GB units');
|
||||||
|
assert.equal(Number(storageItem.amount_cents), 2400, 'storage overage amount should match unit pricing');
|
||||||
|
|
||||||
|
const noOverageInvoice = await pool.query(
|
||||||
|
`
|
||||||
|
select count(*)::integer as count
|
||||||
|
from public.tenant_invoices
|
||||||
|
where tenant_id = $1 and invoice_type = 'usage_overage'
|
||||||
|
`,
|
||||||
|
[ids.noOverageTenant],
|
||||||
|
);
|
||||||
|
assert.equal(noOverageInvoice.rows[0]?.count, 0, 'tenant under quota should not get an overage invoice');
|
||||||
|
|
||||||
|
const audit = await pool.query(
|
||||||
|
`
|
||||||
|
select action, target_type, target_id, details
|
||||||
|
from public.audit_logs
|
||||||
|
where tenant_id = $1 and action = 'platform.invoice.usage_overage_created'
|
||||||
|
order by created_at desc
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[ids.tenant],
|
||||||
|
);
|
||||||
|
assert.equal(audit.rows[0]?.target_type, 'tenant_invoice', 'worker should audit generated invoice');
|
||||||
|
assert.equal(audit.rows[0]?.target_id, invoice.id, 'worker audit should target invoice id');
|
||||||
|
assert.equal(audit.rows[0]?.details?.workerId, 'platform-usage-overage-integration-test', 'worker audit should include worker id');
|
||||||
|
|
||||||
|
const secondOutput = await runWorkerOnce();
|
||||||
|
assert.match(secondOutput, /created=0/, 'second worker run should not duplicate usage overage invoice');
|
||||||
|
assert.match(secondOutput, /skipped=1/, 'second worker run should report the existing invoice as skipped');
|
||||||
|
|
||||||
|
const invoiceCount = await pool.query(
|
||||||
|
`
|
||||||
|
select count(*)::integer as count
|
||||||
|
from public.tenant_invoices
|
||||||
|
where tenant_id = $1 and invoice_type = 'usage_overage'
|
||||||
|
`,
|
||||||
|
[ids.tenant],
|
||||||
|
);
|
||||||
|
assert.equal(invoiceCount.rows[0]?.count, 1, 'worker should be idempotent for usage overage invoices');
|
||||||
|
|
||||||
|
console.log('Platform usage overage worker integration test complete.');
|
||||||
|
} finally {
|
||||||
|
await cleanup(pool).catch(() => {});
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user