using System.ComponentModel.DataAnnotations; using Hutopy.Web.Features.Memberships.Data; using Hutopy.Web.Features.Memberships.Events; using Microsoft.Extensions.Options; using Stripe; using Stripe.Checkout; using Subscription = Stripe.Subscription; namespace Hutopy.Web.Features.Memberships.Infrastructure; public class StripeOptions { [Required] public required string SecretKey { get; init; } [Required] public required string WebhookSecret { get; init; } [Required] [Range(0, 1)] public required decimal HutopyRate { get; init; } } public sealed class StripeService( IOptions paymentOptions, MembershipDbContext dbContext, PushNotificationService notificationService) { public async Task CreateProductAsync( Guid creatorId, Guid tierId, string productName, string currencyCode, decimal amount) { StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey; // Create the product var productService = new ProductService(); var product = await productService.CreateAsync( new ProductCreateOptions { Name = productName, Metadata = { { "creatorId", creatorId.ToString() }, { "tierId", tierId.ToString() } } }); // Create the price for the product var priceService = new PriceService(); await priceService.CreateAsync( new PriceCreateOptions { Product = product.Id, UnitAmountDecimal = amount * 100, // Convert amount to cents Currency = currencyCode, Recurring = new PriceRecurringOptions { Interval = "month" } }); return product.Id; } public async Task CreateTipCheckoutSessionAsync( Guid creatorId, string creatorName, decimal amount, string currencyCode, string message, string creatorAccountId, string successUrl, string cancelUrl, CancellationToken ct = default) { StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey; // Create Stripe customer for the user if not already created var customerService = new CustomerService(); var customer = await customerService.CreateAsync( new CustomerCreateOptions{}, cancellationToken: ct); // Create paymentIntent for the user var sessionService = new SessionService(); return await sessionService.CreateAsync( new SessionCreateOptions { Customer = customer.Id, PaymentMethodTypes = ["card"], LineItems = [ new SessionLineItemOptions { PriceData = new SessionLineItemPriceDataOptions { Currency = currencyCode, UnitAmountDecimal = amount, // Amount in cents ProductData = new SessionLineItemPriceDataProductDataOptions { Name = $"Tip for {creatorName}", // or any descriptive name for the tip Metadata = new Dictionary { { "creatorId", creatorId.ToString() } } } }, Quantity = 1 } ], Mode = "payment", PaymentIntentData = new SessionPaymentIntentDataOptions { ApplicationFeeAmount = Convert.ToInt64(amount * 100 * paymentOptions.Value.HutopyRate), // Platform fee TransferData = new SessionPaymentIntentDataTransferDataOptions { Destination = creatorAccountId // Creator's Stripe account ID } }, SuccessUrl = successUrl, // Redirect after successful payment CancelUrl = cancelUrl, // Redirect after canceled payment Metadata = new Dictionary { { "creatorId", creatorId.ToString() }, { "creatorName", creatorName }, { "message", message }, } }, cancellationToken: ct); } public async Task CreateSubscriptionCheckoutSession( Guid userId, Guid creatorId, string creatorName, string creatorAccountId, Guid tierId, string priceId, string successUrl, string cancelUrl) { StripeConfiguration.ApiKey = paymentOptions.Value.SecretKey; // Create Stripe customer for the user if not already created var customerService = new CustomerService(); var customer = await customerService.CreateAsync( new CustomerCreateOptions { Metadata = new Dictionary { { "userId", userId.ToString() } } }); // Create Checkout Session for the subscription var sessionService = new SessionService(); return await sessionService.CreateAsync( new SessionCreateOptions { Customer = customer.Id, PaymentMethodTypes = ["card"], LineItems = [ new SessionLineItemOptions { Price = priceId, Quantity = 1 } ], Mode = "subscription", SubscriptionData = new SessionSubscriptionDataOptions { ApplicationFeePercent = paymentOptions.Value.HutopyRate, TransferData = new SessionSubscriptionDataTransferDataOptions { Destination = creatorAccountId } }, SuccessUrl = successUrl, // Redirect after successful payment CancelUrl = cancelUrl, // Redirect after canceled payment Metadata = new Dictionary { { "userId", userId.ToString() }, { "creatorId", creatorId.ToString() }, { "creatorName", creatorName }, { "tierId", tierId.ToString() } } }); } public async Task CancelSubscription( Guid subscriptionId) { var subscriptionService = new SubscriptionService(); await subscriptionService.CancelAsync(subscriptionId.ToString()); } public async Task HandleInvoicePaymentSucceeded( Event stripeEvent, CancellationToken ct = default) { // Ensure we have an invoice related to a Subscription if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice) { return; } var subscription = await dbContext .Subscriptions .FirstOrDefaultAsync( subscription => subscription.StripeSubscriptionId == invoice.Subscription.Id, cancellationToken: ct); if (subscription == null) { return; } // Record the Transaction var transaction = new Transaction { Id = Guid.NewGuid(), CreatedAt = DateTimeOffset.UtcNow, Amount = invoice.AmountPaid / 100m, // Convert amount from cents to dollars Currency = invoice.Currency, Type = "Subscription", Timestamp = DateTime.UtcNow, StripeInvoiceUrl = invoice.HostedInvoiceUrl }; dbContext.Transactions.Add(transaction); // Link the Transaction to the Subscription subscription.Transactions.Add(transaction); await dbContext.SaveChangesAsync(ct); } public async Task HandleInvoicePaymentFailed( Event stripeEvent, CancellationToken ct = default) { if (stripeEvent.Data.Object is not Invoice { Subscription: not null } invoice) { return; } var subscription = await dbContext .Subscriptions .SingleOrDefaultAsync( subscription => subscription.StripeSubscriptionId == invoice.SubscriptionId, cancellationToken: ct); if (subscription != null) { subscription.EndDate = DateTimeOffset.UtcNow; // Mark as expired or failed await dbContext.SaveChangesAsync(ct); } } private async Task HandleTipPayment( Session session, CancellationToken ct) { // Record the Tip var tip = new Tip { Id = Guid.NewGuid(), CreatedAt = DateTimeOffset.UtcNow, StripeSessionId = session.Id, TipperId = Guid.Parse(session.Metadata["tipperId"]), TipperName = session.Metadata["tipperName"], CreatorId = Guid.Parse(session.Metadata["creatorId"]), CreatorName = session.Metadata["creatorName"], Amount = session.AmountTotal!.Value / 100m, Currency = session.Currency, Message = session.Metadata["message"] }; dbContext.Tips.Add(tip); // Record the Transaction var transaction = new Transaction { Id = Guid.NewGuid(), CreatedAt = DateTimeOffset.UtcNow, Amount = tip.Amount, Currency = tip.Currency, Type = "Tip", Timestamp = DateTime.UtcNow, // TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set. }; dbContext.Transactions.Add(transaction); // Link the Transaction to the Tip tip.TransactionId = transaction.Id; // Save the changes await dbContext.SaveChangesAsync(ct); // Notify the Creator notificationService.NotifyCreator( tip.CreatorId, new TipPaid( tip.CreatorId, tip.CreatorName, tip.Amount, tip.Currency, tip.Message) ); } private async Task HandleSubscriptionPayment( Session session, CancellationToken ct) { // Record the Subscription var subscription = new Data.Subscription { Id = Guid.NewGuid(), CreatedAt = DateTimeOffset.UtcNow, UserId = Guid.Parse(session.Metadata["userId"]), CreatorId = Guid.Parse(session.Metadata["creatorId"]), TierId = Guid.Parse(session.Metadata["tierId"]), StartDate = DateTimeOffset.UtcNow, StripeSessionId = session.Id, StripeSubscriptionId = session.SubscriptionId }; dbContext.Subscriptions.Add(subscription); // Record the Transaction var transaction = new Transaction { Id = Guid.NewGuid(), CreatedAt = DateTimeOffset.UtcNow, Amount = session.AmountTotal!.Value / 100m, // Convert amount from cents to dollars Currency = session.Currency, Type = "Subscription", Timestamp = DateTime.UtcNow, // TODO: __StripeInvoiceUrl = session.Invoice.HostedInvoiceUrl__ How come nor Invoice or InvoiceId are set. }; dbContext.Transactions.Add(transaction); // Link the Transaction to the Subscription subscription.Transactions.Add(transaction); // Save the changes await dbContext.SaveChangesAsync(ct); // Notify the Creator notificationService.NotifyCreator( subscription.CreatorId, new SubscriptionPaid( subscription.CreatorId, session.Metadata["creatorName"], subscription.TierId.ToString(), subscription.StartDate) ); } public async Task HandleCheckoutSessionCompleted( Event stripeEvent, CancellationToken ct = default) { if (stripeEvent.Data.Object is not Session session) { return; } switch (session.Mode) { // Check if this is a one-time tip case "payment" when session.PaymentIntentId != null: await HandleTipPayment(session, ct); break; // Check if this is a subscription case "subscription" when session.SubscriptionId != null: await HandleSubscriptionPayment(session, ct); break; } } public async Task HandleCustomerSubscriptionCreated( Event stripeEvent, CancellationToken ct) { if (stripeEvent.Data.Object is not Subscription stripeSubscription) return; var subscription = await dbContext .Subscriptions .SingleOrDefaultAsync( subscription => subscription.StripeSubscriptionId == stripeSubscription.Id, cancellationToken: ct); if (subscription != null) { subscription.StartDate = stripeSubscription.CurrentPeriodStart; subscription.EndDate = null; // Active subscription await dbContext.SaveChangesAsync(ct); } } public async Task HandleCustomerSubscriptionUpdated( Event stripeEvent, CancellationToken ct) { if (stripeEvent.Data.Object is Subscription stripeSubscription) { var subscription = await dbContext .Subscriptions .SingleOrDefaultAsync( s => s.StripeSubscriptionId == stripeSubscription.Id, cancellationToken: ct); if (subscription != null) { subscription.StartDate = stripeSubscription.CurrentPeriodStart; subscription.EndDate = null; // Active subscription await dbContext.SaveChangesAsync(ct); } } } public async Task HandleCustomerSubscriptionDeleted( Event stripeEvent, CancellationToken ct) { var subscription = stripeEvent.Data.Object as Subscription; var existingSubscription = await dbContext .Subscriptions .FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscription!.Id, ct); if (existingSubscription != null) { var today = DateTime.Today; int lastDay = DateTime.DaysInMonth(today.Year, today.Month); var lastDayOfMonth = new DateTime(today.Year, today.Month, lastDay); existingSubscription.EndDate = new DateTimeOffset(lastDayOfMonth); await dbContext.SaveChangesAsync(ct); } } }