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
+123 -82
View File
@@ -477,99 +477,139 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
return { ok: true, updated: result.affectedRows > 0 };
};
// Process auto-renewals for subscriptions that just expired with auto_renew = 1.
// Should be called BEFORE expireStaleSubscriptions in the periodic worker.
const processAutoRenewals = async () => {
const now = Date.now();
// Find active subscriptions that have expired with auto_renew on.
const [rows] = await pool.query(
// Process auto-renewals for subscriptions that expired with auto_renew = 1.
// Retries until balance is sufficient; does not disable auto_renew on failure.
const processAutoRenewalsForUser = async (userId, { now = Date.now() } = {}) => {
const [activeRows] = await pool.query(
`SELECT id FROM h5_subscriptions
WHERE user_id = ? AND status = 'active' AND expires_at > ?
LIMIT 1`,
[userId, now],
);
if (activeRows.length) {
return { ok: false, reason: 'already_active' };
}
const [candidateRows] = await pool.query(
`SELECT s.*, w.balance_cents
FROM h5_subscriptions s
JOIN h5_user_wallets w ON w.user_id = s.user_id
WHERE s.status = 'active' AND s.auto_renew = 1 AND s.expires_at <= ?`,
[now],
WHERE s.user_id = ? AND s.auto_renew = 1 AND s.expires_at <= ?
ORDER BY s.expires_at DESC
LIMIT 1`,
[userId, now],
);
const row = candidateRows[0];
if (!row) {
return { ok: false, reason: 'no_candidate' };
}
const sub = mapSubRow(row);
const plan = await resolvePlan(sub.planType);
if (!plan || plan.priceCents === 0) {
return { ok: false, reason: 'invalid_plan' };
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [walletRows] = await conn.query(
`SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
[sub.userId],
);
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
if (balanceCents < plan.priceCents) {
await conn.rollback();
console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`);
return { ok: false, reason: 'insufficient_balance', balanceCents, requiredCents: plan.priceCents };
}
await conn.query(
`UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`,
[plan.priceCents, now, sub.userId],
);
await conn.query(
`INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now],
);
if (sub.status === 'active') {
await conn.query(
`UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`,
[now, sub.id],
);
}
const newId = crypto.randomUUID();
const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000;
await conn.query(
`INSERT INTO h5_subscriptions
(id, user_id, plan_type, status, period_tokens_limit, period_tokens_used,
period_images_limit, period_images_used, period_images_bonus,
period_start, period_end, expires_at, overage_rate, auto_renew,
operator_id, note, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`,
[
newId, sub.userId, sub.planType, plan.periodTokens,
plan.periodImages ?? 0,
sub.periodImagesBonus ?? 0,
now, newPeriodEnd, newPeriodEnd,
Number(plan.overageRate.toFixed(2)),
now, now,
],
);
await conn.query(
`UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`,
[sub.planType, now, sub.userId],
);
await conn.commit();
console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`);
const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]);
return { ok: true, subscription: mapSubRow(newRows[0]), balanceCents: balanceCents - plan.priceCents };
} catch (err) {
await conn.rollback();
console.warn(`Auto-renew error for user ${sub.userId}:`, err);
throw err;
} finally {
conn.release();
}
};
const processAutoRenewals = async () => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT DISTINCT s.user_id
FROM h5_subscriptions s
WHERE s.auto_renew = 1
AND s.expires_at <= ?
AND s.plan_type != 'free'
AND NOT EXISTS (
SELECT 1 FROM h5_subscriptions s2
WHERE s2.user_id = s.user_id
AND s2.status = 'active'
AND s2.expires_at > ?
)`,
[now, now],
);
let renewed = 0;
let failed = 0;
for (const row of rows) {
const sub = mapSubRow(row);
const plan = await resolvePlan(sub.planType);
if (!plan || plan.priceCents === 0) continue;
const balance = Number(row.balance_cents ?? 0);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Re-lock wallet and verify balance
const [walletRows] = await conn.query(
`SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
[sub.userId],
);
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
if (balanceCents < plan.priceCents) {
// Not enough — let expireStaleSubscriptions handle the reset
await conn.rollback();
failed++;
console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`);
continue;
const result = await processAutoRenewalsForUser(row.user_id, { now });
if (result.ok) {
renewed += 1;
} else if (result.reason === 'insufficient_balance') {
failed += 1;
}
// Deduct wallet
await conn.query(
`UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`,
[plan.priceCents, now, sub.userId],
);
await conn.query(
`INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now],
);
// Expire old subscription
await conn.query(
`UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`,
[now, sub.id],
);
// Create new subscription
const newId = crypto.randomUUID();
const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000;
await conn.query(
`INSERT INTO h5_subscriptions
(id, user_id, plan_type, status, period_tokens_limit, period_tokens_used,
period_images_limit, period_images_used, period_images_bonus,
period_start, period_end, expires_at, overage_rate, auto_renew,
operator_id, note, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`,
[
newId, sub.userId, sub.planType, plan.periodTokens,
plan.periodImages ?? 0,
sub.periodImagesBonus ?? 0,
now, newPeriodEnd, newPeriodEnd,
Number(plan.overageRate.toFixed(2)),
now, now,
],
);
// Keep user's plan_type current
await conn.query(
`UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`,
[sub.planType, now, sub.userId],
);
await conn.commit();
renewed++;
console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`);
} catch (err) {
await conn.rollback();
console.warn(`Auto-renew error for user ${sub.userId}:`, err);
failed++;
} finally {
conn.release();
} catch {
failed += 1;
}
}
@@ -901,6 +941,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
purchaseSubscription,
setAutoRenew,
processAutoRenewals,
processAutoRenewalsForUser,
consumeQuota,
consumeImageQuota,
getImageQuota,