Skip to content
← Alle Beiträge
· Delta1 Labs KeyrightLizenzierung.NETTiefenanalyse

Verlängerungshinweise, die nicht nerven: „läuft in N Tagen ab" aus LicenseKind ableiten, nicht aus einem Datum im Code

Ein Countdown-Banner lässt sich auf zwei entgegengesetzte Arten falsch machen: Es nervt einen Perpetual-Kunden, der nichts zu verlängern hat, oder es schweigt, bis ein Abonnement bereits abgelaufen ist und die App fail-closed auf Free zurückgefallen ist. Beide Fehler stammen aus demselben Irrtum — die Meldung aus der rohen Lizenzdatei zu berechnen statt aus dem validierten Ergebnis. Dieser Artikel baut den Verlängerungshinweis richtig auf, aus Keyrights LicenseInfo: Kind unterscheidet eine Perpetual-Lizenz von einem Abonnement und einer Testversion, DaysRemaining und IsExpiringSoon entscheiden, wann gesprochen wird, und die Tatsache, dass ein abgelaufenes Abonnement zu Free validiert, bedeutet, dass der Hinweis feuern muss, solange die Lizenz noch gültig ist, nicht danach. Er liest die kleine Logik hinter Kind, zeigt, warum eine Perpetual-Lizenz ein null ExpiryUtc hat und niemals einen Countdown zeigen darf, und verdrahtet ein Banner, das den leise ablaufenden Kunden verlängert und alle anderen in Ruhe lässt.

Ein Verlängerungshinweis ist eine kleine Funktion mit großem Wirkungsradius, wenn man sie falsch macht, und sie scheitert in zwei entgegengesetzten Richtungen. Nerven Sie zu eifrig und Sie zeigen einem Kunden, der eine Perpetual-Lizenz gekauft hat und nichts zu verlängern hat, einen Countdown — der schnellste Weg, Menschen beizubringen, Ihre Banner zu ignorieren. Sprechen Sie zu spät und das Abonnement ist bereits abgelaufen, die App ist fail-closed auf Free gefallen, und Ihre „bald verlängern”-Meldung kommt als „Sie sind ausgesperrt”-Meldung an. Beide Fehler führen auf dieselbe Grundursache zurück: Die Meldung wurde aus dem Falschen berechnet.

Dieser Artikel baut den Hinweis aus dem Richtigen auf — dem validierten LicenseInfo von Keyright — und die beiden entgegengesetzten Fehler sind nicht mehr möglich.

Die falsche Quelle: die rohe Lizenzdatei

Die verlockende Abkürzung ist, das Ablaufdatum direkt aus dem Lizenz-JSON zu lesen und darauf herunterzuzählen:

// 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");

Zwei Dinge sind hier falsch, und sie sind die ganze Lektion. Das Ablaufdatum wurde nicht verifiziert — doc ist nur geparstes JSON, also verschiebt ein Angreifer, der die Datei editiert, den Countdown Ihres Banners mit einem Texteditor. Und doc.ExpiryUtc ist nullable: Eine Perpetual-Lizenz hat kein Ablaufdatum, also wirft DateTime.Parse(null) eine Ausnahme, und der „Fix”, zu dem die meisten greifen — ein null Ablaufdatum per Default auf DateTime.MaxValue zu setzen — macht stillschweigend aus jedem Perpetual-Kunden jemanden, von dem das Banner glaubt, er laufe in ein paar Milliarden Tagen ab. Die Information, die Sie wollen, lebt nicht in brauchbarer Form in der Datei. Sie lebt im Ergebnis der Validierung der Datei.

Die richtige Quelle: LicenseInfo, nach der Validierung

KeyrightClient.Validate() gibt ein LicenseInfo zurück, dessen relevante Oberfläche klein ist und jede Frage des Hinweises bereits beantwortet:

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);

Das Feld, das den Hinweis sauber macht, ist Kind. Es ist abgeleitet, kein Freitext-Anspruch, den Sie interpretieren müssen — und weil es aus den beiden Fakten abgeleitet ist, die wirklich zählen, ist ein switch darauf sicher:

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

Eine Testversion ist eine Testversion, was auch sonst gilt. Andernfalls ist eine Lizenz mit Ablaufdatum ein verlängerbares Subscription und eine Lizenz ohne Ablaufdatum eine Perpetual-Lizenz, die nie einen Countdown sehen darf. Drei reale Situationen, ein Enum, keine Null-Behandlung an der Aufrufstelle.

Ein switch auf Kind, nach dem Gültig-und-bezahlt-TorLicenseInfo info =client.Validate();nicht Valid o. nicht Paid→ Free (fail-closed):zeigt Wiederherstellen, kein CountdownPerpetualExpiryUtc null → sagt nichtsSubscriptionIsExpiringSoon(14) → „verlängertsich in {DaysRemaining}"; sonst stillTrial„noch {DaysRemaining} Tage Testversion"gültig & bezahlt →Warum diese Reihenfolge zähltDer Countdown lebt auf demgültigen und bezahlten Zweig, feuertalso im Fenster VOR dem Ablauf.Nach dem Ablauf ist das Abo bereitsFree — zu spät zum Hinweis, alsoeine andere Meldung.

Verbleibende Tage, gegen eine Uhr, der Sie trauen

DaysRemaining ist das andere Teil, und das Wichtige ist, welche Uhr es benutzt. Es ist nicht (ExpiryUtc - DateTime.UtcNow), abgelesen von der lokalen Maschine — das ist die Uhr, die ein Angreifer zurückdreht, und die Uhr, der ein in der Oberflächenschicht berechneter Hinweis naiv trauen würde. Es wird innerhalb des validierten Ergebnisses berechnet, gegen dasselbe vertrauenswürdige now, mit dem der Validator entschieden hat, dass die Lizenz nicht bereits abgelaufen war (die Toleranz gegen Uhr-Manipulation, die dem zugrunde liegt, wird in der Uhr vertrauen behandelt):

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

Das null für eine Perpetual-Lizenz ist beabsichtigt und Ihr Freund: Es gibt keine Zahl zu zeigen, weil es kein Ablaufdatum gibt, und das Typsystem zwingt Sie, das zu behandeln, statt es per Default auf etwas Absurdes zu setzen. IsExpiringSoon(withinDays) verpackt denselben Vergleich in das eine Boolean, das der Hinweis wirklich fragt — „sind wir im Verlängerungsfenster?” — mit einem vernünftigen Default von 14 Tagen, den Sie für einen Jahresplan verbreitern oder für einen monatlichen verengen können.

Der Hinweis: sprechen, bevor die Lizenz verfällt

Jetzt schreibt sich die Funktion von selbst, und sie schreibt sich korrekt, weil das Tor zuerst kommt. Der Hinweis betrachtet nur eine Lizenz, die noch gültig und noch bezahlt ist — was der einzige Zustand ist, in dem „bald verlängern” wahr ist statt „Sie sind bereits ausgesperrt”:

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
    };
}

Lesen Sie, was jeder Zweig garantiert. Ein Perpetual-Kunde erhält null — kein Banner, nie, weil er nichts zu verlängern hat. Ein Abonnementkunde sieht den Countdown nur im Fenster, und weil diese ganze Methode auf dem Pfad IsValid && IsPaid läuft, erscheint der Countdown, während das Produkt noch voll funktioniert. Der einzige Ort, den eine abgelaufene Lizenz erreicht, ist der vorzeitige Rücksprung bei IsPaid == false, wo sie zu einem lapsed-Hinweis mit einer bestimmteren Meldung wird — die Funktion ist aus, so stellen Sie sie wieder her — was ein anderer Satz als der Countdown ist, und das mit Absicht.

Die DaysRemaining!.Value-Dereferenzierungen sind sicher, gerade weil Kind bereits bewiesen hat, dass ein Ablaufdatum existiert: Ein Trial oder Subscription hat immer eins, ein Perpetual nie und erreicht diese Zweige nie. Das Enum, auf dem Sie den switch gemacht haben, ist das, was den null-vergebenden Operator ehrlich macht statt zu einem Glücksspiel.

Das Rendern: ein Hinweis, drei Sätze

Die Oberflächenschicht berührt nie eine Lizenzdatei, ein Datum oder ein null. Sie rendert ein 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);

Kein Countdown für Perpetual. Kein stilles Verfallen für Abonnements, weil der Hinweis im Fenster feuerte, bevor IsPaid auf false kippte. Kein Absturz bei einem null Ablaufdatum, weil das null nie aus der Validierungsschicht entkam. Und kein Netzaufruf für all das: Alles hier kam aus einem einzigen Offline-Validate(), also funktioniert das Banner auf einer isolierten Maschine, die einmal aktiviert wurde und seither nicht online war — was genau der Kunde ist, der am ehesten über eine Verlängerung hinwegdriftet.

Fazit

Ein Verlängerungshinweis ist nur so gut wie das, was er liest. Lesen Sie die rohe Datei und Sie bauen ein Banner, das Perpetual-Kunden nervt, einem fälschbaren Datum traut und bei einem null Ablaufdatum abstürzt. Lesen Sie das validierte LicenseInfo — Kind, um die drei Lizenzformen zu unterscheiden, DaysRemaining, gegen eine vertrauenswürdige Uhr berechnet, IsExpiringSoon, um das Fenster zu finden, und das Tor IsValid && IsPaid, um sicherzustellen, dass Sie sprechen, bevor die Lizenz fail-closed fällt — und die beiden entgegengesetzten Fehler sind nicht Fehler, die Sie behoben haben, sondern Fehler, die Sie undarstellbar gemacht haben. Der Hinweis spricht zu dem Kunden, der darauf reagieren kann, und schweigt für alle anderen.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.