feat(billing): add dual formulas, disable 10yuan gift, retry auto-renew
Memind CI / Test, build, and release guards (push) Successful in 3m43s

Turn off the low-balance 10 CNY signup bonus while keeping the 5 CNY
registration credit, split metering into independent formula A/B scopes,
and retry expired auto-renew subscriptions after recharge or hourly worker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-22 08:45:43 +08:00
parent f57e080b49
commit 4d12ea438b
9 changed files with 474 additions and 164 deletions
+61 -2
View File
@@ -353,8 +353,11 @@ describe('createSubscriptionService', () => {
describe('processAutoRenewals', () => {
it('uses DB-backed plan definitions for renewed subscriptions', async () => {
const userId = 'user-lite-1';
const expiredSub = makeSubRow({
user_id: userId,
plan_type: 'lite',
status: 'expired',
auto_renew: 1,
expires_at: Date.now() - 1000,
balance_cents: 9999,
@@ -364,6 +367,9 @@ describe('createSubscriptionService', () => {
async query(sql, params) {
this.queries.push({ sql, params });
if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: 9999 }]];
if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) {
return [[{ ...expiredSub, id: 'new-sub-id', status: 'active' }]];
}
return [{ affectedRows: 1 }];
},
async beginTransaction() {},
@@ -372,8 +378,12 @@ describe('createSubscriptionService', () => {
release() {},
};
const pool = {
async query(sql) {
if (sql.includes('FROM h5_subscriptions s')) return [[expiredSub]];
async query(sql, params = []) {
if (sql.includes('SELECT DISTINCT s.user_id')) return [[{ user_id: userId }]];
if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]];
if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) {
return [[expiredSub]];
}
return [[]];
},
async getConnection() { return conn; },
@@ -407,5 +417,54 @@ describe('createSubscriptionService', () => {
assert.equal(insert.params[5], 0);
assert.equal(insert.params[9], 0.45);
});
it('retries expired subscriptions when balance becomes sufficient', async () => {
const userId = 'user-retry-1';
let balance = 500;
const expiredSub = makeSubRow({
user_id: userId,
plan_type: 'lite',
status: 'expired',
auto_renew: 1,
expires_at: Date.now() - 1000,
});
const conn = {
async query(sql) {
if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: balance }]];
if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) {
return [[{ ...expiredSub, id: 'renewed-sub', status: 'active' }]];
}
return [{ affectedRows: 1 }];
},
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
};
const pool = {
async query(sql) {
if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]];
if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) {
return [[expiredSub]];
}
return [[]];
},
async getConnection() { return conn; },
};
const svc = createSubscriptionService(pool, {
getPlanAsync: async (planType) => planType === 'lite'
? { priceCents: 990, periodTokens: 1000, periodImages: 0, overageRate: 1, periodDays: 30 }
: null,
});
const failed = await svc.processAutoRenewalsForUser(userId);
assert.equal(failed.ok, false);
assert.equal(failed.reason, 'insufficient_balance');
balance = 5000;
const renewed = await svc.processAutoRenewalsForUser(userId);
assert.equal(renewed.ok, true);
assert.equal(renewed.balanceCents, 4010);
});
});
});