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,6 @@
namespace Hutopy.Modules.Memberships.Contracts;
public interface IMembershipCancellationProcessor
{
Task<DateTimeOffset?> CancelAsync(string subscriptionId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,26 @@
namespace Hutopy.Modules.Memberships.Contracts;
public interface IMembershipNotifier
{
Task NotifyCheckoutSessionCompleted(string stripeSessionId, string stripeSubscriptionId,
string userId,
string creatorId,
string tierId,
CancellationToken cancellationToken = default);
Task NotifyPaymentSucceedAsync(
string stripeSubscriptionId,
string hostedInvoiceUrl,
decimal amount,
string currency,
CancellationToken cancellationToken = default);
Task NotifySubscriptionUpdatedAsync(
string subscriptionId,
DateTimeOffset? endDate,
CancellationToken cancellationToken = default);
Task NotifySubscriptionDeletedAsync(
string subscriptionId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using Hutopy.Modules.Creators.Contracts;
namespace Hutopy.Modules.Memberships.Contracts;
public interface IMembershipPaymentProcessor
{
Task<MembershipCheckoutSession> CreateCheckoutSessionAsync(
Guid userId,
CreatorReference creatorReference,
Guid tierId,
string priceId,
string successUrl,
string cancelUrl);
}

View File

@@ -0,0 +1,11 @@
namespace Hutopy.Modules.Memberships.Contracts;
public interface IMembershipTierProcessor
{
Task<string> CreateAsync(
Guid creatorId,
Guid tierId,
string productName,
string currencyCode,
decimal amount);
}

View File

@@ -0,0 +1,6 @@
namespace Hutopy.Modules.Memberships.Contracts;
[PublicAPI]
public record MembershipCheckoutSession(
string Id,
string Url);

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Modules.Memberships.Data;
public class Membership
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Guid UserId { get; set; }
public Guid CreatorId { get; set; }
public Guid TierId { get; set; }
public MembershipTier? MembershipTier { get; set; }
public MembershipState State { get; set; }
public DateTimeOffset? StartDate { get; set; }
public DateTimeOffset? EndDate { get; set; }
public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow;
[MaxLength(256)]public string? StripeSubscriptionId { get; set; }
public ICollection<Payment> Payments { get; set; } = [];
}

View File

@@ -0,0 +1,8 @@
namespace Hutopy.Modules.Memberships.Data;
public enum MembershipState
{
Pending,
Active,
Inactive
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using Hutopy.Common.Domain;
namespace Hutopy.Modules.Memberships.Data;
public class MembershipTier : Entity
{
public Guid CreatorId { get; set; }
[MaxLength(128)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string Description { get; set; } = null!;
public decimal Price { get; set; }
[MaxLength(128)] public string CurrencyCode { get; set; } = null!;
[MaxLength(128)] public string StripeProductId { get; set; } = null!;
[MaxLength(128)] public string StripePriceId { get; set; } = null!;
public ICollection<Membership> Subscriptions { get; set; } = [];
}

View File

@@ -0,0 +1,36 @@
namespace Hutopy.Modules.Memberships.Data;
public sealed class MembershipsDbContext(
DbContextOptions<MembershipsDbContext> options)
: DbContext(options)
{
public const string SchemaName = "Memberships";
public DbSet<MembershipTier> MembershipTiers => Set<MembershipTier>();
public DbSet<Membership> Memberships => Set<Membership>();
public DbSet<Payment> Payments => Set<Payment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder
.Entity<MembershipTier>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Membership>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
modelBuilder
.Entity<Payment>()
.Property(c => c.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
}
}

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Modules.Memberships.Data;
public class Payment
{
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public decimal Amount { get; set; }
[MaxLength(8)] public required string Currency { get; set; }
[MaxLength(2048)] public required string InvoiceUrl { get; set; }
}

View File

@@ -0,0 +1,32 @@
using Hutopy.Modules.Memberships.Contracts;
using Hutopy.Modules.Memberships.Data;
using Hutopy.Modules.Memberships.Services;
namespace Hutopy.Modules.Memberships;
public static class DependencyInjection
{
public static WebApplicationBuilder AddMembershipModule(
this WebApplicationBuilder builder,
Action<DbContextOptionsBuilder>? configureAction = null)
{
builder.Services.AddDbContext<MembershipsDbContext>(configureAction);
builder.Services.AddTransient<IMembershipNotifier, MembershipNotifier>();
return builder;
}
public static async Task<IApplicationBuilder> UseMembershipModuleAsync(
this IApplicationBuilder app,
CancellationToken cancellationToken = default)
{
var scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
using var scope = scopeFactory.CreateScope();
await using var context = scope.ServiceProvider.GetRequiredService<MembershipsDbContext>();
await context.Database.MigrateAsync(cancellationToken: cancellationToken);
return app;
}
}

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

View File

@@ -0,0 +1,190 @@
// <auto-generated />
using System;
using Hutopy.Modules.Memberships.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Modules.Memberships.Migrations
{
[DbContext(typeof(MembershipsDbContext))]
[Migration("20250609212641_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Memberships")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MembershipTierId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("State")
.HasColumnType("integer");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MembershipTierId");
b.ToTable("Memberships", "Memberships");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.ToTable("MembershipTiers", "Memberships");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("InvoiceUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("MembershipId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MembershipId");
b.ToTable("Payments", "Memberships");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
{
b.HasOne("Hutopy.Modules.Memberships.Data.MembershipTier", "MembershipTier")
.WithMany("Subscriptions")
.HasForeignKey("MembershipTierId");
b.Navigation("MembershipTier");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
{
b.HasOne("Hutopy.Modules.Memberships.Data.Membership", null)
.WithMany("Payments")
.HasForeignKey("MembershipId");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
{
b.Navigation("Payments");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Modules.Memberships.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Memberships");
migrationBuilder.CreateTable(
name: "MembershipTiers",
schema: "Memberships",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
Price = table.Column<decimal>(type: "numeric", nullable: false),
CurrencyCode = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
StripeProductId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
StripePriceId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
DeletedBy = table.Column<Guid>(type: "uuid", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MembershipTiers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Memberships",
schema: "Memberships",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
TierId = table.Column<Guid>(type: "uuid", nullable: false),
MembershipTierId = table.Column<Guid>(type: "uuid", nullable: true),
State = table.Column<int>(type: "integer", nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
StripeSubscriptionId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Memberships", x => x.Id);
table.ForeignKey(
name: "FK_Memberships_MembershipTiers_MembershipTierId",
column: x => x.MembershipTierId,
principalSchema: "Memberships",
principalTable: "MembershipTiers",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Payments",
schema: "Memberships",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
InvoiceUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
MembershipId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Payments", x => x.Id);
table.ForeignKey(
name: "FK_Payments_Memberships_MembershipId",
column: x => x.MembershipId,
principalSchema: "Memberships",
principalTable: "Memberships",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Memberships_MembershipTierId",
schema: "Memberships",
table: "Memberships",
column: "MembershipTierId");
migrationBuilder.CreateIndex(
name: "IX_Payments_MembershipId",
schema: "Memberships",
table: "Payments",
column: "MembershipId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Payments",
schema: "Memberships");
migrationBuilder.DropTable(
name: "Memberships",
schema: "Memberships");
migrationBuilder.DropTable(
name: "MembershipTiers",
schema: "Memberships");
}
}
}

View File

@@ -0,0 +1,187 @@
// <auto-generated />
using System;
using Hutopy.Modules.Memberships.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Modules.Memberships.Migrations
{
[DbContext(typeof(MembershipsDbContext))]
partial class MembershipsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Memberships")
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MembershipTierId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("State")
.HasColumnType("integer");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MembershipTierId");
b.ToTable("Memberships", "Memberships");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price")
.HasColumnType("numeric");
b.Property<string>("StripePriceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("StripeProductId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.ToTable("MembershipTiers", "Memberships");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("InvoiceUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid?>("MembershipId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MembershipId");
b.ToTable("Payments", "Memberships");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
{
b.HasOne("Hutopy.Modules.Memberships.Data.MembershipTier", "MembershipTier")
.WithMany("Subscriptions")
.HasForeignKey("MembershipTierId");
b.Navigation("MembershipTier");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Payment", b =>
{
b.HasOne("Hutopy.Modules.Memberships.Data.Membership", null)
.WithMany("Payments")
.HasForeignKey("MembershipId");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.Membership", b =>
{
b.Navigation("Payments");
});
modelBuilder.Entity("Hutopy.Modules.Memberships.Data.MembershipTier", b =>
{
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,109 @@
using Hutopy.Modules.Memberships.Contracts;
using Hutopy.Modules.Memberships.Data;
namespace Hutopy.Modules.Memberships.Services;
public class MembershipNotifier(
MembershipsDbContext dbContext)
: IMembershipNotifier
{
public async Task NotifyCheckoutSessionCompleted(
string stripeSessionId,
string stripeSubscriptionId,
string userId,
string creatorId,
string tierId,
CancellationToken cancellationToken = default)
{
var membership = new Membership
{
Id = Guid.CreateVersion7(),
CreatedAt = DateTimeOffset.UtcNow,
UserId = Guid.Parse(userId),
CreatorId = Guid.Parse(creatorId),
TierId = Guid.Parse(tierId),
StripeSubscriptionId = stripeSubscriptionId,
State = MembershipState.Pending,
StartDate = null,
EndDate = null
};
dbContext.Memberships.Add(membership);
await dbContext.SaveChangesAsync(cancellationToken);
}
public async Task NotifyPaymentSucceedAsync(
string stripeSubscriptionId,
string hostedInvoiceUrl,
decimal amount,
string currency,
CancellationToken cancellationToken = default)
{
var membership = await dbContext
.Memberships
.SingleOrDefaultAsync(
m => m.StripeSubscriptionId == stripeSubscriptionId,
cancellationToken: cancellationToken);
if (membership is null)
{
return;
}
var payment = new Payment
{
Id = Guid.CreateVersion7(),
CreatedAt = DateTimeOffset.UtcNow,
Amount = amount,
Currency = currency,
InvoiceUrl = hostedInvoiceUrl
};
membership.State = MembershipState.Active;
membership.StartDate = DateTimeOffset.UtcNow;
membership.Payments.Add(payment);
dbContext.Payments.Add(payment);
await dbContext.SaveChangesAsync(cancellationToken);
}
public async Task NotifySubscriptionUpdatedAsync(
string subscriptionId,
DateTimeOffset? endDate,
CancellationToken cancellationToken = default)
{
var membership = await dbContext
.Memberships
.SingleOrDefaultAsync(
s => s.StripeSubscriptionId == subscriptionId,
cancellationToken: cancellationToken);
if (membership == null)
{
return;
}
membership.EndDate = endDate;
await dbContext.SaveChangesAsync(cancellationToken);
}
public async Task NotifySubscriptionDeletedAsync(
string subscriptionId,
CancellationToken cancellationToken)
{
var membership = await dbContext
.Memberships
.SingleOrDefaultAsync(
s => s.StripeSubscriptionId == subscriptionId,
cancellationToken: cancellationToken);
if (membership == null)
{
return;
}
membership.State = MembershipState.Inactive;
await dbContext.SaveChangesAsync(cancellationToken);
}
}