ADR-0005 — Les credits sont livres a l'anniversaire de l'abonnement, pas le 1er du mois
Deux horloges tournaient en parallele et ne se croisaient jamais.
Statut
Accepté · 2026-08-29
Piliers : billing, data-platform
Contexte
Deux horloges tournaient en parallele et ne se croisaient jamais.
Stripe facture a l'anniversaire de l'abonnement. Les credits, eux,
etaient livres sur le mois calendaire : le cron reset-credits accordait
l'allocation le 1er, et le webhook subscription.created en accordait une a
la souscription pour que le client ne paie pas dans le vide en attendant.
Un client souscrivant le 28 aout recevait donc $49 le 28 (webhook) puis $49
le 1er septembre (cron) : deux allocations en quatre jours pour un mois paye.
Les credits mensuels roulent 65 jours (MONTHLY_CREDIT_VALIDITY_DAYS), donc
le premier n'avait pas expire — l'organisation detenait reellement $98. Sur
un Max 20x, c'est $299 de surplus.
Personne ne l'a vu parce que le compte etait toujours juste : exactement une allocation par mois calendaire. Ce qui derivait, c'etait la relation entre l'argent encaisse et les credits livres.
Décision
Chaque abonnement est credite a son propre jour — Subscription.cycleAnchorDay,
lu depuis billing_cycle_anchor de Stripe (a defaut start_date) au moment
de customer.subscription.created.
Une ancre nulle vaut le 1er. C'est exactement le comportement de toute ligne ecrite avant l'existence de la colonne, donc les organisations deja en cours ne bougent pas et aucun grand livre n'est reecrit.
Alternatives écartées
| Option | Pourquoi non |
|---|---|
| Proratiser le premier grant ($49 x 4/31 pour le client du 28) | Aligne l'argent, mais garde deux horloges et ajoute une regle pour masquer l'ecart entre elles. Un abonne paie a sa date : il doit etre credite a sa date. C'etait le correctif le moins cher, pas le bon modele. |
| Supprimer le grant a la souscription | Le client paie le 28 et n'a rien a depenser jusqu'au 1er — jusqu'a 30 jours payes pour rien. C'est precisement le probleme que ce grant a ete ajoute pour resoudre. |
Cle = periode de facturation (MonthlyReset(orgId, periodStart), l'option 1 de billing/0096) | Casse les plans annuels et trimestriels. getPlanByStripePriceId accepte priceIdYearly et priceIdQuarterly, et le cron accorde les credits mensuels a tout abonnement actif sans lire l'intervalle : un client annuel passerait de douze allocations par an a une. Ancrer sur le jour de depart plutot que sur la periode de facture evite exactement ca. En prime : migration de la cle unique et decision de transition, dont rien n'avait besoin. |
| Rejouer et corriger l'historique | Debiter des clients payants pour une erreur qui n'est pas la leur. Le surplus passe est une perte, pas une creance. |
Conséquences
Ce que ca coute — moins que l'item ne le supposait, et pour deux raisons trouvees en lisant le code plutot que l'item :
- Le cron tournait deja quotidiennement.
vercel.jsonplanifiereset-creditsa8 0 * * *; le comportement calendaire tenait dans une ligne —if (today.getUTCDate() !== 1) return. Il n'y avait aucun planning mensuel a migrer. - La cle unique est inchangee.
MonthlyReset(orgId, year, month)reste valide : un anniversaire tombe exactement une fois par mois calendaire, ancres 29-31 comprises puisqu'elles se rabattent sur le dernier jour du mois. Donc pas de migration de cle, pas deDROP INDEX, pas de step danspending-migrations.ts, et la ligne continue de servir de garde d'idempotence partagee entre le cron et les trois chemins webhook.
Ce que ca coute reellement :
- Le cron scanne tous les jours au lieu d'une fois par mois. Le filtre de
jour est pousse dans la requete (
cycleAnchorDay in [...]) pour que ce ne soit pas 30x le travail. - Une regle de plus a connaitre : le rabattement 29-31. Un abonne ancre le 31 est credite le 28 fevrier. Sans ce rabattement il ne serait credite ni en fevrier ni dans aucun mois court.
- Deux horloges coexistent tant que des organisations pre-colonne
existent : les anciennes au 1er, les nouvelles a leur date. C'est le prix
assume de « laisser courir ». Le signal pour revisiter : quand il ne reste
plus d'abonnement payant a
cycleAnchorDay IS NULL, la valeur par defaut peut devenir une contrainte plutot qu'une convention. - L'ancre est posee une seule fois, a la creation, et jamais deplacee par
un changement de plan : la date de credit d'un client n'est pas quelque
chose qu'un
subscription.updatedde routine peut decaler sous ses pieds.
Comment c'est appliqué
src/services/billing/cycle-anchor.ts— la regle, pure, sans I/O.src/services/billing/cycle-anchor.test.ts— dont une propriete qui verifie, pour chaque couple (ancre, mois) d'une annee, qu'il y a exactement une livraison. C'est la propriete dont depend la cleMonthlyReset: si un couple tirait zero ou deux fois, cette cle sauterait un mois paye ou en bloquerait un du.src/app/api/cron/reset-credits/route.ts— filtre la requete et re-teste par organisation, de sorte qu'un filtre qui deriverait ne credite pas au mauvais jour : il ne credite simplement pas, et le mois reste ouvert pour le run suivant.src/test/payment-funnels.test.ts(funnel I) — rejoue la souscription du 28 et epingle le total livre pour le mois paye.