This commit is contained in:
2024-10-22 16:41:11 -04:00
parent 114a10416a
commit 0c11d0aa5e
25 changed files with 1146 additions and 508 deletions

View File

@@ -1,5 +1,5 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Services;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
@@ -10,7 +10,7 @@ public class CancelSubscriptionRequest
}
public class CancelSubscriptionHandler(
MembershipDbContext dbDbContext,
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<CancelSubscriptionRequest>
{
@@ -24,7 +24,7 @@ public class CancelSubscriptionHandler(
CancelSubscriptionRequest req,
CancellationToken ct)
{
var subscription = await dbDbContext
var subscription = await dbContext
.Subscriptions
.FindAsync(
[req.SubscriptionId],
@@ -41,7 +41,7 @@ public class CancelSubscriptionHandler(
// Update subscription in the system
subscription.EndDate = DateTime.UtcNow;
await dbDbContext.SaveChangesAsync(ct);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(subscription.Id, ct);
}

View File

@@ -1,6 +1,6 @@
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Services;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;

View File

@@ -1,18 +1,20 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public class CreateMembershipTierRequest
{
public Guid CreatorId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public record struct CreateMembershipTierRequest(
Guid CreatorId,
string Name,
string Description,
decimal Price,
string Currency = "CAD");
[PublicAPI]
public class CreateMembershipTierEndpoint(
MembershipDbContext dbDbContext)
MembershipDbContext dbContext,
StripeService stripe)
: Endpoint<CreateMembershipTierRequest>
{
public override void Configure()
@@ -25,11 +27,29 @@ public class CreateMembershipTierEndpoint(
CreateMembershipTierRequest req,
CancellationToken ct)
{
var tier = dbDbContext
.Tiers
.Add(new Tier { CreatorId = req.CreatorId, Price = req.Price, Name = req.Name });
var tierId = Guid.NewGuid();
await dbDbContext.SaveChangesAsync(ct);
var productId = await stripe.CreateProductAsync(
req.CreatorId,
tierId,
req.Name,
req.Currency,
req.Price);
// Record the new Tier
var tier = new Tier
{
Id = tierId,
CreatorId = req.CreatorId,
Price = req.Price,
Name = req.Name,
Description = req.Description,
StripeProductId = productId,
};
dbContext.Tiers.Add(tier);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(tier, ct);
}

View File

@@ -1,16 +1,21 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public class GetActiveSubscriptionsRequest;
public record struct GetActiveSubscriptionsResponse(
Guid Id,
Guid CreatorId,
string CreatorName,
string CreatorPortraitUrl,
DateTimeOffset StartDate,
DateTimeOffset? EndDate);
[PublicAPI]
public class GetActiveSubscriptionsHandler(
MembershipDbContext dbDbContext)
: Endpoint<GetActiveSubscriptionsRequest>
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<GetActiveSubscriptionsResponse>>
{
public override void Configure()
{
@@ -19,13 +24,19 @@ public class GetActiveSubscriptionsHandler(
}
public override async Task HandleAsync(
GetActiveSubscriptionsRequest req,
CancellationToken ct)
{
var subscriptions = await dbDbContext
var subscriptions = await dbContext
.Subscriptions
.Where(subscription => subscription.UserId == User.GetUserId())
.Where(subscription => subscription.IsActive)
.Where(subscription => subscription.EndDate == null || subscription.EndDate > DateTimeOffset.UtcNow)
.Select(subscription => new GetActiveSubscriptionsResponse(
subscription.Id,
subscription.Creator.Id,
subscription.Creator.Name,
subscription.Creator.PortraitUrl,
subscription.StartDate,
subscription.EndDate))
.ToListAsync(ct);
await SendOkAsync(subscriptions, ct);

View File

@@ -3,8 +3,8 @@
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public class GetMembershipTierRequest
{
public record GetMembershipTierRequest
{
public Guid CreatorId { get; set; }
}
@@ -13,35 +13,40 @@ public record struct TierModel(
Guid Id,
DateTime CreatedAt,
string Name,
string Description,
decimal Price,
string CurrencyCode);
string CurrencyCode,
string StripeProductId);
[PublicAPI]
public class GetMembershipTierEndpoint(
MembershipDbContext dbDbContext)
: Endpoint<CreateMembershipTierRequest, List<TierModel>>
MembershipDbContext dbContext)
: Endpoint<GetMembershipTierRequest, List<TierModel>>
{
public override void Configure()
{
Get("/api/membership/tiers");
Get("/api/membership/tiers/{CreatorId:guid}");
Options(o => o.WithTags("Memberships"));
AllowAnonymous();
}
public override async Task HandleAsync(
CreateMembershipTierRequest req,
GetMembershipTierRequest req,
CancellationToken ct)
{
var tiers = await dbDbContext
var tiers = await dbContext
.Tiers
.Where(tier => tier.CreatorId == req.CreatorId)
.Select(tier => new TierModel(
tier.Id,
tier.CreatedAt,
tier.Name,
tier.Description,
tier.Price,
tier.CurrencyCode))
tier.CurrencyCode,
tier.StripeProductId))
.ToListAsync(ct);
await SendOkAsync(tiers, ct);
}
}

View File

@@ -16,7 +16,7 @@ public record struct TipReceivedModel(
[PublicAPI]
public class GetReceivedTipsHandler(
MembershipDbContext dbDbContext)
MembershipDbContext dbContext)
: EndpointWithoutRequest<List<TipReceivedModel>>
{
public override void Configure()
@@ -28,7 +28,7 @@ public class GetReceivedTipsHandler(
public override async Task HandleAsync(
CancellationToken ct)
{
var tipsReceived = await dbDbContext
var tipsReceived = await dbContext
.Tips
.Where(tip => tip.CreatorId == User.GetUserId())
.Select(tip => new TipReceivedModel(

View File

@@ -1,21 +1,10 @@
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Services;
using Hutopy.Web.Features.Memberships.Infrastructure;
using Microsoft.Extensions.Options;
using Stripe;
namespace Hutopy.Web.Features.Memberships.Handlers;
public static class StripeEvents
{
public const string SubscriptionCreated = "subscription_created";
public const string CustomerSubscriptionDeleted = "customer.subscription_deleted";
public const string InvoicePaymentSucceeded = "invoice.payment_succeeded";
public const string InvoicePaymentFailed = "invoice.payment_failed";
public const string CheckoutSessionCompleted = "checkout.session.completed";
}
public class StripeWebhookEndpoint(
MembershipDbContext dbContext,
StripeService stripeService,
IOptions<StripeOptions> options)
: EndpointWithoutRequest
@@ -37,33 +26,24 @@ public class StripeWebhookEndpoint(
switch (stripeEvent.Type)
{
case StripeEvents.InvoicePaymentSucceeded:
await stripeService.HandlePaymentSucceeded(stripeEvent, ct);
break;
case StripeEvents.InvoicePaymentFailed:
await stripeService.HandlePaymentFailed(stripeEvent, ct);
break;
case StripeEvents.CheckoutSessionCompleted:
case "checkout.session.completed":
await stripeService.HandleCheckoutSessionCompleted(stripeEvent, ct);
break;
case StripeEvents.CustomerSubscriptionDeleted:
{
var subscription = stripeEvent.Data.Object as Stripe.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);
}
break;
}
case "invoice.payment_succeeded":
await stripeService.HandleInvoicePaymentSucceeded(stripeEvent, ct);
break;
case "invoice.payment_failed":
await stripeService.HandleInvoicePaymentFailed(stripeEvent, ct);
break;
case "customer.subscription.created":
await stripeService.HandleCustomerSubscriptionCreated(stripeEvent, ct);
break;
case "customer.subscription.updated":
await stripeService.HandleCustomerSubscriptionUpdated(stripeEvent, ct);
break;
case "customer.subscription.deleted":
await stripeService.HandleCustomerSubscriptionDeleted(stripeEvent, ct);
break;
}
await SendOkAsync(ct);

View File

@@ -1,27 +1,22 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Services;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI]
public record SendTipRequest
{
public Guid CreatorId { get; set; }
public required decimal Amount { get; init; }
public required string Currency { get; init; }
public required string Message { get; init; }
public required string CheckoutSuccessUrl { get; init; }
public required string CheckoutCancelledUrl { get; init; }
}
public record SendTipRequest(
Guid CreatorId,
decimal Amount,
string Currency,
string Message,
string CheckoutSuccessUrl,
string CheckoutCancelledUrl);
[PublicAPI]
public class SendTipResponse
{
public required string Status { get; init; }
public required string StripeCheckoutUrl { get; init; }
}
public record SendTipResponse(
string Status,
string StripeCheckoutUrl);
[PublicAPI]
public class SendTipRequestValidator : Validator<SendTipRequest>
@@ -35,11 +30,11 @@ public class SendTipRequestValidator : Validator<SendTipRequest>
RuleFor(x => x.CreatorId)
.NotEmpty()
.WithMessage("Creator ID is required");
RuleFor(x => x.CheckoutSuccessUrl)
.NotEmpty()
.WithMessage("CheckoutSuccessUrl is required");
RuleFor(x => x.CheckoutCancelledUrl)
.NotEmpty()
.WithMessage("CheckoutCancelledUrl is required");
@@ -48,13 +43,13 @@ public class SendTipRequestValidator : Validator<SendTipRequest>
[PublicAPI]
public class SendTipHandler(
MembershipDbContext dbDbContext,
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<SendTipRequest, SendTipResponse>
{
public override void Configure()
{
Post("/api/tips/{CreatorId}");
Post("/api/tips");
Options(o => o.WithTags("Memberships"));
}
@@ -62,57 +57,30 @@ public class SendTipHandler(
SendTipRequest req,
CancellationToken ct)
{
var userId = User.GetUserId();
var userName = User.GetName();
var creator = await dbDbContext.Creators.FindAsync(
var creator = await dbContext.Creators.FindAsync(
[req.CreatorId],
cancellationToken: ct);
if (creator == null)
{
await SendNotFoundAsync(ct);
return;
}
var checkoutSession = await stripeService.CreateTipCheckoutSession(
userId,
req.Amount,
req.Currency,
var checkoutSession = await stripeService.CreateTipCheckoutSessionAsync(
User.GetUserId(),
User.GetAlias()!,
creator.Id,
creator.Name,
req.Amount,
req.Currency,
req.Message,
creator.StripeAccountId,
req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl);
dbDbContext.Tips.Add(
new Tip
{
Id = Guid.NewGuid(),
CreatedAt = DateTimeOffset.UtcNow,
TipperId = userId,
TipperName = userName,
CreatorId = creator.Id,
CreatorName = creator.Name,
Amount = req.Amount,
Currency = req.Currency,
Message = req.Message,
StripeSessionId = checkoutSession.Id
});
dbDbContext.Transactions.Add(
new Transaction
{
Id = Guid.NewGuid(),
StripeCheckoutSessionId = checkoutSession.Id,
Amount = req.Amount,
Type = "Tip",
Timestamp = DateTime.UtcNow
});
await dbDbContext.SaveChangesAsync(ct);
await SendAsync(
new SendTipResponse { Status = "Pending", StripeCheckoutUrl = checkoutSession.Url },
new SendTipResponse("Pending", checkoutSession.Url),
cancellation: ct);
}
}

View File

@@ -1,7 +1,6 @@
using Hutopy.Web.Common;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Services;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers;
@@ -10,17 +9,13 @@ public class SubscribeRequest
{
public Guid CreatorId { get; set; }
public Guid TierId { get; set; }
public required string CheckoutSuccessUrl { get; init; }
public required string CheckoutCancelledUrl { get; init; }
}
[PublicAPI]
public record struct SubscriptionResponse(
Guid SubscriptionId,
Guid CreatorId,
Guid UserId,
bool IsActive,
string Tier,
DateTimeOffset StartDate,
DateTimeOffset? EndDate);
string StripeCheckoutUrl);
[PublicAPI]
public class SubscribeValidator : Validator<SubscribeRequest>
@@ -33,7 +28,7 @@ public class SubscribeValidator : Validator<SubscribeRequest>
[PublicAPI]
public class SubscribeHandler(
MembershipDbContext dbDbContext,
MembershipDbContext dbContext,
StripeService stripeService)
: Endpoint<SubscribeRequest, SubscriptionResponse>
{
@@ -47,12 +42,12 @@ public class SubscribeHandler(
SubscribeRequest req,
CancellationToken ct)
{
var tier = await dbDbContext
var tier = await dbContext
.Tiers
.Include(tier => tier.Creator) // Include the related table
.Where(tier => tier.Id == req.TierId)
.FirstOrDefaultAsync(ct);
if (tier == null)
{
await SendNotFoundAsync(ct);
@@ -60,52 +55,18 @@ public class SubscribeHandler(
}
// Process Stripe subscription
var stripeSubscription = await stripeService.CreateSubscriptionCheckoutSession(
var checkoutSession = await stripeService.CreateSubscriptionCheckoutSession(
User.GetUserId(),
tier.Price,
tier.CurrencyCode,
$"{tier.Name} from {tier.Creator.Name}",
tier.Creator.Id,
tier.Creator.Name,
tier.Creator.StripeAccountId,
"",
"");
// Record subscription and transaction
var subscription = new Subscription
{
Id = Guid.NewGuid(),
StripeSubscriptionId = stripeSubscription.Id,
CreatorId = tier.CreatorId,
UserId = User.GetUserId(),
Tier = tier,
StartDate = DateTimeOffset.Now,
EndDate = DateTimeOffset.Now.AddMonths(1)
};
dbDbContext.Subscriptions.Add(subscription);
dbDbContext.Transactions.Add(
new Transaction
{
Id = Guid.NewGuid(),
StripeCheckoutSessionId = stripeSubscription.Id,
Amount = tier.Price,
Type = "Subscription",
Timestamp = DateTime.UtcNow
});
await dbDbContext.SaveChangesAsync(ct);
tier.Id,
tier.StripePriceId,
req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl);
await SendOkAsync(
new SubscriptionResponse
{
UserId = subscription.UserId,
CreatorId = subscription.CreatorId,
SubscriptionId = subscription.Id,
IsActive = subscription.IsActive,
StartDate = subscription.StartDate,
EndDate = subscription.EndDate,
Tier = tier.Name,
},
ct);
new SubscriptionResponse { StripeCheckoutUrl = checkoutSession.Url },
cancellation: ct);
}
}