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:
@@ -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 d’un 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 }>> {
|
||||
|
||||
Reference in New Issue
Block a user