ADRADR-0005 · Les credits sont livres a l'anniversaire de l'abonnement

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

OptionPourquoi 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 souscriptionLe 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'historiqueDebiter 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 :

  1. Le cron tournait deja quotidiennement. vercel.json planifie reset-credits a 8 0 * * * ; le comportement calendaire tenait dans une ligne — if (today.getUTCDate() !== 1) return. Il n'y avait aucun planning mensuel a migrer.
  2. 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 de DROP INDEX, pas de step dans pending-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.updated de 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 cle MonthlyReset : 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.