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é :
Credittable (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)
| # | Source | Type | Écrit par | Expiration | Idempotence |
|---|---|---|---|---|---|
| 1 | Grant mensuel, cron | monthly | api/cron/reset-credits (quotidien, crédite chaque org à son cycleAnchorDay) | 65 j (monthlyCreditExpiry) | MonthlyReset(orgId, year, month) |
| 2 | Grant mensuel, webhooks : subscription.created, activation ou changement de plan sur subscription.updated, backstop invoice.paid (factures subscription_create / subscription_cycle) | monthly | seedMonthlyGrantIfMissing (services/webhooks.ts) | 65 j | même ligne MonthlyReset que le cron |
| 3 | Top-up d'upgrade en cours de cycle : le delta entre le grant déjà livré et celui du nouveau plan, jamais à la baisse | monthly | topUpMonthlyGrant | 65 j | compare-and-swap sur MonthlyReset.amount |
| 4 | Achat 1:1 (slider $10-$5000) | purchase | handleCheckoutCompleted | 12 mois | metadata.stripeSessionId + StripeEvent |
| 5 | Restauration après litige gagné : ce que le clawback avait retiré revient | purchase (metadata.stripeDisputeId) | charge.dispute.closed | 12 mois | metadata.stripeDisputeId |
| 6 | Bonus quotidien (per-Org, $1 / $3 / $5) | bonus | api/cron/daily-bonus, 00:05 UTC | minuit UTC | DailyBonus(orgId, day) |
| 7 | Code redeem filleul : le crédit du code, côté filleul | referral | api/credits/redeem | 12 mois | AffiliateRedemption @@unique |
| 8 | Commission en crédits du propriétaire du code, au redeem | referral | api/credits/redeem (même transaction) | 12 mois | même AffiliateRedemption |
| 9 | Plan offert admin : le grant mensuel d'un plan donné à la main | monthly | action admin de plan offert (admin/people/users/[id]) | traité par un autre item | MonthlyReset |
| 10 | Ajustement manuel admin (grant / refund / adjustment), audité avec l'admin et la raison | grant, refund, adjustment | adjustCredits (admin/people/users/[id]) | aucune | aucune : 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)
| Sortie | Type | Écrit par |
|---|---|---|
| Usage, facturé au coût fournisseur × markup du plan (1.5× Pro / Max, 1.0× Custom) | usage | trackUsage / 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 tour | hold | runtime/credits-check.ts |
| Clawback : retrait des crédits achetés quand le paiement est contesté ou remboursé | clawback | services/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)
| Plan | Sub price | Credits inclus |
|---|---|---|
| Pro | $79 | $49 |
| Max 5x | $199 | $149 |
| Max 20x | $399 | $299 |
| Custom | Né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
| Action | Effet sur les credits |
|---|---|
| Upgrade tier en milieu de cycle | Diffé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 mois | Pas de pro-rata, prend effet next cycle |
| Cancel subscription | Subscription credits + daily bonus arrêtés en fin de cycle, purchased credits préservés |
| Resume subscription | Nouveau cycle démarre, nouveaux credits délivrés, daily bonus réactivé |
Daily bonus (per-Org)
| Plan | Bonus quotidien | Max mensuel | Coû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 |
| Custom | Négocié | — | — |
Mécanique :
- Crédité par le cron
/api/cron/daily-bonus, planifié5 0 * * *dansvercel.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 (
activeOwnerIdsdans la route). Une ligneAuditLogécrite par lui (édition de store, membre, clé d'API, code utilisé) ; une ligneCreditde typeusage(un tour de chat, un rendu Studio — la seule trace qu'on ait de « il s'est servi du produit ») ; ou unUser.lastActivefrais, que le chemin OTP met à jour à chaque connexion. Cette page disait « une ligneAuditLog», 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-creditset via le filetinvoice.paid. Les trois écrivent désormais"system", ce queauditBillingdocumentait déjà, etsrc/test/cron-audit-author.test.tsle 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.
- 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
- La journée de lecture compte aussi, depuis le 2026-09-06. Elle
n'écrivait rien du tout (sessions JWT,
maxAge30 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.tsle corrige —/api/meenregistre « 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 écritureupdateManyfiltrée surlastActive < minuit UTCqui 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
DailyBonusavec@@unique([orgId, day])— la contrainte a été migrée de(userId, day)le 2026-05-12, ce que cette page décrivait encore.userIdreste 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
AuditLogd'actionbilling.dispute.createdposées parhandleDisputeCreated(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é dansplans.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.
| Item | Valeur |
|---|---|
| Slider | $10 → $5000 par transaction, step $1 (style v0) |
| Ratio d'achat | 1:1 — $1 payé = $1 de crédit dépensable (CREDITS_PURCHASE_CONFIG.creditRatio) |
| Markup à la consommation | 1.5×, comme les credits d'abonnement — voir l'encadré ci-dessous |
| Validité | 12 mois depuis date d'achat (validityDays: 365) |
| Rollover | Oui, pas de reset cyclique |
| Stripe Tax | Activé 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.0a existé danssrc/types/billing-plans.tssans aucun lecteur :git grepne rendait que sa déclaration et trois commentaires, et lecalculateUserPriceForPurchasedécrit à côté comme « codepath séparé » n'a jamais existé. La constante est supprimée. Ce qui facture, et qui a toujours facturé, estresolveMarkup(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 :
- Daily bonus credits d'abord (expirent à minuit UTC)
- Subscription credits ensuite (expirent à 65 jours par grant)
- 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) parpnpm docs:claims, et réécrites parpnpm 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/0156avait 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, quemodel-pricing.tset l'endpoint public citent tous deux comme leur source.
| Alias UI | Input $/1M | Output $/1M | Cache write $/1M | Cache read $/1M | Markup / unité |
|---|---|---|---|---|---|
| @Atlas Pro | $3.00 | $15.00 | $3.75 | $0.30 | 1.5× |
| @Atlas Studio | — | — | — | — | $0.21 / image · $0.23 / s de vidéo |
| @Atlas Mini | $0.30 | $1.80 | $0.38 | $0.03 | 1.5× |
| @Atlas Max | $6.00 | $30.00 | $7.50 | $0.30 | 1.5× |
| @Atlas Max Fast | $12.00 | $60.00 | $15.00 | $0.60 | 1.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
| Cas | Politique |
|---|---|
| Subscription cancellée < 7j sans aucun message envoyé | Refund pro-rata possible |
| Subscription cancellée après usage | Aucun refund, accès jusqu'à fin de cycle |
| Purchased credits non utilisés | Aucun refund (prévention abus) |
| Purchased credits sur compte inactif > 12 mois | Expirent, pas de refund |
| Purchased credits achetés par erreur (montant incorrect) | Refund manuel possible si demande dans les 24h, avant tout usage |
| Chargeback Stripe | Clawback 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
| Risque | Garde-fou |
|---|---|
| Free user obtient des credits | requirePaidPlan gate sur tous les endpoints credits |
| Achat credits puis chargeback | Stripe Radar + clawback 14j sur tout chargeback |
| Multi-account farming daily bonus | Un 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 / replay | Stripe webhooks idempotents via StripeEvent.eventId |
| Race condition double-grant | Transactions 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
| Fonction | Path | Statut 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 check | src/features/ai/orchestrator/runtime/credits-check.ts | ✅ Existant |
| Mid-stream hard cap | src/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") |
| Purchase | src/app/api/credits/purchase/route.ts | ✅ Existant, slider à passer $10-$5000 |
| Stripe webhook handler | src/services/webhooks.ts | ✅ Existant |
| Cron monthly reset | src/app/api/cron/reset-credits/route.ts | ✅ Existant — le rollover 65 j est écrit : expiresAt: monthlyCreditExpiry() sur chaque grant |
| Daily bonus cron | src/app/api/cron/daily-bonus/route.ts | ✅ Existant — calibration par plan via getPlanDailyBonusAmount(plan), adossée à PLAN_DAILY_BONUS_USD |
| Affiliate redeem | src/app/api/credits/redeem/route.ts | ✅ Existant |
| Token pricing endpoint | src/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 breakdown | src/app/[orgSlug]/~/billing/page.tsx | ⚠️ Enrichir par store/agent/model/user |
| Free MCP rate-limit | src/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.completedmonthly.reset(+ rollover expire)daily.bonus_grantedaffiliate.redeemedsubscription.upgraded/downgraded/cancelledchargeback.clawbackmanual.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 ;
isInternalOrgest 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.