diff --git a/src/Web/Common/Security/ClaimsPrincipalExtensions.cs b/src/Web/Common/Security/ClaimsPrincipalExtensions.cs index d655e9e..07ac0c3 100644 --- a/src/Web/Common/Security/ClaimsPrincipalExtensions.cs +++ b/src/Web/Common/Security/ClaimsPrincipalExtensions.cs @@ -39,15 +39,15 @@ public static class ClaimsPrincipalExtensions return (string)claims.GetRequiredClaim(ClaimTypes.Email); } - public static object? GetClaim(this ClaimsPrincipal claims, string key) + private static object? GetClaim(this ClaimsPrincipal claims, string key) { var claim = claims.FindFirst(key); if (claim is null) return default; return claims.GetRequiredClaim(key); } - - public static object GetRequiredClaim(this ClaimsPrincipal claims, string key) + + private static object GetRequiredClaim(this ClaimsPrincipal claims, string key) { var claim = claims.FindFirst(key); diff --git a/src/Web/Features/Memberships/Data/Creator.cs b/src/Web/Features/Memberships/Data/Creator.cs index 72ebd31..b07160e 100644 --- a/src/Web/Features/Memberships/Data/Creator.cs +++ b/src/Web/Features/Memberships/Data/Creator.cs @@ -5,4 +5,5 @@ public class Creator public Guid Id { get; set; } public string Name { get; set; } public string StripeAccountId { get; set; } + public string PortraitUrl { get; set; } } diff --git a/src/Web/Features/Memberships/Data/Migrations/20241011100852_Initial.Designer.cs b/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.Designer.cs similarity index 66% rename from src/Web/Features/Memberships/Data/Migrations/20241011100852_Initial.Designer.cs rename to src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.Designer.cs index 8e61a0b..a392276 100644 --- a/src/Web/Features/Memberships/Data/Migrations/20241011100852_Initial.Designer.cs +++ b/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.Designer.cs @@ -9,10 +9,10 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Hutopy.Web.Features.Memberships.Migrations +namespace Hutopy.Web.Features.Memberships.Data.Migrations { [DbContext(typeof(MembershipDbContext))] - [Migration("20241011100852_Initial")] + [Migration("20241022191000_Initial")] partial class Initial { /// @@ -21,12 +21,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Membership") - .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Creator", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -45,7 +45,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.ToTable("Creators", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -66,10 +66,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations .HasColumnType("timestamp with time zone"); b.Property("StripeSessionId") - .HasColumnType("text"); + .HasMaxLength(255) + .HasColumnType("character varying(255)"); b.Property("StripeSubscriptionId") - .HasColumnType("text"); + .HasMaxLength(255) + .HasColumnType("character varying(255)"); b.Property("TierId") .HasColumnType("uuid"); @@ -86,7 +88,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.ToTable("Subscriptions", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -102,15 +104,32 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.Property("CurrencyCode") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Price") .HasColumnType("numeric"); + b.Property("StripePriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + b.HasKey("Id"); b.HasIndex("CreatorId"); @@ -118,7 +137,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.ToTable("Tiers", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tip", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -158,12 +177,17 @@ namespace Hutopy.Web.Features.Memberships.Migrations .IsRequired() .HasColumnType("text"); + b.Property("TransactionId") + .HasColumnType("uuid"); + b.HasKey("Id"); + b.HasIndex("TransactionId"); + b.ToTable("Tips", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Transaction", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -177,10 +201,16 @@ namespace Hutopy.Web.Features.Memberships.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("StripeCheckoutSessionId") + b.Property("Currency") .IsRequired() .HasColumnType("text"); + b.Property("StripeInvoiceUrl") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + b.Property("Timestamp") .HasColumnType("timestamp with time zone"); @@ -190,18 +220,20 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.HasKey("Id"); + b.HasIndex("SubscriptionId"); + b.ToTable("Transactions", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => { - b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator") + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") .WithMany() .HasForeignKey("CreatorId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier") + b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier") .WithMany("Subscriptions") .HasForeignKey("TierId") .OnDelete(DeleteBehavior.Cascade) @@ -212,9 +244,9 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.Navigation("Tier"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => { - b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator") + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") .WithMany() .HasForeignKey("CreatorId") .OnDelete(DeleteBehavior.Cascade) @@ -223,7 +255,30 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.Navigation("Creator"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction") + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null) + .WithMany("Transactions") + .HasForeignKey("SubscriptionId"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => { b.Navigation("Subscriptions"); }); diff --git a/src/Web/Features/Memberships/Data/Migrations/20241011100852_Initial.cs b/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.cs similarity index 75% rename from src/Web/Features/Memberships/Data/Migrations/20241011100852_Initial.cs rename to src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.cs index a792a52..60ee58d 100644 --- a/src/Web/Features/Memberships/Data/Migrations/20241011100852_Initial.cs +++ b/src/Web/Features/Memberships/Data/Migrations/20241022191000_Initial.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Hutopy.Web.Features.Memberships.Migrations +namespace Hutopy.Web.Features.Memberships.Data.Migrations { /// public partial class Initial : Migration @@ -28,44 +28,6 @@ namespace Hutopy.Web.Features.Memberships.Migrations table.PrimaryKey("PK_Creators", x => x.Id); }); - migrationBuilder.CreateTable( - name: "Tips", - schema: "Membership", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - StripeSessionId = table.Column(type: "text", nullable: false), - TipperId = table.Column(type: "uuid", nullable: false), - TipperName = table.Column(type: "text", nullable: false), - CreatorId = table.Column(type: "uuid", nullable: false), - CreatorName = table.Column(type: "text", nullable: false), - Amount = table.Column(type: "numeric", nullable: false), - Currency = table.Column(type: "text", nullable: false), - Message = table.Column(type: "text", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Tips", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Transactions", - schema: "Membership", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), - StripeCheckoutSessionId = table.Column(type: "text", nullable: false), - Amount = table.Column(type: "numeric", nullable: false), - Type = table.Column(type: "text", nullable: false), - Timestamp = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Transactions", x => x.Id); - }); - migrationBuilder.CreateTable( name: "Tiers", schema: "Membership", @@ -74,9 +36,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations Id = table.Column(type: "uuid", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), CreatorId = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: false), Price = table.Column(type: "numeric", nullable: false), - CurrencyCode = table.Column(type: "text", nullable: false) + CurrencyCode = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + StripeProductId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + StripePriceId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false) }, constraints: table => { @@ -102,8 +67,8 @@ namespace Hutopy.Web.Features.Memberships.Migrations TierId = table.Column(type: "uuid", nullable: false), StartDate = table.Column(type: "timestamp with time zone", nullable: false), EndDate = table.Column(type: "timestamp with time zone", nullable: true), - StripeSessionId = table.Column(type: "text", nullable: true), - StripeSubscriptionId = table.Column(type: "text", nullable: true) + StripeSessionId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + StripeSubscriptionId = table.Column(type: "character varying(255)", maxLength: 255, nullable: true) }, constraints: table => { @@ -124,6 +89,60 @@ namespace Hutopy.Web.Features.Memberships.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "Transactions", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + Amount = table.Column(type: "numeric", nullable: false), + Currency = table.Column(type: "text", nullable: false), + Type = table.Column(type: "text", nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + StripeInvoiceUrl = table.Column(type: "text", nullable: true), + SubscriptionId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.Id); + table.ForeignKey( + name: "FK_Transactions_Subscriptions_SubscriptionId", + column: x => x.SubscriptionId, + principalSchema: "Membership", + principalTable: "Subscriptions", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "Tips", + schema: "Membership", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), + StripeSessionId = table.Column(type: "text", nullable: false), + TipperId = table.Column(type: "uuid", nullable: false), + TipperName = table.Column(type: "text", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + CreatorName = table.Column(type: "text", nullable: false), + Amount = table.Column(type: "numeric", nullable: false), + Currency = table.Column(type: "text", nullable: false), + Message = table.Column(type: "text", nullable: false), + TransactionId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tips", x => x.Id); + table.ForeignKey( + name: "FK_Tips_Transactions_TransactionId", + column: x => x.TransactionId, + principalSchema: "Membership", + principalTable: "Transactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateIndex( name: "IX_Subscriptions_CreatorId", schema: "Membership", @@ -141,15 +160,23 @@ namespace Hutopy.Web.Features.Memberships.Migrations schema: "Membership", table: "Tiers", column: "CreatorId"); + + migrationBuilder.CreateIndex( + name: "IX_Tips_TransactionId", + schema: "Membership", + table: "Tips", + column: "TransactionId"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_SubscriptionId", + schema: "Membership", + table: "Transactions", + column: "SubscriptionId"); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "Subscriptions", - schema: "Membership"); - migrationBuilder.DropTable( name: "Tips", schema: "Membership"); @@ -158,6 +185,10 @@ namespace Hutopy.Web.Features.Memberships.Migrations name: "Transactions", schema: "Membership"); + migrationBuilder.DropTable( + name: "Subscriptions", + schema: "Membership"); + migrationBuilder.DropTable( name: "Tiers", schema: "Membership"); diff --git a/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.Designer.cs b/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.Designer.cs new file mode 100644 index 0000000..74c8285 --- /dev/null +++ b/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.Designer.cs @@ -0,0 +1,292 @@ +// +using System; +using Hutopy.Web.Features.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.Web.Features.Memberships.Data.Migrations +{ + [DbContext(typeof(MembershipDbContext))] + [Migration("20241022203207_PortraitUrlToCreator")] + partial class PortraitUrlToCreator + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Membership") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortraitUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeAccountId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSessionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("StripeSubscriptionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TierId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("TierId"); + + b.ToTable("Subscriptions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("StripePriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("Tiers", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeSessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TipperId") + .HasColumnType("uuid"); + + b.Property("TipperName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId"); + + b.ToTable("Tips", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("StripeInvoiceUrl") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("Transactions", "Membership"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier") + .WithMany("Subscriptions") + .HasForeignKey("TierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Tier"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction") + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null) + .WithMany("Transactions") + .HasForeignKey("SubscriptionId"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => + { + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.cs b/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.cs new file mode 100644 index 0000000..47dc9c9 --- /dev/null +++ b/src/Web/Features/Memberships/Data/Migrations/20241022203207_PortraitUrlToCreator.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Memberships.Data.Migrations +{ + /// + public partial class PortraitUrlToCreator : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PortraitUrl", + schema: "Membership", + table: "Creators", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PortraitUrl", + schema: "Membership", + table: "Creators"); + } + } +} diff --git a/src/Web/Features/Memberships/Data/Migrations/MembershipDbContextModelSnapshot.cs b/src/Web/Features/Memberships/Data/Migrations/MembershipDbContextModelSnapshot.cs index d0354ae..8539153 100644 --- a/src/Web/Features/Memberships/Data/Migrations/MembershipDbContextModelSnapshot.cs +++ b/src/Web/Features/Memberships/Data/Migrations/MembershipDbContextModelSnapshot.cs @@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable -namespace Hutopy.Web.Features.Memberships.Migrations +namespace Hutopy.Web.Features.Memberships.Data.Migrations { [DbContext(typeof(MembershipDbContext))] partial class MembershipDbContextModelSnapshot : ModelSnapshot @@ -18,12 +18,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Membership") - .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Creator", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -33,6 +33,10 @@ namespace Hutopy.Web.Features.Memberships.Migrations .IsRequired() .HasColumnType("text"); + b.Property("PortraitUrl") + .IsRequired() + .HasColumnType("text"); + b.Property("StripeAccountId") .IsRequired() .HasColumnType("text"); @@ -42,7 +46,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.ToTable("Creators", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -63,10 +67,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations .HasColumnType("timestamp with time zone"); b.Property("StripeSessionId") - .HasColumnType("text"); + .HasMaxLength(255) + .HasColumnType("character varying(255)"); b.Property("StripeSubscriptionId") - .HasColumnType("text"); + .HasMaxLength(255) + .HasColumnType("character varying(255)"); b.Property("TierId") .HasColumnType("uuid"); @@ -83,7 +89,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.ToTable("Subscriptions", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -99,15 +105,32 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.Property("CurrencyCode") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); b.Property("Name") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(128) + .HasColumnType("character varying(128)"); b.Property("Price") .HasColumnType("numeric"); + b.Property("StripePriceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + b.HasKey("Id"); b.HasIndex("CreatorId"); @@ -115,7 +138,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.ToTable("Tiers", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tip", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -155,12 +178,17 @@ namespace Hutopy.Web.Features.Memberships.Migrations .IsRequired() .HasColumnType("text"); + b.Property("TransactionId") + .HasColumnType("uuid"); + b.HasKey("Id"); + b.HasIndex("TransactionId"); + b.ToTable("Tips", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Transaction", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -174,10 +202,16 @@ namespace Hutopy.Web.Features.Memberships.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("StripeCheckoutSessionId") + b.Property("Currency") .IsRequired() .HasColumnType("text"); + b.Property("StripeInvoiceUrl") + .HasColumnType("text"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + b.Property("Timestamp") .HasColumnType("timestamp with time zone"); @@ -187,18 +221,20 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.HasKey("Id"); + b.HasIndex("SubscriptionId"); + b.ToTable("Transactions", "Membership"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => { - b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator") + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") .WithMany() .HasForeignKey("CreatorId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier") + b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier") .WithMany("Subscriptions") .HasForeignKey("TierId") .OnDelete(DeleteBehavior.Cascade) @@ -209,9 +245,9 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.Navigation("Tier"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => { - b.HasOne("Hutopy.Web.Features.Membership.Data.Creator", "Creator") + b.HasOne("Hutopy.Web.Features.Memberships.Data.Creator", "Creator") .WithMany() .HasForeignKey("CreatorId") .OnDelete(DeleteBehavior.Cascade) @@ -220,7 +256,30 @@ namespace Hutopy.Web.Features.Memberships.Migrations b.Navigation("Creator"); }); - modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Transaction", "Transaction") + .WithMany() + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b => + { + b.HasOne("Hutopy.Web.Features.Memberships.Data.Subscription", null) + .WithMany("Transactions") + .HasForeignKey("SubscriptionId"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b => { b.Navigation("Subscriptions"); }); diff --git a/src/Web/Features/Memberships/Data/Subscription.cs b/src/Web/Features/Memberships/Data/Subscription.cs index 6bd00d1..67b3ad1 100644 --- a/src/Web/Features/Memberships/Data/Subscription.cs +++ b/src/Web/Features/Memberships/Data/Subscription.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Hutopy.Web.Features.Memberships.Data; public class Subscription @@ -11,8 +13,9 @@ public class Subscription public Tier Tier { get; set; } public DateTimeOffset StartDate { get; set; } public DateTimeOffset? EndDate { get; set; } - public bool IsActive => EndDate == null || EndDate > DateTime.UtcNow; - public string? StripeSessionId { get; set; } - public string? StripeSubscriptionId { get; set; } + public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow; + [MaxLength(255)]public string? StripeSessionId { get; set; } + [MaxLength(255)]public string? StripeSubscriptionId { get; set; } + public ICollection Transactions { get; set; } = []; } diff --git a/src/Web/Features/Memberships/Data/Tier.cs b/src/Web/Features/Memberships/Data/Tier.cs index 9563852..ec35cc6 100644 --- a/src/Web/Features/Memberships/Data/Tier.cs +++ b/src/Web/Features/Memberships/Data/Tier.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Hutopy.Web.Features.Memberships.Data; public class Tier @@ -5,10 +7,13 @@ public class Tier public Guid Id { get; set; } public DateTime CreatedAt { get; set; } public Guid CreatorId { get; set; } - public Creator Creator { get; set; } - public string Name { get; set; } + public Creator Creator { get; set; } = null!; + [MaxLength(128)] public string Name { get; set; } = null!; + [MaxLength(4096)] public string Description { get; set; } = null!; public decimal Price { get; set; } - public string CurrencyCode { get; set; } - - public ICollection Subscriptions { 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 Subscriptions { get; set; } = []; } diff --git a/src/Web/Features/Memberships/Data/Tip.cs b/src/Web/Features/Memberships/Data/Tip.cs index 57fe7b7..0f4bd09 100644 --- a/src/Web/Features/Memberships/Data/Tip.cs +++ b/src/Web/Features/Memberships/Data/Tip.cs @@ -12,4 +12,7 @@ public class Tip public decimal Amount { get; set; } public string Currency { get; set; } public string Message { get; set; } + + public Guid TransactionId { get; set; } + public Transaction Transaction { get; set; } } diff --git a/src/Web/Features/Memberships/Data/Transaction.cs b/src/Web/Features/Memberships/Data/Transaction.cs index ae28704..5b62f41 100644 --- a/src/Web/Features/Memberships/Data/Transaction.cs +++ b/src/Web/Features/Memberships/Data/Transaction.cs @@ -4,8 +4,9 @@ public class Transaction { public Guid Id { get; set; } public DateTimeOffset CreatedAt { get; set; } - public string StripeCheckoutSessionId { get; set; } public decimal Amount { get; set; } + public string Currency { get; set; } public string Type { get; set; } // Subscription, Tip public DateTime Timestamp { get; set; } + public string? StripeInvoiceUrl { get; set; } } diff --git a/src/Web/Features/Memberships/DependencyInjection.cs b/src/Web/Features/Memberships/DependencyInjection.cs index 8203882..eb14bd1 100644 --- a/src/Web/Features/Memberships/DependencyInjection.cs +++ b/src/Web/Features/Memberships/DependencyInjection.cs @@ -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; diff --git a/src/Web/Features/Memberships/Handlers/CancelSubscription.cs b/src/Web/Features/Memberships/Handlers/CancelSubscription.cs index 676c3f7..6acc122 100644 --- a/src/Web/Features/Memberships/Handlers/CancelSubscription.cs +++ b/src/Web/Features/Memberships/Handlers/CancelSubscription.cs @@ -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 { @@ -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); } diff --git a/src/Web/Features/Memberships/Handlers/ConfigureStripeAccount.cs b/src/Web/Features/Memberships/Handlers/ConfigureStripeAccount.cs index f9fabb3..f2b3f25 100644 --- a/src/Web/Features/Memberships/Handlers/ConfigureStripeAccount.cs +++ b/src/Web/Features/Memberships/Handlers/ConfigureStripeAccount.cs @@ -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; diff --git a/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs b/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs index d3dc138..5f7925f 100644 --- a/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs +++ b/src/Web/Features/Memberships/Handlers/CreateMembershipTier.cs @@ -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 { 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); } diff --git a/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs b/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs index ffff3c4..a42601f 100644 --- a/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs +++ b/src/Web/Features/Memberships/Handlers/GetActiveSubscriptions.cs @@ -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 + MembershipDbContext dbContext) + : EndpointWithoutRequest> { 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); diff --git a/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs b/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs index abb40f4..a3981cc 100644 --- a/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs +++ b/src/Web/Features/Memberships/Handlers/GetMembershipTier.cs @@ -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> + MembershipDbContext dbContext) + : Endpoint> { 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); } } diff --git a/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs b/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs index e40992b..85e1d7f 100644 --- a/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs +++ b/src/Web/Features/Memberships/Handlers/GetReceivedTips.cs @@ -16,7 +16,7 @@ public record struct TipReceivedModel( [PublicAPI] public class GetReceivedTipsHandler( - MembershipDbContext dbDbContext) + MembershipDbContext dbContext) : EndpointWithoutRequest> { 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( diff --git a/src/Web/Features/Memberships/Handlers/HandleStripe.cs b/src/Web/Features/Memberships/Handlers/HandleStripe.cs index a46dc67..5578604 100644 --- a/src/Web/Features/Memberships/Handlers/HandleStripe.cs +++ b/src/Web/Features/Memberships/Handlers/HandleStripe.cs @@ -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 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); diff --git a/src/Web/Features/Memberships/Handlers/SendTip.cs b/src/Web/Features/Memberships/Handlers/SendTip.cs index b2a961c..3c80edb 100644 --- a/src/Web/Features/Memberships/Handlers/SendTip.cs +++ b/src/Web/Features/Memberships/Handlers/SendTip.cs @@ -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 @@ -35,11 +30,11 @@ public class SendTipRequestValidator : Validator 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 [PublicAPI] public class SendTipHandler( - MembershipDbContext dbDbContext, + MembershipDbContext dbContext, StripeService stripeService) : Endpoint { 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); } } diff --git a/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs b/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs index 9b394ef..1b9cb82 100644 --- a/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs +++ b/src/Web/Features/Memberships/Handlers/SubscribeToCreator.cs @@ -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 @@ -33,7 +28,7 @@ public class SubscribeValidator : Validator [PublicAPI] public class SubscribeHandler( - MembershipDbContext dbDbContext, + MembershipDbContext dbContext, StripeService stripeService) : Endpoint { @@ -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); } } diff --git a/src/Web/Features/Memberships/Services/PushNotificationService.cs b/src/Web/Features/Memberships/Infrastructure/PushNotificationService.cs similarity index 84% rename from src/Web/Features/Memberships/Services/PushNotificationService.cs rename to src/Web/Features/Memberships/Infrastructure/PushNotificationService.cs index ab751c1..425b323 100644 --- a/src/Web/Features/Memberships/Services/PushNotificationService.cs +++ b/src/Web/Features/Memberships/Infrastructure/PushNotificationService.cs @@ -1,4 +1,4 @@ -namespace Hutopy.Web.Features.Memberships.Services; +namespace Hutopy.Web.Features.Memberships.Infrastructure; public sealed class PushNotificationService( ILogger logger) diff --git a/src/Web/Features/Memberships/Infrastructure/StripeService.cs b/src/Web/Features/Memberships/Infrastructure/StripeService.cs new file mode 100644 index 0000000..0a69a60 --- /dev/null +++ b/src/Web/Features/Memberships/Infrastructure/StripeService.cs @@ -0,0 +1,435 @@ +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 paymentOptions, + MembershipDbContext dbContext, + PushNotificationService notificationService) +{ + public async Task 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 CreateTipCheckoutSessionAsync( + Guid tipperId, + string tipperName, + 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 + { + Metadata = new Dictionary { { "userId", tipperId.ToString() } } + }, + 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 { { "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 + { + { "tipperId", tipperId.ToString() }, + { "tipperName", tipperName }, + { "creatorId", creatorId.ToString() }, + { "creatorName", creatorName }, + { "message", message }, + } + }, + cancellationToken: ct); + } + + public async Task 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 { { "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 + { + { "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); + } + } +} diff --git a/src/Web/Features/Memberships/Services/StripeService.cs b/src/Web/Features/Memberships/Services/StripeService.cs deleted file mode 100644 index 12e3769..0000000 --- a/src/Web/Features/Memberships/Services/StripeService.cs +++ /dev/null @@ -1,224 +0,0 @@ -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; - -namespace Hutopy.Web.Features.Memberships.Services; - -public class StripeOptions -{ - [Required] public required string SecretKey { get; init; } - - [Required] public required string WebhookSecret { get; init; } - - [Range(0, 1)] public required decimal HutopyRate { get; init; } -} - -public sealed class StripeService( - IOptions paymentOptions, - MembershipDbContext dbDbContext, - PushNotificationService notificationService) -{ - public async Task CreateTipCheckoutSession( - Guid userId, - decimal amount, - string currencyCode, - Guid creatorId, - string creatorName, - string creatorAccountId, - 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 { { "userId", userId.ToString() } } - }); - - // 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 { { "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 - }); - } - - public async Task CreateSubscriptionCheckoutSession( - Guid userId, - decimal amount, - string currencyCode, - string productName, - string creatorAccountId, - string successUrl, - string cancelUrl) - { - // Create Stripe customer for the user if not already created - var customerService = new CustomerService(); - var customer = await customerService.CreateAsync( - new CustomerCreateOptions - { - Metadata = new Dictionary { { "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 - { - PriceData = new SessionLineItemPriceDataOptions - { - Currency = currencyCode, - Recurring = new SessionLineItemPriceDataRecurringOptions { Interval = "month" }, - UnitAmountDecimal = amount, // Amount in cents - ProductData = new SessionLineItemPriceDataProductDataOptions { Name = productName } - }, - Quantity = 1 - } - ], - Mode = "subscription", - SubscriptionData = new SessionSubscriptionDataOptions - { - ApplicationFeePercent = paymentOptions.Value.HutopyRate, // Platform fee as a percentage - TransferData = new SessionSubscriptionDataTransferDataOptions - { - Destination = creatorAccountId // Creator's Stripe account ID - } - }, - SuccessUrl = successUrl, // Redirect after successful payment - CancelUrl = cancelUrl // Redirect after canceled payment - }); - } - - public async Task CancelSubscription( - Guid subscriptionId) - { - var subscriptionService = new SubscriptionService(); - await subscriptionService.CancelAsync(subscriptionId.ToString()); - } - - public async Task HandlePaymentSucceeded( - Event stripeEvent, - CancellationToken ct = default) - { - var invoice = stripeEvent.Data.Object as Invoice; - var subscriptionId = invoice.SubscriptionId; - - var subscription = await dbDbContext - .Subscriptions - .FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscriptionId, ct); - - if (subscription != null) - { - subscription.EndDate = null; - await dbDbContext.SaveChangesAsync(ct); - } - } - - public async Task HandlePaymentFailed( - Event stripeEvent, - CancellationToken ct = default) - { - var invoice = stripeEvent.Data.Object as Invoice; - var subscriptionId = invoice!.SubscriptionId; - - var subscription = await dbDbContext - .Subscriptions - .FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscriptionId, ct); - - if (subscription != null) - { - var today = DateTime.Today; - var lastDay = DateTime.DaysInMonth(today.Year, today.Month); - var lastDayOfMonth = new DateTime(today.Year, today.Month, lastDay); - subscription.EndDate = lastDayOfMonth; - await dbDbContext.SaveChangesAsync(ct); - } - } - - public async Task HandleCheckoutSessionCompleted( - Event stripeEvent, - CancellationToken ct = default) - { - var session = stripeEvent.Data.Object as Session; - var sessionId = session!.Id; - - var tip = await dbDbContext - .Tips - .Where(tip => tip.StripeSessionId == sessionId) - .SingleOrDefaultAsync(ct); - - if (tip is not null) - { - notificationService.NotifyCreator( - tip.CreatorId, - new TipPaid( - tip.CreatorId, - tip.CreatorName, - tip.Amount, - tip.Currency, - tip.Message)); - } - else - { - var subscription = await dbDbContext - .Subscriptions - .Where(subscription => subscription.StripeSessionId == sessionId) - .Include(subscription => subscription.Tier) - .Include(subscription => subscription.Creator) - .SingleOrDefaultAsync(ct); - - if (subscription is not null) - { - notificationService.NotifyCreator( - subscription.CreatorId, - new SubscriptionPaid( - subscription.CreatorId, - subscription.Creator.Name, - subscription.Tier.Name, - subscription.StartDate)); - } - } - } -} diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index 09a4a6e..e5bfd9d 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -31,6 +31,7 @@ +