Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions apps/app/config/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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)
}
1 change: 1 addition & 0 deletions apps/app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check declared Node engine/toolchain versions
rg -n '"engines"' -A5 apps/app/package.json package.json 2>/dev/null
fd -a '.nvmrc' -o -a '.tool-versions'

Repository: Tbeaumont79/cv-optimizer

Length of output: 552


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## apps/app/package.json\n'
cat -n apps/app/package.json

printf '\n## root package.json\n'
cat -n package.json

printf '\n## version/toolchain files\n'
fd -a -H '(.nvmrc|.tool-versions|volta.json|package.json|pnpm-workspace.yaml|dockerfile|Dockerfile)' . .

printf '\n## CI references to Node\n'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' 'node\s*[:=]|setup-node|actions/setup-node|20\.[0-9]|18\.[0-9]|22\.[0-9]' .github . apps package.json .nvmrc .tool-versions volta.json 2>/dev/null || true

Repository: Tbeaumont79/cv-optimizer

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## .nvmrc\n'
cat -n .nvmrc

printf '\n## .github/workflows/ci.yml (Node setup lines)\n'
rg -n -A4 -B2 'setup-node|node-version|node-version-file' .github/workflows/ci.yml

printf '\n## exact package-manager/engine lines in workspace packages\n'
rg -n '"engines"|"packageManager"|node: ' package.json apps/app/package.json packages/shared/package.json

Repository: Tbeaumont79/cv-optimizer

Length of output: 878


🌐 Web query:

Node.js --env-file --import introduced version 20.6.0 release notes

💡 Result:

The Node.js version 20.6.0, released on September 4, 2023, officially introduced the --env-file and --import command-line flags [1][2]. --env-file This flag provides built-in support for loading environment variables from a file [2][3]. It parses a specified file (often named.env) and makes the defined variables available on process.env [3]. It also supports defining NODE_OPTIONS within these files [2]. If a variable is defined in both the environment and the file, the existing environment value takes precedence [3]. --import This flag is used to preload specified ECMAScript modules [3]. A primary use case highlighted in the 20.6.0 release is to facilitate the registration of module customization hooks (via the register function from node:module) before any application code, including the entry point, executes [2][4]. It serves as a modern alternative to using --require for loading modules, as --require is limited to CommonJS modules [3]. Modules preloaded with --require will run before those preloaded with --import [3].

Citations:


Bump Node to 20.6+ for stripe:seed node --env-file and --import require Node 20.6.0+. The repo still declares >=20.0.0 in package.json and 20 in .nvmrc/CI, so older 20.0–20.5 installs can fail here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/package.json` at line 10, The stripe:seed script now relies on Node
flags that only work on 20.6+, so update the Node version requirements used by
the repo to match. Bump the engines declaration in package.json and the version
pin in .nvmrc/CI to at least 20.6.0, and make sure any related version checks or
toolchain configs stay consistent with the new minimum. Refer to the stripe:seed
script when verifying the change.

"postinstall": "nuxt prepare",
"dev": "prisma generate && nuxt dev",
"build": "prisma generate && nuxt build",
Expand All @@ -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": {
Expand All @@ -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"
Expand Down
41 changes: 20 additions & 21 deletions apps/app/pages/candidature.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -32,22 +32,21 @@ const offerText = ref('')
const offerError = ref('')
const analysis = ref<AnalyzeCandidatureResponse | null>(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<UsageSummary>(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<BillingSummary>(
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 ──────────────────
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -363,20 +362,20 @@ watch(step, async (s) => {
</p>
</div>

<!-- Quota épuisé : on explique et on oriente vers les crédits -->
<!-- Solde épuisé : on explique et on oriente vers l'achat de crédits -->
<div
v-if="quotaExhausted"
class="flex flex-wrap items-center justify-between gap-3 rounded-card bg-danger-50 p-4 ring-1 ring-danger-200"
>
<p class="text-sm leading-relaxed text-danger-700">
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.
</p>
<a
href="/#tarifs"
<NuxtLink
to="/credits"
class="rounded text-sm font-semibold text-danger-700 underline underline-offset-2 transition-colors duration-300 ease-out hover:text-danger-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger-500"
>
Recharger en crédits
</a>
Acheter des crédits
</NuxtLink>
</div>

<div class="flex flex-wrap items-center justify-end gap-3 pt-1">
Expand Down
143 changes: 143 additions & 0 deletions apps/app/pages/credits.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<script setup lang="ts">
import type { BillingSummary, CreateCheckoutResponse, CreditPackKey } from '@cvo/shared'
import { BILLING_SUMMARY_PATH, BILLING_CHECKOUT_PATH } from '@cvo/shared'
import { CREDIT_PACKS } from '~/config/pricing'
import { fr } from '~/i18n/fr'
import { Sparkles, Check } from '@lucide/vue'

definePageMeta({ middleware: 'auth' })

const toast = useToast()
const route = useRoute()

const { data: billing, refresh } = await useFetch<BillingSummary>(BILLING_SUMMARY_PATH, { lazy: true })
const creditBalance = computed(() => billing.value?.creditBalance ?? null)

const packs = fr.pricing.packs

// Solde mémorisé juste avant la redirection Checkout : sert de référence fiable au
// retour (survit au rechargement complet de page imposé par Stripe).
const CREDITS_BEFORE_KEY = 'teven:creditsBefore'

/**
* Le webhook Stripe crédite de façon ASYNCHRONE (souvent 1–3 s après le retour) :
* on relit le solde plusieurs fois avec un backoff court, en s'arrêtant dès qu'il
* dépasse la référence. Renvoie le nouveau solde, ou `null` si rien n'a bougé.
*/
async function waitForCredit(previous: number): Promise<number | null> {
for (const delay of [800, 1200, 1800, 2500, 3500]) {
await new Promise((resolve) => setTimeout(resolve, delay))
await refresh()
const now = billing.value?.creditBalance ?? previous
if (now > previous) return now
}
return null
}

// Retour de Stripe Checkout : confirmation / annulation, puis on rafraîchit le solde.
onMounted(async () => {
const status = route.query.status
if (status === 'success') {
toast.success('Paiement reçu', 'Tes crédits arrivent dans un instant.')
const before = Number(localStorage.getItem(CREDITS_BEFORE_KEY) ?? creditBalance.value ?? 0)
localStorage.removeItem(CREDITS_BEFORE_KEY)
const credited = await waitForCredit(before)
if (credited !== null) {
toast.success('Crédits ajoutés', `Ton solde est de ${credited} crédit${credited > 1 ? 's' : ''}.`)
} else {
// Webhook non encore reçu après ~10 s : le solde restera correct au prochain chargement.
toast.info('Crédits en cours', 'Paiement confirmé — le solde se met à jour sous peu.')
}
} else if (status === 'cancel') {
toast.info('Paiement annulé', "Aucun montant n'a été débité.")
}
if (status) navigateTo('/credits', { replace: true })
})

const buying = ref<CreditPackKey | null>(null)

async function buy(packKey: CreditPackKey) {
if (buying.value) return
buying.value = packKey
try {
const res = await $fetch<CreateCheckoutResponse>(BILLING_CHECKOUT_PATH, {
method: 'POST',
body: { packKey },
})
// Référence pour détecter l'augmentation du solde au retour (cf. waitForCredit).
localStorage.setItem(CREDITS_BEFORE_KEY, String(creditBalance.value ?? 0))
// Redirection vers la page de paiement hébergée par Stripe.
window.location.href = res.url
} catch {
toast.error('Paiement indisponible', 'Réessaie dans un instant.')
buying.value = null
}
}
</script>

<template>
<div class="mx-auto w-full max-w-5xl px-6 py-10">
<header class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-ink-900 sm:text-3xl">Crédits</h1>
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-ink-500 sm:text-base">
1 crédit = 1 CV adapté à une offre. Packs sans abonnement — tu paies quand tu postules.
</p>
</div>
<UiBadge :variant="creditBalance !== null && creditBalance < 1 ? 'danger' : 'brand'" class="mt-1">
<template v-if="creditBalance === null">…</template>
<template v-else>{{ creditBalance }} crédit{{ creditBalance > 1 ? 's' : '' }}</template>
</UiBadge>
</header>

<div class="mt-8 grid gap-5 sm:grid-cols-2">
<UiCard
v-for="pack in CREDIT_PACKS"
:key="pack.key"
:class="pack.featured ? 'ring-2 ring-brand-500' : ''"
>
<div class="flex h-full flex-col gap-4 p-1">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-ink-900">{{ packs[pack.key].name }}</h2>
<UiBadge v-if="pack.featured" variant="brand">
<Sparkles class="h-3.5 w-3.5" :stroke-width="2" aria-hidden="true" />
{{ fr.pricing.featuredBadge }}
</UiBadge>
</div>

<div class="flex items-baseline gap-1.5">
<span class="text-3xl font-bold text-ink-900">{{ pack.priceEur }} €</span>
<span class="text-sm text-ink-500">· {{ pack.credits }} crédits</span>
</div>

<p class="text-sm leading-relaxed text-ink-500">{{ packs[pack.key].tagline }}</p>

<ul class="space-y-1.5">
<li
v-for="(feat, i) in packs[pack.key].features"
:key="i"
class="flex items-start gap-2 text-sm text-ink-700"
>
<Check class="mt-0.5 h-4 w-4 shrink-0 text-brand-600" :stroke-width="2" aria-hidden="true" />
{{ feat }}
</li>
</ul>

<div class="mt-auto pt-2">
<UiButton
class="w-full"
:variant="pack.featured ? 'primary' : 'secondary'"
:loading="buying === pack.key"
:disabled="buying !== null"
@click="buy(pack.key)"
>
{{ packs[pack.key].cta }}
</UiButton>
</div>
</div>
</UiCard>
</div>

<p class="mt-6 text-center text-xs text-ink-400">{{ fr.pricing.legal }}</p>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -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");

Loading
Loading