La .NET CONF 2024 acaba de terminar. Descubre las NOVEDADES de .NET 9 junto a los CAMBIOS que trae C# 13.
Puedes ver el contenido de este vídeo junto con su curso en el modo vídeo (similar a Udemy) si pulsas aquí.

Suscripciones en Stripe

Este postes el Segundo de un curso completo y gratuito sobre Stripe, el cual está disponible tanto esta misma web así como en YouTube.

Tanto el curso como el código del mismo estan disponibles de forma abierta y gratuita, si te gusta el contenido ya sabes que puedes apoyar a la web haciendote premium, o comprando mi libro.

En el vídeo de YouTube, el contenido de este post incia en el minuto 142:21 (2h 22 min 21 seg).

 

 

Una de las funcionalidades más importantes de los softwares como servicio es la posibilidad de estar suscrito, donde con esa suscripción recibes beneficios. Este es el caso de por ejemplo Netflix, HBO o  Amazon Prime pero las suscripciones no son únicamente para servicios de streaming en la nube, En una empresa de libros puedes tener suscripciones donde mientras estás suscrito puedes acceder a todos los libros de forma gratuita.

 

Esa es la versión que vamos a simular en nuestro caso de uso. Las subscripciones suelen ser muy dependientes del dominio y tener varios Tier o capas donde en unas tenemos más funcionalidades que en otras por ejemplo. 

Aquí vamos a ver una, que en mi experiencia es la más común, porque al final, añadir capas en las subscripciones únicamente significa tener diferentes permisos en la aplicación y diferentes precios en Stripe. 

 

 

Lo que vamos a hacer es ir a Stripe y crear un producto, el cual tiene un precio y un pago recurrente. 

Pago recurrente stripe

 

Personalmente lo he seleccionado cada mes, pero eso ya es el sistema de cada uno, habrá quien cobre cada mes, cada semana o por año, que suele ser más barato.

 

 

1 - Crear Usuarios premium en nuestro sistema

 

Obviamente lo que tenemos que hacer ahora es incluir en el código la forma de identificar si somos usuarios premium o no.

Para ello vamos a hacer uso de los claims, técnicamente podríamos utilizar roles porque estamos en C# y aquí tenemos roles, pero para mi un rol es una información del usuario “interna” que agrupa ciertas responsabilidades, mientras que un claim es una característica del usuario. 

Independientemente de la forma que queramos utilizar (claims o roles) lo que debemos hacer es programar una forma para identificar que el usuario es un usuario premium.

 

En nuestro caso tan simple como crear un caso de uso donde añadimos al usuario la nueva claim:

public static class UserConstants
 {
     public const string PREMIUM_ACCOUNT = "PREMIUM_ACOUNT";
 }

 public class SetPremium(ApplicationDbContext applicationDbContext)
 {
     public async Task Execute(string userId)
     {
         if (applicationDbContext.UserClaims
             .Any(a => a.UserId == userId
             && a.ClaimType == UserConstants.PREMIUM_ACCOUNT))
             return;

         await applicationDbContext.UserClaims.AddAsync(new IdentityUserClaim<string>()
         {
             ClaimType = UserConstants.PREMIUM_ACCOUNT,
             ClaimValue = "enabled",
             UserId = userId
         });

         await applicationDbContext.SaveChangesAsync();
     }
 }

 public class RemovePremium(ApplicationDbContext applicationDbContext)
{
    public async Task Execute(string userId)
    {
        IdentityUserClaim<string>? claim = applicationDbContext.UserClaims
            .FirstOrDefault(a => a.UserId == userId
            && a.ClaimType != UserConstants.PREMIUM_ACCOUNT);

        if (claim is not null)
            applicationDbContext.UserClaims.Remove(claim);

        await applicationDbContext.SaveChangesAsync();
    }
}

Como vemos es una forma muy sencilla de indicar que un usuario es premium.

 

Lo que debemos hacer ahora es crear un endpoint, el cual va a crear un checkout de subscripción para Stripe, en este caso vamos a mandar el ID del precio de la subscripción que hemos creado anteriormente y cambiamos el modo a subscription.

[HttpGet("subscription")]
[Authorize]
public async Task<IActionResult> ChekoutSubscription()
{

    SessionCreateOptions options = new SessionCreateOptions
    {
        SuccessUrl = "https://localhost:7265/payment-completed",
        CustomerEmail = User?.FindFirstValue(ClaimTypes.Email),
        LineItems = new List<SessionLineItemOptions>
        {
            new SessionLineItemOptions
            {
                Price = "price_1PK0LiCNTtv2rbe14Z3THGqr", 👈
                Quantity = 1,
            },
        },
        Mode = "subscription" 👈
    };

    SessionService service = new SessionService();
    Session stripeSession = await service.CreateAsync(options);

    return Redirect(stripeSession.Url);
}

 

Solo nos queda crear un botón en el Front end que invoque este endpoint y podemos ver como funciona:

subscription stripe

NOTA: el tema de la subscripción es un poco diferente al del pago único, ya que aquí vamos a cambiar los claims del usuario, estos claims no están reflejados hasta que el usuario refresque el token, en otras palabras, haga login y logout otra vez o fuerces a que se refresque el token. 

 

 

2 - Leer eventos de suscripción de stripe

 

Cuando hemos visto la pasarela de pago en capítulos anteriores hemos visto que Stripe nos genera un evento, en este caso tenemos exactamente lo mismo, stripe nos genera un evento el cual podemos leer para saber el estado de la subscripción.

 

En concreto tenemos 5 eventos diferentes.

  • Created
  • Deleted
  • Paused
  • Resumed
  • Updated

Pese a tener 5 eventos, en la práctica únicamente se utilizan principalmente dos de ellos, esto es debido a que el pago de la subscripción como tal se realiza con un checkout.session.completed, igual que al pagar un producto individual. 

 

Eso quiere decir que debemos implementar lógica para identificar si un producto es un item, en nuestro caso un libro, o es una subscripción.

Para no perder tiempo, yo voy a poner una sentencia IF, pero si estás creando un proyecto que vas a poner online, no pongas el ID del precio de la suscripción en el propio código. 

public async Task HandleSessionCompleted(Session checkoutSession)
{
	Console.WriteLine("Session Completed Event");
	Console.WriteLine($"By user: {checkoutSession.CustomerDetails.Name} ({checkoutSession.CustomerDetails.Email})");

	var options = new SessionGetOptions();
	options.AddExpand("line_items");
	var service = new SessionService();
	Session sessionWithLineItems = service.Get(checkoutSession.Id, options);
	Console.WriteLine("Items:");
	foreach (var item in sessionWithLineItems.LineItems)
	{

		if (item.Price.Id.Equals("price_1PK0LiCNTtv2rbe14Z3THGqr", StringComparison.OrdinalIgnoreCase)) 👈
		{
			if (checkoutSession.Metadata.TryGetValue("userid", out string userId))
			{
				await setPremium.Execute(userId);
			}
		}

		Console.WriteLine($"PriceId: {item.Price.Id} - Quantity: {item.Quantity}");
	}


	Console.WriteLine("In production this will lookup the id from the " +
		"stripePaymentID and send them by email, create an order, etc");
}

Posiblemente te hayas dado cuenta de un detalle muy importante, y es que estamos leyendo el userId de los metadatos del evento, lo que quiere decir que lo tenemos que enviar. Esta no es la única opción, de hecho, tenemos tres formas de identificar al usuario:

 

  • La primera es, al crear un usuario, podemos crear un cliente en Stripe, esa creación nos devolverá un Id el cual tendremos guardado como una propiedad del usuario y simplemente buscamos en la base de datos dicho ID para actualizarlo.
  • En la segunda, podemos realizar la misma acción si utilizamos el email del usuario, actualmente estamos enviando a Stripe el email con el que el usuario se ha registrado, el evento que Stripe manda al webhook contiene ese email, podemos leerlo y buscar en la base de datos.
  • La opción final es hacer uso de la propiedad metadata del objeto que enviamos a Stripe, para ello a la hora de crear el checkout debemos añadir la propiedad metadata enviando el Id del usuario, y esta es la que hemos realizado en este ejemplo.
[HttpGet("subscription")]
[Authorize]
public async Task<IActionResult> ChekoutSubscription()
{

    SessionCreateOptions options = new SessionCreateOptions
    {
        SuccessUrl = "https://localhost:7265/payment-completed",
        CustomerEmail = User?.FindFirstValue(ClaimTypes.Email),
        LineItems = new List<SessionLineItemOptions>
        {
            new SessionLineItemOptions
            {
                Price = "price_1PK0LiCNTtv2rbe14Z3THGqr",
                Quantity = 1,
            },
        },
        Metadata = new Dictionary<string, string> 👈
        {
            {"userid", User!.FindFirstValue(ClaimTypes.NameIdentifier)! }
        },
        Mode = "subscription"
    };

    SessionService service = new SessionService();
    Session stripeSession = await service.CreateAsync(options);

    return Redirect(stripeSession.Url);
}

Nota: no tenemos que preocuparnos de que sea nulo pues el endpoint tiene el atributo  Authorize.

 

Antes de probar la funcionalidad, lo que vamos a hacer es modificar la vista para que si el usuario está registrado y es premium, tenga un botón de descargar en vez de los de comprar y añadir al carrito.

@if (User.IsPremium())
{
	<a href="#" class="btn btn-success">Descargar</a>
}
else
{
	<a href="/checkout/@product.StripePriceId" class="btn btn-primary">comprar (€@product.Price)</a>
	<span class="btn btn-info addBookToCart" data-priceid="@product.StripePriceId">Add to cart</span>
}

//estos son los métodos de ayuda
public static class UserExtensions
{
	public static bool IsPremium(this ClaimsPrincipal claimsPrincipal)
	{
		Claim? result = claimsPrincipal.FindFirst(UserConstants.PREMIUM_ACCOUNT);
		return result is not null;
	}

	public static bool IsAuthenticated(this ClaimsPrincipal user)
	{
		return user.Identity is { IsAuthenticated: true };
	}
}

Nota: Esta validación, también la debes hacer en el back end antes de permitir que nadie se descargue un libro simplemente porque tenga la URL.

 

Ahora, simplemente vamos a probar que todo funciona como es debido, no olvides conectar la CLI de Stripe para que el evento vaya a tu PC

stripe listen -e checkout.session.completed --forward-to https://localhost:7265/api/webhook --latest

 

Simplemente vemos cómo funciona correctamente (al ser premium simplemente descargamos, no compramos): 

subscription enabled stripe

 

3 - Cancelar una subscripción

 

Puede parecerte que cancelar una subscripción va a ser tan fácil como pulsar un botón y que todo funcione, pero desafortunadamente no es así, esto es debido a que los eventos de subscripción en stripe NO contienen información del usuario como el email o el metadata que hemos pasado manualmente.

Lo único que contienen que nos puede servir para identificar al usuario es o bien el Id del cliente dentro de stripe, o el ID de la subscripción.

 

Así que para poder cancelar una subscripción primero debemos almacenar uno de esos dos Ids en una tabla. 

Vamos a crear una nueva tabla donde almacenamos el usuario y el Id de suscripción que Stripe nos devuelve, en este curso estoy utilizando Entity framework con code first, así que la forma es la siguiente;

Primero crearemos la entidad:

public class UserSubscriptionEntity
{
	public required string userId { get; set; }
	public required string SubscriptionId { get; set; }
	public bool IsActive { get; set; }
}

 

Ahora debemos añadir la entidad al DbContext y para mejorar el código del SQL simplemente he creado la tabla con la key compuesta por ambas columnas:

public class ApplicationDbContext : IdentityDbContext
{

	public DbSet<ProductEntity> Products { get; set; }
	public DbSet<UserSubscriptionEntity> UserSubscriptions { get; set; } 👈

	public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
		: base(options)
	{
	}

	protected override void OnModelCreating(ModelBuilder modelBuilder)
	{
		modelBuilder.Entity<UserSubscriptionEntity>()
			.HasKey(a => new { a.userId, a.SubscriptionId }); 👈

		base.OnModelCreating(modelBuilder);
	}
}

 

Debemos construir las migraciones:

dotnet ef migrations add UserSubscription

 

Ahora, antes de ejecutar el código vamos a modificar nuestros casos de uso SetPremium y RemovePremium, para tener esta tabla también en consideración cuando realizamos dichos casos:

public class SetPremium(ApplicationDbContext applicationDbContext)
{
	public async Task Execute(string userId, string subscriptionId)
	{
		if (applicationDbContext.UserClaims
			.Any(a => a.UserId == userId
			&& a.ClaimType == UserConstants.PREMIUM_ACCOUNT))
			return;

		await applicationDbContext.UserClaims.AddAsync(new IdentityUserClaim<string>()
		{
			ClaimType = UserConstants.PREMIUM_ACCOUNT,
			ClaimValue = "enabled",
			UserId = userId
		});

        // 👇
		UserSubscriptionEntity? subscription = await applicationDbContext.UserSubscriptions
			.FirstOrDefaultAsync(a => a.SubscriptionId == subscriptionId
			&& a.userId == userId);

		if (subscription is not null)
		{
			subscription.IsActive = true;
			applicationDbContext.UserSubscriptions.Update(subscription);
		}
		else
		{
			applicationDbContext
				.UserSubscriptions
				.Add(new UserSubscriptionEntity()
				{
					SubscriptionId = subscriptionId,
					userId = userId,
					IsActive = true
				});
		}
        // 👆


		await applicationDbContext.SaveChangesAsync();
	}
}

 

Puedes ver también cómo estamos enviando el ID de la subscripción como parámetro, lo que significa que tenemos que modificar el método que llama a dicho caso de uso:

public async Task HandleSessionCompleted(Session checkoutSession)
{
	....
	foreach (var item in sessionWithLineItems.LineItems)
	{

		if (item.Price.Id.Equals("price_1PK0LiCNTtv2rbe14Z3THGqr", StringComparison.OrdinalIgnoreCase))
		{
			if (checkoutSession.Metadata.TryGetValue("userid", out string userId))
			{
				await setPremium.Execute(userId, checkoutSession.SubscriptionId);
			}
		}
	}
    ...
}

Con este cambio ya podemos almacenar el ID de la subscripción en la base de datos, que va a ser la información que vamos a recibir desde el evento de cancelación, el cual , debemos leer también.

 

 

3.1 - Permitir cancelar la subscripción

 

Por ahora hemos creado un sistema que es capaz de cancelar una subscripción internamente, pero el usuario no tiene forma ninguna de hacerlo. 

 

La lógica es muy sencilla, debemos leer el ID de la subscripción del usuario y simplemente llamar a la api de stripe:

public class CancelSubscription(ApplicationDbContext applicationDbContext)
{
	public async Task Execute(string userId)
	{
		UserSubscriptionEntity subscription = await applicationDbContext.UserSubscriptions
			.SingleAsync(a => a.userId == userId && a.IsActive == true);

		var options = new SubscriptionUpdateOptions { CancelAtPeriodEnd = true };
		var service = new SubscriptionService();
		service.Update(subscription.SubscriptionId, options);
	}
}

 

Y este caso de uso únicamente lo llamamos desde un botón, por lo que es parte de un controlador normal:

[Route("[controller]")]
public class SubscriptionController(CancelSubscription cancelSubscription) : Controller
{
	[Authorize]
	[HttpGet("cancel")]
	public async Task<IActionResult> CancelSubscription()
	{
		await cancelSubscription.Execute(User!.FindFirstValue(ClaimTypes.NameIdentifier)!);
		return View();
	}
}

 

 

3.2 - Administrar una cancelación

 

Cuando creamos una cancelación, o en otras palabras cuando pulsamos el botón para cancelar y completamos el proceso, stripe va a generar un evento de actualización, donde nos va a notificar el cambio.

 

Esto se hace a través de customer.subscription.updated, y debemos leer dicho evento, ya que va a contener hasta cuando esa subscripción va a estar disponible, esto nos abre una nueva funcionalidad, y es que necesitamos almacenar la fecha final de dicha subscripción, porque así le podemos mostrar al usuario un mensaje típico de “Te quedan X días para renovar”, lo que debemos hacer es expandir la entidad UserSubscription para que contenga dicho valor

public class UserSubscriptionEntity
{
	public required string userId { get; set; }
	public required string SubscriptionId { get; set; }
	public bool IsActive { get; set; }
	public DateTime? ValidUntilUTC { get; set; }
}

 

Y crear un caso de uso, que permita indicar cuando una subscripción va a terminar, únicamente modificamos ese campo:

public class SetPremiumEnd(ApplicationDbContext applicationDbContext)
{
	public async Task Execute(string subscriptionId, DateTime subscriptionEndDate)
	{
		UserSubscriptionEntity subscription = await applicationDbContext.UserSubscriptions
			.SingleAsync(a => a.SubscriptionId == subscriptionId);

		subscription.ValidUntilUTC = subscriptionEndDate;
		applicationDbContext.UserSubscriptions.Update(subscription);

		// Here we should generate an event to be executed on the subscription end date
		// which should execute  RemovePremium

		await applicationDbContext.SaveChangesAsync();
	}
}

 

Ahora debemos leer dicho evento, así que en nuestro webhook creamos una nueva opción:

[HttpPost]
public async Task<IActionResult> Post()
{
	var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
	try
	{
		Event? stripeEvent = EventUtility.ConstructEvent(json,
				Request.Headers["Stripe-Signature"], configuration["StripeWebhookSecret"]);

		// Handle the event
		switch (stripeEvent.Type)
		{
			case Events.CheckoutSessionCompleted:
				await HandleSessionCompleted(stripeEvent.Data.Object as Session);
				break;
			case Events.CustomerSubscriptionUpdated: 👈
				await HandleSetEndSubscriptionDate(stripeEvent.Data.Object as Subscription);
				break;
			default:
				Console.WriteLine("Unhandled event type: {0}", stripeEvent.Type);
				break;
		}
		return Ok();
	}
	catch (StripeException e)
	{
		return BadRequest();
	}
}

public async Task HandleSetEndSubscriptionDate(Subscription subscription)
{
	if (subscription.CancelAtPeriodEnd && subscription.CancelAt is not null)
	{
        await setPremiumEnd.Execute(subscription.Id, (DateTime)subscription.CancelAt);
    }
}

NOTA: Antes de testear la funcionalidad, no olvides que debes de escuchar con la CLI el evento nuevo.

 

Una cosa a tener en cuenta es que recibiremos cualquier evento de actualización, por lo que en este caso, vamos a escuchar únicamente a los que tengan la propiedad “cancelationatPeriodEnd” como true y CancelAt que no sea null, estos son eventos de cancelación de subscripciones. 

 

 

3.3 - Fin de ciclo de una subscripción

 

Cuando una subscripción llega al final, osea, al último día Stripe nos va a enviar un evento, de hecho, algo muy bueno es que podemos simular esta funcionalidad en stripe test, si vamos a una subscripción podemos ver los detalles:

suscripciones stripe

Aquí podemos ver información, como cuando ha empezado o cuando va a finalizar, pero lo importante está arriba del todo, en la barra morada, donde dice, ejecutar una simulación. 

Esto nos permite simular el estado de la subscripción a dicha fecha y hora (obviamente exclusivo a test).

 

En mi caso, si lo adelanto un mes, cuando la subscripción ha terminado:

adelantar fecha stripe

Esto va a hacer que genere TODOS los eventos que habrían sucedido hasta ese tiempo, Incluido el evento customer.subscription.deleted, el cual es el que indica que el usuario no está pagando y toca eliminarlo de la lista de usuarios premium; 

 

Para ello creamos un caso de uso el cual se encarga de eliminar todo lo que tenemos que quitar: 

public class RemovePremium(ApplicationDbContext applicationDbContext)
{
	public async Task Execute(string subscriptionId)
	{
		UserSubscriptionEntity subscription = await applicationDbContext.UserSubscriptions
			.SingleAsync(a => a.SubscriptionId == subscriptionId);

		IdentityUserClaim<string>? claim = applicationDbContext.UserClaims
			.FirstOrDefault(a => a.UserId == subscription.userId
			&& a.ClaimType == UserConstants.PREMIUM_ACCOUNT);

		if (claim is not null)
			applicationDbContext.UserClaims.Remove(claim);


		if (subscription is not null)
		{
			subscription.IsActive = false;
			applicationDbContext.UserSubscriptions.Update(subscription);
		}

		await applicationDbContext.SaveChangesAsync();
	}
}

 

Lo que significa eliminar la claim premium y marcar su subscripción como falsa. A la acción de marcar el registro como eliminado o inactivo se le llama hacer un soft delete, si quieres puedes eliminar un registro (hard delete) no hay ningún problema. Aquí te dejo un post sobre el tema.

 

Y  únicamente debemos de llamar a este caso de uso desde nuestro webhook:

[HttpPost]
public async Task<IActionResult> Post()
{
	var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
	try
	{
		Event? stripeEvent = EventUtility.ConstructEvent(json,
				Request.Headers["Stripe-Signature"], configuration["StripeWebhookSecret"]);

		// Handle the event
		switch (stripeEvent.Type)
		{
			case Events.CheckoutSessionCompleted:
				await HandleSessionCompleted(stripeEvent.Data.Object as Session);
				break;
			case Events.CustomerSubscriptionUpdated:
				await HandleSetEndSubscriptionDate(stripeEvent.Data.Object as Subscription);
				break;
			case Events.CustomerSubscriptionDeleted: 👈
				await HandleRemoveUserPremium(stripeEvent.Data.Object as Subscription);
				break;
			default:
				Console.WriteLine("Unhandled event type: {0}", stripeEvent.Type);
				break;
		}
		return Ok();
	}
	catch (StripeException e)
	{
		return BadRequest();
	}
} 

public async Task HandleRemoveUserPremium(Subscription subscription)
{
	await removePremium.Execute(subscription.Id);
}

 

Y con eso la aplicación es capaz de administrar el ciclo completo de una subscripción.

 

Uso del bloqueador de anuncios adblock

Hola!

Primero de todo bienvenido a la web de NetMentor donde podrás aprender programación en C# y .NET desde un nivel de principiante hasta más avanzado.


Yo entiendo que utilices un bloqueador de anuncios como AdBlock, Ublock o el propio navegador Brave. Pero te tengo que pedir por favor que desactives el bloqueador para esta web.


Intento personalmente no poner mucha publicidad, la justa para pagar el servidor y por supuesto que no sea intrusiva; Si pese a ello piensas que es intrusiva siempre me puedes escribir por privado o por Twitter a @NetMentorTW.


Si ya lo has desactivado, por favor recarga la página.


Un saludo y muchas gracias por tu colaboración

© copyright 2024 NetMentor | Todos los derechos reservados | RSS Feed

Buy me a coffee Invitame a un café