git-subtree-dir: backend git-subtree-mainline:ab911955edgit-subtree-split:040cfd7a75
429 lines
15 KiB
C#
429 lines
15 KiB
C#
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<StripeOptions> paymentOptions,
|
|
MembershipDbContext dbContext,
|
|
PushNotificationService notificationService)
|
|
{
|
|
public async Task<string> 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<Session> 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<string, string> { { "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<string, string>
|
|
{
|
|
{ "creatorId", creatorId.ToString() },
|
|
{ "creatorName", creatorName },
|
|
{ "message", message },
|
|
}
|
|
},
|
|
cancellationToken: ct);
|
|
}
|
|
|
|
public async Task<Session> 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<string, string> { { "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<string, string>
|
|
{
|
|
{ "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);
|
|
}
|
|
}
|
|
}
|