ÉconomieCrédits

Crédits

Source de vérité : Credit table (append-only ledger). Solde = SUM(amount) WHERE orgId = X. Jamais de balance dénormalisée : uniquement la somme du ledger. v3.0 : caps msg/5h supprimés. Modèle credits-only (v0-style) —…

Source de vérité : Credit table (append-only ledger). Solde = SUM(amount) WHERE orgId = X. Jamais de balance dénormalisée : uniquement la somme du ledger.

v3.0 : caps msg/5h supprimés. Modèle credits-only (v0-style) — daily bonus per-Org sur tous paid plans, rollover 65 jours, achats 1:1 valides 12 mois et consommés au même markup 1.5× que les credits d'abonnement. Le « markup 2× sur achat » n'a jamais eu de lecteur et a été supprimé le 2026-09-06 (ADR 0010).

v4.0 (2026-09-06) : les credits inclus ne sont plus 1.0× du prix de l'abonnement. C'est désormais un montant fixe par palier ($49 / $149 / $299, inchangé depuis v3.0), pendant que le prix de l'abonnement monte ($79 / $199 / $399). Rien d'autre dans ce document ne change : mêmes sources, même rollover, même ordre de consommation, même markup. Voir ADR 0012.


Origines des crédits

La liste ci-dessous est celle des écritures que le code produit réellement dans Credit (relevé du 2026-10-02, billing/3137). Une source absente d'ici n'existe pas : « Marketplace earnings — 80 % en credits » et « Promotional credits » ont figuré ici sans qu'aucun code ne les écrive, et ont été retirés.

Entrées (montant positif)

#SourceTypeÉcrit parExpirationIdempotence
1Grant mensuel, cronmonthlyapi/cron/reset-credits (quotidien, crédite chaque org à son cycleAnchorDay)65 j (monthlyCreditExpiry)MonthlyReset(orgId, year, month)
2Grant mensuel, webhooks : subscription.created, activation ou changement de plan sur subscription.updated, backstop invoice.paid (factures subscription_create / subscription_cycle)monthlyseedMonthlyGrantIfMissing (services/webhooks.ts)65 jmême ligne MonthlyReset que le cron
3Top-up d'upgrade en cours de cycle : le delta entre le grant déjà livré et celui du nouveau plan, jamais à la baissemonthlytopUpMonthlyGrant65 jcompare-and-swap sur MonthlyReset.amount
4Achat 1:1 (slider $10-$5000)purchasehandleCheckoutCompleted12 moismetadata.stripeSessionId + StripeEvent
5Restauration après litige gagné : ce que le clawback avait retiré revientpurchase (metadata.stripeDisputeId)charge.dispute.closed12 moismetadata.stripeDisputeId
6Bonus quotidien (per-Org, $1 / $3 / $5)bonusapi/cron/daily-bonus, 00:05 UTCminuit UTCDailyBonus(orgId, day)
7Code redeem filleul : le crédit du code, côté filleulreferralapi/credits/redeem12 moisAffiliateRedemption @@unique
8Commission en crédits du propriétaire du code, au redeemreferralapi/credits/redeem (même transaction)12 moismême AffiliateRedemption
9Plan offert admin : le grant mensuel d'un plan donné à la mainmonthlyaction admin de plan offert (admin/people/users/[id])traité par un autre itemMonthlyReset
10Ajustement manuel admin (grant / refund / adjustment), audité avec l'admin et la raisongrant, refund, adjustmentadjustCredits (admin/people/users/[id])aucuneaucune : une action humaine unique

Les trois chemins 1, 2 et 3 dérivent la clé MonthlyReset de la même fonction pure, creditCycleKey(cycleAnchorDay, now) (src/services/billing/cycle-anchor.ts) : le mois calendaire du dernier anniversaire. Avant le 2026-10-02 les webhooks prenaient le mois calendaire courant pendant que le cron prenait l'anniversaire : une org ancrée le 25 qui passait de Pro à Max 5x le 2 novembre recevait un mois Max 5x complet en plus des $49 du 25 octobre ($198 livrés pour un cycle à $149) au lieu du delta de $100 (src/services/billing/monthly-reset-key.test.ts).

La création d'organisation n'écrit aucune ligne : toute org naît sur Free, qui n'a pas de crédits mensuels. Si Free en avait un jour, la ligne passerait par la même clé MonthlyReset et la même expiration de 65 j (api/organizations/route.ts).

Sorties (montant négatif)

SortieTypeÉcrit par
Usage, facturé au coût fournisseur × markup du plan (1.5× Pro / Max, 1.0× Custom)usagetrackUsage / runtime/billing.ts, sessions store-runtime
Hold : réservation du coût estimé avant le stream, remplacée par l'usage réel à la fin du tourholdruntime/credits-check.ts
Clawback : retrait des crédits achetés quand le paiement est contesté ou rembourséclawbackservices/webhooks.ts (litige, remboursement)

Aucune autre source. Tout crédit positif a été payé par quelqu'un (abonnement, achat), rendu (litige gagné), gagné (bonus quotidien, parrainage) ou posé par un admin identifié.


Subscription credits (montant fixe par palier)

PlanSub priceCredits inclus
Pro$79$49
Max 5x$199$149
Max 20x$399$299
CustomNégociéNégocié

Ces deux colonnes ne coïncident plus depuis v4.0 (ADR 0012). Sous v3.0 elles étaient la même colonne redite deux fois (Pro $49/mo → $49 de credits) ; le repricing les a délibérément séparées, sans toucher au grant.

Pourquoi un montant fixe plutôt qu'un pourcentage du prix :

  • Sous v3.0 (100% du prix rendu en credits), la subscription ne portait aucune marge fixe : toute la marge venait du sous-usage moyen du pool. Un client qui consommait son pool en entier ramenait sa subscription à marge quasi nulle
  • Un montant fixe crée une marge garantie par construction, avant même de savoir combien l'organisation va consommer — chiffrage complet dans financial-model.md
  • Margin additionnelle garantie via markup 1.5× sur AI Gateway + utilisation moyenne réelle ~30-50% (standard SaaS B2B) : les deux mécanismes s'additionnent, ils ne se substituent pas
  • Heavy users qui consomment 100% du grant restent rentables via la marge fixe elle-même, plus les achats de packs (au même markup 1.5×, cf. Purchased credits)

Mécanique de delivery (monthly recurring)

J0    — Sub Pro souscrite ($79/mo)  → +$49 credits crédités (type=monthly,
                                        montant fixe, indépendant du prix)
J1-30 — User consomme à son rythme  (ex: $30 consommés, $19 restants)
J30   — Renewal Stripe automatique  → +$49 nouveaux credits crédités
        Reliquat M1 ($19) reste valide jusqu'à J65 (rollover 65 jours depuis grant)
        Balance J30 = $19 + $49 = $68
J95   — Reliquat M1 expire           → consommé en priorité d'ici là (FIFO)

Pas du "first month only". Chaque renewal = nouveaux credits. Le rollover 65 jours évite la perte si consommation irrégulière.

Politique de reset

Subscription credits rollover 65 jours puis expirent. Aligné v0 (vérifié sur docs).

Pourquoi 65 jours et pas zero rollover :

  • Standard 2026 (v0, Cursor) : l'industrie a évolué vers le buffer
  • User en vacances 1 mois ne perd pas ses credits → moins de churn
  • Pousse quand même à consommer (expire bientôt) → engagement

Idempotency

Le cron /api/cron/reset-credits et les webhooks utilisent la table MonthlyReset(orgId, year, month) avec @@unique pour bloquer les double-processing. Le (year, month) est celui que rend creditCycleKey(cycleAnchorDay, now) sur les trois chemins.

Cette cle reste valide sous l'horloge anniversaire decrite ci-dessous : un anniversaire tombe exactement une fois par mois calendaire, ancres 29-31 comprises puisqu'elles se rabattent sur le dernier jour du mois. Ce qui a change n'est pas le nombre de livraisons par mois, c'est quel jour dans le mois.

Le cycle de credits suit l'abonnement, pas le calendrier

Chaque abonnement est credite a son propre jour : Subscription.cycleAnchorDay, lu depuis billing_cycle_anchor de Stripe au moment de la souscription. Un client abonne le 28 est credite le 28.

Jusqu'en aout 2026 les credits tombaient le 1er pour tout le monde, alors que Stripe facturait a l'anniversaire — deux horloges qui ne se croisaient jamais. Le client du 28 aout recevait $49 le 28 (webhook subscription.created) et $49 le 1er septembre (cron) : deux allocations pour un mois paye, que le rollover de 65 jours laissait cumulees. Le compte etait juste — une allocation par mois calendaire — mais pas la relation entre l'argent encaisse et les credits livres. Voir ADR 0005 pour les options ecartees, dont la proratisation du premier mois et la cle sur periode de facturation (qui aurait fait tomber un abonne annuel de douze livraisons a une).

Une ancre nulle vaut le 1er : c'est le comportement de toute organisation anterieure a la colonne, donc rien n'a bouge pour un client existant. Les deux horloges coexistent tant qu'il reste des abonnements pre-colonne.

Ces surfaces l'appellent cycle de credits et jamais « periode de facturation » : trois d'entre elles ont porte le mauvais mot jusqu'en aout 2026 (home org, /~/billing, /~/billing/usage), ou « 23 j restants » se lisait comme un compte a rebours avant prelevement — y compris pour une org en plan offert, qui ne sera jamais prelevee. Le mot reste juste : le cycle suit desormais la meme date que la facture pour un client Stripe, mais il vaut aussi pour une org en plan offert, qui n'est prelevee jamais.

Le cycle vaut pour toutes les organisations, plans offerts inclus : reset-credits resout le plan via entitlementFromSubscription, donc une org en plan offert recoit ses credits chaque mois comme une autre.

Deux sources, et elles ne se confondent pas : src/services/billing/cycle-anchor.ts decide (en UTC, c'est l'autorite), src/components/shared/credits/credit-cycle.ts affiche la fenetre correspondante. Toute surface qui montre les bornes ou le decompte importe la seconde. Ne pas la « corriger » vers Subscription.currentPeriodEnd : pour un abonne annuel cette borne est a un an, alors que les credits tombent tous les mois.

Comportement aux changements de plan

ActionEffet sur les credits
Upgrade tier en milieu de cycleDifférence de credits inclus ajoutée immédiatement, en entier (non proratisée), sur la clé MonthlyReset du cycle en cours (creditCycleKey)
Downgrade tier en milieu de moisPas de pro-rata, prend effet next cycle
Cancel subscriptionSubscription credits + daily bonus arrêtés en fin de cycle, purchased credits préservés
Resume subscriptionNouveau cycle démarre, nouveaux credits délivrés, daily bonus réactivé

Daily bonus (per-Org)

PlanBonus quotidienMax mensuelCoût AI réel BoostEcom (50% engage × 50% consume / 1.5×)
Free———
Pro$1/jour/Org$30/mo~$5/mo
Max 5x$3/jour/Org$90/mo~$15/mo
Max 20x$5/jour/Org$150/mo~$25/mo
CustomNégocié——

Mécanique :

  • Crédité par le cron /api/cron/daily-bonus, planifié 5 0 * * * dans vercel.json (00:05 UTC), qui balaie toutes les organisations par lots de 500 — que quiconque se connecte ou non. Cette page a décrit un crédit « au premier login du jour » jusqu'au 2026-09-05 : il n'y a aucun chemin de login qui crédite quoi que ce soit.
  • Deux portes, et deux seulement, avant le grant : (1) un entitlement payant, (2) le propriétaire de l'org a fait quelque chose lui-même dans les dernières 24 h (ACTIVITY_WINDOW_MS). C'est là qu'est la conséquence assumée ci-dessous.
  • Ce que « lui-même » veut dire, depuis le 2026-09-05 : trois signaux, réunis (activeOwnerIds dans la route). Une ligne AuditLog écrite par lui (édition de store, membre, clé d'API, code utilisé) ; une ligne Credit de type usage (un tour de chat, un rendu Studio — la seule trace qu'on ait de « il s'est servi du produit ») ; ou un User.lastActive frais, que le chemin OTP met à jour à chaque connexion. Cette page disait « une ligne AuditLog », ce qui était à la fois trop large et trop étroit :
    • Trop large : le grant écrivait lui-même sa ligne d'audit au nom du propriétaire. Celle d'hier tombait dans la fenêtre d'aujourd'hui, donc toute org ayant reçu un bonus se requalifiait seule, indéfiniment. Même chose une fois par mois via reset-credits et via le filet invoice.paid. Les trois écrivent désormais "system", ce que auditBilling documentait déjà, et src/test/cron-audit-author.test.ts le dérive de l'arbre pour que le prochain cron ne rouvre pas le trou.
    • Trop étroit : ni la connexion ni un tour de chat n'écrivent de ligne d'audit. Un opérateur qui revenait tous les jours et utilisait le produit était invisible pour cette porte. Fermer la première moitié sans celle-ci aurait transformé « tout le monde touche » en « presque personne ne touche » — exactement l'incident de la fenêtre de cinq minutes.
  • La journée de lecture compte aussi, depuis le 2026-09-06. Elle n'écrivait rien du tout (sessions JWT, maxAge 30 j, donc se connecter est mensuel et non quotidien) : l'opérateur qui ouvrait son dashboard tous les matins pour lire son Intelligence était indistinguable de celui qui était parti. src/lib/presence/daily.ts le corrige — /api/me enregistre « revenu aujourd'hui » au plus une fois par jour UTC, au-dessus de sa branche de cache (une journée de lecture, ce sont surtout des hits de cache : sous la branche, la visite serait invisible). Deux limites indépendantes, et la seconde est ce qui rend la première facultative : un claim Upstash, puis une écriture updateMany filtrée sur lastActive < minuit UTC qui ne matche plus rien une fois la journée enregistrée. Upstash indisponible dégrade donc en « une requête indexée qui ne met rien à jour », jamais en « personne n'est payé ».
  • Expire à minuit UTC (pas de cumul cross-day)
  • Idempotency : table DailyBonus avec @@unique([orgId, day]) — la contrainte a été migrée de (userId, day) le 2026-05-12, ce que cette page décrivait encore. userId reste stocké pour l'audit trail seulement.
  • Per-Org (pas per-user) car users illimités sur l'Org → bonus per-user exploserait le coût
  • Conséquence assumée, et elle est écrite dans le schéma : « un User qui possède N Orgs reçoit N bonus/jour ». La porte d'activité du cron regarde l'activité du propriétaire, pas celle de chaque Org, donc un seul opérateur actif déclenche le bonus de toutes ses Orgs payantes. C'est le levier à connaître avant de comparer un empilement de Pro à un plan supérieur (cf. plans.md).

Anti-abuse — deux contrôles, et il faut dire lesquels :

  • ✅ 1 bonus / Org / jour, tenu par DailyBonus @@unique([orgId, day]). Structurel : la base le refuse, ce n'est pas une branche qu'on peut oublier d'appeler.
  • ✅ Chargeback récent (30 j) → aucun bonus, depuis le 2026-09-06. Le cron lit les lignes AuditLog d'action billing.dispute.created posées par handleDisputeCreated (src/services/webhooks.ts) et saute l'org (orgsWithRecentChargeback + CHARGEBACK_WINDOW_MS). Le contrôle suspend le cadeau, pas le compte : l'org garde son plan, ses crédits achetés et son accès. C'est le mécanisme étroit que le handler de chargeback réclamait à la place d'un bannissement de connexion.

Cette liste en annonçait quatre, dont trois n'avaient jamais été écrites. La première des trois vient de l'être — et il a d'abord fallu écrire le signal : billing.dispute.created était déclaré dans BillingAuditAction sans aucun writer, alors que le handler de chargeback affirmait en commentaire écrire une ligne d'audit. Un chargeback ne laissait donc aucune trace dans nos tables, et sur un abonnement il n'en laissait rien du tout (le clawback sort tôt sur une charge qui n'a acheté aucun crédit). Une porte au-dessus d'un signal que personne n'écrit est une décoration : les deux sont partis ensemble.

Les deux autres ne seront pas écrites, et ce n'est pas de la dette. La raison est arithmétique, et elle vaut mieux qu'un contrôle :

Le bonus qu'un plan débloque vaut strictement moins que le plan. Au mieux $31/mois contre $79 (Pro), $93 contre $199 (Max 5x), $155 contre $399 (Max 20x) — un coussin bien plus large que sous v3.0 ($49 / $149 / $299), effet mécanique du repricing (ADR 0012) sans qu'on ait touché au bonus lui-même. Acheter un abonnement pour récolter le cadeau est une opération perdante. Ce n'est pas une attaque, c'est une remise.

  • ❌ Détection de farming multi-comptes (email + IP + device fingerprint) — pas implémenté, délibérément. Elle demande une donnée d'identité que le produit ne collecte pas (le seul fingerprint existant est le hash visiteur analytics de src/proxy.ts, sans lien avec la facturation), et d'après l'arithmétique ci-dessus elle fermerait une porte qui coûte plus cher à l'attaquant qu'elle ne rapporte. Le vrai levier multi-comptes est la conséquence assumée ci-dessus (un propriétaire actif déclenche le bonus de toutes ses orgs payantes) : il est tranché dans plans.md, et gardé parce que chacune de ces orgs est payée plein tarif.
  • ❌ Suspension après 90 j de dormance — pas implémenté, et redondant. Le grant exige déjà une activité du propriétaire dans les 24 h ; « actif dans les 24 h » implique strictement « pas dormant depuis 90 j ». La seule lecture non redondante serait une dormance par org plutôt que par propriétaire, ce qui est la question multi-orgs ci-dessus, tranchée ailleurs et autrement.

Ce qui casse l'arithmétique, c'est de ne pas payer l'abonnement après coup : garder les crédits et reprendre les $79 (Pro). D'où le seul contrôle qui méritait du code. Et il ne suffisait pas d'attendre la fin d'abonnement : une facture contestée n'annule pas la subscription, le statut reste active jusqu'à un évènement de cycle de vie qui peut ne jamais venir, donc sans cette porte l'org continue d'encaisser chaque nuit avec de l'argent qu'elle a repris.

src/test/daily-bonus-window.test.ts tient les deux sens : il échoue si ce bloc annonce un contrôle que le cron n'a pas, et si le catalogue laisse un jour le bonus mensuel dépasser le prix du plan — le jour où cette section cesserait d'être vraie. src/app/api/cron/daily-bonus/chargeback-gate.test.ts prouve qu'un grant est réellement retenu.

Purchased credits (slider $10-$5000, step $1)

C'est là que la plateforme prend sa marge, et la mécanique ne change pas (décision du propriétaire, 2026-10-02) : un crédit acheté se paie 1:1 ($1 payé = $1 de crédit) et se consomme au markup du plan (markupByPlan, 1.5× sur Pro / Max 5x / Max 20x). Un dollar de crédit acheté achète donc $0.67 de coût fournisseur ; les 33 % restants, moins les frais Stripe, sont le revenu de la plateforme sur l'usage. Ni le ratio ni le markup ne sont à « corriger » : ils SONT le modèle.

ItemValeur
Slider$10 → $5000 par transaction, step $1 (style v0)
Ratio d'achat1:1 — $1 payé = $1 de crédit dépensable (CREDITS_PURCHASE_CONFIG.creditRatio)
Markup à la consommation1.5×, comme les credits d'abonnement — voir l'encadré ci-dessous
Validité12 mois depuis date d'achat (validityDays: 365)
RolloverOui, pas de reset cyclique
Stripe TaxActivé sur le checkout

Le markup ne dépend pas de l'origine du crédit, et le 2× a été supprimé (ADR 0010).

CREDIT_PURCHASE_MARKUP = 2.0 a existé dans src/types/billing-plans.ts sans aucun lecteur : git grep ne rendait que sa déclaration et trois commentaires, et le calculateUserPriceForPurchase décrit à côté comme « codepath séparé » n'a jamais existé. La constante est supprimée. Ce qui facture, et qui a toujours facturé, est resolveMarkup(plan) — 1.5× sur Pro / Max 5x / Max 20x, 1.0× sur Custom — quelle que soit l'ORIGINE des crédits.

Un crédit acheté vaut donc exactement autant qu'un crédit d'abonnement. La marge nette réelle sur un achat est celle du markup de plan moins les frais Stripe (~27% sur $10, ~30% au-delà), pas les ~46-47% que ce tableau annonçait sur la base du 2×. Le calcul recalculé vit dans financial-model.md, qui reste la seule autorité sur les chiffres de rentabilité : cette page n'en publie pas de deuxième version.

Le ledger ne pourrait de toute façon pas facturer par origine : le solde est un scalaire FIFO unique (credit-balance.ts), un tour de chat décrémente une somme et ne consomme pas un lot identifiable.

Facturer les achats plus cher — 2× à la consommation, ou un ratio de vente inférieur à 1:1 sur le slider — est une hausse de prix et donc une décision commerciale ouverte, suivie dans backlog/billing/0411.

Ordre de consommation

Quand un user fait un appel IA sur une Org, on consomme dans cet ordre :

  1. Daily bonus credits d'abord (expirent à minuit UTC)
  2. Subscription credits ensuite (expirent à 65 jours par grant)
  3. Purchased credits en dernier (valides 12 mois, on les protège)

Cet ordre maximise la rétention : l'user voit ses credits se renouveler quotidiennement et mensuellement, son solde acheté reste utilisable longtemps.


Token pricing transparent (UI compteur live)

Affiché dans l'UI avec coût exact après chaque réponse (style v0), et servi par GET /api/pricing/models.

Ce tableau est GÉNÉRÉ. Ne pas l'éditer à la main : les lignes sont dérivées de MODEL_PRICING × MODELS[key].wholesale (src/config/model-pricing.ts) par pnpm docs:claims, et réécrites par pnpm docs:claims:fix.

Il a été tenu à la main jusqu'au 2026-09-05, et il était faux de 1.5× à 3× : Opus y était vendu $22.50 / $112.50 — exactement le triple que ai-platform/0156 avait déjà remboursé côté code — Sonnet 1.5× trop haut, une ligne « @Atlas GPT » pour un palier qui n'a jamais existé, et aucune ligne pour @Atlas Max Fast, qui est en vente. Le côté code était dérivé depuis le début ; le seul maillon non gardé de la chaîne était ce document, que model-pricing.ts et l'endpoint public citent tous deux comme leur source.

Alias UIInput $/1MOutput $/1MCache write $/1MCache read $/1MMarkup / unité
@Atlas Pro$3.00$15.00$3.75$0.301.5×
@Atlas Studio————$0.21 / image · $0.23 / s de vidéo
@Atlas Mini$0.30$1.80$0.38$0.031.5×
@Atlas Max$6.00$30.00$7.50$0.301.5×
@Atlas Max Fast$12.00$60.00$15.00$0.601.5×

Le modèle wholesale exact derrière chaque alias (anthropic/claude-opus-5, google/gemini-3-pro-image, …) vit dans src/config/ai-models.ts et n'est pas recopié ici : un second endroit où écrire un identifiant de modèle est un second endroit où il peut être faux.

@Atlas Studio n'a AUCUN tarif au token, et c'est volontaire. Il est facturé à l'unité — par image, par seconde de clip — et les tokens du tour appartiennent au modèle de chat qui a appelé l'outil. Une ligne input / output sur cette entrée décrit une route qui n'existe pas : c'est le défaut retiré de atlas-vision dans model-pricing.ts, et le générateur ci-dessus ne peut pas le réintroduire.

Prime 1.5× vs wholesale. Justification : spécialisation Shopify + multi-store + agents BoostEcom.

Exemple usage Pro ($79/mo) + daily bonus :

  • Total potentiel : $49 (credits sub, montant fixe) + $30 (bonus max) = $79/mo de credits potentiels — coïncidence avec le prix de l'abonnement (lui aussi $79/mo depuis v4.0), pas une relation : les deux sont des nombres indépendants (ADR 0012)
  • Ordre de grandeur seulement : le coût réel d'un message dépend du contexte envoyé, du cache et des outils appelés. Le compteur live est la seule réponse exacte, et c'est la raison pour laquelle il existe.
  • Pas de cap : si dépassement, achat pack $10-$5000

Cost allocation (toutes Orgs)

Le ledger Credit.metadata track le détail de chaque consommation :

Credit.metadata {
  // Identité (cost allocation)
  storeId: string?       // store qui a consommé
  userId: string?        // user qui a déclenché (utile multi-user futur)
  agentId: string?       // @Atlas | @Maya | @Marco | @Otis | @Faye | @Sam
  conversationId: string? // session origine
  
  // Modèle + tokens
  model: string          // ex: "anthropic/claude-sonnet-4.6"
  inputTokens: number
  outputTokens: number
  cacheReadTokens: number
  cacheWriteTokens: number
  reasoningTokens: number?
  
  // Pricing
  providerCost: number   // coût wholesale réel
  userPrice: number      // prix retail facturé à l'user
  margin: number         // différence (= notre marge)
  markup: number         // typiquement 1.5
  gatewayCost: number    // coût AI Gateway facturé à BoostEcom
  
  // Context
  plan: string           // plan de l'Org au moment de l'usage
  framework: string?     // workflow / chat / cron / etc.
  skill: string?         // skill invoqué (si applicable)
  thinkingEnabled: boolean?
}

Dashboard /[orgSlug]/~/billing affiche :

  • Par store : breakdown $ et tokens (combien chaque store consomme)
  • Par agent : breakdown $ et calls (@Atlas vs spécialistes)
  • Par modèle : Mini / Pro / Max / GPT / Vision
  • Par user : qui dans l'Org consomme quoi (utile audit)
  • Trend mensuel : graphique 30 jours

Refunds policy

CasPolitique
Subscription cancellée < 7j sans aucun message envoyéRefund pro-rata possible
Subscription cancellée après usageAucun refund, accès jusqu'à fin de cycle
Purchased credits non utilisésAucun refund (prévention abus)
Purchased credits sur compte inactif > 12 moisExpirent, pas de refund
Purchased credits achetés par erreur (montant incorrect)Refund manuel possible si demande dans les 24h, avant tout usage
Chargeback StripeClawback automatique des credits sur charge.dispute.created. Pas de suspension automatique ni de daily bonus désactivé : la seule suspension du code est User.banned, qui refuse la connexion, et une dispute n'est pas une fraude prouvée. La suspension reste une décision opérateur, cf. operations.md

Anti-fraud

RisqueGarde-fou
Free user obtient des creditsrequirePaidPlan gate sur tous les endpoints credits
Achat credits puis chargebackStripe Radar + clawback 14j sur tout chargeback
Multi-account farming daily bonusUn seul garde-fou réel : DailyBonus @@unique([orgId, day]) + entitlement payant obligatoire. Ni fingerprint, ni carte au signup, ni détection email/IP — voir la section Daily bonus et backlog/billing/0361
Token leak / replayStripe webhooks idempotents via StripeEvent.eventId
Race condition double-grantTransactions Prisma sur tous les writes Credit
Burst inférence (un user consomme $500 d'Opus en 1h)Pre-stream estimate (refus 402 si insufficient credits) + mid-stream hard cap (abort si balance épuisé pendant streaming)
MCP abuse (Free tier)Rate-limit Upstash 50/min, 2k/jour anti-DoS + push upgrade
Souscription en fin de mois : deux allocations pour un mois payéHorloge anniversaire — chaque abonnement est crédité à son propre jour (Subscription.cycleAnchorDay), plus le 1er pour tout le monde (ADR 0005)
Un membre sans le droit dépense les credits de l'org (viewer, ou droit retiré nominativement)canUseAi — la permission ai.use, jusqu'en août 2026 déclarée et lue par personne, est désormais demandée aux quatre points d'entrée qui débitent
Hold jamais relâché après un échec (solde artificiellement bas, 402 sur une requête payable)releaseHold sur tous les chemins d'échec des quatre canaux, plus un test qui assert l'effet de bord sur le grand livre et non le code HTTP

Les caps msg/5h v2.0 sont remplacés par les guards budget-based (pre-stream + mid-stream, déjà implémentés). Effet identique sur le pire cas (run-away cost) mais zéro friction sur l'usage normal.


Code de référence

FonctionPathStatut v3.0
Horloge de livraison (quel jour on crédite)src/services/billing/cycle-anchor.ts✅ Existant — anniversaire de l'abonnement, ancre nulle = le 1er
Garde ai.use (qui a le droit de dépenser)src/lib/security/ai-use.ts✅ Existant — 4 portes, comptées par src/test/ai-use-every-door.test.ts
Pre-stream checksrc/features/ai/orchestrator/runtime/credits-check.ts✅ Existant
Mid-stream hard capsrc/features/ai/orchestrator/runtime/handler.ts✅ Existant
Track usage (cost allocation)src/features/ai/orchestrator/runtime/billing.ts✅ Existant — agentId est écrit dans Credit.metadata (défaut "atlas")
Purchasesrc/app/api/credits/purchase/route.ts✅ Existant, slider à passer $10-$5000
Stripe webhook handlersrc/services/webhooks.ts✅ Existant
Cron monthly resetsrc/app/api/cron/reset-credits/route.ts✅ Existant — le rollover 65 j est écrit : expiresAt: monthlyCreditExpiry() sur chaque grant
Daily bonus cronsrc/app/api/cron/daily-bonus/route.ts✅ Existant — calibration par plan via getPlanDailyBonusAmount(plan), adossée à PLAN_DAILY_BONUS_USD
Affiliate redeemsrc/app/api/credits/redeem/route.ts✅ Existant
Token pricing endpointsrc/app/api/pricing/models/route.ts✅ Existant — sert MODEL_PRICING, la source du tableau généré plus haut
Usage caps Upstash (msg/5h, Opus/jour)n/a❌ À retirer (supprimés en v3.0)
Billing dashboard breakdownsrc/app/[orgSlug]/~/billing/page.tsx⚠️ Enrichir par store/agent/model/user
Free MCP rate-limitsrc/app/api/mcp/[storeId]/rate-limit.ts✅ Existant — applyMcpRateLimit appelé avant l'auth sur les deux verbes, quotas lus dans PLAN_MCP_RATE_LIMITS

Audit trail

Tous les events credits sont audit-loggés via auditBilling() dans src/services/audit-billing.ts. Events trackés :

  • purchase.completed
  • monthly.reset (+ rollover expire)
  • daily.bonus_granted
  • affiliate.redeemed
  • subscription.upgraded / downgraded / cancelled
  • chargeback.clawback
  • manual.adjustment (refunds manuels admin)
  • usage.budget_exceeded (pre-stream 402 ou mid-stream abort)

La plateforme produit au coût fournisseur, sur TOUTES ses surfaces

Une organisation nommée par INTERNAL_ORG_SLUGS est un tenant de notre propre Studio (ADR 0024 §4) : ses générations sont débitées au coût fournisseur, sans markup. La ligne Credit existe quand même — la dépense est réelle et doit être comptée quelque part — elle ne porte simplement pas la marge, pour que les chiffres du cockpit interne soient ceux qu'une banque reconnaîtrait.

Ce qui a changé avec billing/2829 : le barème est désormais résolu par canAffordStudioMedia et trackStudioMediaUsage quand l'appelant ne le passe pas. Avant, ils retombaient sur retail, et seules les server actions du Studio le passaient — donc le même clip demandé au chat était facturé 1,5× celui demandé au composer. Même moteur, même organisation, deux additions.

Trois propriétés qui ne se devinent pas :

  • un appelant qui passe le barème garde la main ; un appelant qui l'oublie ne peut plus se tromper ;
  • isInternalOrg est mémoïsé par requête (cache() de React), donc une génération le lit une fois même quand le contrôle de solde, le journal et le compteur le demandent tous les trois. Pas de cache à durée de vie : un barème périmé sur une facture est le genre d'économie qu'on paie une fois et cher ;
  • une base qui hoquette retombe sur le prix client. Se facturer trop est réversible, ne pas se facturer du tout ne l'est pas.

Aucun client n'est concerné : seules les organisations que INTERNAL_ORG_SLUGS nomme changent de barème, et la variable est vide par défaut.

Et le solde ne garde plus de porte en interne

Tranché le 2026-09-19 : en interne on ne surfacture pas et on ne compte pas en crédits, on compte en coût réel. Or canAffordStudioMedia refusait un rendu interne dès que le solde de notre propre organisation tombait sous le coût fournisseur — le siège cessait donc de produire faute de crédits, alors que les crédits sont le mécanisme par lequel un client nous paie. Il n'a rien à dire de ce que la plateforme se doit à elle-même.

pricing === "internal" rend donc { ok: true } sans regarder le solde. Ce qui disparaît est la porte, pas la mesure : la ligne Credit continue d'être écrite par le compteur, en dollars, au coût fournisseur, donc le cockpit interne compte toujours ce qu'il a dépensé.

Ce que ça laisse ouvert était une question ouverte, et elle est tranchée depuis le 2026-09-19 : une alerte, jamais un refus (billing/2832). INTERNAL_SPEND_ALERT_USD pose un seuil en dollars ; au-delà, le digest horaire platform-alerts le signale au propriétaire une fois par jour tant qu'il reste franchi. Rien n'est bloqué, et c'est le point : la production interne est déclenchée à la main, donc un plafond dur achèterait peu et coûterait un siège qui s'arrête le 28 du mois. Seuil vide ou nul, le défaut : aucune alerte.

Le jour où un agent MCP pourra déclencher une génération payante (integrations/0714), la question se repose avec un vrai risque de boucle, et c'est là qu'un plafond dur aura un sens.