Audit — pilier billing
Troisieme tour de la rotation (docs/team/roster.md, @codebase-auditor), apres data-platform et security-identity. Lecture seule sur le code. Ce document et deux items de backlog sont la sortie complete. Perimetre…
Troisieme tour de la rotation (
docs/team/roster.md, @codebase-auditor), apresdata-platformetsecurity-identity.Lecture seule sur le code. Ce document et deux items de backlog sont la sortie complete.
Perimetre :
src/modules/billing/**,src/services/billing|stripe/**,src/app/api/credits|stripe|usage|pricing|webhooks/stripe/**,src/app/(marketing)/pricing/**,src/app/(dashboard)/[orgSlug]/~/billing/**,src/types/billing-*.ts,src/config/plans.ts,src/services/audit-billing.ts,docs/business-model/**.Etat du depot :
origin/maina3eec7034, 25 aout 2026.
Ce qui a ete verifie
| Sonde | Resultat |
|---|---|
| Mecanismes d'idempotence avec une vraie contrainte unique | 6 sur 6 |
Le grant de credits lit metadata.amount (hors taxe) | oui, avec repli documente |
| Le clawback de remboursement melange-t-il TTC et HT | non — ratio coherent |
| Garde pre-stream (402) | presente, et plus stricte que la doc |
| Garde mid-stream | presente (handler-cost-guard.ts) |
| Catalogues de prix independants dans le code | 2 |
| Endroits ou un prix de plan est ecrit a la main | 4 |
| Test comparant les deux catalogues | 0 |
Claims de prix couvertes par pnpm docs:claims | 0 sur 9 |
Les garde-fous et l'idempotence sont en bon etat : les six mecanismes cites
par CLAUDE.md ont tous la contrainte qu'ils annoncent, et le chemin de
remboursement, qu'on pouvait soupconner, est correct. Le probleme de ce
pilier n'est pas la mecanique. C'est le nombre d'endroits ou le prix est
ecrit.
Constat 1 — deux catalogues de plans independants alimentent les deux moities d'une meme transaction
Severite : haute. → backlog/billing/0054
Deux fichiers declarent le catalogue, chacun se presentant comme la source unique :
types/billing-plans.ts | config/plans.ts | |
|---|---|---|
| Constante | PLAN_PRICING | PLANS |
| Se declare | source de verite (et CLAUDE.md la designe) | « single source of truth shared by /pricing and the onboarding plan picker » |
| Importe l'autre | non | non |
pro | monthlyPrice: 49, yearlyPrice: 470, includedCredits: 49 | monthlyPrice: 49, yearlyPrice: 470, monthlyCredits: 49 |
max_5x | 149 / 1430 / 149 | 149 / 1430 / 149 |
max_20x | 299 / 2870 / 299 | 299 / 2870 / 299 |
Ils sont d'accord aujourd'hui. C'est pour cela que personne ne l'a vu.
Ce qui rend le constat grave, c'est qui lit quoi :
| Catalogue | Consomme par |
|---|---|
PLANS (config/plans.ts) | /pricing, le plan picker, l'onboarding, l'editeur admin des plans, plans-service.ts — ce que le client voit |
PLAN_PRICING | le cron reset-credits, orchestrator/runtime/billing.ts, billing-credits.ts — ce qui est credite et debite |
Et le point le plus net, dans une seule fonction :
src/services/webhooks.ts:712 getPlanByStripePriceId(priceId) ← config/plans.ts
src/services/webhooks.ts:790 PLAN_PRICING[args.plan].includedCredits ← billing-plans.ts
Le handler de souscription Stripe resout le plan dans un catalogue et la dotation dans l'autre.
Le scenario d'echec, concret
Quelqu'un passe Pro a $59 dans config/plans.ts (le fichier qu'on ouvre
naturellement pour changer un prix affiche) et pas dans PLAN_PRICING :
- la page de tarifs, le picker et l'onboarding annoncent $59 ;
- Stripe facture selon son propre price ID ;
reset-creditset le webhook creditent $49.
Le client paie un prix et recoit la dotation d'un autre. Rien ne casse, aucun test ne rougit, et l'ecart n'apparait que sur le plan modifie.
Aucun test ne compare les deux catalogues, verifie : aucun fichier de test n'importe les deux.
Un troisieme PlanId
config/plans.ts:38 declare export type PlanId = "free" | "pro" | "max_5x" | "max_20x" | "custom" — a la main, a cote de l'enum Prisma PlanId qui
porte exactement les memes valeurs. Ajouter un plan demande donc de toucher
l'enum, ce type, et les deux catalogues, sans que rien ne le rappelle.
Constat 2 — le prix est ecrit a la main a quatre endroits, et le garde-fou du depot n'en couvre aucun
Severite : moyenne. → backlog/billing/0055
Au-dela des deux catalogues de code, les memes montants sont ecrits :
- dans
docs/business-model/— 60 occurrences litterales ($49×25,$149×18,$299×17,$470×2) reparties surplans.md,financial-model.md,README.md,credits.md,roadmap.md; - dans le tableau « Business model » de
CLAUDE.md.
Soit quatre endroits pour un meme nombre, dont deux en prose.
Ce qui rend ce constat plus qu'une remarque de tenue : ce depot a
construit un mecanisme exactement pour ca.
scripts/check-doc-claims.mjs derive
neuf affirmations du code et echoue si l'une ne correspond plus, nombre de
crons, handlers d'API, handlers de webhooks, pages /features et leurs
slugs, piliers d'ownership.json. Sa doctrine est ecrite dans CLAUDE.md :
« les chiffres de cette page sont derives, pas tenus a la main ».
Zero de ces neuf claims ne porte sur un prix. Le depot derive automatiquement le nombre de ses crons, et laisse a la main le nombre que ses clients paient.
Releve, sans item
- La garde pre-stream fait plus que ce que la doc annonce.
CLAUDE.mddecrit « refus 402 si le cout estime depasse le balance » ;handler.ts:664reserve en plus l'estimation comme un hold sur les credits, pour que des requetes concurrentes ne depensent pas deux fois le meme solde. C'est un ecart doc↔code, mais dans le bon sens : le code protege mieux qu'annonce. A corriger dans la doc le jour ou quelqu'un y touche, pas avant. - Le clawback de remboursement (
webhooks.ts:258-266) applique une fraction TTC (amount_refunded / charge.amount) a une base HT (metadata.amount). C'etait le candidat evident a une erreur de taxe ; les deux cotes du ratio sont TTC, donc la fraction est juste. Verifie, correct, rien a faire.
Ce que cet audit n'a pas couvert
- le cycle Stripe complet : aucun evenement rejoue, aucun test d'integration lance ; l'idempotence est verifiee par la presence des contraintes, pas par un doublon reellement soumis ;
docs/business-model/financial-model.md— les hypotheses financieres n'ont pas ete confrontees au code, seulement les prix ;- la coherence des price IDs Stripe (
serverEnv) avec les plans : hors du depot, c'est l'objet de l'itembilling/0007deja ouvert ; - le markup 1.5× et le calcul de cout par token : non exerces.
Prochain pilier de la rotation : ai-platform. Motif : c'est la plus
grande surface du depot, elle vient d'absorber deux outils Studio et un
changement de garde de cron, et ATLAS_SURFACE_PATHS y attache une regle de
versionnement dont la dette a ete filee en inbox/0050, un pilier dont le
frottement est deja documente merite d'etre regarde en entier.