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