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:
+197
-63
@@ -1,7 +1,9 @@
|
||||
import { loadBillingConfig } from './billing.mjs';
|
||||
|
||||
const CONFIG_TABLE = 'h5_billing_admin_config';
|
||||
const CONFIG_SCOPE = 'global';
|
||||
const CONFIG_SCOPE_LEGACY = 'global';
|
||||
const CONFIG_SCOPE_A = 'formula-a';
|
||||
const CONFIG_SCOPE_B = 'formula-b';
|
||||
const SOURCE_ADMIN_DB = 'admin-db';
|
||||
const SOURCE_ENV = 'env';
|
||||
const SOURCE_ENV_OVERRIDE = 'env-override';
|
||||
@@ -9,6 +11,13 @@ const SOURCE_DEFAULT = 'default';
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
const DEFAULT_ESTIMATE_INPUT_USD_PER_1M = 0.27;
|
||||
const DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M = 1.1;
|
||||
const DEFAULT_FORMULA = 'A';
|
||||
const FORMULA_KEYS = ['A', 'B'];
|
||||
|
||||
const SCOPE_BY_FORMULA = {
|
||||
A: CONFIG_SCOPE_A,
|
||||
B: CONFIG_SCOPE_B,
|
||||
};
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value == null || value === '') return fallback;
|
||||
@@ -31,6 +40,11 @@ function normalizeNonNegativeNumber(value, fallback) {
|
||||
return num;
|
||||
}
|
||||
|
||||
function normalizeFormulaKey(value) {
|
||||
const key = String(value ?? DEFAULT_FORMULA).trim().toUpperCase();
|
||||
return FORMULA_KEYS.includes(key) ? key : DEFAULT_FORMULA;
|
||||
}
|
||||
|
||||
function defaultConfigShape(env = process.env) {
|
||||
const billing = loadBillingConfig(env);
|
||||
const costEstimateFromTokens = env.H5_COST_ESTIMATE_FROM_TOKENS !== '0';
|
||||
@@ -160,24 +174,98 @@ async function ensureConfigTable(pool) {
|
||||
`);
|
||||
}
|
||||
|
||||
async function loadStoredState(pool) {
|
||||
async function loadStoredStateByScope(pool, scope, env = process.env) {
|
||||
await ensureConfigTable(pool);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT config_json, updated_by, updated_at
|
||||
FROM ${CONFIG_TABLE}
|
||||
WHERE config_scope = ?
|
||||
LIMIT 1`,
|
||||
[CONFIG_SCOPE],
|
||||
[scope],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
config: mergePatch(defaultConfigShape(), parseJsonLike(row.config_json, {})),
|
||||
config: mergePatch(defaultConfigShape(env), parseJsonLike(row.config_json, {}), env),
|
||||
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertStoredConfig(pool, scope, config, { updatedBy = null } = {}, env = process.env) {
|
||||
await ensureConfigTable(pool);
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`INSERT INTO ${CONFIG_TABLE}
|
||||
(config_scope, config_json, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_json = VALUES(config_json),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[scope, JSON.stringify(cloneConfig(config, env)), updatedBy, now],
|
||||
);
|
||||
return {
|
||||
config: cloneConfig(config, env),
|
||||
updatedAt: now,
|
||||
updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultSource(env = process.env) {
|
||||
if (envLocked(env)) return SOURCE_ENV_OVERRIDE;
|
||||
if (envLooksCustomized(env)) return SOURCE_ENV;
|
||||
return SOURCE_DEFAULT;
|
||||
}
|
||||
|
||||
function buildFormulaState(stored, env = process.env) {
|
||||
if (stored) {
|
||||
return {
|
||||
config: cloneConfig(stored.config, env),
|
||||
updatedAt: stored.updatedAt,
|
||||
updatedBy: stored.updatedBy,
|
||||
source: SOURCE_ADMIN_DB,
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: resolveDefaultSource(env),
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateLegacyConfig(pool, env = process.env) {
|
||||
await ensureConfigTable(pool);
|
||||
const legacy = await loadStoredStateByScope(pool, CONFIG_SCOPE_LEGACY, env);
|
||||
const formulaA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env);
|
||||
const formulaB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env);
|
||||
|
||||
const seedConfig = legacy?.config
|
||||
?? formulaA?.config
|
||||
?? formulaB?.config
|
||||
?? defaultConfigShape(env);
|
||||
|
||||
if (!formulaA) {
|
||||
await upsertStoredConfig(
|
||||
pool,
|
||||
CONFIG_SCOPE_A,
|
||||
seedConfig,
|
||||
{ updatedBy: legacy?.updatedBy ?? formulaB?.updatedBy ?? null },
|
||||
env,
|
||||
);
|
||||
}
|
||||
if (!formulaB) {
|
||||
await upsertStoredConfig(
|
||||
pool,
|
||||
CONFIG_SCOPE_B,
|
||||
cloneConfig(formulaA?.config ?? seedConfig, env),
|
||||
{ updatedBy: legacy?.updatedBy ?? formulaA?.updatedBy ?? null },
|
||||
env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBillingAdminConfigService(pool, { env = process.env, cacheTtlMs = CACHE_TTL_MS } = {}) {
|
||||
let cache = null;
|
||||
|
||||
@@ -185,101 +273,142 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache
|
||||
cache = null;
|
||||
}
|
||||
|
||||
async function loadEffectiveConfig({ bypassCache = false } = {}) {
|
||||
async function loadAllFormulaStates({ bypassCache = false } = {}) {
|
||||
const now = Date.now();
|
||||
if (!bypassCache && cache && now - cache.loadedAt < cacheTtlMs) {
|
||||
return cache.state;
|
||||
}
|
||||
|
||||
if (envLocked(env)) {
|
||||
const shared = defaultConfigShape(env);
|
||||
const state = {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV_OVERRIDE,
|
||||
formulas: {
|
||||
A: {
|
||||
config: cloneConfig(shared, env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV_OVERRIDE,
|
||||
},
|
||||
B: {
|
||||
config: cloneConfig(shared, env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV_OVERRIDE,
|
||||
},
|
||||
},
|
||||
defaultFormula: DEFAULT_FORMULA,
|
||||
};
|
||||
cache = { loadedAt: now, state };
|
||||
return state;
|
||||
}
|
||||
|
||||
const stored = await loadStoredState(pool);
|
||||
let state;
|
||||
if (stored) {
|
||||
state = {
|
||||
config: cloneConfig(stored.config, env),
|
||||
updatedAt: stored.updatedAt,
|
||||
updatedBy: stored.updatedBy,
|
||||
source: SOURCE_ADMIN_DB,
|
||||
};
|
||||
} else if (envLooksCustomized(env)) {
|
||||
state = {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV,
|
||||
};
|
||||
} else {
|
||||
state = {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_DEFAULT,
|
||||
};
|
||||
}
|
||||
await migrateLegacyConfig(pool, env);
|
||||
const storedA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env);
|
||||
const storedB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env);
|
||||
const state = {
|
||||
formulas: {
|
||||
A: buildFormulaState(storedA, env),
|
||||
B: buildFormulaState(storedB, env),
|
||||
},
|
||||
defaultFormula: DEFAULT_FORMULA,
|
||||
};
|
||||
cache = { loadedAt: now, state };
|
||||
return state;
|
||||
}
|
||||
|
||||
async function getUserFormulaKey(userId) {
|
||||
if (!userId) return DEFAULT_FORMULA;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT billing_formula FROM h5_users WHERE id = ? LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return normalizeFormulaKey(rows[0]?.billing_formula);
|
||||
}
|
||||
|
||||
async function loadFormulaState(formulaKey, options = {}) {
|
||||
const all = await loadAllFormulaStates(options);
|
||||
return all.formulas[normalizeFormulaKey(formulaKey)];
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureSchema() {
|
||||
await ensureConfigTable(pool);
|
||||
await migrateLegacyConfig(pool, env);
|
||||
},
|
||||
|
||||
clearCache,
|
||||
|
||||
async getAdminConfig() {
|
||||
const state = await loadEffectiveConfig({ bypassCache: true });
|
||||
async getAdminConfig({ formula = DEFAULT_FORMULA } = {}) {
|
||||
const all = await loadAllFormulaStates({ bypassCache: true });
|
||||
const activeFormula = normalizeFormulaKey(formula);
|
||||
const active = all.formulas[activeFormula];
|
||||
return {
|
||||
config: state.config,
|
||||
updatedAt: state.updatedAt,
|
||||
updatedBy: state.updatedBy,
|
||||
source: state.source,
|
||||
envOverrideActive: state.source === SOURCE_ENV_OVERRIDE,
|
||||
config: active.config,
|
||||
formulas: {
|
||||
A: all.formulas.A.config,
|
||||
B: all.formulas.B.config,
|
||||
},
|
||||
formulaMeta: {
|
||||
A: {
|
||||
updatedAt: all.formulas.A.updatedAt,
|
||||
updatedBy: all.formulas.A.updatedBy,
|
||||
source: all.formulas.A.source,
|
||||
},
|
||||
B: {
|
||||
updatedAt: all.formulas.B.updatedAt,
|
||||
updatedBy: all.formulas.B.updatedBy,
|
||||
source: all.formulas.B.source,
|
||||
},
|
||||
},
|
||||
activeFormula,
|
||||
defaultFormula: DEFAULT_FORMULA,
|
||||
updatedAt: active.updatedAt,
|
||||
updatedBy: active.updatedBy,
|
||||
source: active.source,
|
||||
envOverrideActive: active.source === SOURCE_ENV_OVERRIDE,
|
||||
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数(成本模式);无上游成本时回退 Token 单价',
|
||||
};
|
||||
},
|
||||
|
||||
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
|
||||
async updateAdminConfig(patch = {}, { updatedBy = null, formula = DEFAULT_FORMULA } = {}) {
|
||||
if (envLocked(env)) {
|
||||
throw Object.assign(new Error('H5_BILLING_CONFIG_SOURCE=env 时不允许通过后台修改'), {
|
||||
code: 'BILLING_CONFIG_ENV_LOCKED',
|
||||
});
|
||||
}
|
||||
const stored = await loadStoredState(pool);
|
||||
const formulaKey = normalizeFormulaKey(formula);
|
||||
const scope = SCOPE_BY_FORMULA[formulaKey];
|
||||
const stored = await loadStoredStateByScope(pool, scope, env);
|
||||
const base = stored?.config ?? defaultConfigShape(env);
|
||||
const nextConfig = mergePatch(base, patch, env);
|
||||
await ensureConfigTable(pool);
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`INSERT INTO ${CONFIG_TABLE}
|
||||
(config_scope, config_json, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_json = VALUES(config_json),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now],
|
||||
);
|
||||
await upsertStoredConfig(pool, scope, nextConfig, { updatedBy }, env);
|
||||
clearCache();
|
||||
return this.getAdminConfig();
|
||||
return this.getAdminConfig({ formula: formulaKey });
|
||||
},
|
||||
|
||||
async getRuntimeState() {
|
||||
const state = await loadEffectiveConfig();
|
||||
async assignBillingFormula(userIds = [], formula = DEFAULT_FORMULA) {
|
||||
const formulaKey = normalizeFormulaKey(formula);
|
||||
const ids = [...new Set((Array.isArray(userIds) ? userIds : []).map((id) => String(id).trim()).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
return { ok: false, message: '请选择至少一个用户', updated: 0 };
|
||||
}
|
||||
const now = Date.now();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const [result] = await pool.query(
|
||||
`UPDATE h5_users SET billing_formula = ?, updated_at = ? WHERE id IN (${placeholders})`,
|
||||
[formulaKey, now, ...ids],
|
||||
);
|
||||
return { ok: true, updated: Number(result.affectedRows ?? 0), formula: formulaKey };
|
||||
},
|
||||
|
||||
async getRuntimeState({ formula = DEFAULT_FORMULA, userId = null } = {}) {
|
||||
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
|
||||
const state = await loadFormulaState(formulaKey);
|
||||
return {
|
||||
source: state.source,
|
||||
updatedAt: state.updatedAt,
|
||||
updatedBy: state.updatedBy,
|
||||
formula: formulaKey,
|
||||
config: state.config,
|
||||
compute: toComputeBillingConfig(state.config),
|
||||
estimate: toCostEstimateConfig(state.config),
|
||||
@@ -287,23 +416,28 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache
|
||||
};
|
||||
},
|
||||
|
||||
async getEffectiveBillingConfig() {
|
||||
const state = await loadEffectiveConfig();
|
||||
async getEffectiveBillingConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) {
|
||||
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
|
||||
const state = await loadFormulaState(formulaKey);
|
||||
return toComputeBillingConfig(state.config);
|
||||
},
|
||||
|
||||
async getEffectiveCostEstimateConfig() {
|
||||
const state = await loadEffectiveConfig();
|
||||
async getEffectiveCostEstimateConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) {
|
||||
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
|
||||
const state = await loadFormulaState(formulaKey);
|
||||
return toCostEstimateConfig(state.config);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const billingAdminConfigInternals = {
|
||||
CONFIG_SCOPE,
|
||||
CONFIG_TABLE,
|
||||
CONFIG_SCOPE_LEGACY,
|
||||
CONFIG_SCOPE_A,
|
||||
CONFIG_SCOPE_B,
|
||||
DEFAULT_FORMULA,
|
||||
defaultConfigShape,
|
||||
mergePatch,
|
||||
normalizeFormulaKey,
|
||||
toComputeBillingConfig,
|
||||
toCostEstimateConfig,
|
||||
envLocked,
|
||||
|
||||
@@ -7,13 +7,21 @@ import {
|
||||
toCostEstimateConfig,
|
||||
} from './billing-admin-config.mjs';
|
||||
|
||||
function createMemoryPool(initialRows = []) {
|
||||
function createMemoryPool(initialRows = [], userFormulas = new Map()) {
|
||||
const rows = new Map(
|
||||
initialRows.map((row) => [row.config_scope, { ...row }]),
|
||||
);
|
||||
return {
|
||||
async query(sql, params = []) {
|
||||
if (/CREATE TABLE/i.test(sql)) return [{}, undefined];
|
||||
if (/SELECT billing_formula FROM h5_users/i.test(sql)) {
|
||||
const userId = params[0];
|
||||
return [[{ billing_formula: userFormulas.get(userId) ?? 'A' }], undefined];
|
||||
}
|
||||
if (/SELECT/i.test(sql) && /config_scope IN/i.test(sql)) {
|
||||
const scopes = params.slice(0, 3);
|
||||
return [scopes.map((scope) => rows.get(scope)).filter(Boolean), undefined];
|
||||
}
|
||||
if (/SELECT/i.test(sql)) {
|
||||
const scope = params[0];
|
||||
const row = rows.get(scope);
|
||||
@@ -29,9 +37,15 @@ function createMemoryPool(initialRows = []) {
|
||||
});
|
||||
return [{ affectedRows: 1 }, undefined];
|
||||
}
|
||||
if (/UPDATE h5_users SET billing_formula/i.test(sql)) {
|
||||
const [formula, , ...ids] = params;
|
||||
ids.forEach((id) => userFormulas.set(id, formula));
|
||||
return [{ affectedRows: ids.length }, undefined];
|
||||
}
|
||||
return [{}, undefined];
|
||||
},
|
||||
_rows: rows,
|
||||
_userFormulas: userFormulas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,14 +103,38 @@ test('admin-db config wins over env for effective billing', async () => {
|
||||
const service = createBillingAdminConfigService(pool, { env, cacheTtlMs: 0 });
|
||||
await service.updateAdminConfig(
|
||||
{ marginMultiplier: 2, usdCnyRate: 7.5, useBackendCost: true },
|
||||
{ updatedBy: 'admin-1' },
|
||||
{ updatedBy: 'admin-1', formula: 'A' },
|
||||
);
|
||||
const effective = await service.getEffectiveBillingConfig();
|
||||
const effective = await service.getEffectiveBillingConfig({ userId: 'user-a' });
|
||||
assert.equal(effective.marginMultiplier, 2);
|
||||
assert.equal(effective.usdCnyRate, 7.5);
|
||||
const admin = await service.getAdminConfig();
|
||||
assert.equal(admin.source, 'admin-db');
|
||||
assert.equal(admin.updatedBy, 'admin-1');
|
||||
assert.equal(admin.formulas?.A.marginMultiplier, 2);
|
||||
});
|
||||
|
||||
test('formula A and B can diverge', async () => {
|
||||
const pool = createMemoryPool([], new Map([['user-b', 'B']]));
|
||||
const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 });
|
||||
await service.updateAdminConfig({ marginMultiplier: 1.1 }, { formula: 'A' });
|
||||
await service.updateAdminConfig({ marginMultiplier: 2.2 }, { formula: 'B' });
|
||||
const admin = await service.getAdminConfig();
|
||||
assert.equal(admin.formulas.A.marginMultiplier, 1.1);
|
||||
assert.equal(admin.formulas.B.marginMultiplier, 2.2);
|
||||
const userA = await service.getEffectiveBillingConfig({ userId: 'user-a' });
|
||||
const userB = await service.getEffectiveBillingConfig({ userId: 'user-b' });
|
||||
assert.equal(userA.marginMultiplier, 1.1);
|
||||
assert.equal(userB.marginMultiplier, 2.2);
|
||||
});
|
||||
|
||||
test('assignBillingFormula updates users', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 });
|
||||
const result = await service.assignBillingFormula(['u1', 'u2'], 'B');
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.updated, 2);
|
||||
assert.equal(pool._userFormulas.get('u1'), 'B');
|
||||
});
|
||||
|
||||
test('H5_BILLING_CONFIG_SOURCE=env locks admin writes', async () => {
|
||||
@@ -125,7 +163,8 @@ test('env fallback used when no admin row', async () => {
|
||||
cacheTtlMs: 0,
|
||||
});
|
||||
const admin = await service.getAdminConfig();
|
||||
assert.equal(admin.source, 'env');
|
||||
assert.equal(admin.config.marginMultiplier, 1.2);
|
||||
assert.equal(admin.config.useBackendCost, true);
|
||||
assert.equal(admin.formulas.A.marginMultiplier, 1.2);
|
||||
assert.equal(admin.formulas.B.marginMultiplier, 1.2);
|
||||
});
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -583,6 +583,12 @@ export async function migrateSchema(pool) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await columnExists(pool, 'h5_users', 'billing_formula'))) {
|
||||
await pool.query(
|
||||
`ALTER TABLE h5_users ADD COLUMN billing_formula ENUM('A','B') NOT NULL DEFAULT 'A' AFTER low_balance_gift_granted_at`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_node'))) {
|
||||
await pool.query(
|
||||
`ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`,
|
||||
|
||||
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS h5_users (
|
||||
workspace_root VARCHAR(512) NOT NULL,
|
||||
low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0,
|
||||
low_balance_gift_granted_at BIGINT NULL,
|
||||
billing_formula ENUM('A','B') NOT NULL DEFAULT 'A',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_users_slug (slug),
|
||||
|
||||
+8
-4
@@ -2109,12 +2109,12 @@ export function createTkmindProxy({
|
||||
return upstream.json();
|
||||
}
|
||||
|
||||
async function resolveSessionBillingTokenState(sessionId, tokenStateRaw) {
|
||||
async function resolveSessionBillingTokenState(sessionId, tokenStateRaw, userId = null) {
|
||||
return resolveBillingTokenState(tokenStateRaw, {
|
||||
sessionId,
|
||||
fetchSession: fetchSessionBillingCost,
|
||||
loadEstimateConfig: billingConfigService?.getEffectiveCostEstimateConfig
|
||||
? () => billingConfigService.getEffectiveCostEstimateConfig()
|
||||
? () => billingConfigService.getEffectiveCostEstimateConfig({ userId })
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -2218,7 +2218,7 @@ export function createTkmindProxy({
|
||||
}
|
||||
if (!finishResult.ok) throw finishResult.error;
|
||||
const finish = finishResult.value;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState);
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState, userId);
|
||||
if (tokenState && userAuth.billSessionUsage) {
|
||||
await userAuth
|
||||
.billSessionUsage(userId, sessionId, tokenState, requestId)
|
||||
@@ -2720,7 +2720,11 @@ export function createTkmindProxy({
|
||||
.catch(() => {});
|
||||
}
|
||||
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, event.token_state);
|
||||
const tokenState = await resolveSessionBillingTokenState(
|
||||
sessionId,
|
||||
event.token_state,
|
||||
req.currentUser.id,
|
||||
);
|
||||
const result = await userAuth.billSessionUsage(
|
||||
req.currentUser.id,
|
||||
sessionId,
|
||||
|
||||
+34
-8
@@ -129,15 +129,19 @@ export function createUserAuth(pool, options = {}) {
|
||||
const { storageRoot, publicBaseUrl } = resolveMindSpaceRuntimeConfig(h5Root, env);
|
||||
const skillCatalog = listPlatformSkillCatalog(h5Root);
|
||||
const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
|
||||
const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100);
|
||||
const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1000);
|
||||
const lowBalanceGiftThresholdCents = Number(
|
||||
options.lowBalanceGiftThresholdCents ?? env.H5_LOW_BALANCE_GIFT_THRESHOLD_CENTS ?? 100,
|
||||
);
|
||||
const lowBalanceGiftAmountCents = Number(
|
||||
options.lowBalanceGiftAmountCents ?? env.H5_LOW_BALANCE_GIFT_AMOUNT_CENTS ?? 0,
|
||||
);
|
||||
const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000);
|
||||
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
|
||||
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000);
|
||||
const persistSessions = options.persistSessions !== false && Boolean(pool);
|
||||
let rechargeNotifier =
|
||||
typeof options.onRechargeNotification === 'function' ? options.onRechargeNotification : null;
|
||||
const subscriptionService = options.subscriptionService ?? null;
|
||||
let subscriptionService = options.subscriptionService ?? null;
|
||||
const provisionUserDataSpace = typeof options.provisionUserDataSpace === 'function'
|
||||
? options.provisionUserDataSpace
|
||||
: null;
|
||||
@@ -190,7 +194,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
const getUserById = async (userId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||
u.plan_type, u.workspace_root,
|
||||
u.plan_type, u.workspace_root, u.billing_formula,
|
||||
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
||||
w.balance_cents, w.tokens_used,
|
||||
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
|
||||
@@ -218,6 +222,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
status: row.status,
|
||||
planType: row.plan_type ?? 'free',
|
||||
workspaceRoot: row.workspace_root,
|
||||
billingFormula: row.billing_formula === 'B' ? 'B' : 'A',
|
||||
balanceCents,
|
||||
totalCreditCents: balanceCents + spentCents,
|
||||
tokensUsed: Number(row.tokens_used ?? 0),
|
||||
@@ -447,7 +452,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
||||
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 0, NULL, ?, ?)`,
|
||||
[
|
||||
userId,
|
||||
normalized,
|
||||
@@ -900,7 +905,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||
u.plan_type, u.workspace_root,
|
||||
u.plan_type, u.workspace_root, u.billing_formula,
|
||||
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
||||
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
|
||||
FROM h5_users u
|
||||
@@ -1038,6 +1043,14 @@ export function createUserAuth(pool, options = {}) {
|
||||
fields.push('role = ?');
|
||||
values.push(patch.role === 'admin' ? 'admin' : 'user');
|
||||
}
|
||||
if (patch.billingFormula !== undefined) {
|
||||
const formula = String(patch.billingFormula).trim().toUpperCase();
|
||||
if (!['A', 'B'].includes(formula)) {
|
||||
return { ok: false, message: '计量公式只能是 A 或 B' };
|
||||
}
|
||||
fields.push('billing_formula = ?');
|
||||
values.push(formula);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
fields.push('updated_at = ?');
|
||||
@@ -1272,6 +1285,16 @@ export function createUserAuth(pool, options = {}) {
|
||||
]);
|
||||
}
|
||||
if (ownsConnection) await conn.commit();
|
||||
if (subscriptionService?.processAutoRenewalsForUser) {
|
||||
try {
|
||||
await subscriptionService.processAutoRenewalsForUser(userId);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Post-recharge auto-renew retry failed:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
const updated = await getUserById(userId);
|
||||
if (rechargeNotifier) {
|
||||
try {
|
||||
@@ -1336,7 +1359,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
}
|
||||
const tokenState = normalizeTokenState(tokenStateRaw);
|
||||
const config = billingConfigService?.getEffectiveBillingConfig
|
||||
? await billingConfigService.getEffectiveBillingConfig()
|
||||
? await billingConfigService.getEffectiveBillingConfig({ userId })
|
||||
: loadBillingConfig(env);
|
||||
const normalizedRequestId = requestId ? String(requestId).trim() || null : null;
|
||||
const now = Date.now();
|
||||
@@ -2747,7 +2770,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
||||
role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible,
|
||||
low_balance_gift_granted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`,
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 0, NULL, ?, ?)`,
|
||||
[
|
||||
userId,
|
||||
normalized,
|
||||
@@ -3079,6 +3102,9 @@ export function createUserAuth(pool, options = {}) {
|
||||
setRechargeNotifier(callback) {
|
||||
rechargeNotifier = typeof callback === 'function' ? callback : null;
|
||||
},
|
||||
setSubscriptionService(service) {
|
||||
subscriptionService = service ?? null;
|
||||
},
|
||||
findWechatUserByOpenid,
|
||||
getWechatAgentRoute,
|
||||
upsertWechatAgentRoute,
|
||||
|
||||
+1
-1
@@ -743,7 +743,7 @@ test('billSessionUsage auto gifts low-balance bonus once for eligible new users'
|
||||
},
|
||||
};
|
||||
|
||||
const auth = createUserAuth(pool, { persistSessions: false });
|
||||
const auth = createUserAuth(pool, { persistSessions: false, lowBalanceGiftAmountCents: 1000 });
|
||||
|
||||
const first = await auth.billSessionUsage(
|
||||
userRow.id,
|
||||
|
||||
Reference in New Issue
Block a user