many fixes and improvements - rework for modules/ and common/

feat(emailer): add Postmark and Resend providers
This commit is contained in:
2025-06-06 12:21:43 -04:00
parent 31ba18fa8d
commit 25b94d3e02
313 changed files with 6586 additions and 18260 deletions

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}