ÉconomieFacturation Stripe

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) :

OffreCe qu'elle donneColonne dérivéeAvant activation
Orbit (le Brain, facturé en CU, un abonnement)la capacité de contexte actif : 120 CU + 60 CU par pasWorkspace.brainCapacityCu0 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 membresWorkspace.studioMonthlyCreditsUsd0 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 / colonneRôle
Workspace.stripeCustomerIdle client Stripe (cus_…) du workspace, créé avant son premier Checkout. Unique.
Workspace.brainCapacityCula capacité active effective. 0 par défaut : Orbit n'a pas de plan gratuit
Workspace.modeSTANDARD (le plan public) ou CUSTOM (conditions B2B négociées). Écrit uniquement par l'app apps/admin
Workspace.customBrainCapacityCu, customMonthlyReads, customTermsmode CUSTOM seulement : capacité imposée, quota de lectures, note des conditions (CHECK : absents en STANDARD)
BillingSubscriptionun 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
StripeEventchaque événement appliqué, clé = l'id Stripe (evt_…) : l'idempotence des webhooks
BrainReadCounterle 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 planlookup_key base (quantité 1)lookup_key pas de capacité (quantité = nombre de pas)BasePas
orbit_monthlyorbit_base_monthlyorbit_capacity_step_monthly49 $/mois25 $/mois
orbit_annualorbit_base_annualorbit_capacity_step_annual468 $/an240 $/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éDroitlookup_keyMontant proposé (script)
studio_starter25 $ de crédits/moisorbit_studio_starter_monthly29 $/mois
studio_pro100 $ de crédits/moisorbit_studio_pro_monthly109 $/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

  1. 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.
  2. 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.
  3. L'action relit le membre en base et exige billing.manage (le propriétaire, rôle FOUNDER). Le service (service.ts) résout les Prices par lookup_key, crée une fois le client Stripe du workspace (clé d'idempotence orbit-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_id et metadata.workspaceId ; clé d'idempotence par minute : un double clic ouvre une seule session).
  4. Retour sur /brain?checkout=success. L'activation s'applique quand le webhook arrive (jamais au retour du navigateur) : le Brain lit GET /api/brain/capacity toutes 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énementEffet
checkout.session.completedrelit l'abonnement chez Stripe et l'applique : l'activation
customer.subscription.createdapplique l'abonnement
customer.subscription.updatedpas de capacité ajouté ou retiré, changement d'intervalle, résiliation programmée, statut
customer.subscription.deletedstatut canceled : capacité 0, les Memories restent
invoice.paidpériode renouvelée : état relu chez Stripe (currentPeriodEnd, fin de past_due) ; reçu envoyé au propriétaire (montant non nul)
invoice.payment_failedstatut relu chez Stripe (past_due) : le droit reste pendant les relances ; e-mail « paiement échoué »
invoice.payment_action_requiredauthentification (SCA, 3-D Secure) à faire : e-mail au propriétaire avec le lien de la facture hébergée ; aucun état ne change
charge.refundedremboursement 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.createdlitige ouvert : enregistré et signalé (journal billing.dispute.opened), rien n'est retiré : un litige n'est pas prouvé
charge.dispute.closedlost : 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 StripeEvent avec son outcome (unreadable, noSubscription, unknownCharge, unknownSubscription…), jamais un plantage.
  • Les droits viennent du prix, jamais du montant payé : l'abonnement est lu article par article (lookup_key et quantité). amount_total inclut 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.

CasEffet
Remboursement total d'un paiement de l'abonnementstatut refunded : capacité retirée. Reste tant que l'abonnement n'est pas terminé ou refait
Remboursement partielaucun changement (enregistré partialRefund)
Litige ouvertaucun changement ; journal billing.dispute.opened pour qu'une personne réponde dans le délai de preuves de Stripe
Litige perdustatut 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 retenuela 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/searchles 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

VariableOùValeur
ORBIT_STRIPE_SECRET_KEYVercel (Production : sk_live_/rk_live_ ; Preview : sk_test_)clé secrète (ou restreinte) du compte Stripe d'Orbit
ORBIT_STRIPE_WEBHOOK_SECRETVercel, par environnementwhsec_… de l'endpoint /api/webhooks/stripe
ORBIT_STRIPE_TAXVercel, par environnement1 une fois Stripe Tax activé (vide : pas de taxe, Checkout inchangé)
CRON_SECRETVercelporte du cron reconcile-stripe (et des autres crons)
FOUNDER_BRAIN_CAPACITY_CUlocal / CI, lue par pnpm db:seedCU 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.