Skip to content
← Tous les articles
· Delta1 Labs KeyrightLicences.NETAnalyse approfondie

Des rappels de renouvellement qui n'agacent pas : afficher « expire dans N jours » à partir de LicenseKind, pas d'une date dans votre code

Une bannière de compte à rebours est facile à rater de deux façons opposées : elle agace un client perpétuel qui n'a rien à renouveler, ou elle reste muette jusqu'à ce qu'un abonnement ait déjà expiré et que l'application soit retombée en fail-closed sur Free. Les deux bugs viennent de la même erreur : calculer le message à partir du fichier de licence brut au lieu du résultat validé. Cet article construit le rappel de renouvellement de la bonne manière, à partir du LicenseInfo de Keyright : en utilisant Kind pour distinguer une licence perpétuelle d'un abonnement et d'un essai, DaysRemaining et IsExpiringSoon pour décider quand parler, et le fait qu'un abonnement expiré se valide en Free — donc le rappel doit se déclencher tant que la licence est encore valide, pas après. Il lit la petite logique derrière Kind, montre pourquoi une licence perpétuelle a un ExpiryUtc nul et ne doit jamais afficher de compte à rebours, et câble une bannière qui relance le client qui expire en silence et laisse tous les autres tranquilles.

Un rappel de renouvellement est une petite fonctionnalité au large rayon d”impact quand on la rate, et elle échoue dans deux directions opposées. Agacez trop tôt et vous montrez un compte à rebours à un client qui a acheté une licence perpétuelle et n”a rien à renouveler — le moyen le plus rapide d”apprendre aux gens à ignorer vos bannières. Parlez trop tard et l”abonnement a déjà expiré, l”application est retombée en fail-closed sur Free, et votre message « renouvelez bientôt » arrive comme un message « vous êtes bloqué ». Les deux bugs remontent à la même cause racine : le message a été calculé à partir de la mauvaise chose.

Cet article construit le rappel à partir de la bonne chose — le LicenseInfo validé de Keyright — et les deux bugs opposés cessent d”être possibles.

La mauvaise source : le fichier de licence brut

Le raccourci tentant est de lire l”expiration directement dans le JSON de la licence et de compter vers elle :

// Don't do this.
var doc = LicenseDocument.Parse(File.ReadAllText(path));
var days = (DateTime.Parse(doc.ExpiryUtc) - DateTime.UtcNow).TotalDays;
if (days < 14) ShowBanner($"Expires in {(int)days} days");

Deux choses sont fausses ici, et c”est toute la leçon. L”expiration n”a pas été vérifiée — doc n”est que du JSON parsé, donc un attaquant qui édite le fichier déplace le compte à rebours de votre bannière avec un éditeur de texte. Et doc.ExpiryUtc accepte le null : une licence perpétuelle n”a pas d”expiration, donc DateTime.Parse(null) lève une exception, et le « correctif » que la plupart adoptent — mettre une expiration nulle par défaut à DateTime.MaxValue — transforme en silence chaque client perpétuel en quelqu”un que la bannière croit expirer dans quelques milliards de jours. L”information que vous voulez ne vit pas dans le fichier sous une forme utilisable. Elle vit dans le résultat de la validation du fichier.

La bonne source : LicenseInfo, après validation

KeyrightClient.Validate() renvoie un LicenseInfo dont la surface pertinente est petite et répond déjà à chaque question du rappel :

public LicenseStatus Status { get; }     // Valid, Expired, NoLicense, …
public Edition Edition { get; }           // Free, Pro, Enterprise
public DateTime? ExpiryUtc { get; }       // null == perpetual
public bool IsTrial { get; }
public LicenseKind Kind { get; }          // Perpetual | Subscription | Trial
public bool IsValid => Status == LicenseStatus.Valid;
public bool IsPaid  => Edition != Edition.Free;
public int? DaysRemaining { get; }
public bool IsExpiringSoon(int withinDays = 14);

Le champ qui rend le rappel propre est Kind. Il est dérivé, pas une affirmation en texte libre que vous devez interpréter — et parce qu”il est dérivé des deux faits qui comptent vraiment, y appliquer un switch est sûr :

public LicenseKind Kind => IsTrial
    ? LicenseKind.Trial
    : (ExpiryUtc is null ? LicenseKind.Perpetual : LicenseKind.Subscription);

Un essai est un essai quoi qu”il en soit par ailleurs. Sinon, une licence avec expiration est un Subscription renouvelable et une licence sans expiration est une licence Perpetual qui ne doit jamais voir de compte à rebours. Trois situations réelles, un enum, aucune gestion de null au point d”appel.

Un seul switch sur Kind, après la porte valide-et-payantLicenseInfo info =client.Validate();pas Valid ou pas Paid→ Free (fail-closed) :affiche restaurer, pas de compte à reboursPerpetualExpiryUtc nul → ne dit rienSubscriptionIsExpiringSoon(14) → « se renouvelledans {DaysRemaining} » ; sinon, silenceTrial« il reste {DaysRemaining} jours d'essai »valide et payant →Pourquoi cet ordre compteLe compte à rebours vit sur labranche valide et payant, il sedéclenche donc AVANT l'expiration.Après l'expiration, l'abonnement estdéjà Free — trop tard pour rappeler,donc un message différent.

Jours restants, contre une horloge de confiance

DaysRemaining est l”autre pièce, et l”important est quelle horloge il utilise. Ce n”est pas (ExpiryUtc - DateTime.UtcNow) lu sur la machine locale — c”est l”horloge qu”un attaquant recule, et l”horloge à laquelle un rappel calculé dans la couche interface se fierait naïvement. Il est calculé à l”intérieur du résultat validé, contre le même now de confiance que le validateur a utilisé pour décider que la licence n”avait pas déjà expiré (la tolérance à la manipulation d”horloge qui le sous-tend est traitée dans faire confiance à l”horloge) :

public int? DaysRemaining =>
    ExpiryUtc is { } e ? Math.Max(0, (int)Math.Ceiling((e - _now).TotalDays)) : null;

Le null pour une licence perpétuelle est délibéré et c”est votre allié : il n”y a pas de nombre à afficher parce qu”il n”y a pas d”expiration, et le système de types vous force à le gérer plutôt que de le mettre par défaut à quelque chose d”absurde. IsExpiringSoon(withinDays) emballe la même comparaison dans le seul booléen que le rappel demande réellement — « sommes-nous dans la fenêtre de renouvellement ? » — avec une valeur par défaut raisonnable de 14 jours que vous pouvez élargir pour un plan annuel ou réduire pour un mensuel.

Le rappel : parlez avant que la licence ne caduque

Maintenant la fonctionnalité s”écrit d”elle-même, et elle s”écrit correctement, parce que la porte vient d”abord. Le rappel ne considère jamais qu”une licence encore valide et encore payante — qui est le seul état où « renouvelez bientôt » est vrai plutôt que « vous êtes déjà bloqué » :

public RenewalNotice? GetRenewalNotice(LicenseInfo info, int windowDays = 14)
{
    // Fail-closed first. An expired subscription is ALREADY Free here.
    if (!info.IsValid || !info.IsPaid)
        return info.Status == LicenseStatus.Expired
            ? RenewalNotice.Lapsed(info.Message)   // firmer: feature is off, restore it
            : null;                                 // never licensed — a different flow

    return info.Kind switch
    {
        LicenseKind.Perpetual   => null,            // nothing to renew — stay quiet
        LicenseKind.Trial       => RenewalNotice.Trial(info.DaysRemaining!.Value),
        LicenseKind.Subscription when info.IsExpiringSoon(windowDays)
                                => RenewalNotice.Renew(info.DaysRemaining!.Value),
        _                       => null,            // subscription, but not soon — silent
    };
}

Lisez ce que chaque branche garantit. Un client perpétuel obtient null — aucune bannière, jamais, parce qu”il n”a rien à renouveler. Un client d”abonnement voit le compte à rebours uniquement dans la fenêtre, et comme toute cette méthode s”exécute sur le chemin IsValid && IsPaid, le compte à rebours apparaît pendant que le produit fonctionne encore pleinement. Le seul endroit qu”atteint une licence expirée est le retour anticipé IsPaid == false, où elle devient un avis lapsed avec un message plus ferme — la fonctionnalité est désactivée, voici comment la restaurer — qui est une phrase différente du compte à rebours, et délibérément.

Les DaysRemaining!.Value sont sûrs précisément parce que Kind a déjà prouvé qu”une expiration existe : un Trial ou un Subscription en a toujours une, un Perpetual jamais et n”atteint jamais ces branches. L”enum sur lequel vous avez fait le switch est ce qui rend l”opérateur de pardon de null honnête plutôt qu”un pari.

Le rendu : un avis, trois phrases

La couche interface ne touche jamais à un fichier de licence, à une date ou à un null. Elle rend un RenewalNotice :

var notice = licensing.GetRenewalNotice(client.Validate());
if (notice is { } n)
    banner.Show(n.Kind switch
    {
        NoticeKind.Trial  => $"{n.Days} days left in your trial — upgrade to keep your work",
        NoticeKind.Renew  => $"Your plan renews in {n.Days} days",
        NoticeKind.Lapsed => "Your subscription has ended — renew to restore Pro features",
    }, action: n.RenewUrl);

Pas de compte à rebours pour une perpétuelle. Pas de caducité silencieuse pour les abonnements, parce que le rappel s”est déclenché dans la fenêtre avant que IsPaid ne bascule à false. Pas de plantage sur une expiration nulle, parce que le null n”a jamais quitté la couche de validation. Et aucun appel réseau pour rien de tout cela : tout ici est venu d”un unique Validate() hors ligne, donc la bannière fonctionne sur une machine isolée qui a activé une fois et n”a pas été en ligne depuis — qui est exactement le client le plus susceptible de dériver au-delà d”un renouvellement.

À retenir

Un rappel de renouvellement ne vaut que ce qu”il lit. Lisez le fichier brut et vous construisez une bannière qui agace les clients perpétuels, se fie à une date falsifiable et plante sur une expiration nulle. Lisez le LicenseInfo validé — Kind pour distinguer les trois formes de licence, DaysRemaining calculé contre une horloge de confiance, IsExpiringSoon pour trouver la fenêtre, et la porte IsValid && IsPaid pour vous assurer de parler avant que la licence ne retombe en fail-closed — et les deux bugs opposés ne sont pas des bugs que vous avez corrigés, ce sont des bugs que vous avez rendus irreprésentables. Le rappel parle au client qui peut agir dessus, et se tait pour tous les autres.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.