feat(billing): add formula A/B admin UI and subscription renew status

Expose dual metering formula editing with batch user assignment, show
auto-renew and pending-recharge states in subscriptions, and document
billing acceptance checks for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-22 08:45:43 +08:00
parent a503fa9cbe
commit f6bb02254e
8 changed files with 503 additions and 171 deletions
+21 -5
View File
@@ -660,11 +660,12 @@ export function createAdminApp(services) {
res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
});
adminApi.get('/billing/config', requireAdmin, async (_req, res) => {
adminApi.get('/billing/config', requireAdmin, async (req, res) => {
if (!billingConfigService?.getAdminConfig) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
return res.json(await billingConfigService.getAdminConfig());
const formula = req.query.formula ? String(req.query.formula) : undefined;
return res.json(await billingConfigService.getAdminConfig({ formula }));
});
const updateBillingConfig = async (req, res) => {
@@ -672,9 +673,10 @@ export function createAdminApp(services) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
try {
const formula = req.body?.formula ? String(req.body.formula) : undefined;
return res.json(await billingConfigService.updateAdminConfig(
req.body?.config ?? req.body ?? {},
{ updatedBy: req.currentUser.id },
{ updatedBy: req.currentUser.id, formula },
));
} catch (error) {
if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') {
@@ -687,11 +689,25 @@ export function createAdminApp(services) {
adminApi.put('/billing/config', requireAdmin, updateBillingConfig);
adminApi.patch('/billing/config', requireAdmin, updateBillingConfig);
adminApi.get('/billing/runtime', requireAdmin, async (_req, res) => {
adminApi.post('/billing/formula-assignments', requireAdmin, async (req, res) => {
if (!billingConfigService?.assignBillingFormula) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
const { userIds, formula } = req.body ?? {};
const result = await billingConfigService.assignBillingFormula(userIds, formula);
if (!result.ok) {
return res.status(400).json(result);
}
return res.json(result);
});
adminApi.get('/billing/runtime', requireAdmin, async (req, res) => {
if (!billingConfigService?.getRuntimeState) {
return res.status(503).json({ message: '计费公式配置服务未启用' });
}
return res.json(await billingConfigService.getRuntimeState());
const formula = req.query.formula ? String(req.query.formula) : undefined;
const userId = req.query.userId ? String(req.query.userId) : undefined;
return res.json(await billingConfigService.getRuntimeState({ formula, userId }));
});
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
+20 -1
View File
@@ -28,8 +28,15 @@ function createServices({ role = 'admin' } = {}) {
getMe: async () => ({ id: 'admin-id', username: 'admin', role }),
},
billingConfigService: {
getAdminConfig: async () => ({
getAdminConfig: async ({ formula = 'A' } = {}) => ({
config: stored,
formulas: { A: stored, B: stored },
formulaMeta: {
A: { source: 'env', updatedAt: null, updatedBy: null },
B: { source: 'env', updatedAt: null, updatedBy: null },
},
activeFormula: formula,
defaultFormula: 'A',
source: 'env',
updatedAt: null,
updatedBy: null,
@@ -39,12 +46,24 @@ function createServices({ role = 'admin' } = {}) {
stored = { ...stored, ...(patch.config ?? patch) };
return {
config: stored,
formulas: { A: stored, B: stored },
formulaMeta: {
A: { source: 'admin-db', updatedAt: Date.now(), updatedBy: context?.updatedBy ?? null },
B: { source: 'admin-db', updatedAt: null, updatedBy: null },
},
activeFormula: context?.formula ?? 'A',
defaultFormula: 'A',
source: 'admin-db',
updatedAt: Date.now(),
updatedBy: context?.updatedBy ?? null,
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数',
};
},
assignBillingFormula: async (userIds, formula) => ({
ok: true,
updated: userIds.length,
formula,
}),
getRuntimeState: async () => ({
source: 'admin-db',
config: stored,
+1
View File
@@ -165,6 +165,7 @@ export async function bootstrapAdminServices() {
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
});
subscriptionService._planCatalogService = planCatalogService;
userAuth.setSubscriptionService(subscriptionService);
const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs');
const templateCatalogService = createPageTemplateCatalogService(pool, {
userAuth,