feat(credits): solde IA unique (option M), packs Stripe et polish billing

Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -6,6 +6,7 @@ import {
getRedisKey,
parseRedisInt,
isValidFeature,
VALID_FEATURES,
} from './quota-utils';
import {
getLimitAsync,
@@ -138,15 +139,17 @@ async function reserveUsageInDatabase(
userId: string,
feature: string,
limit: number,
amount: number = 1,
): Promise<number> {
const { periodStart, periodEnd } = getPeriodDates();
const units = Math.max(1, Math.floor(amount));
return prisma.$transaction(async (tx) => {
const existing = await tx.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current >= limit) return -1;
if (current + units > limit) return -1;
const updated = await tx.usageLog.upsert({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
@@ -155,12 +158,12 @@ async function reserveUsageInDatabase(
feature,
periodStart,
periodEnd,
requestsCount: 1,
requestsCount: units,
tokensUsed: 0,
syncedAt: new Date(),
},
update: {
requestsCount: { increment: 1 },
requestsCount: { increment: units },
syncedAt: new Date(),
},
});
@@ -191,53 +194,37 @@ return newCount
`;
const RELEASE_LUA = `
local amount = tonumber(ARGV[1]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current <= 0 then return 0 end
redis.call('DECR', KEYS[1])
local dec = math.min(amount, current)
redis.call('DECRBY', KEYS[1], dec)
return tonumber(redis.call('GET', KEYS[1]) or '0')
`;
export async function releaseUsage(userId: string, feature: string): Promise<void> {
/** Libère des crédits précédemment réservés (modèle A). */
export async function releaseUsage(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) return;
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
if (limit === undefined || limit === Infinity) return;
try {
const key = getRedisKey(userId, feature);
const raw = await redis.eval(RELEASE_LUA, 1, key);
const newCount = parseReserveResult(raw);
if (Number.isFinite(newCount) && newCount >= 0) {
await mirrorUsageCountToDatabase(userId, feature, newCount);
}
} catch (err) {
console.error('[entitlements] Redis release failed, trying DB fallback:', err);
try {
const { periodStart } = getPeriodDates();
const existing = await prisma.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current <= 0) return;
await prisma.usageLog.update({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
data: { requestsCount: current - 1, syncedAt: new Date() },
});
} catch (dbErr) {
console.error('[entitlements] DB release fallback failed:', dbErr);
}
}
const units = Math.max(1, Math.floor(Number(amount) || 1));
const { releaseCredits } = await import('@/lib/credits');
await releaseCredits(userId, units, { feature });
}
const RESERVE_LUA = `
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local amount = tonumber(ARGV[3]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= limit then
if current + amount > limit then
return -1
end
redis.call('INCRBY', KEYS[1], 1)
redis.call('INCRBY', KEYS[1], amount)
local ttlResult = redis.call('TTL', KEYS[1])
if ttlResult == -1 then
redis.call('EXPIRE', KEYS[1], ttl)
@@ -370,86 +357,47 @@ export async function checkEntitlementOrThrow(
}
}
/** Mappe une feature de quota vers la voie IA (pour le BYOK). */
function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
if (feature === 'auto_tag' || feature === 'auto_title') return 'tags';
if (feature === 'semantic_search') return 'embedding';
return 'chat';
}
/**
* Réserve des crédits globaux (modèle A).
* `amount` = coût en crédits (ex. slides multi-étapes : 1+N).
* Si l'utilisateur a une clé perso (BYOK) pour la voie concernée, aucun débit.
* Conserve le nom historique pour ne pas casser tous les appelants.
*/
export async function reserveUsageOrThrow(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) {
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
}
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
const { getSystemConfig } = await import('@/lib/config');
const { willUseByokForLane } = await import('@/lib/ai/provider-for-user');
const config = await getSystemConfig();
const lane = featureToAiLane(feature);
const { usedByok } = await willUseByokForLane(lane, config, userId);
if (usedByok) return;
if (limit === undefined) {
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
0,
0,
false,
{ currentTier: tier },
);
}
const { reserveCreditsOrThrow, resolveCreditCost } = await import('@/lib/credits');
const cost = resolveCreditCost(feature, { amount });
await reserveCreditsOrThrow(userId, cost, { feature });
}
if (limit === Infinity) return;
try {
const key = getRedisKey(userId, feature);
const raw = await redis.eval(
RESERVE_LUA,
1,
key,
String(limit),
String(TTL_SECONDS),
);
const newCount = parseReserveResult(raw);
if (newCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
limit,
limit,
byokConfigured,
{ currentTier: tier },
);
}
if (!Number.isFinite(newCount) || newCount < 1) {
throw new Error(`Invalid Redis reserve result: ${String(raw)}`);
}
await mirrorUsageCountToDatabase(userId, feature, newCount);
} catch (err) {
if (err instanceof QuotaExceededError) throw err;
console.error('[entitlements] Redis reserve failed, trying DB fallback:', err);
try {
const dbCount = await reserveUsageInDatabase(userId, feature, limit);
if (dbCount === -1) {
const byokConfigured = await hasAnyActiveByok(userId);
throw new QuotaExceededError(
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
feature,
limit,
limit,
byokConfigured,
{ currentTier: tier },
);
}
return;
} catch (dbErr) {
if (dbErr instanceof QuotaExceededError) throw dbErr;
console.error('[entitlements] DB reserve fallback failed:', dbErr);
}
if (shouldFailClosedOnRedisError()) {
throw new QuotaServiceUnavailableError();
}
}
/** Units charged for one multi-step slide generation (outline + per-slide expand). */
export function slideGenerateQuotaUnits(slideCount?: number | null): number {
// 1 outline + N expand calls (clamped). Reflects real multi-call cost.
const n = typeof slideCount === 'number' && Number.isFinite(slideCount)
? Math.min(8, Math.max(3, Math.round(slideCount)))
: 6;
return 1 + n;
}
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
@@ -480,6 +428,68 @@ export function incrementUsageAsync(userId: string, feature: string, count: numb
});
}
export type ResetUserUsageOptions = {
/** Si fourni, ne remet à zéro que ces fonctionnalités. Sinon : toutes. */
features?: string[];
};
/**
* Remet à zéro la consommation IA dun utilisateur pour la période mensuelle en cours
* (compteurs Redis + lignes UsageLog en base).
* Réservé aux actions admin.
*/
export async function resetUserUsageForCurrentPeriod(
userId: string,
options?: ResetUserUsageOptions,
): Promise<{ period: string; featuresReset: string[]; redisDeleted: number; dbUpdated: number }> {
if (!userId?.trim()) {
throw new Error('userId required');
}
const period = getCurrentPeriodKey();
const { periodStart } = getPeriodDates();
const features = (options?.features?.length
? options.features.filter((f) => isValidFeature(f))
: [...VALID_FEATURES]) as string[];
if (features.length === 0) {
return { period, featuresReset: [], redisDeleted: 0, dbUpdated: 0 };
}
const keys = features.map((f) => getRedisKey(userId, f));
let redisDeleted = 0;
try {
if (keys.length === 1) {
redisDeleted = await redis.del(keys[0]);
} else {
redisDeleted = await redis.del(...keys);
}
} catch (err) {
console.error('[entitlements] resetUserUsage Redis del failed:', err);
}
const dbResult = await prisma.usageLog.updateMany({
where: {
userId,
periodStart,
feature: { in: features },
},
data: {
requestsCount: 0,
tokensUsed: 0,
syncedAt: new Date(),
},
});
return {
period,
featuresReset: features,
redisDeleted,
dbUpdated: dbResult.count,
};
}
export async function getUserQuotas(
userId: string,
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {