feat(billing): add dual formulas, disable 10yuan gift, retry auto-renew
Memind CI / Test, build, and release guards (push) Successful in 3m43s
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:
+123
-82
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user