Avisos de renovación que no molestan: mostrar "caduca en N días" a partir de LicenseKind, no de una fecha en tu código
Un banner de cuenta atrás es fácil de equivocar de dos formas opuestas: molesta a un cliente perpetuo que no tiene nada que renovar, o calla hasta que una suscripción ya ha caducado y la aplicación ha fallado en cerrado a Free. Ambos errores nacen del mismo fallo: calcular el mensaje a partir del archivo de licencia en bruto en lugar del resultado validado. Este artículo construye el aviso de renovación de la forma correcta, a partir del LicenseInfo de Keyright: usando Kind para distinguir una licencia perpetua de una suscripción y una de prueba, DaysRemaining e IsExpiringSoon para decidir cuándo hablar, y el hecho de que una suscripción caducada se valida a Free, por lo que el aviso debe dispararse mientras la licencia sigue siendo válida, no después. Lee la pequeña lógica tras Kind, muestra por qué una licencia perpetua tiene un ExpiryUtc nulo y nunca debe mostrar una cuenta atrás, y conecta un banner que renueva al cliente que caduca en silencio y deja en paz a todos los demás.
Un aviso de renovación es una funcionalidad pequeña con un gran radio de impacto cuando se hace mal, y falla en dos direcciones opuestas. Molesta demasiado pronto y le muestras una cuenta atrás a un cliente que compró una licencia perpetua y no tiene nada que renovar: la forma más rápida de enseñar a la gente a ignorar tus banners. Habla demasiado tarde y la suscripción ya ha caducado, la aplicación ha fallado en cerrado a Free y tu mensaje de “renueva pronto” llega como un mensaje de “te han bloqueado”. Ambos errores se remontan a la misma causa raíz: el mensaje se calculó a partir de lo equivocado.
Este artículo construye el aviso a partir de lo correcto —el LicenseInfo validado de Keyright— y los dos errores opuestos dejan de ser posibles.
La fuente equivocada: el archivo de licencia en bruto
El atajo tentador es leer la caducidad directamente del JSON de la licencia y contar hacia ella:
// No hagas esto.
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");
Aquí hay dos cosas mal, y son toda la lección. La caducidad no se ha verificado —doc es solo JSON parseado, así que un atacante que edite el archivo mueve la cuenta atrás de tu banner con un editor de texto—. Y doc.ExpiryUtc admite nulo: una licencia perpetua no tiene caducidad, así que DateTime.Parse(null) lanza una excepción, y el “arreglo” que casi todos buscan —poner una caducidad nula por defecto a DateTime.MaxValue— convierte en silencio a cada cliente perpetuo en alguien que el banner cree que caduca dentro de unos miles de millones de días. La información que quieres no vive en el archivo de forma utilizable. Vive en el resultado de validar el archivo.
La fuente correcta: LicenseInfo, tras la validación
KeyrightClient.Validate() devuelve un LicenseInfo cuya superficie relevante es pequeña y ya responde a cada pregunta del aviso:
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);
El campo que deja limpio el aviso es Kind. Es derivado, no una afirmación de texto libre que tengas que interpretar —y, como se deriva de los dos hechos que de verdad importan, hacer un switch sobre él es seguro—:
public LicenseKind Kind => IsTrial
? LicenseKind.Trial
: (ExpiryUtc is null ? LicenseKind.Perpetual : LicenseKind.Subscription);
Una prueba es una prueba sea lo que sea lo demás. Si no, una licencia con caducidad es una Subscription renovable y una licencia sin caducidad es una licencia Perpetual que nunca debe ver una cuenta atrás. Tres situaciones del mundo real, un enum, sin manejo de nulos en el punto de llamada.
Días restantes, contra un reloj en el que confías
DaysRemaining es la otra pieza, y lo importante es qué reloj usa. No es (ExpiryUtc - DateTime.UtcNow) leído de la máquina local —ese es el reloj que un atacante atrasa, y el reloj en el que un aviso calculado en la capa de interfaz confiaría ingenuamente—. Se calcula dentro del resultado validado, contra el mismo now de confianza que el validador usó para decidir que la licencia aún no había caducado (la tolerancia a la manipulación del reloj que lo respalda se cubre en confiar en el reloj):
public int? DaysRemaining =>
ExpiryUtc is { } e ? Math.Max(0, (int)Math.Ceiling((e - _now).TotalDays)) : null;
El null para una licencia perpetua es deliberado y está de tu lado: no hay número que mostrar porque no hay caducidad, y el sistema de tipos te obliga a tratarlo en lugar de dejarlo por defecto en algo absurdo. IsExpiringSoon(withinDays) envuelve la misma comparación en el único booleano que el aviso realmente pregunta —“¿estamos dentro de la ventana de renovación?”— con un valor por defecto sensato de 14 días que puedes ampliar para un plan anual o reducir para uno mensual.
El aviso: habla antes de que caduque la licencia
Ahora la funcionalidad se escribe sola, y se escribe correctamente, porque la puerta va primero. El aviso solo considera una licencia que siga siendo válida y siga siendo de pago —que es el único estado en el que “renueva pronto” es verdad en lugar de “ya te han bloqueado”—:
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
};
}
Lee lo que garantiza cada rama. Un cliente perpetuo obtiene null —ningún banner, nunca, porque no tiene nada que renovar—. Un cliente de suscripción ve la cuenta atrás solo dentro de la ventana, y como todo este método se ejecuta en la ruta IsValid && IsPaid, la cuenta atrás aparece mientras el producto sigue funcionando por completo. El único sitio al que llega una licencia caducada es el retorno temprano de IsPaid == false, donde se vuelve un aviso lapsed con un mensaje más firme —la función está desactivada, así se restaura— que es una frase distinta de la cuenta atrás, y deliberadamente.
Los DaysRemaining!.Value son seguros precisamente porque Kind ya demostró que existe una caducidad: una Trial o Subscription siempre tiene una, una Perpetual nunca y nunca llega a esas ramas. El enum sobre el que hiciste el switch es lo que hace honesto al operador de perdón de nulo en vez de una apuesta.
Renderizarlo: un aviso, tres frases
La capa de interfaz nunca toca un archivo de licencia, una fecha ni un nulo. Renderiza 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);
Sin cuenta atrás para perpetua. Sin caducidad silenciosa para suscripciones, porque el aviso se disparó en la ventana antes de que IsPaid pasara a false. Sin fallo con una caducidad nula, porque el nulo nunca escapó de la capa de validación. Y sin llamada de red para nada de esto: todo aquí vino de un único Validate() sin conexión, así que el banner funciona en una máquina aislada que activó una vez y no ha estado en línea desde entonces, que es justo el cliente con más probabilidad de pasarse de una renovación.
La conclusión
Un aviso de renovación es tan bueno como aquello que lee. Lee el archivo en bruto y construyes un banner que molesta a los clientes perpetuos, confía en una fecha falsificable y falla con una caducidad nula. Lee el LicenseInfo validado —Kind para distinguir las tres formas de licencia, DaysRemaining calculado contra un reloj de confianza, IsExpiringSoon para encontrar la ventana y la puerta IsValid && IsPaid para asegurarte de hablar antes de que la licencia falle en cerrado— y los dos errores opuestos no son errores que arreglaste, son errores que hiciste irrepresentables. El aviso habla al cliente que puede actuar sobre él, y calla para todos los demás.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.