many fixes and improvements - rework for modules/ and common/
feat(emailer): add Postmark and Resend providers
This commit is contained in:
49
backend/Modules/Memberships/Handlers/CancelMembership.cs
Normal file
49
backend/Modules/Memberships/Handlers/CancelMembership.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class CancelMembershipRequest
|
||||
{
|
||||
public Guid SubscriptionId { get; set; }
|
||||
}
|
||||
|
||||
public class CancelMembershipHandler(
|
||||
MembershipsDbContext dbContext,
|
||||
IMembershipCancellationProcessor cancellationProcessor)
|
||||
: Endpoint<CancelMembershipRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/memberships");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancelMembershipRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var subscription = await dbContext
|
||||
.Memberships
|
||||
.FindAsync(
|
||||
[req.SubscriptionId],
|
||||
cancellationToken: ct);
|
||||
|
||||
if (subscription is not { EndDate: null }
|
||||
|| subscription.StripeSubscriptionId is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel Stripe subscription
|
||||
await cancellationProcessor.CancelAsync(subscription.StripeSubscriptionId, ct);
|
||||
|
||||
// Update subscription in the system
|
||||
subscription.EndDate = DateTime.UtcNow;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(subscription.Id, ct);
|
||||
}
|
||||
}
|
||||
56
backend/Modules/Memberships/Handlers/CreateMembershipTier.cs
Normal file
56
backend/Modules/Memberships/Handlers/CreateMembershipTier.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record struct CreateMembershipTierRequest(
|
||||
Guid CreatorId,
|
||||
string Name,
|
||||
string Description,
|
||||
decimal Price,
|
||||
string Currency = "CAD");
|
||||
|
||||
[PublicAPI]
|
||||
public class CreateMembershipTierEndpoint(
|
||||
MembershipsDbContext dbContext,
|
||||
IMembershipTierProcessor membershipTierProcessor)
|
||||
: Endpoint<CreateMembershipTierRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/memberships/tiers");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateMembershipTierRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tierId = Guid.CreateVersion7();
|
||||
|
||||
var productId = await membershipTierProcessor.CreateAsync(
|
||||
req.CreatorId,
|
||||
tierId,
|
||||
req.Name,
|
||||
req.Currency,
|
||||
req.Price);
|
||||
|
||||
// Record the new Tier
|
||||
var tier = new MembershipTier
|
||||
{
|
||||
Id = tierId,
|
||||
CreatorId = req.CreatorId,
|
||||
Price = req.Price,
|
||||
Name = req.Name,
|
||||
Description = req.Description,
|
||||
StripeProductId = productId,
|
||||
};
|
||||
|
||||
dbContext.MembershipTiers.Add(tier);
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(tier, ct);
|
||||
}
|
||||
}
|
||||
54
backend/Modules/Memberships/Handlers/GetActiveMemberships.cs
Normal file
54
backend/Modules/Memberships/Handlers/GetActiveMemberships.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record struct GetActiveMembershipsResponse(
|
||||
Guid Id,
|
||||
Guid CreatorId,
|
||||
string CreatorName,
|
||||
string CreatorPortraitUrl,
|
||||
DateTimeOffset? StartDate,
|
||||
DateTimeOffset? EndDate);
|
||||
|
||||
[PublicAPI]
|
||||
public class GetActiveMembershipsHandler(
|
||||
ICreatorLookup creatorLookup,
|
||||
MembershipsDbContext dbContext)
|
||||
: EndpointWithoutRequest<IEnumerable<GetActiveMembershipsResponse>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/memberships/active");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var subscriptions = await dbContext
|
||||
.Memberships
|
||||
.Where(subscription => subscription.UserId == User.GetUserId())
|
||||
.Where(subscription => subscription.State == MembershipState.Active)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = await Task.WhenAll(
|
||||
subscriptions.Select(async subscription =>
|
||||
{
|
||||
var creator = await creatorLookup.GetCreatorAsync(subscription.CreatorId, ct);
|
||||
|
||||
return new GetActiveMembershipsResponse(
|
||||
subscription.Id,
|
||||
subscription.CreatorId,
|
||||
creator?.Name ?? "Unknown Creator",
|
||||
creator?.PortraitUrl ?? string.Empty,
|
||||
subscription.StartDate,
|
||||
subscription.EndDate);
|
||||
}));
|
||||
|
||||
|
||||
await SendOkAsync(result, ct);
|
||||
}
|
||||
}
|
||||
52
backend/Modules/Memberships/Handlers/GetMembershipTiers.cs
Normal file
52
backend/Modules/Memberships/Handlers/GetMembershipTiers.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record GetMembershipTiersRequest
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record struct TierModel(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Name,
|
||||
string Description,
|
||||
decimal Price,
|
||||
string CurrencyCode,
|
||||
string StripeProductId);
|
||||
|
||||
[PublicAPI]
|
||||
public class GetMembershipTiersEndpoint(
|
||||
MembershipsDbContext dbContext)
|
||||
: Endpoint<GetMembershipTiersRequest, List<TierModel>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/memberships/tiers/{CreatorId:guid}");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetMembershipTiersRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tiers = await dbContext
|
||||
.MembershipTiers
|
||||
.Where(tier => tier.CreatorId == req.CreatorId)
|
||||
.Select(tier => new TierModel(
|
||||
tier.Id,
|
||||
tier.CreatedAt,
|
||||
tier.Name,
|
||||
tier.Description,
|
||||
tier.Price,
|
||||
tier.CurrencyCode,
|
||||
tier.StripeProductId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
await SendOkAsync(tiers, ct);
|
||||
}
|
||||
}
|
||||
90
backend/Modules/Memberships/Handlers/HandleStripe.cs
Normal file
90
backend/Modules/Memberships/Handlers/HandleStripe.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.Diagnostics;
|
||||
using Hutopy.Infrastructure.Payments.Stripe.Configuration;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Tipping.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
public class StripeWebhookEndpoint(
|
||||
ITipPaymentNotifier tipPaymentNotifier,
|
||||
IMembershipNotifier membershipNotifier,
|
||||
IOptions<StripeOptions> options)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/stripe");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags( "Webhooks"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
using var streamReader = new StreamReader(HttpContext.Request.Body);
|
||||
var json = await streamReader.ReadToEndAsync(ct);
|
||||
|
||||
var signatureHeader = HttpContext.Request.Headers["Stripe-Signature"];
|
||||
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.WebhookSecret);
|
||||
|
||||
var stripeSession = stripeEvent.Data.Object as Session;
|
||||
var stripeSubscription = stripeEvent.Data.Object as Subscription;
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case "checkout.session.completed":
|
||||
Debug.Assert(stripeSession != null);
|
||||
switch (stripeSession.Mode)
|
||||
{
|
||||
// Check if this is a one-time tip
|
||||
case "payment" when stripeSession.PaymentIntentId != null
|
||||
&& stripeSession.PaymentIntent.Status == "paid":
|
||||
await tipPaymentNotifier.NotifyPaymentSucceedAsync(
|
||||
stripeSession.Id,
|
||||
stripeSession.Invoice.HostedInvoiceUrl,
|
||||
ct);
|
||||
break;
|
||||
// Check if this is a subscription
|
||||
case "subscription" when stripeSession.SubscriptionId != null:
|
||||
await membershipNotifier.NotifyPaymentSucceedAsync(
|
||||
stripeSession.SubscriptionId,
|
||||
stripeSession.Invoice.HostedInvoiceUrl,
|
||||
stripeSession.Invoice.Total,
|
||||
stripeSession.Invoice.Currency,
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
var invoice = (stripeEvent.Data.Object as Invoice);
|
||||
Debug.Assert(invoice != null);
|
||||
Debug.Assert(invoice.Subscription != null);
|
||||
await membershipNotifier.NotifyPaymentSucceedAsync(
|
||||
invoice.SubscriptionId,
|
||||
invoice.HostedInvoiceUrl,
|
||||
invoice.Total,
|
||||
invoice.Currency,
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
Debug.Assert(stripeSubscription != null);
|
||||
await membershipNotifier.NotifySubscriptionUpdatedAsync(
|
||||
stripeSubscription.Id,
|
||||
stripeSubscription.CancelAt ?? stripeSubscription.CanceledAt,
|
||||
ct);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
Debug.Assert(stripeSubscription != null);
|
||||
await membershipNotifier.NotifySubscriptionDeletedAsync(
|
||||
stripeSubscription.Id,
|
||||
ct);
|
||||
break;
|
||||
}
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
83
backend/Modules/Memberships/Handlers/SubscribeToCreator.cs
Normal file
83
backend/Modules/Memberships/Handlers/SubscribeToCreator.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Creators.Contracts;
|
||||
using Hutopy.Modules.Memberships.Contracts;
|
||||
using Hutopy.Modules.Memberships.Data;
|
||||
|
||||
namespace Hutopy.Modules.Memberships.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeRequest
|
||||
{
|
||||
public Guid CreatorId { get; set; }
|
||||
public Guid MembershipTierId { get; set; }
|
||||
public required string CheckoutSuccessUrl { get; init; }
|
||||
public required string CheckoutCancelledUrl { get; init; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record struct SubscriptionResponse(
|
||||
string StripeCheckoutUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeValidator : Validator<SubscribeRequest>
|
||||
{
|
||||
public SubscribeValidator()
|
||||
{
|
||||
RuleFor(x => x.MembershipTierId).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class SubscribeHandler(
|
||||
MembershipsDbContext dbContext,
|
||||
ICreatorLookup creatorLookup,
|
||||
IMembershipPaymentProcessor membershipPaymentProcessor)
|
||||
: Endpoint<SubscribeRequest, SubscriptionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/memberships/subscribe");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
SubscribeRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tier = await dbContext
|
||||
.MembershipTiers
|
||||
.Where(tier => tier.Id == req.MembershipTierId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (tier == null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var creator = await creatorLookup.GetCreatorAsync(tier.CreatorId, ct);
|
||||
if (creator == null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!creator.AcceptCharges)
|
||||
{
|
||||
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process Stripe subscription
|
||||
var checkoutSession = await membershipPaymentProcessor.CreateCheckoutSessionAsync(
|
||||
User.GetUserId(),
|
||||
creator,
|
||||
tier.Id,
|
||||
tier.StripePriceId,
|
||||
req.CheckoutSuccessUrl,
|
||||
req.CheckoutCancelledUrl);
|
||||
|
||||
await SendOkAsync(
|
||||
new SubscriptionResponse { StripeCheckoutUrl = checkoutSession.Url },
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user