Audits · août 2026Audit — pilier billing

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), apres data-platform et security-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/main a 3eec7034, 25 aout 2026.

Ce qui a ete verifie

SondeResultat
Mecanismes d'idempotence avec une vraie contrainte unique6 sur 6
Le grant de credits lit metadata.amount (hors taxe)oui, avec repli documente
Le clawback de remboursement melange-t-il TTC et HTnon — ratio coherent
Garde pre-stream (402)presente, et plus stricte que la doc
Garde mid-streampresente (handler-cost-guard.ts)
Catalogues de prix independants dans le code2
Endroits ou un prix de plan est ecrit a la main4
Test comparant les deux catalogues0
Claims de prix couvertes par pnpm docs:claims0 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.tsconfig/plans.ts
ConstantePLAN_PRICINGPLANS
Se declaresource de verite (et CLAUDE.md la designe)« single source of truth shared by /pricing and the onboarding plan picker »
Importe l'autrenonnon
promonthlyPrice: 49, yearlyPrice: 470, includedCredits: 49monthlyPrice: 49, yearlyPrice: 470, monthlyCredits: 49
max_5x149 / 1430 / 149149 / 1430 / 149
max_20x299 / 2870 / 299299 / 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 :

CatalogueConsomme 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_PRICINGle 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-credits et 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 sur plans.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.md decrit « refus 402 si le cout estime depasse le balance » ; handler.ts:664 reserve 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'item billing/0007 deja 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.