Codes promo
Coupons PERCENT, FREE_MONTHS et LIFETIME — validation, redeem, audit.
Trois types de coupons couvrent 99% des besoins :
- PERCENT — réduction en %, sur N mois (lié à un Stripe coupon)
- FREE_MONTHS — N mois offerts (lié à un Stripe coupon)
- LIFETIME — accès à vie, sans Stripe (flag
User.lifetimeAccess)
Modèle Prisma
enum CouponKind {
PERCENT
FREE_MONTHS
LIFETIME
}
model Coupon {
id String @id @default(cuid())
code String @unique // toujours uppercase
kind CouponKind
percentValue Int? // pour PERCENT
appliedMonths Int? // durée d'application PERCENT
freeMonths Int? // durée FREE_MONTHS
maxRedemptions Int? // null = illimité
redemptionsCount Int @default(0)
expiresAt DateTime?
appliesToPlanKeys String[] // [] = tous les plans
description String?
stripeCouponId String? // créé à la main dans Stripe
}
model CouponRedemption {
id String @id @default(cuid())
couponId String
userId String
appliedUntil DateTime?
@@unique([couponId, userId])
}Génération de code
generateCouponCode(length = 10, prefix = "") dans lib/coupons.ts produit
un code uppercase, sans 0/O/1/I (alphabet identique au parrainage). Tu
peux préfixer ("NEWYEAR-") pour catégoriser.
Validation
import { validateCoupon } from "@/lib/coupons";
const res = await validateCoupon("BLACKFRIDAY", userId, "pro");
if (!res.ok) {
// res.reason: "not_found" | "expired" | "exhausted"
// | "already_redeemed" | "user_has_other" | "invalid_plan"
}C'est une lecture pure — pas de mutation. À utiliser dans l'UI checkout avant de soumettre.
Anti-cumul
Un user ne peut avoir qu'un seul coupon actif à la fois. La validation
échoue avec user_has_other si un CouponRedemption existe déjà avec
appliedUntil dans le futur (ou null pour LIFETIME).
Restrictions plans
coupon.appliesToPlanKeys = ["pro", "team"] limite le coupon à ces plans.
Vide = applicable partout.
Redeem
import { redeemCoupon } from "@/lib/coupons";
const { redemption, coupon } = await redeemCoupon("BLACKFRIDAY", userId, "pro");Effets selon le kind :
- LIFETIME →
User.lifetimeAccess = true(jamais besoin de Stripe) - PERCENT →
appliedUntil = now + appliedMonths. Tu dois attachercoupon.stripeCouponIdà la session checkout côté Stripe. - FREE_MONTHS → idem, durée =
freeMonths.
Tout dans une transaction. redemptionsCount incrémenté.
Stripe coupon — TODO
Le boilerplate ne crée pas automatiquement les coupons Stripe. Tu les
crées à la main dans le Stripe Dashboard (ou via API) puis tu colles
l'ID dans coupon.stripeCouponId. Côté checkout, tu attaches :
const session = await stripe.checkout.sessions.create({
// ...
discounts: [{ coupon: dbCoupon.stripeCouponId }],
});Pour automatiser la création Stripe → c'est un TODO documenté dans
/admin/coupons (commentaire dans le formulaire).
Audit & soft-disable
Création, update et disableCoupon(id) (qui set expiresAt = now) sont
audités. La suppression hard n'est pas exposée — soft-disable préserve
l'historique des redemptions.
Les redemptions log coupon.redeemed avec userId et couponId.
Page admin
Route : app/[locale]/(dashboard)/coupons/page.tsx. Liste DataList avec
filtres par kind, recherche par code. API : /api/admin/coupons.
Allez plus loin
- Plans tarifaires —
appliesToPlanKeys - Webhooks Stripe — appliquer le coupon au checkout
- Audit log