-
Notifications
You must be signed in to change notification settings - Fork 0
feat(billing): packs de crédits Stripe (Checkout, ledger, gate génération) #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
28 changes: 28 additions & 0 deletions
28
apps/app/prisma/migrations/20260622084853_billing_credits/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
|
|
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
Repository: Tbeaumont79/cv-optimizer
Length of output: 552
🏁 Script executed:
Repository: Tbeaumont79/cv-optimizer
Length of output: 50380
🏁 Script executed:
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-fileand--importcommand-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:seednode --env-fileand--importrequire Node 20.6.0+. The repo still declares>=20.0.0inpackage.jsonand20in.nvmrc/CI, so older 20.0–20.5 installs can fail here.🤖 Prompt for AI Agents