diff --git a/apps/app/.env.example b/apps/app/.env.example index f232577..830ccf1 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -14,10 +14,19 @@ APP_URL=http://localhost:3000 # (THI-124 / flux candidature). Sans cette clé, /api/candidature/* répond 502. ANTHROPIC_API_KEY=sk-ant-changeme -# Dev uniquement : si "true", désactive le quota de générations gratuites -# (le modèle freemium reste codé ; à ne PAS activer en prod). +# Dev uniquement : si "true", désactive le gate de crédits à la génération +# (le modèle crédits reste codé ; à ne PAS activer en prod). # DISABLE_GENERATION_LIMIT=true +# Stripe — billing par packs de crédits (Checkout hébergé). Clés de TEST en dev. +# STRIPE_SECRET_KEY : clé restreinte (rk_test_…) recommandée, ou sk_test_…. JAMAIS committée. +# STRIPE_WEBHOOK_SECRET : donné par `stripe listen --forward-to localhost:3000/api/billing/webhook`. +# STRIPE_PUBLISHABLE_KEY : publique (pk_test_…) — NON requise pour Checkout hébergé +# (réservée à un futur Payment Element embarqué). +STRIPE_SECRET_KEY=rk_test_changeme +STRIPE_WEBHOOK_SECRET=whsec_changeme +# STRIPE_PUBLISHABLE_KEY=pk_test_changeme + # SMTP (optionnel en dev — si absent, le magic-link est loggé dans la console). # SMTP_HOST=smtp.example.fr # SMTP_PORT=587 diff --git a/apps/app/config/pricing.ts b/apps/app/config/pricing.ts index 76a8a62..da0e605 100644 --- a/apps/app/config/pricing.ts +++ b/apps/app/config/pricing.ts @@ -7,8 +7,8 @@ * scénario déficitaire (coût API ~0,30–0,40 € par génération, marge packs ~60 %). */ -/** Générations offertes à l'inscription (aligné sur FREE_TIER_QUOTAS.generation). */ -export const FREE_GENERATIONS = 2 as const +/** Générations offertes À VIE pour tester (octroyées une fois via le ledger crédits). */ +export const FREE_GENERATIONS = 5 as const export interface CreditPackConfig { /** clé i18n du pack dans i18n/fr.ts > pricing.packs */ @@ -23,3 +23,21 @@ export const CREDIT_PACKS: readonly CreditPackConfig[] = [ { key: 'starter', priceEur: 5, credits: 5, featured: false }, { key: 'standard', priceEur: 12, credits: 15, featured: true }, ] + +/** + * `lookup_key` Stripe d'un pack — clé stable partagée entre le seed (création des + * prix) et le checkout (résolution du prix sans stocker d'ID Stripe en base). + */ +export function packLookupKey(key: CreditPackConfig['key']): string { + return `teven_credits_${key}` +} + +/** Résout un pack par sa clé (validation d'entrée d'API). `null` si inconnue. Pur. */ +export function resolveCreditPack(key: string): CreditPackConfig | null { + return CREDIT_PACKS.find((p) => p.key === key) ?? null +} + +/** Montant en plus petite unité (centimes) pour Stripe. Pur. */ +export function packAmountCents(pack: CreditPackConfig): number { + return Math.round(pack.priceEur * 100) +} diff --git a/apps/app/layouts/default.vue b/apps/app/layouts/default.vue index 74a2e9b..b27ae53 100644 --- a/apps/app/layouts/default.vue +++ b/apps/app/layouts/default.vue @@ -19,6 +19,7 @@ const navLinks = [ { label: 'Nouvelle candidature', to: '/candidature' }, { label: 'Mon profil', to: '/profil' }, { label: 'Mes candidatures', to: '/candidatures' }, + { label: 'Crédits', to: '/credits' }, ] as const function isActive(to: string) { diff --git a/apps/app/package.json b/apps/app/package.json index 7fcdada..dbbc264 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -7,6 +7,7 @@ "prisma:generate": "prisma generate", "prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:deploy": "prisma migrate deploy", + "stripe:seed": "node --env-file=.env --import tsx scripts/stripe-seed.ts", "postinstall": "nuxt prepare", "dev": "prisma generate && nuxt dev", "build": "prisma generate && nuxt build", @@ -22,6 +23,7 @@ "better-auth": "^1.6.15", "nodemailer": "^8.0.10", "playwright-core": "^1.60.0", + "stripe": "^22.2.2", "zod": "^4.4.3" }, "devDependencies": { @@ -32,6 +34,7 @@ "nuxt": "^3.15.0", "prisma": "^6.2.1", "tailwindcss": "^4.0.0", + "tsx": "^4.22.4", "typescript": "^5.7.2", "vitest": "^3.2.4", "vue-tsc": "^2.2.0" diff --git a/apps/app/pages/candidature.vue b/apps/app/pages/candidature.vue index 39e95ef..099cc02 100644 --- a/apps/app/pages/candidature.vue +++ b/apps/app/pages/candidature.vue @@ -2,16 +2,16 @@ import type { AnalyzeCandidatureRequest, AnalyzeCandidatureResponse, + BillingSummary, GenerateCandidatureRequest, GenerateCandidatureResponse, ProfileDTO, - UsageSummary, } from '@cvo/shared' import { + BILLING_SUMMARY_PATH, CANDIDATURE_ANALYZE_PATH, CANDIDATURE_GENERATE_PATH, QUOTA_EXCEEDED_CODE, - USAGE_CURRENT_PATH, matchVerdict, } from '@cvo/shared' import type { MatchVerdict } from '@cvo/shared' @@ -32,22 +32,21 @@ const offerText = ref('') const offerError = ref('') const analysis = ref(null) -// ─── Quota de générations (badge + gating des boutons) ─────────────────────── +// ─── Solde de crédits (badge + gating des boutons) ─────────────────────────── -const { data: usage, refresh: refreshUsage } = await useFetch(USAGE_CURRENT_PATH, { - lazy: true, -}) - -/** `null` = illimité (ou usage pas encore chargé) → pas de badge, pas de blocage. */ -const generationRemaining = computed( - () => usage.value?.quotas.find((q) => q.type === 'generation')?.remaining ?? null, +const { data: billing, refresh: refreshUsage } = await useFetch( + BILLING_SUMMARY_PATH, + { lazy: true }, ) -const quotaExhausted = computed(() => generationRemaining.value === 0) + +/** `null` = solde pas encore chargé → pas de blocage prématuré. */ +const creditBalance = computed(() => billing.value?.creditBalance ?? null) +const quotaExhausted = computed(() => creditBalance.value !== null && creditBalance.value < 1) const quotaBadgeLabel = computed(() => { - const r = generationRemaining.value - if (r === null) return null - return r > 1 ? `${r} générations offertes restantes` : `${r} génération offerte restante` + const c = creditBalance.value + if (c === null) return null + return c > 1 ? `${c} crédits restants` : `${c} crédit restant` }) // ─── Garde profil : pas de génération sans matière première ────────────────── @@ -168,7 +167,7 @@ async function generate() { if (status === 403 || errorCode(err) === QUOTA_EXCEEDED_CODE) { // Le refresh fait passer remaining à 0 → l'encart quota s'affiche seul. await refreshUsage() - toast.error('Quota atteint', 'Tu as épuisé tes générations offertes.') + toast.error('Plus de crédits', 'Achète un pack pour continuer à générer.') } else if (status === 422) { toast.error('Génération impossible', errorMessage(err, 'Réessaie dans quelques instants.')) } else if (status === 409) { @@ -363,20 +362,20 @@ watch(step, async (s) => {

- +

- Tu as utilisé toutes tes générations offertes ce mois-ci. + Tu n'as plus de crédits. Achète un pack pour continuer à générer.

- - Recharger en crédits - + Acheter des crédits +
diff --git a/apps/app/pages/credits.vue b/apps/app/pages/credits.vue new file mode 100644 index 0000000..92561b9 --- /dev/null +++ b/apps/app/pages/credits.vue @@ -0,0 +1,143 @@ + + + diff --git a/apps/app/prisma/migrations/20260622084853_billing_credits/migration.sql b/apps/app/prisma/migrations/20260622084853_billing_credits/migration.sql new file mode 100644 index 0000000..86adf07 --- /dev/null +++ b/apps/app/prisma/migrations/20260622084853_billing_credits/migration.sql @@ -0,0 +1,28 @@ +-- CreateEnum +CREATE TYPE "CreditReason" AS ENUM ('FREE_GRANT', 'PURCHASE', 'GENERATION', 'ADJUSTMENT'); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "stripeCustomerId" TEXT; + +-- CreateTable +CREATE TABLE "credit_ledger" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "delta" INTEGER NOT NULL, + "reason" "CreditReason" NOT NULL, + "idempotencyKey" TEXT, + "packKey" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "credit_ledger_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "credit_ledger_userId_idx" ON "credit_ledger"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "credit_ledger_idempotencyKey_key" ON "credit_ledger"("idempotencyKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_stripeCustomerId_key" ON "users"("stripeCustomerId"); + diff --git a/apps/app/prisma/schema.prisma b/apps/app/prisma/schema.prisma index 7cb3942..348954b 100644 --- a/apps/app/prisma/schema.prisma +++ b/apps/app/prisma/schema.prisma @@ -73,15 +73,17 @@ enum LanguageLevel { // Compte utilisateur — ancre d'authentification (le WS Auth y greffera Better // Auth + ses tables satellites). Données auth hébergées en FR/UE (RGPD). model User { - id String @id @default(uuid()) - email String @unique + id String @id @default(uuid()) + email String @unique // Champs compatibles Better Auth (renseignés par le WS Auth) : - emailVerified Boolean @default(false) - name String? - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? // soft-delete RGPD (droit à l'effacement) + emailVerified Boolean @default(false) + name String? + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? // soft-delete RGPD (droit à l'effacement) + // Client Stripe (billing crédits) — créé à la 1re intention d'achat. Opaque côté app. + stripeCustomerId String? @unique profile Profile? sessions Session[] @@ -93,25 +95,25 @@ model User { // Profil candidat — 1-1 avec User. En-tête + résumé libres. model Profile { - id String @id @default(uuid()) - userId String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Identité affichée en tête de CV — FACTUELLE, jamais reformulée par le moteur // (cf. @cvo/shared cv.ts). Injectée déterministe-ment dans le header généré. - fullName String? // nom complet du candidat (ex. « Camille Martin ») - email String? // email de contact affiché (défaut : email du compte) - phone String? // téléphone de contact - location String? // localisation (ex. « Paris, France ») - links String[] // liens (portfolio, LinkedIn, GitHub…) - keySkills String[] // compétences clés : phrases (verbe d'action), section au-dessus des expériences - headline String? // intitulé court (ex. « Développeur Full-Stack ») - summary String? // résumé / accroche + fullName String? // nom complet du candidat (ex. « Camille Martin ») + email String? // email de contact affiché (défaut : email du compte) + phone String? // téléphone de contact + location String? // localisation (ex. « Paris, France ») + links String[] // liens (portfolio, LinkedIn, GitHub…) + keySkills String[] // compétences clés : phrases (verbe d'action), section au-dessus des expériences + headline String? // intitulé court (ex. « Développeur Full-Stack ») + summary String? // résumé / accroche // Thème « CV de base » capturé depuis un PDF (style uniquement, jamais de // contenu) : { css, googleFont, summary }. Le PDF source n'est JAMAIS stocké. baseCvDesign Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? experiences Experience[] skills Skill[] @@ -349,3 +351,45 @@ model Verification { // Back-relations Better Auth sur User (ajoutées ici pour éviter de modifier // le bloc User déjà posé dans le data model slice 1). // Prisma les génère via la relation inverse côté Session/Account. + +// ───────────────────────────────────────────────────────────────────────────── +// Billing — crédits (Stripe Checkout, packs one-shot) · voir config/pricing.ts +// +// Modèle : grand livre (ledger) APPEND-ONLY. Le solde = somme des `delta`. Aucune +// écriture directe d'un solde : chaque octroi (gratuit/achat) ou consommation est +// une ligne, ce qui garantit l'audit et l'invariant « jamais déficitaire ». +// FREE_GRANT : crédits offerts à l'inscription (1 seule ligne/user, idempotent). +// PURCHASE : achat d'un pack (webhook Stripe ; idempotent par session id). +// GENERATION : consommation d'1 crédit à la génération d'un CV (delta négatif). +// ADJUSTMENT : correction manuelle (SAV) — trace explicite. +// +// RGPD : aucune donnée carte ici (gérée par Stripe). On ne stocke qu'un id de +// session/customer Stripe opaque pour l'idempotence et le rapprochement. +// ───────────────────────────────────────────────────────────────────────────── + +enum CreditReason { + FREE_GRANT + PURCHASE + GENERATION + ADJUSTMENT +} + +model CreditLedger { + id String @id @default(uuid()) + userId String + // Variation de solde : +N (octroi/achat) ou -1 (génération). Jamais 0. + delta Int + reason CreditReason + // Clé d'idempotence. PURCHASE : id de session Stripe (1 crédit/paiement). + // FREE_GRANT : sentinelle `free:` (1 octroi gratuit/utilisateur). + // GENERATION / ADJUSTMENT : null (plusieurs lignes — NULLs distincts en Postgres). + idempotencyKey String? + // Nombre de crédits du pack acheté (PURCHASE) — info d'affichage/audit. + packKey String? + createdAt DateTime @default(now()) + + // Un même paiement OU un même octroi gratuit ne s'applique qu'une fois. + @@unique([idempotencyKey]) + @@index([userId]) + @@map("credit_ledger") +} diff --git a/apps/app/scripts/stripe-seed.ts b/apps/app/scripts/stripe-seed.ts new file mode 100644 index 0000000..d56ad8c --- /dev/null +++ b/apps/app/scripts/stripe-seed.ts @@ -0,0 +1,50 @@ +/** + * Seed Stripe idempotent — crée les produits + prix des packs de crédits depuis + * `config/pricing.ts`. Source de vérité = le code (reproductible test → prod). + * + * Idempotence : on cherche le prix par `lookup_key` ; s'il existe, on passe. Le + * checkout résout ensuite le prix par cette même clé (aucun ID Stripe en base). + * + * Lancement (depuis apps/app, .env chargé) : + * node --env-file=.env --import tsx scripts/stripe-seed.ts + * (ou `pnpm --filter @cvo/app stripe:seed`) + */ +import Stripe from 'stripe' +import { CREDIT_PACKS, packLookupKey, packAmountCents } from '../config/pricing' + +async function main() { + const key = process.env.STRIPE_SECRET_KEY + if (!key || key.includes('changeme')) { + throw new Error('STRIPE_SECRET_KEY manquante : renseigne apps/app/.env (clé de TEST).') + } + const stripe = new Stripe(key) + + for (const pack of CREDIT_PACKS) { + const lookupKey = packLookupKey(pack.key) + const existing = await stripe.prices.list({ lookup_keys: [lookupKey], active: true, limit: 1 }) + if (existing.data.length > 0) { + console.log(`= pack "${pack.key}" : prix déjà présent (${lookupKey}) — ignoré`) + continue + } + + const product = await stripe.products.create({ + name: `Teven — ${pack.credits} crédits`, + description: `${pack.credits} générations de CV optimisé`, + metadata: { packKey: pack.key, credits: String(pack.credits) }, + }) + const price = await stripe.prices.create({ + product: product.id, + currency: 'eur', + unit_amount: packAmountCents(pack), + lookup_key: lookupKey, + metadata: { packKey: pack.key, credits: String(pack.credits) }, + }) + console.log(`+ pack "${pack.key}" : ${pack.priceEur}€ / ${pack.credits} crédits (price ${price.id})`) + } + console.log('Seed Stripe terminé.') +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/apps/app/server/api/billing/checkout.post.ts b/apps/app/server/api/billing/checkout.post.ts new file mode 100644 index 0000000..1721503 --- /dev/null +++ b/apps/app/server/api/billing/checkout.post.ts @@ -0,0 +1,83 @@ +/** + * POST /api/billing/checkout — démarre l'achat d'un pack de crédits. + * + * Crée (ou réutilise) le client Stripe de l'utilisateur, puis une session + * Checkout hébergée (mode `payment`, one-shot). Le prix est résolu par `lookup_key` + * (posé par le seed) → aucun ID Stripe stocké en base. Les crédits ne sont accordés + * qu'au webhook `checkout.session.completed` (paiement confirmé), jamais ici. + * + * Sécurité : montant/crédits déterminés SERVER-SIDE depuis le catalogue (le client + * n'envoie qu'une clé de pack) — pas de prix piloté par le client. + */ +import { z } from 'zod' +import type { CreateCheckoutResponse } from '@cvo/shared' +import { requireUserId } from '../../utils/session' +import { prisma } from '../../utils/prisma' +import { getStripe, StripeNotConfiguredError } from '../../utils/stripe' +import { resolveCreditPack, packLookupKey } from '../../../config/pricing' + +const bodySchema = z.object({ packKey: z.string() }) + +export default defineEventHandler(async (event): Promise => { + const userId = requireUserId(event) + + const parsed = bodySchema.safeParse(await readBody(event)) + if (!parsed.success) throw createError({ statusCode: 400, message: 'Corps invalide : { packKey } attendu.' }) + + // Catalogue = source de vérité serveur (prix/crédits jamais pilotés par le client). + const pack = resolveCreditPack(parsed.data.packKey) + if (!pack) throw createError({ statusCode: 400, message: 'Pack inconnu.' }) + + try { + const stripe = getStripe() + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, name: true, stripeCustomerId: true }, + }) + if (!user) throw createError({ statusCode: 401, message: 'Non authentifié.' }) + + // Client Stripe : créé à la première intention d'achat, puis mémorisé. + let customerId = user.stripeCustomerId + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + name: user.name ?? undefined, + metadata: { userId }, + }) + customerId = customer.id + await prisma.user.update({ where: { id: userId }, data: { stripeCustomerId: customerId } }) + } + + // Prix résolu par lookup_key (posé par le seed). Absent ⇒ seed non exécuté. + const prices = await stripe.prices.list({ + lookup_keys: [packLookupKey(pack.key)], + active: true, + limit: 1, + }) + const price = prices.data[0] + if (!price) { + throw createError({ statusCode: 503, message: 'Catalogue Stripe non initialisé (lance le seed).' }) + } + + const origin = process.env.APP_URL || getRequestURL(event).origin + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + customer: customerId, + line_items: [{ price: price.id, quantity: 1 }], + success_url: `${origin}/credits?status=success`, + cancel_url: `${origin}/credits?status=cancel`, + // Métadonnées relues par le webhook pour créditer le bon compte. + metadata: { userId, packKey: pack.key, credits: String(pack.credits) }, + payment_intent_data: { metadata: { userId, packKey: pack.key, credits: String(pack.credits) } }, + }) + + if (!session.url) throw createError({ statusCode: 502, message: 'Session Checkout sans URL.' }) + return { url: session.url } + } catch (err) { + if (err instanceof StripeNotConfiguredError) { + throw createError({ statusCode: 503, message: 'Paiement indisponible (Stripe non configuré).' }) + } + throw err + } +}) diff --git a/apps/app/server/api/billing/summary.get.ts b/apps/app/server/api/billing/summary.get.ts new file mode 100644 index 0000000..82d9745 --- /dev/null +++ b/apps/app/server/api/billing/summary.get.ts @@ -0,0 +1,13 @@ +/** + * GET /api/billing/summary — solde de crédits de l'utilisateur connecté. + * Octroie les crédits gratuits au premier accès (idempotent). + */ +import type { BillingSummary } from '@cvo/shared' +import { requireUserId } from '../../utils/session' +import { getOrInitBalance } from '../../utils/credits' + +export default defineEventHandler(async (event): Promise => { + const userId = requireUserId(event) + const creditBalance = await getOrInitBalance(userId) + return { creditBalance } +}) diff --git a/apps/app/server/api/billing/webhook.post.ts b/apps/app/server/api/billing/webhook.post.ts new file mode 100644 index 0000000..f41d3e3 --- /dev/null +++ b/apps/app/server/api/billing/webhook.post.ts @@ -0,0 +1,50 @@ +/** + * POST /api/billing/webhook — réception des événements Stripe. + * + * Sécurité : la signature est vérifiée avec `STRIPE_WEBHOOK_SECRET` sur le corps + * BRUT (readRawBody) — un webhook non signé/altéré est rejeté (400). Idempotent : + * créditer un achat repose sur la contrainte unique du ledger (rejeu sans effet). + * + * On ne crédite QUE sur `checkout.session.completed` payé : les crédits proviennent + * d'un paiement confirmé, jamais d'un appel client. + */ +import type Stripe from 'stripe' +import { getStripe, StripeNotConfiguredError } from '../../utils/stripe' +import { grantPurchasedCredits } from '../../utils/credits' + +export default defineEventHandler(async (event) => { + const secret = process.env.STRIPE_WEBHOOK_SECRET + if (!secret || secret.includes('changeme')) { + throw createError({ statusCode: 503, message: 'Webhook Stripe non configuré.' }) + } + + const signature = getHeader(event, 'stripe-signature') + const raw = await readRawBody(event) + if (!signature || !raw) throw createError({ statusCode: 400, message: 'Signature ou corps manquant.' }) + + let stripeEvent: Stripe.Event + try { + stripeEvent = getStripe().webhooks.constructEvent(raw, signature, secret) + } catch (err) { + if (err instanceof StripeNotConfiguredError) { + throw createError({ statusCode: 503, message: 'Webhook Stripe non configuré.' }) + } + // Signature invalide ⇒ requête non authentique (ou corps altéré). + throw createError({ statusCode: 400, message: 'Signature de webhook invalide.' }) + } + + if (stripeEvent.type === 'checkout.session.completed') { + const session = stripeEvent.data.object as Stripe.Checkout.Session + if (session.payment_status === 'paid') { + const userId = session.metadata?.userId + const packKey = session.metadata?.packKey ?? '' + const credits = Number(session.metadata?.credits) + if (userId && Number.isFinite(credits) && credits > 0) { + // Idempotent : un rejeu du même paiement ne crédite pas deux fois. + await grantPurchasedCredits({ userId, sessionId: session.id, packKey, credits }) + } + } + } + + return { received: true } +}) diff --git a/apps/app/server/api/candidature/generate.post.ts b/apps/app/server/api/candidature/generate.post.ts index 6b97eb8..f21961d 100644 --- a/apps/app/server/api/candidature/generate.post.ts +++ b/apps/app/server/api/candidature/generate.post.ts @@ -12,7 +12,6 @@ import { z } from 'zod' import { Prisma } from '@prisma/client' import { - FREE_TIER_QUOTAS, ProvenanceError, QUOTA_EXCEEDED_CODE, type AnalyzedOffer, @@ -24,7 +23,8 @@ import { import { requireUserId } from '../../utils/session' import { prisma } from '../../utils/prisma' import { NOT_DELETED, toProfileDTO } from '../../utils/profile-serialize' -import { isUsageAllowed, recordUsageEvent } from '../../utils/metering' +import { recordUsageEvent } from '../../utils/metering' +import { getOrInitBalance, consumeOneCredit } from '../../utils/credits' import { anthropicComplete, LlmError, type LlmComplete } from '../../utils/anthropic' import { matchProfileToOffer } from '../../services/matching' import { adaptKeySkills } from '../../services/keyskills-adapt' @@ -58,18 +58,19 @@ export default defineEventHandler(async (event): Promise => { const userId = requireUserId(event) - // Cohérent avec le gate de /generate : si la limite est désactivée (dev), la - // génération est reportée comme illimitée (l'UI ne grise plus le bouton). - const quotas: UsageQuotas = - process.env.DISABLE_GENERATION_LIMIT === 'true' - ? { ...FREE_TIER_QUOTAS, generation: undefined } - : FREE_TIER_QUOTAS - return readUsageSummary(prisma, userId, quotas, new Date()) + return readUsageSummary(prisma, userId, FREE_TIER_QUOTAS, new Date()) }) diff --git a/apps/app/server/utils/credits.ts b/apps/app/server/utils/credits.ts new file mode 100644 index 0000000..7a11f68 --- /dev/null +++ b/apps/app/server/utils/credits.ts @@ -0,0 +1,110 @@ +/** + * Service crédits — grand livre (ledger) append-only. Le solde = somme des `delta`. + * + * Invariant produit « jamais déficitaire » : aucune écriture directe du solde ; + * chaque octroi (gratuit/achat) ou consommation est une ligne. L'idempotence est + * portée par `idempotencyKey` (unique) : + * - FREE_GRANT : `free:` → 1 octroi gratuit par utilisateur ; + * - PURCHASE : id de session Stripe → 1 crédit par paiement ; + * - GENERATION : null → plusieurs consommations autorisées. + * + * RGPD : aucune donnée carte ; on ne stocke qu'un id Stripe opaque. + */ +import { Prisma, type PrismaClient } from '@prisma/client' +import { FREE_GENERATIONS } from '../../config/pricing' +import { prisma as defaultPrisma } from './prisma' + +/** Client Prisma ou client de transaction (les deux exposent `creditLedger`). */ +type Db = PrismaClient | Prisma.TransactionClient + +/** Erreur métier : solde insuffisant pour consommer un crédit. */ +export class InsufficientCreditsError extends Error { + constructor() { + super('Solde de crédits insuffisant') + this.name = 'InsufficientCreditsError' + } +} + +/** Clé d'idempotence de l'octroi gratuit (1 par utilisateur). Pur. */ +export function freeGrantKey(userId: string): string { + return `free:${userId}` +} + +/** Solde courant = somme des deltas du ledger (≥ 0 par construction). */ +export async function getCreditBalance(db: Db, userId: string): Promise { + const agg = await db.creditLedger.aggregate({ where: { userId }, _sum: { delta: true } }) + return agg._sum.delta ?? 0 +} + +/** + * Octroie les crédits gratuits une seule fois par utilisateur (idempotent via la + * contrainte unique sur `idempotencyKey`). No-op si déjà octroyés. + */ +export async function ensureFreeGrant(db: Db, userId: string): Promise { + if (FREE_GENERATIONS <= 0) return + try { + await db.creditLedger.create({ + data: { + userId, + delta: FREE_GENERATIONS, + reason: 'FREE_GRANT', + idempotencyKey: freeGrantKey(userId), + }, + }) + } catch (err) { + // P2002 = violation d'unicité ⇒ octroi déjà effectué : on ignore. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') return + throw err + } +} + +/** Octroi gratuit (si besoin) puis lecture du solde. */ +export async function getOrInitBalance(userId: string, db: Db = defaultPrisma): Promise { + await ensureFreeGrant(db, userId) + return getCreditBalance(db, userId) +} + +/** + * Consomme 1 crédit pour une génération RÉUSSIE, de façon atomique et sûre : + * dans une transaction, on relit le solde puis on insère la ligne -1 seulement + * s'il reste ≥ 1 crédit. Lève `InsufficientCreditsError` sinon (jamais déficitaire). + */ +export async function consumeOneCredit(userId: string, db: PrismaClient = defaultPrisma): Promise { + // Octroi gratuit AVANT la transaction : il s'appuie sur un catch de violation + // d'unicité (P2002), or une erreur SQL dans une transaction Postgres l'avorte + // entièrement (25P02). On le fait donc hors transaction (idempotent, sans risque). + await ensureFreeGrant(db, userId) + return db.$transaction(async (tx) => { + const balance = await getCreditBalance(tx, userId) + if (balance < 1) throw new InsufficientCreditsError() + await tx.creditLedger.create({ + data: { userId, delta: -1, reason: 'GENERATION' }, + }) + return balance - 1 + }) +} + +/** + * Crédite un achat (webhook Stripe), idempotent par id de session. Renvoie `true` + * si l'achat a été appliqué, `false` s'il l'avait déjà été (rejeu de webhook). + */ +export async function grantPurchasedCredits( + params: { userId: string; sessionId: string; packKey: string; credits: number }, + db: Db = defaultPrisma, +): Promise { + try { + await db.creditLedger.create({ + data: { + userId: params.userId, + delta: params.credits, + reason: 'PURCHASE', + idempotencyKey: params.sessionId, + packKey: params.packKey, + }, + }) + return true + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') return false + throw err + } +} diff --git a/apps/app/server/utils/stripe.ts b/apps/app/server/utils/stripe.ts new file mode 100644 index 0000000..1e4185e --- /dev/null +++ b/apps/app/server/utils/stripe.ts @@ -0,0 +1,26 @@ +/** + * Client Stripe serveur (singleton paresseux). La clé vient de l'env + * `STRIPE_SECRET_KEY` (restreinte `rk_` recommandée) — JAMAIS en dur, jamais loggée. + * + * On n'épingle pas `apiVersion` explicitement : le SDK utilise sa version intégrée, + * et la version par défaut du compte gouverne — suffisant pour Checkout + webhooks. + */ +import Stripe from 'stripe' + +let client: Stripe | null = null + +/** Erreur de configuration (clé absente) — distincte des erreurs Stripe runtime. */ +export class StripeNotConfiguredError extends Error { + constructor() { + super('STRIPE_SECRET_KEY manquante') + this.name = 'StripeNotConfiguredError' + } +} + +/** Retourne le client Stripe, ou lève `StripeNotConfiguredError` si non configuré. */ +export function getStripe(): Stripe { + const key = process.env.STRIPE_SECRET_KEY + if (!key || key.includes('changeme')) throw new StripeNotConfiguredError() + if (!client) client = new Stripe(key) + return client +} diff --git a/apps/app/test/pricing.spec.ts b/apps/app/test/pricing.spec.ts new file mode 100644 index 0000000..3651f1b --- /dev/null +++ b/apps/app/test/pricing.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { + CREDIT_PACKS, + FREE_GENERATIONS, + packLookupKey, + resolveCreditPack, + packAmountCents, +} from '../config/pricing' +import { freeGrantKey } from '../server/utils/credits' + +describe('pricing — catalogue de packs', () => { + it('packs attendus (starter 5€/5, standard 12€/15)', () => { + expect(CREDIT_PACKS.map((p) => [p.key, p.priceEur, p.credits])).toEqual([ + ['starter', 5, 5], + ['standard', 12, 15], + ]) + }) + + it('FREE_GENERATIONS = 5 (offertes à vie)', () => { + expect(FREE_GENERATIONS).toBe(5) + }) + + it('resolveCreditPack : clé connue → pack, inconnue → null', () => { + expect(resolveCreditPack('standard')?.credits).toBe(15) + expect(resolveCreditPack('starter')?.priceEur).toBe(5) + expect(resolveCreditPack('inconnu')).toBeNull() + expect(resolveCreditPack('')).toBeNull() + }) + + it('packAmountCents : euros → centimes entiers', () => { + expect(packAmountCents({ key: 'starter', priceEur: 5, credits: 5, featured: false })).toBe(500) + expect(packAmountCents({ key: 'standard', priceEur: 12, credits: 15, featured: true })).toBe(1200) + }) + + it('packLookupKey : clé Stripe stable et préfixée', () => { + expect(packLookupKey('starter')).toBe('teven_credits_starter') + expect(packLookupKey('standard')).toBe('teven_credits_standard') + }) +}) + +describe('credits — clé d’idempotence de l’octroi gratuit', () => { + it('freeGrantKey : 1 clé déterministe par utilisateur', () => { + expect(freeGrantKey('user-123')).toBe('free:user-123') + expect(freeGrantKey('abc')).not.toBe(freeGrantKey('abd')) + }) +}) diff --git a/apps/app/test/usage.spec.ts b/apps/app/test/usage.spec.ts index bf082a1..f1a7810 100644 --- a/apps/app/test/usage.spec.ts +++ b/apps/app/test/usage.spec.ts @@ -74,13 +74,19 @@ describe('buildUsageSummary', () => { it('assemble période + compteur + un quota par type', () => { const summary = buildUsageSummary( '202606', - { ...EMPTY_USAGE_SNAPSHOT, generation: 5 }, + { ...EMPTY_USAGE_SNAPSHOT, generation: 5, export_pdf: 4 }, FREE_TIER_QUOTAS, ) expect(summary.period).toBe('202606') expect(summary.counter.generation).toBe(5) expect(summary.quotas.map((q) => q.type)).toEqual(['generation', 'export_pdf', 'extraction']) + // La génération n'est plus bornée par un quota mensuel (gouvernée par les crédits). const gen = summary.quotas.find((q) => q.type === 'generation')! - expect(gen.exceeded).toBe(true) // 5 utilisées ≥ quota gratuit (2) + expect(gen.limit).toBeNull() + expect(gen.exceeded).toBe(false) + // L'export PDF reste borné par période (4) → 4 utilisés = atteint. + const pdf = summary.quotas.find((q) => q.type === 'export_pdf')! + expect(pdf.limit).toBe(4) + expect(pdf.exceeded).toBe(true) }) }) diff --git a/packages/shared/src/billing.ts b/packages/shared/src/billing.ts new file mode 100644 index 0000000..c3d3783 --- /dev/null +++ b/packages/shared/src/billing.ts @@ -0,0 +1,34 @@ +/** + * Billing — contrats partagés client ↔ serveur (Nitro). + * + * Modèle : packs de crédits one-shot (Stripe Checkout hébergé). 1 crédit = 1 + * génération de CV. Le catalogue des packs (prix/crédits) vit dans + * `apps/app/config/pricing.ts` (source de vérité serveur+client de l'app) ; ce + * module ne porte que les CONTRATS d'API neutres réutilisables des deux côtés. + * + * RGPD : aucune donnée carte ne transite par notre API (gérée par Stripe). + */ + +/** Clé d'un pack de crédits (miroir de CreditPackConfig.key côté app). */ +export type CreditPackKey = 'starter' | 'standard' + +/** Réponse de `GET /api/billing/summary` — solde de crédits de l'utilisateur. */ +export interface BillingSummary { + /** Crédits disponibles (générations restantes). Jamais négatif. */ + creditBalance: number +} + +/** Corps de `POST /api/billing/checkout` — démarre l'achat d'un pack. */ +export interface CreateCheckoutRequest { + packKey: CreditPackKey +} + +/** Réponse de `POST /api/billing/checkout` — URL de la page Stripe hébergée. */ +export interface CreateCheckoutResponse { + /** URL Stripe Checkout vers laquelle rediriger le navigateur. */ + url: string +} + +export const BILLING_SUMMARY_PATH = '/api/billing/summary' as const +export const BILLING_CHECKOUT_PATH = '/api/billing/checkout' as const +export const BILLING_WEBHOOK_PATH = '/api/billing/webhook' as const diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ff6571d..ce4c6ee 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -39,3 +39,6 @@ export * from './match.js' // Candidature persistée (offre + CV éditable + suivi). export * from './candidature.js' + +// Billing — contrats d'achat de crédits (Stripe Checkout). +export * from './billing.js' diff --git a/packages/shared/src/usage.ts b/packages/shared/src/usage.ts index 3bfa86e..3556163 100644 --- a/packages/shared/src/usage.ts +++ b/packages/shared/src/usage.ts @@ -59,12 +59,12 @@ export const EMPTY_USAGE_SNAPSHOT: UsageCounterSnapshot = { export type UsageQuotas = Partial> /** - * Palier gratuit — modèle acté (2026-06) : 2 générations offertes pour tester, - * ensuite achat de crédits (config/pricing.ts). Les exports PDF sont plus larges - * (ré-export d'un CV déjà généré) mais bornés pour éviter l'abus de Chromium. + * Quotas par période. La GÉNÉRATION n'est plus un quota mensuel : elle est + * gouvernée par le solde de CRÉDITS (générations offertes + packs achetés, cf. + * billing/credits). Restent ici les bornes par période non monétisées : l'export + * PDF (ré-export d'un CV déjà généré) borné pour éviter l'abus de Chromium. */ export const FREE_TIER_QUOTAS: UsageQuotas = { - generation: 2, export_pdf: 4, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb23ad9..94b4861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,23 +43,26 @@ importers: version: 6.19.3(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(typescript@5.9.3) better-auth: specifier: ^1.6.15 - version: 1.6.15(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) + version: 1.6.15(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) nodemailer: specifier: ^8.0.10 version: 8.0.10 playwright-core: specifier: ^1.60.0 version: 1.60.0 + stripe: + specifier: ^22.2.2 + version: 22.2.2(@types/node@22.19.20) zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: '@nuxt/fonts': specifier: ^0.14.0 - version: 0.14.0(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 0.14.0(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 4.3.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@types/node': specifier: ^22.10.5 version: 22.19.20 @@ -68,19 +71,22 @@ importers: version: 8.0.0 nuxt: specifier: ^3.15.0 - version: 3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0) + version: 3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0) prisma: specifier: ^6.2.1 version: 6.19.3(magicast@0.5.3)(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.3.0 + tsx: + specifier: ^4.22.4 + version: 4.22.4 typescript: specifier: ^5.7.2 version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue-tsc: specifier: ^2.2.0 version: 2.2.12(typescript@5.9.3) @@ -92,7 +98,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) packages: @@ -3984,6 +3990,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@22.2.2: + resolution: {integrity: sha512-lRXLiarjW/I2QVa6QuFfz1Ma7Dc2yBQ7vlYH6NEmp5htMJNQGfiWExmOv0McfqY5wMCGV8DLuCvsyVRIPJlUUQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + structured-clone-es@2.0.0: resolution: {integrity: sha512-5UuAHmBLXYPCl22xWJrFuGmIhBKQzxISPVz6E7nmTmTcAOpUzlbjKJsRrCE4vADmMQ0dzeCnlWn9XufnAGf76Q==} @@ -4099,6 +4114,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5179,11 +5199,11 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) execa: 8.0.1 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - magicast @@ -5198,9 +5218,9 @@ snapshots: pkg-types: 2.3.1 semver: 7.8.3 - '@nuxt/devtools@3.2.4(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3))': + '@nuxt/devtools@3.2.4(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@nuxt/devtools-wizard': 3.2.4 '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vue/devtools-core': 8.1.2(vue@3.5.35(typescript@5.9.3)) @@ -5228,9 +5248,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.0 tinyglobby: 0.2.17 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-inspect: 11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) - vite-plugin-vue-tracer: 1.4.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-inspect: 11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + vite-plugin-vue-tracer: 1.4.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) which: 6.0.1 ws: 8.21.0 transitivePeerDependencies: @@ -5239,13 +5259,13 @@ snapshots: - utf-8-validate - vue - '@nuxt/fonts@0.14.0(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@nuxt/fonts@0.14.0(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@nuxt/kit': 4.4.8(magicast@0.5.3) consola: 3.4.2 defu: 6.1.7 - fontless: 0.2.1(db0@0.3.4)(ioredis@5.11.1)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + fontless: 0.2.1(db0@0.3.4)(ioredis@5.11.1)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) h3: 1.15.11 magic-regexp: 0.10.0 ofetch: 1.5.1 @@ -5330,7 +5350,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@3.21.8(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.132.0)(srvx@0.11.16)(typescript@5.9.3)': + '@nuxt/nitro-server@3.21.8(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.132.0)(srvx@0.11.16)(typescript@5.9.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 3.21.8(magicast@0.5.3) @@ -5348,7 +5368,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(oxc-parser@0.132.0)(srvx@0.11.16) - nuxt: 3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0) + nuxt: 3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.1 @@ -5415,12 +5435,12 @@ snapshots: rc9: 3.0.1 std-env: 4.1.0 - '@nuxt/vite-builder@3.21.8(@types/node@22.19.20)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.35(typescript@5.9.3))(yaml@2.9.0)': + '@nuxt/vite-builder@3.21.8(@types/node@22.19.20)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.35(typescript@5.9.3))(yaml@2.9.0)': dependencies: '@nuxt/kit': 3.21.8(magicast@0.5.3) '@rollup/plugin-replace': 6.0.3(rollup@4.61.1) - '@vitejs/plugin-vue': 6.0.7(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.7(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) autoprefixer: 10.5.0(postcss@8.5.15) consola: 3.4.2 cssnano: 7.1.9(postcss@8.5.15) @@ -5434,7 +5454,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0) + nuxt: 3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.6 ohash: 2.0.11 pathe: 2.0.3 @@ -5445,9 +5465,9 @@ snapshots: std-env: 4.1.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-node: 5.3.0(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-checker: 0.13.0(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3)) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-node: 5.3.0(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-checker: 0.13.0(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3)) vue: 3.5.35(typescript@5.9.3) vue-bundle-renderer: 2.2.0 optionalDependencies: @@ -6005,12 +6025,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) '@tybys/wasm-util@0.10.2': dependencies: @@ -6154,22 +6174,22 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.1 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7) - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.35(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.35(typescript@5.9.3) '@vitest/expect@3.2.6': @@ -6180,13 +6200,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@3.2.6': dependencies: @@ -6509,7 +6529,7 @@ snapshots: baseline-browser-mapping@2.10.34: {} - better-auth@1.6.15(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)): + better-auth@1.6.15(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)): dependencies: '@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1) @@ -6531,7 +6551,7 @@ snapshots: optionalDependencies: '@prisma/client': 6.19.3(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(typescript@5.9.3) prisma: 6.19.3(magicast@0.5.3)(typescript@5.9.3) - vitest: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.35(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -7204,7 +7224,7 @@ snapshots: dependencies: tiny-inflate: 1.0.3 - fontless@0.2.1(db0@0.3.4)(ioredis@5.11.1)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + fontless@0.2.1(db0@0.3.4)(ioredis@5.11.1)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: consola: 3.4.2 css-tree: 3.2.1 @@ -7220,7 +7240,7 @@ snapshots: unifont: 0.7.4 unstorage: 1.17.5(db0@0.3.4)(ioredis@5.11.1) optionalDependencies: - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7861,16 +7881,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0): + nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@5.9.3) '@nuxt/cli': 3.35.2(@nuxt/schema@3.21.8)(cac@6.7.14)(magicast@0.5.3) - '@nuxt/devtools': 3.2.4(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) + '@nuxt/devtools': 3.2.4(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)) '@nuxt/kit': 3.21.8(magicast@0.5.3) - '@nuxt/nitro-server': 3.21.8(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.132.0)(srvx@0.11.16)(typescript@5.9.3) + '@nuxt/nitro-server': 3.21.8(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.132.0)(srvx@0.11.16)(typescript@5.9.3) '@nuxt/schema': 3.21.8 '@nuxt/telemetry': 2.8.0(@nuxt/kit@3.21.8(magicast@0.5.3)) - '@nuxt/vite-builder': 3.21.8(@types/node@22.19.20)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.35(typescript@5.9.3))(yaml@2.9.0) + '@nuxt/vite-builder': 3.21.8(@types/node@22.19.20)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.32.0)(magicast@0.5.3)(nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3))(vue@3.5.35(typescript@5.9.3))(yaml@2.9.0) '@unhead/vue': 2.1.15(vue@3.5.35(typescript@5.9.3)) '@vue/shared': 3.5.35 c12: 3.3.4(magicast@0.5.3) @@ -8652,6 +8672,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@22.2.2(@types/node@22.19.20): + optionalDependencies: + '@types/node': 22.19.20 + structured-clone-es@2.0.0: {} stylehacks@7.0.11(postcss@8.5.15): @@ -8763,6 +8787,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.22.4: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -8934,23 +8964,23 @@ snapshots: util-deprecate@1.0.2: {} - vite-dev-rpc@2.0.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-dev-rpc@2.0.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: birpc: 4.0.0 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-hot-client: 2.2.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) - vite-hot-client@2.2.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-hot-client@2.2.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vite-node@3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -8965,13 +8995,13 @@ snapshots: - tsx - yaml - vite-node@5.3.0(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite-node@5.3.0(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: cac: 6.7.14 es-module-lexer: 2.1.0 obug: 2.1.2 pathe: 2.0.3 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -8985,7 +9015,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.13.0(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3)): + vite-plugin-checker@0.13.0(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 4.0.3 @@ -8995,7 +9025,7 @@ snapshots: proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 tinyglobby: 0.2.17 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vscode-uri: 3.1.0 optionalDependencies: eslint: 9.39.4(jiti@2.7.0) @@ -9003,7 +9033,7 @@ snapshots: typescript: 5.9.3 vue-tsc: 2.2.12(typescript@5.9.3) - vite-plugin-inspect@11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-inspect@11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: ansis: 4.3.1 error-stack-parser-es: 1.0.5 @@ -9013,22 +9043,22 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-dev-rpc: 2.0.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-dev-rpc: 2.0.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) optionalDependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) - vite-plugin-vue-tracer@1.4.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)): + vite-plugin-vue-tracer@1.4.0(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.8 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.35(typescript@5.9.3) - vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -9042,13 +9072,14 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.48.0 + tsx: 4.22.4 yaml: 2.9.0 - vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -9066,8 +9097,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.20