Facturation Stripe
title: Facturation Stripe description: Facturation par workspace sur le compte Stripe propre à Orbit : un produit, un plan, catalogue, webhooks, remboursements, taxes, attribution et quota de lectures.
Statut (2026-10) : construit en mode test, inerte tant que les clés ne sont pas posées. Les montants d'Orbit sont ceux du prototype (bloc « ACTIVATION / BILLING CANONIQUE » de
apps/web/internal/orbit-brain-demo.html) ; les crédits Studio restent à valider par le fondateur. L'économie (marge, garde-fou, quota de lectures) est dans Économie & paywall.
Orbit facture par workspace (le tenant), sur son propre compte Stripe. Jamais celui d'un autre
produit : les variables s'appellent ORBIT_STRIPE_*, et le schéma d'env refuse toute clé STRIPE_*
(AGENTS.md, règle 2).
Un seul produit, un seul plan
Le plan (120 CU à 49 $/mois, 468 $/an, puis +60 CU par pas à 25 $/mois ou 240 $/an), le paywall d'activation et le paywall de capacité sont décrits dans Économie & paywall. Les formules et les montants (en centimes, entiers) vivent à un seul endroit : packages/engine/src/brain/pricing.ts (priceCents, capacityForSteps, capacitySteps). Le catalogue de facturation, le script Stripe, l'admin et le paywall les lisent ; pricing.test.ts et plans.test.ts vérifient qu'ils sont identiques à la formule du prototype.
Deux choses distinctes, jamais mélangées (ADR 0020) :
| Offre | Ce qu'elle donne | Colonne dérivée | Avant activation |
|---|---|---|---|
| Orbit (le Brain, facturé en CU, un abonnement) | la capacité de contexte actif : 120 CU + 60 CU par pas | Workspace.brainCapacityCu | 0 CU (on crée des Spaces, on n'encode rien) |
| Crédits Studio (les outils, facturés en crédits, à l'usage) | l'enveloppe mensuelle de crédits Studio en USD que le propriétaire alloue aux membres | Workspace.studioMonthlyCreditsUsd | 0 USD |
Un workspace peut avoir Orbit, des crédits Studio, les deux ou aucun. Les crédits Studio sont un compteur d'usage séparé (paquets mensuels, montants à valider par le fondateur) : le Brain ne les consomme jamais, et Studio ne consomme jamais de CU.
Modèle
| Table / colonne | Rôle |
|---|---|
Workspace.stripeCustomerId | le client Stripe (cus_…) du workspace, créé avant son premier Checkout. Unique. |
Workspace.brainCapacityCu | la capacité active effective. 0 par défaut : Orbit n'a pas de plan gratuit |
Workspace.mode | STANDARD (le plan public) ou CUSTOM (conditions B2B négociées). Écrit uniquement par l'app apps/admin |
Workspace.customBrainCapacityCu, customMonthlyReads, customTerms | mode CUSTOM seulement : capacité imposée, quota de lectures, note des conditions (CHECK : absents en STANDARD) |
BillingSubscription | un abonnement Stripe, tel que Stripe l'a dit en dernier : kind (BRAIN/STUDIO), planKey, capacitySteps (la quantité de l'article « pas de capacité »), status (le statut Stripe, tel quel), currentPeriodEnd, cancelAtPeriodEnd, stripeEventAt |
StripeEvent | chaque événement appliqué, clé = l'id Stripe (evt_…) : l'idempotence des webhooks |
BrainReadCounter | le compteur de lectures MCP/REST : une ligne par workspace et par mois UTC (voir « Quota de lectures ») |
Migrations : 20261002130000_billing puis 20261002140000_single_orbit_plan (capacité 0 par défaut, pas de
capacité sur l'abonnement, mode, compteur de lectures ; phase pre-users : les workspaces sans
abonnement Brain entitlé passent à 0 CU, le seed rend sa capacité au fondateur). CHECK écrits à la main
(préfixes Stripe, droits non négatifs, capacitySteps ≥ 0 et nul hors Brain, surcharges custom
absentes en standard, lectures ≥ 0), prouvés par schema.test.ts et billing.integration.test.ts.
Aucune donnée de carte n'est stockée : Checkout et le portail Stripe les gardent.
Les droits sont dérivés (apps/web/src/modules/billing/entitlements.ts, pur et testé) : pour chaque
type, la meilleure offre d'un abonnement encore vivant (active, trialing, past_due). Orbit :
120 + 60 × capacitySteps CU, sinon 0. Ils sont réécrits dans Workspace dans la même
transaction que l'événement ; un workspace custom garde sa capacité imposée quoi que dise Stripe.
Résiliation, impayé (politique inchangée) : past_due garde le droit pendant que Stripe relance ;
dès que Stripe abandonne (canceled, unpaid…), la capacité retombe à 0 : les Memories restent,
lisibles et modifiables, mais toute nouvelle écriture est refusée par le paywall d'activation
(activationRequired, jamais le paywall de capacité qui n'existe qu'une fois Orbit actif).
Le seed accorde au workspace du fondateur ses crédits Studio (
FOUNDER_STUDIO_CREDITS_USD) et sa capacité Brain (FOUNDER_BRAIN_CAPACITY_CU, entier de CU). Sans cette variable le seed laisse la capacité telle quelle (0 sur un workspace neuf). Si ce workspace s'abonne un jour, ce sont les abonnements qui décident.
Catalogue Stripe
Le catalogue est une donnée du code : apps/web/src/modules/billing/plans.ts. Chaque offre a une clé,
un type, un libellé et une description en français, et les lookup_key de ses Prices Stripe.
Jamais un id de prix, ni dans le code ni dans l'env : changer un montant, ou passer en live, se fait
chez Stripe en transférant la lookup_key, sans déploiement.
Un seul produit Stripe « Orbit » (id orbit), quatre prix récurrents :
| Clé de plan | lookup_key base (quantité 1) | lookup_key pas de capacité (quantité = nombre de pas) | Base | Pas |
|---|---|---|---|---|
orbit_monthly | orbit_base_monthly | orbit_capacity_step_monthly | 49 $/mois | 25 $/mois |
orbit_annual | orbit_base_annual | orbit_capacity_step_annual | 468 $/an | 240 $/an |
Un abonnement Orbit a donc un article de base (quantité 1) et, dès le premier pas, un article de
pas de capacité dont la quantité est le nombre de pas. Un événement qui ne décrit pas exactement cela
(prix inconnu, palier retiré brain_pro/brain_max, article de pas sans base, pas d'un autre intervalle,
base en quantité 2) est une erreur : jamais un plan par défaut (règle 6).
Crédits Studio — à valider par le fondateur (compteur d'usage séparé, mensuels) :
| Clé | Droit | lookup_key | Montant proposé (script) |
|---|---|---|---|
studio_starter | 25 $ de crédits/mois | orbit_studio_starter_monthly | 29 $/mois |
studio_pro | 100 $ de crédits/mois | orbit_studio_pro_monthly | 109 $/mois |
Les montants vivent dans apps/web/scripts/stripe-catalog.mjs (en centimes) et chez Stripe ;
plans.test.ts vérifie que le script, le catalogue et pricing.ts nomment les mêmes offres, les mêmes
lookup_key et les mêmes montants. Les anciens produits orbit_brain_pro / orbit_brain_max et
leurs prix, s'ils existent dans le compte de test, sont à archiver à la main : le script ne touche que
ce qu'il crée.
Flux
Activer Orbit (propriétaire) : le paywall d'activation de /brain
- Le membre envoie sa première source depuis le Brain vide (URL, fichier, écriture). Piloté par
le serveur : la page lit
capacité === 0; le client ouvre le panneau « Activer Orbit » sans rien envoyer, et le serveur refuse de toute façon (activationRequired) avant toute lecture : aucun coût fournisseur n'est engagé pour un compte qui n'a pas payé (site, image :guardRead), ni une écriture de Memory, par l'interface, l'API REST ou le MCP. - Le panneau (fidèle au prototype) annonce « 120 CU actifs · 49 $/mois », rappelle « Compte, profil et
création du premier espace gratuits », l'annuel (468 $/an) et les pas de +60 CU. « Activer et
encoder » appelle l'action serveur
startCheckout(planKey, steps, 'brain'). Sans clés Stripe : « Bientôt disponible », sans erreur. - L'action relit le membre en base et exige
billing.manage(le propriétaire, rôleFOUNDER). Le service (service.ts) résout les Prices parlookup_key, crée une fois le client Stripe du workspace (clé d'idempotenceorbit-customer-<workspaceId>), puis ouvre Stripe Checkout (mode: subscription, article de base quantité 1 et, si des pas sont demandés, l'article de pas en quantité ;client_reference_idetmetadata.workspaceId; clé d'idempotence par minute : un double clic ouvre une seule session). - Retour sur
/brain?checkout=success. L'activation s'applique quand le webhook arrive (jamais au retour du navigateur) : le Brain litGET /api/brain/capacitytoutes les 2 s jusqu'à ce que la capacité passe à 120 CU, puis le membre renvoie sa source.
Étendre la capacité : le paywall de capacité
N'existe qu'une fois Orbit actif, quand une nouvelle mémoire dépasserait les CU actifs. Il propose
d'abord de libérer de la place (archiver, compresser, remplacer : le contexte se relit et se
supprime depuis « Revoir le contexte »), puis d'étendre le même plan d'un pas : « Passer à 180 CU ·
74 $/mois » (le prix total, pas le supplément). Le geste appelle extendCapacityAction(1) : le service
ajoute le pas à la même souscription (l'article de pas est créé au premier pas, puis sa quantité
grandit), au prorata, avec le moyen de paiement déjà enregistré, clé d'idempotence par minute. L'écran
/billing montre le prix exact et demande une confirmation avant. La capacité ne change que quand
customer.subscription.updated arrive. Sans Stripe : « Bientôt disponible ». Au maximum standard (40
pas) : conditions négociées.
Limite connue : « archiver » et « compresser » ne sont pas encore des opérations du produit (pas d'état « archivé » sur une Memory, pas de fusion de mémoires) ; seules « remplacer » (modifier) et supprimer libèrent réellement des CU aujourd'hui. Le paywall les propose dans cet ordre, avant l'extension, comme le prototype. Décision ouverte, voir Économie & paywall.
Changer d'intervalle, gérer la facturation (portail)
/billing → « Passer à l'offre annuel/mensuel » → même action startCheckout : le workspace paie déjà
Orbit, donc pas de second abonnement : le portail Stripe s'ouvre sur la confirmation du changement
(subscription_update_confirm), articles existants repriçés, pas conservés. « Gérer la facturation »
(openBillingPortal) ouvre le portail : factures, moyen de paiement, résiliation. Réglage du portail :
voir « Mise en ligne ».
Webhook
POST /api/webhooks/stripe (apps/web/src/app/api/webhooks/stripe/route.ts) : porte machine, publique au
proxy, ouverte par la signature Stripe du corps brut sous ORBIT_STRIPE_WEBHOOK_SECRET (SDK Stripe),
vérifiée avant toute lecture. Signature fausse ou secret absent : 400, rien n'est appliqué.
| Événement | Effet |
|---|---|
checkout.session.completed | relit l'abonnement chez Stripe et l'applique : l'activation |
customer.subscription.created | applique l'abonnement |
customer.subscription.updated | pas de capacité ajouté ou retiré, changement d'intervalle, résiliation programmée, statut |
customer.subscription.deleted | statut canceled : capacité 0, les Memories restent |
invoice.paid | période renouvelée : état relu chez Stripe (currentPeriodEnd, fin de past_due) ; reçu envoyé au propriétaire (montant non nul) |
invoice.payment_failed | statut relu chez Stripe (past_due) : le droit reste pendant les relances ; e-mail « paiement échoué » |
invoice.payment_action_required | authentification (SCA, 3-D Secure) à faire : e-mail au propriétaire avec le lien de la facture hébergée ; aucun état ne change |
charge.refunded | remboursement total : l'abonnement passe en statut local refunded, la capacité retombe comme à une résiliation (voir « Remboursement et litige ») ; un remboursement partiel ne change rien |
charge.dispute.created | litige ouvert : enregistré et signalé (journal billing.dispute.opened), rien n'est retiré : un litige n'est pas prouvé |
charge.dispute.closed | lost : statut local dispute_lost, capacité retirée ; won : le statut que Stripe tient pour l'abonnement est rétabli ; warning_closed et charge_refunded ne changent rien |
La liste est une seule liste : HANDLED_EVENTS (service.ts), WEBHOOK_EVENTS (scripts/stripe-catalog.mjs,
imprimée à la fin du script), ce tableau, le runbook de lancement et le skill billing-stripe ;
billing-events.test.ts les garde égaux.
Chaque événement appliqué peut demander des effets après le commit (effects.ts) : l'objectif
subscription_started de DataFast (à la création d'un abonnement entitlé, avec le plan et le montant
du prix), les e-mails du cycle de vie (payment_succeeded, payment_failed, payment_action_required,
subscription_canceled) et l'alerte de litige. Ils échouent en silence vers l'avant : une boîte mail
en panne ne fait pas rejouer un événement déjà appliqué ; la table StripeEvent les rend uniques.
Règles :
- Idempotent : l'événement est inséré d'abord dans
StripeEvent, dans la transaction qui l'applique. Le même événement livré deux fois (même en parallèle) s'applique une fois. - Tenant : un événement s'applique au workspace dont il nomme le client Stripe, jamais à celui
que ses métadonnées prétendent. Un désaccord est ignoré (
workspaceMismatch) et tracé. - Ordre : un événement plus ancien que l'état connu (
stripeEventAt) n'écrase rien (stale). - Prix inconnu : voir plus haut : erreur (500, rien n'est enregistré), Stripe relance.
- Objet inconnu : un événement dont l'objet ne se lit pas, un charge sans abonnement (paiement
ponctuel), un charge que Stripe ne connaît pas : enregistré dans
StripeEventavec sonoutcome(unreadable,noSubscription,unknownCharge,unknownSubscription…), jamais un plantage. - Les droits viennent du prix, jamais du montant payé : l'abonnement est lu article par article
(
lookup_keyet quantité).amount_totalinclut la taxe ; aucun code de facturation ne le lit (billing-events.test.ts). Les montants lus sur une facture ne servent qu'aux e-mails de reçu.
Remboursement et litige : la politique
Écrite dans entitlements.ts (HELD_STATUSES), testée dans billing.integration.test.ts. Deux statuts
locaux, écrits par-dessus celui de Stripe, qu'aucune liste de droits ne contient : un abonnement
retenu fait donc retomber la capacité exactement comme une résiliation (les Memories restent, les
nouvelles écritures sont refusées par le paywall d'activation), sans nouvelle colonne que l'on pourrait
oublier de lire.
| Cas | Effet |
|---|---|
| Remboursement total d'un paiement de l'abonnement | statut refunded : capacité retirée. Reste tant que l'abonnement n'est pas terminé ou refait |
| Remboursement partiel | aucun changement (enregistré partialRefund) |
| Litige ouvert | aucun changement ; journal billing.dispute.opened pour qu'une personne réponde dans le délai de preuves de Stripe |
| Litige perdu | statut dispute_lost : capacité retirée |
| Litige gagné | seul dispute_lost est levé : le statut que Stripe tient pour l'abonnement revient (un refunded ne l'est pas) |
| Mise à jour d'abonnement reçue pendant la retenue | la retenue est conservée tant que Stripe dit l'abonnement vivant ; une fin (canceled…) passe |
Décision ouverte pour le propriétaire : un remboursement total de bonne volonté (geste commercial)
retire aussi la capacité. Pour lever une retenue à la main, passer le workspace en mode custom depuis
l'app admin, ou refaire souscrire le client.
Réconciliation : GET /api/cron/reconcile-stripe
Un webhook peut se perdre (déploiement raté, secret absent, panne). Le cron quotidien (20 5 * * *,
apps/web/vercel.json, porte CRON_SECRET) relit chez Stripe les abonnements de chaque workspace qui a
un client Stripe et ramène le miroir local vers eux : statut, fin de période, résiliation programmée, plan
et pas, et un abonnement dont l'événement n'est jamais arrivé (créé alors, avec son objectif
subscription_started). Idempotent par convergence (un second passage ne répare rien), borné
(pages de 100, budget de temps, reprise par ?cursor= du nextCursor d'un passage interrompu) et
prudent : une retenue (refunded, dispute_lost) est conservée, un abonnement que Stripe dit absent
n'est annulé que cinq fois par passage (une mauvaise clé répond « absent » pour tout), et le rapport
compte ce qu'il a réparé, jamais « propre » pour une ligne corrigée. Il n'écrit jamais chez Stripe.
Limite connue : le curseur n'est pas mémorisé entre deux jours ; au-delà de quelques centaines de
workspaces, il faudra une table de reprise.
Stripe Tax
ORBIT_STRIPE_TAX=1 (une fois Stripe Tax activé et les inscriptions fiscales saisies chez Stripe) ajoute
à Checkout automatic_tax, l'adresse de facturation obligatoire (enregistrée sur le client) et la collecte
du numéro de TVA. Éteint par défaut : Stripe refuse automatic_tax sur un compte sans configuration
fiscale (un compte de test neuf). Les prix du catalogue sont hors taxe (tax_behavior: exclusive,
posé par stripe-catalog.mjs) : le client voit la taxe ajoutée à Checkout.
Attribution du revenu (DataFast)
Les identifiants datafast_visitor_id et datafast_session_id (cookies du script DataFast, posés
seulement après consentement) sont copiés dans les métadonnées de la session Checkout et de
subscription_data (checkout-params.ts) : les renouvellements en héritent. Jamais inventés (pas de
cookie, pas de métadonnée), jamais un Checkout cassé, jamais un écrasement des clés de facturation
(workspaceId, planKey, kind, steps). Voir le skill launch-attribution.
E-mails de facturation
Une mise en page et des jetons de couleur communs (apps/web/src/lib/mail/templates), en anglais ou en
français selon StudioMember.locale du propriétaire du workspace (FOUNDER actif). Transactionnels : pas
d'en-tête List-Unsubscribe (réservé aux envois non essentiels, qu'Orbit n'a pas). Un seul client Resend
(l'adaptateur du connecteur studio.mailer, ADR 0016). Chaque gabarit est rendu par un test, en deux langues.
Lecture
billingView(prisma, workspaceId) (view.ts) rend l'état d'Orbit (actif ou non, intervalle, pas,
capacité, CU utilisés, lectures incluses et utilisées ce mois, prochain pas et son prix), les offres
d'activation et les crédits Studio. La page /billing l'affiche ; tous les membres la lisent, seul le
propriétaire voit les boutons. Sans clés Stripe, les offres affichent « Bientôt disponible ». Lien
depuis /settings.
Quota de lectures MCP/REST : inclus, jamais facturé à l'usage
Pas de facturation à l'usage, pas de Stripe Connect (B2C : pas de facture surprise). Chaque capacité
inclut un quota mensuel (mois UTC) de lectures par les portes développeur, proportionnel à la
capacité active : includedReads(capacité) = capacité × 3 000 / 120 (3 000 lectures par 120 CU,
soit 4 500 à 180 CU ; constante ORBIT_READS_PER_BASE_PER_MONTH, à régler en un seul endroit). Un
compte non activé n'a aucune lecture incluse.
| Compté | Pas compté |
|---|---|
MCP brain_get_context, brain_search ; REST GET /api/v1/spaces/{id}/context, GET /api/v1/search | les lectures du cockpit (/brain), brain_list_spaces, GET /spaces, GET /spaces/{id}, les projets Studio, toute écriture |
Au-delà : 402 avec le corps d'erreur de l'API { "error": { "code": "quota_exceeded", "message": … } }
(REST), un résultat d'outil en erreur (isError, structuredContent.error.code = "quota_exceeded",
status: 402, limit, used, resetsAt) côté MCP, de sorte que le modèle du client lit le message. Le
message nomme la sortie : ajoutez des pas de +60 CU à l'abonnement Orbit depuis /billing, chaque pas
ajoute des lectures ; aucune lecture n'est facturée à l'usage. Avant activation : activation_required
(402). Réponses REST : en-têtes X-Orbit-Quota-Limit et X-Orbit-Quota-Remaining (aussi sur le
402 ; exposés en CORS). Une lecture qui échoue (Space inconnu, paramètre invalide) est rendue : seule
une lecture qui a rendu quelque chose compte.
Implémentation (apps/web/src/modules/brain/quota.ts) : une ligne de compteur par workspace et par
mois (BrainReadCounter), incrémentée par une seule instruction qui refuse de dépasser la limite
(INSERT … ON CONFLICT DO UPDATE … WHERE reads < limite … RETURNING) : deux lectures concurrentes sur la
dernière unité ne passent pas toutes les deux (testé : 40 lectures concurrentes, 3 servies). Le
workspace vient du credential, jamais de la requête (testé : A n'épuise pas B). Le quota d'un workspace
custom est celui que l'opérateur a fixé (customMonthlyReads).
Mode custom (B2B négocié)
Le mode custom (conditions négociées, au-delà de 40 pas) est décrit dans Économie & paywall ; il ne se règle que depuis l'app apps/admin (voir le runbook du back-office).
Variables
| Variable | Où | Valeur |
|---|---|---|
ORBIT_STRIPE_SECRET_KEY | Vercel (Production : sk_live_/rk_live_ ; Preview : sk_test_) | clé secrète (ou restreinte) du compte Stripe d'Orbit |
ORBIT_STRIPE_WEBHOOK_SECRET | Vercel, par environnement | whsec_… de l'endpoint /api/webhooks/stripe |
ORBIT_STRIPE_TAX | Vercel, par environnement | 1 une fois Stripe Tax activé (vide : pas de taxe, Checkout inchangé) |
CRON_SECRET | Vercel | porte du cron reconcile-stripe (et des autres crons) |
FOUNDER_BRAIN_CAPACITY_CU | local / CI, lue par pnpm db:seed | CU accordés au workspace du fondateur (vide : inchangé) |
Aucune clé publique n'est nécessaire (redirection Checkout côté serveur). Les clés ne sont jamais journalisées. Absentes : la facturation est inerte, l'app tourne et tous les tests passent.
Tester en local
# 1. Une clé TEST du compte Stripe d'Orbit dans .env
ORBIT_STRIPE_SECRET_KEY=sk_test_...
# 2. Le catalogue en test (idempotent, refuse une clé live sans --live)
ORBIT_STRIPE_SECRET_KEY=sk_test_... node apps/web/scripts/stripe-catalog.mjs --dry-run
ORBIT_STRIPE_SECRET_KEY=sk_test_... node apps/web/scripts/stripe-catalog.mjs
# 3. Les webhooks vers l'app locale (affiche le whsec_ à mettre dans ORBIT_STRIPE_WEBHOOK_SECRET)
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# 4. Payer avec la carte de test 4242 4242 4242 4242 depuis le paywall de /brain, ou rejouer un événement :
stripe trigger customer.subscription.updated
Tests automatiques (sans Stripe) : pricing.test.ts (formules du prototype), entitlements.test.ts,
plans.test.ts (le script Stripe = le catalogue = les formules), snapshot.test.ts,
billing.integration.test.ts (Postgres réel, faux événements : activation, pas, intervalle, résiliation,
tenant, mode custom), quota.integration.test.ts et brain-quota.integration.test.ts (quota, course,
402), route.test.ts du webhook (signature) ; les Playwright brain.spec.ts livrent de vrais
événements signés au webhook (sans compte Stripe) pour activer, étendre et résilier.
Mise en ligne
La liste des actions du propriétaire (compte Stripe, portail client, catalogue, variables, endpoint webhook, migrations, passage en live) est unique : la checklist de lancement, section 3.