Audit — le systeme de paiement comme un seul parcours
Portee volontairement plus large que le pilier billing : la demande etait de traiter decouverte -> pricing -> inscription -> checkout -> activation -> usage -> upgrade -> facture -> echec -> annulation comme une seule…
Portee volontairement plus large que le pilier
billing: la demande etait de traiter decouverte -> pricing -> inscription -> checkout -> activation -> usage -> upgrade -> facture -> echec -> annulation comme une seule infrastructure, pas comme une page de tarifs plus des composants Stripe.Perimetre :
src/modules/billing/**,src/services/billing|stripe/**,src/services/webhooks.ts,src/lib/security/billing-gate.ts,src/app/api/{stripe,credits,usage,me,stores,organizations}/**,src/app/(marketing)/pricing/**,src/app/(dashboard)/[orgSlug]/~/billing/**,src/app/(minimal)/onboarding/**,src/components/shared/credits/**,src/components/patterns/{paid-gate,upgrade-wall,billing}/**,src/features/ai/orchestrator/runtime/**(gardes de cout),src/types/billing-*.ts,src/config/plans.ts,prisma/schema.prisma.Ce document n'est pas en lecture seule. Contrairement a l'audit du 25 aout, il a ete suivi d'implementation dans la meme PR : chaque constat porte son etat (corrige / ouvert / decision produit).
Ce qui va bien, et qu'il ne faut pas casser
Le premier resultat de l'audit est qu'il n'y a pas de dette mecanique. Verifie ligne a ligne :
| Sonde | Resultat |
|---|---|
| Idempotence webhook | machine a etats reelle (processing → processed | failed), claim atomique par updateMany, reclaim des rows failed |
| Ordre des evenements Stripe | garde stripeLastEventAt sur les trois handlers subscription.* |
| Narrowing de statut | incomplete / unpaid / paused ne deviennent jamais active |
| Grant de credits | lit metadata.amount (HT), jamais amount_total (TTC) |
| Clawback de remboursement | ratio TTC/TTC, coherent |
| Couche d'entitlement | une seule regle pure (entitlementFromSubscription), isomorphe client/serveur |
| Les deux catalogues de plans (audit du 25 aout) | corrige depuis : config/plans.ts derive de PLAN_PRICING |
La qualite de cette couche est reelle et ses commentaires nomment les incidents qu'elle previent. Le probleme n'est pas la mecanique.
Le probleme : tout ce qui entoure la mecanique
Trois familles, et elles se ressemblent :
- Le catalogue promet des choses que rien n'applique.
- Le tunnel n'a ni debut ni fin : l'intention se perd avant le paiement, et le paiement ne mene nulle part apres.
- Des surfaces affichent des nombres que le produit n'a pas.
1. Cartographie — ou vit la verite
| Concept | Source de verite | Lu par |
|---|---|---|
| Prix, credits inclus | types/billing-plans.ts → PLAN_PRICING | webhooks, cron reset-credits, /pricing (via config/plans.ts) |
| Limites comptables (stores, seats) | PLAN_LIMITS | personne, avant cette PR → services/billing/quota.ts |
| Workflows concurrents | PLAN_USAGE_CAPS | api/workflow/[id]/run |
| Appels MCP | PLAN_MCP_RATE_LIMITS (deplace dans le catalogue) | api/mcp/[storeId]/rate-limit.ts, /pricing |
| Minutes de scan | PLAN_SCAN_MINUTES (deplace dans le catalogue) | features/tracking/lib/scan-ledger.ts, /pricing |
| Price IDs Stripe | serverEnv.NEXT_PUBLIC_STRIPE_* via config/plans.ts | getPlanByStripePriceId (checkout + webhook) |
| Plan effectif d'une org | Subscription (la ligne), jamais Organization.plan | entitlement.ts / entitlement-service.ts |
| Solde de credits | ledger Credit append-only, formule FIFO dans credit-balance.ts | /api/me, gate chat, admin |
| Etat d'abonnement | Subscription.status + cancelAtPeriodEnd + currentPeriodEnd | gates, /api/me, page billing |
Les trois couches d'entitlement, apres cette PR
entitlement.ts (pur) "cette org a-t-elle le droit de payer-pour-utiliser"
quota.ts (pur) "peut-elle en ajouter un de plus, et quel plan le debloque"
billing-gate.ts (securite) requirePaidPlan / requireQuota + les deux 402
quota.ts est neuf et comble le trou central : la question comptable
n'avait aucune reponse dans le depot.
Points d'entree du checkout
| Depuis | Vers | Etat |
|---|---|---|
/pricing (cartes + matrice) | POST /api/stripe/checkout | corrige (intention + erreurs) |
| Onboarding (plan picker) | rien | corrige (ouvre le checkout) |
| Page billing (« Acheter des credits ») | /pricing?action=buy (page publique) | corrige (modale in-app) |
| Toast credits epuises (chat) | ~/billing?action=… (inerte) | corrige |
| Carte usage org | ~/billing?action=buy (inerte) | corrige |
| Portail Stripe | POST /api/stripe/billing-portal | corrige (RBAC + open redirect) |
2. Constats
P0 — argent, acces, securite
| # | Constat | Etat |
|---|---|---|
| 1 | Le quota de stores n'etait applique nulle part. PLAN_LIMITS[plan].stores — le nombre qui nomme les offres (max_5x = 5) — n'avait aucun lecteur. Trois portes creaient des stores sans le consulter : la route HTTP, le wizard Shopify, et l'outil @Atlas qui atteignait db.createStore directement | corrige |
| 2 | La voix etait ouverte au Free. requirePaidPlan etait appele et seul .plan etait lu (l'entree du markup), jamais .ok. Le seul refus etait un solde nul, et chaque org neuve recoit un bonus de bienvenue | corrige |
| 3 | Un price id non resolu accordait pro. Les neuf variables NEXT_PUBLIC_STRIPE_* valent "" par defaut : un client Max 20x a $299 recevait $49 de credits par mois, en silence | corrige |
| 4 | Un upgrade en cours de mois n'accordait rien. MonthlyReset(orgId, year, month) n'a pas de dimension plan | corrige (top-up du delta, CAS idempotent) |
| 5 | Le portail de facturation n'avait aucun RBAC. Un viewer pouvait annuler l'abonnement, changer la carte, telecharger les factures | corrige |
| 6 | return_url du portail : open redirect, transmis verbatim a Stripe | corrige (meme origine) |
| 7 | Chaque achat de credits creait un nouveau customer Stripe (customer_creation: "always" sans customer), et le webhook repointait Organization.stripeCustomerId dessus : le portail d'un client payant s'ouvrait ensuite sur un customer sans abonnement | corrige |
| 8 | invoice.payment_failed ne lisait que l'ancienne forme de payload. Sur toute API Stripe moderne (Basil, 2025-03+) le handler voyait undefined et ne passait jamais la ligne en past_due : une carte qui echoue gardait l'acces payant indefiniment | corrige (resolveur partage) |
| 9 | Le cache d'abonnement de l'auth middleware n'avait ni TTL, ni invalidation, ni borne : un downgrade restait invisible pour la duree de vie de l'instance | corrige |
P1 — tunnel casse, etat incoherent
| # | Constat | Etat |
|---|---|---|
| 10 | Le retour de checkout ne menait nulle part. Abonnement → /?checkout=success (page marketing, parametre que rien ne lit). Credits → /settings/billing, qui n'est pas une route : payer finissait sur un 404 | corrige (~/billing/activate, verification serveur) |
| 11 | L'intention d'achat etait jetee. L'onboarding ecrivait le plan choisi dans sessionStorage et rien ne le lisait : choisir Max 20x menait a Free | corrige |
| 12 | Un price id manquant echouait en silence : le bouton s'arretait de tourner, sans erreur ni telemetrie — l'etat actuel d'un deploiement non configure | corrige |
| 13 | Les webhooks subscription.created/updated n'invalidaient pas le cache /api/me : jusqu'a 30 s d'ancien plan apres paiement | corrige |
| 14 | cancelAtPeriodEnd n'etait ecrit que par le cron de reconciliation, et l'interface StripeSubscription ne declarait meme pas le champ | corrige |
| 15 | Quatre routes de lecture billing repondaient pour l'org la plus ANCIENNE de l'utilisateur, pas celle affichee | corrige (resolveRequestOrg) |
| 16 | Le hub : client et serveur en desaccord sur la portee, avec un commentaire client affirmant qu'ils lisaient la meme ligne | corrige |
| 17 | Emails jamais envoyes : subscription_created, subscription_cancelled existaient comme gabarits sans expediteur. Un client payait et n'entendait rien de nous | corrige (les deux) |
| 18 | Le lien « acheter plus » de l'email credits bas pointait sur le meme /settings/billing inexistant | corrige |
P2 — UX, conversion, verite affichee
| # | Constat | Etat |
|---|---|---|
| 19 | Trois tuiles affichaient des constantes. /api/me renvoyait tokensThisMonth: 0 et requestsThisMonth: 0 en dur, rendus contre un plafond || 2_000_000 present dans aucun catalogue | corrige (requetes reelles ; tokens retires) |
| 20 | planLimits?.tokens || 50_000 : 0 est falsy, donc Free etait mesure contre 50 000 tokens ; -1 est truthy, donc les payants contre une limite negative | corrige |
| 21 | Le paywall vendait « Unlimited · $49/mo », une offre supprimee en v3.0, avec le prix ecrit a la main dans les six bundles i18n | corrige (derive du catalogue) |
| 22 | Le 402 disait « requires the Unlimited plan » | corrige |
| 23 | ?action=buy / ?action=upgrade inertes, et BuyCreditsModal — composant complet — rendu par rien | corrige |
| 24 | Boutons du portail rendus pour tout le monde alors que seul owner detient billing.manage | corrige (disabled + raison) |
| 25 | La matrice /pricing reecrivait a la main les stores (5e copie), les credits (3e), les workflows et le bonus quotidien | corrige (derive) |
| 26 | Deux limites appliquees et jamais annoncees : appels MCP (facteur 100 entre Free et Max 20x) et minutes de scan (Free : 5/mois) | corrige (annoncees, tables deplacees dans le catalogue) |
Troisieme passe — ce que le rejeu a trouve
Numerotation separee : ces trois constats sont posterieurs aux tables
ci-dessus, et viennent d'un angle different. Les deux premieres passes
lisaient le code ; celle-ci rejoue des sequences et regarde l'etat
final (src/test/payment-funnels.test.ts).
| # | Constat | Etat |
|---|---|---|
| A | La quatrieme porte a stores. convertProspect (pipeline Studio) atteignait prisma.store.create sans quota. La PR qui a corrige les trois autres affirmait « all three doors » dans sa propre description : le compte etait le bug | corrige |
| B | Cinq chemins de suppression ne resiliaient rien chez Stripe. Subscription.organization est onDelete: Cascade : un client qui supprimait son compte continuait d'etre preleve, et la suppression emportait stripeSubscriptionId, seul fil vers l'abonnement | corrige (services/billing/stripe-cancel) |
| C | Deux abonnements vivants pour une org, c'est un double prelevement. Subscription est unique par orgId, Stripe ne l'est pas : deux checkouts termines depuis deux onglets produisent deux abonnements, le second ecrase la ligne, et le premier facture indefiniment sans qu'aucun id local ne pointe dessus. Meme orphelin que B, par une autre porte | corrige (l'ancien est resilie, les deux ids sont audites) |
| D | Le filigrane d'ordonnancement pouvait reculer. L'estampille etait une ecriture inconditionnelle : deux livraisons concurrentes passent toutes deux le test de fraicheur, et si la plus ANCIENNE ecrit en dernier, la marque descend et rouvre la fenetre pour tout evenement intermediaire | corrige (ecriture conditionnee, la marque ne peut plus que monter) |
Le point D merite une note de methode. Sa premiere version de test etait sequentielle et passait sur le code casse : un evenement perime sort avant d'estampiller, donc rien n'etait exerce. Il a fallu le reecrire en course reelle. C'est la raison pour laquelle chaque affirmation du fichier de rejeu a ete verifiee en remettant le defaut et en regardant le cas nomme echouer : quatorze mutations, quatorze prises. Le detail est en tete du fichier.
3. Ce qui reste ouvert
Decisions produit, pas des bugs
Ces claims de /pricing n'ont aucune implementation dans le depot.
Les corriger, c'est soit construire la fonctionnalite, soit retirer la
ligne : dans les deux cas c'est un arbitrage commercial, et un test ne
doit pas le trancher en silence.
| Affirmation | Etat du code |
|---|---|
| « AI Gateway : Standard / Priority / Dedicated » | aucun mecanisme de priorite ou de file |
| « Skills library : Standard vs Premium » | aucune skill n'est gatee par plan |
| « Affiliate dashboard » (Max) | la route n'existe pas |
| « 30% de commission recurrente » | aucune implementation, et les deux pages publiques se contredisent sur la duree |
| « $9 par store supplementaire » | ADDITIONAL_STORE_PRICE_MONTHLY n'a aucun lecteur et STRIPE_EXTRA_STORE_PRICE n'est declaree nulle part |
| « Annual billing gate J+30 » | POST /api/stripe/checkout ne regarde aucune date d'inscription |
Un cas est symetrique et merite un arbitrage a lui seul :
PLAN_LIMITS.free.stores et PLAN_LIMITS.pro.stores valent tous deux
1. L'offre a $49 n'ajoute donc aucun store, et le plan le moins cher
qui en autorise un deuxieme est Max 5x a $149. C'est peut-etre voulu (Pro
achete les credits et les surfaces payantes, pas des stores) — mais c'est
le genre de marche qu'il vaut mieux decider que subir, d'autant que
l'echappatoire vendue sur les cartes (« +$9 par store ») n'a pas de
chemin de facturation. src/services/billing/quota.test.ts epingle le
comportement actuel, avec la raison ecrite.
Dette identifiee, non traitee dans cette PR
- L'editeur de plans admin ecrit trois colonnes que rien ne lit
(
Plan.markup,Plan.dailyBonusUsd,Plan.additionalStorePrice), avec un toast de succes et une entree d'audit ; etPlan.monthlyCreditsn'est honore qu'a la creation d'org, pas par les grants recurrents. /pricingrend le price id fusionne depuis la DB alors que/api/stripe/checkoutresout contre le catalogue env : remplir le champ dans l'admin produit un CTA qui 400.- Quatre statuts Stripe n'ont aucune representation locale
(
incomplete,incomplete_expired,unpaid,paused), tous replies surpast_due. Sur direction, pas de bug ; mais aucune UI ne peut distinguer « 3DS a authentifier » de « carte refusee ». - Aucune facture n'est jamais lue depuis Stripe : la section « Billing History » est un etat vide inconditionnel dont la copie promet le contraire.
charge.dispute.*n'est pas dispatche, alors quedocs/business-model/operations.mddecrit un clawback de 14 jours sur chargeback comme actif.- Les holds de credits fuient sur les canaux non-web (bot, WhatsApp, API REST) : seul le chat web libere le hold en cas d'echec.
- Le canal API REST n'a pas de hard cap mid-stream, contrairement a ses quatre freres.
4. Ce que cet audit n'a pas couvert
- Aucun Postgres, aucun Stripe, aucun navigateur.
src/test/payment-funnels.test.tsrejoue desormais les huit parcours contre les vrais handlers — machine d'idempotence, filigrane d'ordonnancement, grant mensuel, CAS du top-up, entitlement, quota — mais sur un magasin en memoire. Il prouve que notre code, recevant les evenements que Stripe envoie, atteint l'etat voulu. Il ne prouve pas que Postgres applique les index uniques que ce magasin imite, ni que Stripe accepte une session, ni qu'une page s'affiche. Le seul test d'integration reel restedescribe.skipIf(!TEST_DATABASE_URL), et ne tourne pas en CI. - Aucun rendu verifie. vitest tourne en environnement
nodeici : les gardes statiques lisent la source, ce qui est le bon niveau pour des defauts de cablage, et ne prouve rien sur les pixels. - Le modele financier (
docs/business-model/financial-model.md) n'a pas ete confronte au code. - Le markup 1.5x sur le chemin de charge reel n'a pas ete exerce bout en bout ; seule l'incoherence de l'affichage a ete corrigee.