Documentation

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 :

  • LIFETIMEUser.lifetimeAccess = true (jamais besoin de Stripe)
  • PERCENTappliedUntil = now + appliedMonths. Tu dois attacher coupon.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

On this page