Add 'backend/' from commit '040cfd7a75423d4e6136e58a67b40579af4ee966'
git-subtree-dir: backend git-subtree-mainline:ab911955edgit-subtree-split:040cfd7a75
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user