Audits · août 2026Audit — le systeme de paiement comme un seul parcours

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 :

SondeResultat
Idempotence webhookmachine a etats reelle (processing → processed | failed), claim atomique par updateMany, reclaim des rows failed
Ordre des evenements Stripegarde stripeLastEventAt sur les trois handlers subscription.*
Narrowing de statutincomplete / unpaid / paused ne deviennent jamais active
Grant de creditslit metadata.amount (HT), jamais amount_total (TTC)
Clawback de remboursementratio TTC/TTC, coherent
Couche d'entitlementune 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 :

  1. Le catalogue promet des choses que rien n'applique.
  2. Le tunnel n'a ni debut ni fin : l'intention se perd avant le paiement, et le paiement ne mene nulle part apres.
  3. Des surfaces affichent des nombres que le produit n'a pas.

1. Cartographie — ou vit la verite

ConceptSource de veriteLu par
Prix, credits inclustypes/billing-plans.ts → PLAN_PRICINGwebhooks, cron reset-credits, /pricing (via config/plans.ts)
Limites comptables (stores, seats)PLAN_LIMITSpersonne, avant cette PR → services/billing/quota.ts
Workflows concurrentsPLAN_USAGE_CAPSapi/workflow/[id]/run
Appels MCPPLAN_MCP_RATE_LIMITS (deplace dans le catalogue)api/mcp/[storeId]/rate-limit.ts, /pricing
Minutes de scanPLAN_SCAN_MINUTES (deplace dans le catalogue)features/tracking/lib/scan-ledger.ts, /pricing
Price IDs StripeserverEnv.NEXT_PUBLIC_STRIPE_* via config/plans.tsgetPlanByStripePriceId (checkout + webhook)
Plan effectif d'une orgSubscription (la ligne), jamais Organization.planentitlement.ts / entitlement-service.ts
Solde de creditsledger Credit append-only, formule FIFO dans credit-balance.ts/api/me, gate chat, admin
Etat d'abonnementSubscription.status + cancelAtPeriodEnd + currentPeriodEndgates, /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

DepuisVersEtat
/pricing (cartes + matrice)POST /api/stripe/checkoutcorrige (intention + erreurs)
Onboarding (plan picker)riencorrige (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 StripePOST /api/stripe/billing-portalcorrige (RBAC + open redirect)

2. Constats

P0 — argent, acces, securite

#ConstatEtat
1Le 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 directementcorrige
2La 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 bienvenuecorrige
3Un 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 silencecorrige
4Un upgrade en cours de mois n'accordait rien. MonthlyReset(orgId, year, month) n'a pas de dimension plancorrige (top-up du delta, CAS idempotent)
5Le portail de facturation n'avait aucun RBAC. Un viewer pouvait annuler l'abonnement, changer la carte, telecharger les facturescorrige
6return_url du portail : open redirect, transmis verbatim a Stripecorrige (meme origine)
7Chaque 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 abonnementcorrige
8invoice.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 indefinimentcorrige (resolveur partage)
9Le 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'instancecorrige

P1 — tunnel casse, etat incoherent

#ConstatEtat
10Le 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 404corrige (~/billing/activate, verification serveur)
11L'intention d'achat etait jetee. L'onboarding ecrivait le plan choisi dans sessionStorage et rien ne le lisait : choisir Max 20x menait a Freecorrige
12Un price id manquant echouait en silence : le bouton s'arretait de tourner, sans erreur ni telemetrie — l'etat actuel d'un deploiement non configurecorrige
13Les webhooks subscription.created/updated n'invalidaient pas le cache /api/me : jusqu'a 30 s d'ancien plan apres paiementcorrige
14cancelAtPeriodEnd n'etait ecrit que par le cron de reconciliation, et l'interface StripeSubscription ne declarait meme pas le champcorrige
15Quatre routes de lecture billing repondaient pour l'org la plus ANCIENNE de l'utilisateur, pas celle afficheecorrige (resolveRequestOrg)
16Le hub : client et serveur en desaccord sur la portee, avec un commentaire client affirmant qu'ils lisaient la meme lignecorrige
17Emails jamais envoyes : subscription_created, subscription_cancelled existaient comme gabarits sans expediteur. Un client payait et n'entendait rien de nouscorrige (les deux)
18Le lien « acheter plus » de l'email credits bas pointait sur le meme /settings/billing inexistantcorrige

P2 — UX, conversion, verite affichee

#ConstatEtat
19Trois tuiles affichaient des constantes. /api/me renvoyait tokensThisMonth: 0 et requestsThisMonth: 0 en dur, rendus contre un plafond || 2_000_000 present dans aucun cataloguecorrige (requetes reelles ; tokens retires)
20planLimits?.tokens || 50_000 : 0 est falsy, donc Free etait mesure contre 50 000 tokens ; -1 est truthy, donc les payants contre une limite negativecorrige
21Le paywall vendait « Unlimited · $49/mo », une offre supprimee en v3.0, avec le prix ecrit a la main dans les six bundles i18ncorrige (derive du catalogue)
22Le 402 disait « requires the Unlimited plan »corrige
23?action=buy / ?action=upgrade inertes, et BuyCreditsModal — composant complet — rendu par riencorrige
24Boutons du portail rendus pour tout le monde alors que seul owner detient billing.managecorrige (disabled + raison)
25La matrice /pricing reecrivait a la main les stores (5e copie), les credits (3e), les workflows et le bonus quotidiencorrige (derive)
26Deux 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).

#ConstatEtat
ALa 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 bugcorrige
BCinq 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'abonnementcorrige (services/billing/stripe-cancel)
CDeux 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 portecorrige (l'ancien est resilie, les deux ids sont audites)
DLe 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 intermediairecorrige (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.

AffirmationEtat 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 ; et Plan.monthlyCredits n'est honore qu'a la creation d'org, pas par les grants recurrents.
  • /pricing rend le price id fusionne depuis la DB alors que /api/stripe/checkout resout 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 sur past_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 que docs/business-model/operations.md decrit 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.ts rejoue 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 reste describe.skipIf(!TEST_DATABASE_URL), et ne tourne pas en CI.
  • Aucun rendu verifie. vitest tourne en environnement node ici : 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.