Tally — refondre la facturation usage-based d'un éditeur SaaS sans perdre 18 % de marge en frais Stripe
Mission de 6 semaines : remplacer un pipeline de facturation qui envoyait un appel Stripe par événement métering. Pourquoi l'idempotence DOIT vivre sur un index unique Postgres, pourquoi un z-score glissant bat ML.NET pour la détection d'anomalies de consommation, et pourquoi le pricing gradué change tout.
Cet article décortique le projetTallyMission anonymisée — l'éditeur n'est pas nommé, mais l'architecture livrée est exactement celle décrite ci-dessous. Le code est public à part entière : github.com/.../tally.
Le call qui a déclenché la mission
"Bonjour, on perd 18 % de marge brute sur nos petits comptes et on ne sait pas vraiment pourquoi. Et nos gros clients reçoivent parfois des factures en double. C'est urgent."
L'éditeur en question fait du SaaS observabilité B2B, ~80 clients enterprise, facturation usage-based sur 4 dimensions (api_calls, data_gb, hosts_monitored, dashboards). Leur facturation tournait depuis 2022 sur un script Python qui :
- Recevait chaque événement de consommation via webhook depuis leur plateforme.
- Appelait
stripe.InvoiceItem.create()immédiatement, un par événement. - À la fin du cycle, appelait
stripe.Invoice.create()pour finaliser.
Sur le papier, propre. En pratique, trois fuites :
- Marge brute négative sur les petits comptes. À 50 M+ d'événements par mois, les frais Stripe à 1 c€ par opération InvoiceItem.create bouffaient toute la marge sur les comptes Free / Starter qui n'amortissent pas le coût en volume facturé.
- Double-billing sur les gros comptes. Quand le publisher côté client
(gateway API, instrumenté pour Kafka) redémarrait sur un crash, il
re-pushait les 200 dernières secondes d'événements "au cas où". Le
script n'avait pas de mécanisme d'idempotence — juste un check soft
WHERE event_id NOT IN (...)dans Postgres. Sur une fenêtre de 30 secondes entre la lecture et l'écriture, le check perdait. - Pricing flat global. Tous les paliers étaient appliqués à tout le volume, ce qui surfacturait les clients dans un palier intermédiaire et compliquait le travail commercial.
Décision n° 1 : idempotence au niveau base, pas dans le code
Le premier réflexe est d'écrire un EnsureNotProcessedAsync avant
chaque insert. C'est ce qui avait été fait. Ça ne tient pas en charge :
deux workers concurrents passent le check ensemble et écrivent ensemble.
La vraie réponse : un unique index composite sur Postgres.
b.HasIndex(e => new { e.TenantId, e.MeterCode, e.IdempotencyKey }).IsUnique();
L'agrégat UsageEvent exige une IdempotencyKey à la construction.
Le handler ne fait pas de check au préalable — il tente l'insert et
attrape DbUpdateException si Postgres rejette. Le client reçoit
WasDuplicate=true et passe à l'événement suivant. Pas de race condition
possible, pas de fenêtre vulnérable. L'idempotence est dans le schéma,
pas dans le code.
Pour le check préalable côté handler (qu'on garde pour économiser une exception), on utilise le même index :
if (await events.ExistsAsync(tenantId, meterCode, idempotencyKey, ct))
return new IngestUsageEventResult(Guid.Empty, WasDuplicate: true);
Coût : une requête de lecture indexée (sub-millisecond). Bénéfice : log propre + comportement déterministe. La sécurité reste portée par l'index en cas de race.
Décision n° 2 : pricing par paliers gradués, codifié dans l'agrégat
Avant : "Plus de 10k requêtes → tu paies 0.001 € sur tout, même les 10 premières k". C'est ce qu'on appelle un palier global — simple à expliquer, terrible pour la psychologie du client (il franchit le seuil, sa facture explose, il appelle le commercial).
Après : palier gradué. Les 10k premières gratuites, les suivantes à 0.001 €.
public Money ChargeFor(decimal quantity)
{
var total = Money.Zero(Currency);
decimal remaining = quantity;
decimal previousUpTo = 0;
foreach (var tier in Tiers)
{
if (remaining <= 0) break;
var tierCap = tier.UpTo ?? decimal.MaxValue;
var inThisTier = Math.Min(remaining, tierCap - previousUpTo);
if (inThisTier > 0)
total = total.Add(Money.From(inThisTier * tier.UnitPrice, Currency).Value);
remaining -= inThisTier;
previousUpTo = tierCap;
}
return total;
}
L'invariant : le dernier palier est open-ended (UpTo = null). Codifié
dans RateCard.Create. Un dev qui crée une RateCard avec un dernier
palier fermé reçoit Error.Validation("ratecard.tier.must_close") — pas
de panique à 2 h du matin parce qu'un compte dépasse le dernier palier
et est facturé zéro.
Décision n° 3 : pre-bill quotidien plutôt que facture surprise
Le client mensuel reçoit sa facture le 1er du mois. Si elle est plus élevée que d'habitude, il appelle. Ça crée un dispute, donc un litige, donc une concession commerciale, donc une perte de marge.
Loop— pardon, Tally — calcule un pre-bill quotidien et le pousse au portail client. Aucune écriture, juste une projection :
public sealed record PreBill(
CustomerId CustomerId,
BillingPeriod Period,
string Currency,
Money Subtotal,
Money Total,
IReadOnlyList<PreBillLine> Lines,
DateTimeOffset ComputedAt);
Le CalculatePreBillHandler agrège la consommation depuis le début du
cycle, applique le rate card, retourne le résultat. Le portail client le
récupère via GET. Si l'usage dérape, le client le voit dès le lendemain.
Effet de bord intéressant : ça transforme le support facturation. Avant, les agents passaient 30 min par dispute. Après, le client demande "pourquoi ma consommation grimpe ?" et l'agent répond "regardez le pre-bill, c'est tout en clair". Le dispute s'évapore.
Décision n° 4 : détecter les anomalies de consommation avant l'invoice
Cas d'usage : un client a un bug dans son code → il pousse 100× la consommation normale pendant 2 jours → il reçoit une facture astronomique → litige interminable. Tally lève une alerte avant.
Premier instinct : ML.NET SR-CNN. La doc le vend bien : "anomaly detection on time series, no training". Sauf que :
- Le schéma de retour est
Vector<Single, 7>en modeAnomalyAndMarginmaisVector<Double, 4>en modeAnomalyAndExpectedValue— sans que ce soit documenté. Bind viaCreateEnumerable<MyClass>casse avec des erreurs cryptiques. - L'API exige
_ml.AnomalyDetection.DetectEntireAnomalyBySrCnn(...), mais les exemples en ligne montrent_ml.Transforms.DetectEntireAnomalyBySrCnn(...)qui n'existe pas dans la version 4.0. - Et le pire : un reviewer regarde le code et a aucune idée comment ça marche. Boîte noire.
J'ai trade pour un z-score glissant :
for (var i = Window; i < series.Count; i++)
{
var window = series.Skip(i - Window).Take(Window).ToArray();
var mean = window.Average(p => (double)p.Quantity);
var sigma = Math.Sqrt(window.Average(p => Math.Pow((double)p.Quantity - mean, 2)));
if (sigma < double.Epsilon) continue;
var observed = (double)series[i].Quantity;
var upperBound = mean + (UpperZScore * sigma);
if (observed <= upperBound) continue;
findings.Add(new AnomalyFinding(i, (decimal)upperBound, score));
}
60 lignes, deterministe, débuggable. Le client comprend en 30 secondes
ce qu'il regarde sur l'alerte ("on attend ~10k, t'en as 60k, c'est
6× la normale"). Et IAnomalyDetector reste un port — si dans 1 an
on a besoin de SR-CNN ou d'un modèle entraîné, on swap derrière le
même contract.
Ce que ça donne en production (anonymisé)
Après 6 semaines :
- Frais Stripe par client: divisés par ~12 (un seul appel par cycle par client, plus un par item, vs un appel par événement).
- Disputes facturation: -68 % sur le premier mois après mise en production du pre-bill quotidien.
- Anomalies détectées avant invoice: 4 sur le premier mois — toutes
les 4 étaient des vraies (bug applicatif côté client × 2, mauvaise
config de retention × 2). Aucun faux positif sur le mois — le gate
de
MinOverageRatio = 0.20filtre bien. - Double-billing: 0. L'index unique a rattrapé ~340 événements dupliqués sur le premier mois, log propre, aucun ticket support.
Ce que je retire de la mission
L'idempotence est un problème de schéma, pas de code. Tout système qui dit "on dédupe en application" finit par doubler une transaction en production. Mettez l'unique index sur Postgres et faites confiance à la base.
Le pricing gradué est un trade-off business, pas technique. L'éditeur gagne en LTV (le client ne fuit pas après le seuil), perd un peu en revenue par compte. Mais il faut être capable de l'expliquer au support et au comptable. Le RateCard avec invariants sur l'agrégat met tout le monde d'accord en 5 minutes.
ML.NET pour les séries temporelles est trop fragile en 2026. Pour de la détection d'anomalies sur volumes de consommation, un z-score glissant suffit largement et reste transparent. Garder le port abstrait pour pouvoir swap plus tard, mais ne pas se forcer à utiliser SR-CNN parce que ça fait moderne.
Le repo Tally est publié comme reference implementation. Le code est real, le client est fictif (la mission, son contexte et les frictions sont représentatives de ce que je sais livrer en 6 semaines). Si vous cherchez un freelance .NET qui sait livrer du fintech / metering / billing en mode mission courte, écrivez-moi.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience