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

@@ -2,12 +2,15 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user'
import type { AiFeatureLane } from '@/lib/ai/router'
import { getSystemConfig } from '@/lib/config'
import {
reserveUsageOrThrow,
releaseUsage,
checkSessionEntitlementOrThrow,
QuotaExceededError,
QuotaServiceUnavailableError,
} from '@/lib/entitlements'
import {
releaseCredits,
reserveCreditsOrThrow,
resolveCreditCost,
} from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
@@ -15,22 +18,34 @@ export type ReserveAiUsageOptions = {
lane?: AiFeatureLane
/** Defaults to userId — host-pays brainstorm bills this account. */
billingUserId?: string
/** Units / crédits (default 1). Multi-step features (slides) pass amount > 1. */
amount?: number
/** Pour coût slides précis (1+N). */
slideCount?: number | null
metadata?: Record<string, unknown>
}
/** Reserve one AI unit; skips when BYOK will be used for the lane. */
/** Reserve crédits IA ; saute si BYOK pour le lane. */
export async function reserveAiUsageOrThrow(
userId: string,
feature: FeatureName,
options?: ReserveAiUsageOptions,
): Promise<{ usedByok: boolean; billedUserId: string }> {
): Promise<{ usedByok: boolean; billedUserId: string; amount: number }> {
const billedUserId = options?.billingUserId ?? userId
const lane = options?.lane ?? 'chat'
const cost = resolveCreditCost(feature, {
amount: options?.amount,
slideCount: options?.slideCount,
})
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billedUserId)
if (!usedByok) {
await reserveUsageOrThrow(billedUserId, feature)
await reserveCreditsOrThrow(billedUserId, cost, {
feature,
metadata: options?.metadata,
})
}
return { usedByok, billedUserId }
return { usedByok, billedUserId, amount: cost }
}
/** Host-pays: reserve on session owner with guest metadata on 402. */
@@ -39,30 +54,49 @@ export async function reserveSessionAiUsageOrThrow(
triggeredByUserId: string,
isGuestActor: boolean,
feature: FeatureName,
options?: { lane?: AiFeatureLane },
): Promise<{ usedByok: boolean }> {
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
): Promise<{ usedByok: boolean; amount: number }> {
const lane = options?.lane ?? 'chat'
const cost = resolveCreditCost(feature, {
amount: options?.amount,
slideCount: options?.slideCount,
})
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billingOwnerId)
if (!usedByok) {
await checkSessionEntitlementOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
)
try {
await reserveCreditsOrThrow(billingOwnerId, cost, { feature })
} catch (err) {
if (err instanceof QuotaExceededError) {
err.billingOwnerId = billingOwnerId
err.triggeredByUserId = triggeredByUserId
err.isGuestActor = isGuestActor
// Also keep session path for legacy callers
try {
await checkSessionEntitlementOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
)
} catch {
/* already QuotaExceeded from credits */
}
}
throw err
}
}
return { usedByok }
return { usedByok, amount: cost }
}
/** Run fn under quota; releases the unit if fn throws (unless BYOK). */
/** Run fn under crédits ; libère si fn throw (sauf BYOK). */
export async function withAiQuota<T>(
userId: string,
feature: FeatureName,
fn: () => Promise<T>,
options?: ReserveAiUsageOptions,
): Promise<T> {
const { usedByok, billedUserId } = await reserveAiUsageOrThrow(userId, feature, options)
const { usedByok, billedUserId, amount } = await reserveAiUsageOrThrow(userId, feature, options)
try {
const result = await fn()
if (!usedByok) {
@@ -70,8 +104,8 @@ export async function withAiQuota<T>(
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billedUserId, feature)
if (!usedByok && amount > 0) {
await releaseCredits(billedUserId, amount, { feature })
}
throw err
}
@@ -84,9 +118,9 @@ export async function withSessionAiQuota<T>(
isGuestActor: boolean,
feature: FeatureName,
fn: () => Promise<T>,
options?: { lane?: AiFeatureLane },
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
): Promise<T> {
const { usedByok } = await reserveSessionAiUsageOrThrow(
const { usedByok, amount } = await reserveSessionAiUsageOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
@@ -100,8 +134,8 @@ export async function withSessionAiQuota<T>(
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billingOwnerId, feature)
if (!usedByok && amount > 0) {
await releaseCredits(billingOwnerId, amount, { feature })
}
throw err
}