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

View File

@@ -39,7 +39,7 @@ public static class ClaimsPrincipalExtensions
return (string)claims.GetRequiredClaim<string>(ClaimTypes.Email); return (string)claims.GetRequiredClaim<string>(ClaimTypes.Email);
} }
public static object? GetClaim<TValue>(this ClaimsPrincipal claims, string key) private static object? GetClaim<TValue>(this ClaimsPrincipal claims, string key)
{ {
var claim = claims.FindFirst(key); var claim = claims.FindFirst(key);
@@ -47,7 +47,7 @@ public static class ClaimsPrincipalExtensions
return claims.GetRequiredClaim<TValue>(key); return claims.GetRequiredClaim<TValue>(key);
} }
public static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string key) private static object GetRequiredClaim<TValue>(this ClaimsPrincipal claims, string key)
{ {
var claim = claims.FindFirst(key); var claim = claims.FindFirst(key);

View File

@@ -5,4 +5,5 @@ public class Creator
public Guid Id { get; set; } public Guid Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string StripeAccountId { get; set; } public string StripeAccountId { get; set; }
public string PortraitUrl { get; set; }
} }

View File

@@ -9,10 +9,10 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace Hutopy.Web.Features.Memberships.Migrations namespace Hutopy.Web.Features.Memberships.Data.Migrations
{ {
[DbContext(typeof(MembershipDbContext))] [DbContext(typeof(MembershipDbContext))]
[Migration("20241011100852_Initial")] [Migration("20241022191000_Initial")]
partial class Initial partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -21,12 +21,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasDefaultSchema("Membership") .HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.4") .HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Creator", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -45,7 +45,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.ToTable("Creators", "Membership"); b.ToTable("Creators", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -66,10 +66,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId") b.Property<string>("StripeSessionId")
.HasColumnType("text"); .HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId") b.Property<string>("StripeSubscriptionId")
.HasColumnType("text"); .HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId") b.Property<Guid>("TierId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -86,7 +88,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.ToTable("Subscriptions", "Membership"); b.ToTable("Subscriptions", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -102,15 +104,32 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.Property<string>("CurrencyCode") b.Property<string>("CurrencyCode")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price") b.Property<decimal>("Price")
.HasColumnType("numeric"); .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.HasKey("Id");
b.HasIndex("CreatorId"); b.HasIndex("CreatorId");
@@ -118,7 +137,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.ToTable("Tiers", "Membership"); b.ToTable("Tiers", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tip", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -158,12 +177,17 @@ namespace Hutopy.Web.Features.Memberships.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership"); b.ToTable("Tips", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Transaction", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -177,10 +201,16 @@ namespace Hutopy.Web.Features.Memberships.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("StripeCheckoutSessionId") b.Property<string>("Currency")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp") b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -190,18 +220,20 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership"); 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() .WithMany()
.HasForeignKey("CreatorId") .HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier") b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions") .WithMany("Subscriptions")
.HasForeignKey("TierId") .HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -212,9 +244,9 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.Navigation("Tier"); 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() .WithMany()
.HasForeignKey("CreatorId") .HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -223,7 +255,30 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.Navigation("Creator"); 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"); b.Navigation("Subscriptions");
}); });

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Hutopy.Web.Features.Memberships.Migrations namespace Hutopy.Web.Features.Memberships.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class Initial : Migration public partial class Initial : Migration
@@ -28,44 +28,6 @@ namespace Hutopy.Web.Features.Memberships.Migrations
table.PrimaryKey("PK_Creators", x => x.Id); table.PrimaryKey("PK_Creators", x => x.Id);
}); });
migrationBuilder.CreateTable(
name: "Tips",
schema: "Membership",
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"),
StripeSessionId = table.Column<string>(type: "text", nullable: false),
TipperId = table.Column<Guid>(type: "uuid", nullable: false),
TipperName = table.Column<string>(type: "text", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
CreatorName = table.Column<string>(type: "text", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
StripeCheckoutSessionId = table.Column<string>(type: "text", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Transactions", x => x.Id);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Tiers", name: "Tiers",
schema: "Membership", schema: "Membership",
@@ -74,9 +36,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false), Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"), CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false), CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", 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), Price = table.Column<decimal>(type: "numeric", nullable: false),
CurrencyCode = table.Column<string>(type: "text", 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)
}, },
constraints: table => constraints: table =>
{ {
@@ -102,8 +67,8 @@ namespace Hutopy.Web.Features.Memberships.Migrations
TierId = table.Column<Guid>(type: "uuid", nullable: false), TierId = table.Column<Guid>(type: "uuid", nullable: false),
StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false), StartDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true), EndDate = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
StripeSessionId = table.Column<string>(type: "text", nullable: true), StripeSessionId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
StripeSubscriptionId = table.Column<string>(type: "text", nullable: true) StripeSubscriptionId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true)
}, },
constraints: table => constraints: table =>
{ {
@@ -124,6 +89,60 @@ namespace Hutopy.Web.Features.Memberships.Migrations
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable(
name: "Transactions",
schema: "Membership",
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: "text", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Timestamp = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
StripeInvoiceUrl = table.Column<string>(type: "text", nullable: true),
SubscriptionId = table.Column<Guid>(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<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
StripeSessionId = table.Column<string>(type: "text", nullable: false),
TipperId = table.Column<Guid>(type: "uuid", nullable: false),
TipperName = table.Column<string>(type: "text", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
CreatorName = table.Column<string>(type: "text", nullable: false),
Amount = table.Column<decimal>(type: "numeric", nullable: false),
Currency = table.Column<string>(type: "text", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
TransactionId = table.Column<Guid>(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( migrationBuilder.CreateIndex(
name: "IX_Subscriptions_CreatorId", name: "IX_Subscriptions_CreatorId",
schema: "Membership", schema: "Membership",
@@ -141,15 +160,23 @@ namespace Hutopy.Web.Features.Memberships.Migrations
schema: "Membership", schema: "Membership",
table: "Tiers", table: "Tiers",
column: "CreatorId"); 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");
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(
name: "Subscriptions",
schema: "Membership");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Tips", name: "Tips",
schema: "Membership"); schema: "Membership");
@@ -158,6 +185,10 @@ namespace Hutopy.Web.Features.Memberships.Migrations
name: "Transactions", name: "Transactions",
schema: "Membership"); schema: "Membership");
migrationBuilder.DropTable(
name: "Subscriptions",
schema: "Membership");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Tiers", name: "Tiers",
schema: "Membership"); schema: "Membership");

View File

@@ -0,0 +1,292 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Creators", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", 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<DateTimeOffset>("StartDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
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.HasIndex("CreatorId");
b.ToTable("Tiers", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", 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<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("CreatorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeSessionId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TipperId")
.HasColumnType("uuid");
b.Property<string>("TipperName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership");
});
modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", 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()
.HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Memberships.Data.Migrations
{
/// <inheritdoc />
public partial class PortraitUrlToCreator : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PortraitUrl",
schema: "Membership",
table: "Creators",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PortraitUrl",
schema: "Membership",
table: "Creators");
}
}
}

View File

@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
namespace Hutopy.Web.Features.Memberships.Migrations namespace Hutopy.Web.Features.Memberships.Data.Migrations
{ {
[DbContext(typeof(MembershipDbContext))] [DbContext(typeof(MembershipDbContext))]
partial class MembershipDbContextModelSnapshot : ModelSnapshot partial class MembershipDbContextModelSnapshot : ModelSnapshot
@@ -18,12 +18,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasDefaultSchema("Membership") .HasDefaultSchema("Membership")
.HasAnnotation("ProductVersion", "8.0.4") .HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Creator", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Creator", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -33,6 +33,10 @@ namespace Hutopy.Web.Features.Memberships.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("PortraitUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("StripeAccountId") b.Property<string>("StripeAccountId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -42,7 +46,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.ToTable("Creators", "Membership"); b.ToTable("Creators", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Subscription", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Subscription", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -63,10 +67,12 @@ namespace Hutopy.Web.Features.Memberships.Migrations
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("StripeSessionId") b.Property<string>("StripeSessionId")
.HasColumnType("text"); .HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("StripeSubscriptionId") b.Property<string>("StripeSubscriptionId")
.HasColumnType("text"); .HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("TierId") b.Property<Guid>("TierId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -83,7 +89,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.ToTable("Subscriptions", "Membership"); b.ToTable("Subscriptions", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tier", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tier", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -99,15 +105,32 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.Property<string>("CurrencyCode") b.Property<string>("CurrencyCode")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<decimal>("Price") b.Property<decimal>("Price")
.HasColumnType("numeric"); .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.HasKey("Id");
b.HasIndex("CreatorId"); b.HasIndex("CreatorId");
@@ -115,7 +138,7 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.ToTable("Tiers", "Membership"); b.ToTable("Tiers", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Tip", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Tip", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -155,12 +178,17 @@ namespace Hutopy.Web.Features.Memberships.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<Guid>("TransactionId")
.HasColumnType("uuid");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("TransactionId");
b.ToTable("Tips", "Membership"); b.ToTable("Tips", "Membership");
}); });
modelBuilder.Entity("Hutopy.Web.Features.Membership.Data.Transaction", b => modelBuilder.Entity("Hutopy.Web.Features.Memberships.Data.Transaction", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -174,10 +202,16 @@ namespace Hutopy.Web.Features.Memberships.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("StripeCheckoutSessionId") b.Property<string>("Currency")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("StripeInvoiceUrl")
.HasColumnType("text");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp") b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -187,18 +221,20 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SubscriptionId");
b.ToTable("Transactions", "Membership"); 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() .WithMany()
.HasForeignKey("CreatorId") .HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Hutopy.Web.Features.Membership.Data.Tier", "Tier") b.HasOne("Hutopy.Web.Features.Memberships.Data.Tier", "Tier")
.WithMany("Subscriptions") .WithMany("Subscriptions")
.HasForeignKey("TierId") .HasForeignKey("TierId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -209,9 +245,9 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.Navigation("Tier"); 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() .WithMany()
.HasForeignKey("CreatorId") .HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@@ -220,7 +256,30 @@ namespace Hutopy.Web.Features.Memberships.Migrations
b.Navigation("Creator"); 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"); b.Navigation("Subscriptions");
}); });

View File

@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Memberships.Data; namespace Hutopy.Web.Features.Memberships.Data;
public class Subscription public class Subscription
@@ -11,8 +13,9 @@ public class Subscription
public Tier Tier { get; set; } public Tier Tier { get; set; }
public DateTimeOffset StartDate { get; set; } public DateTimeOffset StartDate { get; set; }
public DateTimeOffset? EndDate { get; set; } public DateTimeOffset? EndDate { get; set; }
public bool IsActive => EndDate == null || EndDate > DateTime.UtcNow; public bool IsActive => EndDate == null || EndDate > DateTimeOffset.UtcNow;
public string? StripeSessionId { get; set; } [MaxLength(255)]public string? StripeSessionId { get; set; }
public string? StripeSubscriptionId { get; set; } [MaxLength(255)]public string? StripeSubscriptionId { get; set; }
public ICollection<Transaction> Transactions { get; set; } = [];
} }

View File

@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Hutopy.Web.Features.Memberships.Data; namespace Hutopy.Web.Features.Memberships.Data;
public class Tier public class Tier
@@ -5,10 +7,13 @@ public class Tier
public Guid Id { get; set; } public Guid Id { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public Guid CreatorId { get; set; } public Guid CreatorId { get; set; }
public Creator Creator { get; set; } public Creator Creator { get; set; } = null!;
public string Name { get; set; } [MaxLength(128)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string Description { get; set; } = null!;
public decimal Price { get; set; } public decimal Price { get; set; }
public string CurrencyCode { 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<Subscription> Subscriptions { get; set; } public ICollection<Subscription> Subscriptions { get; set; } = [];
} }

View File

@@ -12,4 +12,7 @@ public class Tip
public decimal Amount { get; set; } public decimal Amount { get; set; }
public string Currency { get; set; } public string Currency { get; set; }
public string Message { get; set; } public string Message { get; set; }
public Guid TransactionId { get; set; }
public Transaction Transaction { get; set; }
} }

View File

@@ -4,8 +4,9 @@ public class Transaction
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public string StripeCheckoutSessionId { get; set; }
public decimal Amount { get; set; } public decimal Amount { get; set; }
public string Currency { get; set; }
public string Type { get; set; } // Subscription, Tip public string Type { get; set; } // Subscription, Tip
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
public string? StripeInvoiceUrl { get; set; }
} }

View File

@@ -1,5 +1,5 @@
using Hutopy.Web.Features.Memberships.Data; using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Services; using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships; namespace Hutopy.Web.Features.Memberships;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,10 @@
using Hutopy.Web.Features.Memberships.Data; using Hutopy.Web.Features.Memberships.Infrastructure;
using Hutopy.Web.Features.Memberships.Services;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Stripe; using Stripe;
namespace Hutopy.Web.Features.Memberships.Handlers; 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( public class StripeWebhookEndpoint(
MembershipDbContext dbContext,
StripeService stripeService, StripeService stripeService,
IOptions<StripeOptions> options) IOptions<StripeOptions> options)
: EndpointWithoutRequest : EndpointWithoutRequest
@@ -37,33 +26,24 @@ public class StripeWebhookEndpoint(
switch (stripeEvent.Type) switch (stripeEvent.Type)
{ {
case StripeEvents.InvoicePaymentSucceeded: case "checkout.session.completed":
await stripeService.HandlePaymentSucceeded(stripeEvent, ct);
break;
case StripeEvents.InvoicePaymentFailed:
await stripeService.HandlePaymentFailed(stripeEvent, ct);
break;
case StripeEvents.CheckoutSessionCompleted:
await stripeService.HandleCheckoutSessionCompleted(stripeEvent, ct); await stripeService.HandleCheckoutSessionCompleted(stripeEvent, ct);
break; break;
case StripeEvents.CustomerSubscriptionDeleted: case "invoice.payment_succeeded":
{ await stripeService.HandleInvoicePaymentSucceeded(stripeEvent, ct);
var subscription = stripeEvent.Data.Object as Stripe.Subscription; break;
var existingSubscription = await dbContext case "invoice.payment_failed":
.Subscriptions await stripeService.HandleInvoicePaymentFailed(stripeEvent, ct);
.FirstOrDefaultAsync(x => x.StripeSubscriptionId == subscription!.Id, ct); break;
case "customer.subscription.created":
if (existingSubscription != null) await stripeService.HandleCustomerSubscriptionCreated(stripeEvent, ct);
{ break;
var today = DateTime.Today; case "customer.subscription.updated":
int lastDay = DateTime.DaysInMonth(today.Year, today.Month); await stripeService.HandleCustomerSubscriptionUpdated(stripeEvent, ct);
var lastDayOfMonth = new DateTime(today.Year, today.Month, lastDay); break;
existingSubscription.EndDate = new DateTimeOffset(lastDayOfMonth); case "customer.subscription.deleted":
await dbContext.SaveChangesAsync(ct); await stripeService.HandleCustomerSubscriptionDeleted(stripeEvent, ct);
}
break; break;
}
} }
await SendOkAsync(ct); await SendOkAsync(ct);

View File

@@ -1,27 +1,22 @@
using Hutopy.Web.Common; using Hutopy.Web.Common.Security;
using Hutopy.Web.Common.Security;
using Hutopy.Web.Features.Memberships.Data; using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Services; using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Memberships.Handlers; namespace Hutopy.Web.Features.Memberships.Handlers;
[PublicAPI] [PublicAPI]
public record SendTipRequest public record SendTipRequest(
{ Guid CreatorId,
public Guid CreatorId { get; set; } decimal Amount,
public required decimal Amount { get; init; } string Currency,
public required string Currency { get; init; } string Message,
public required string Message { get; init; } string CheckoutSuccessUrl,
public required string CheckoutSuccessUrl { get; init; } string CheckoutCancelledUrl);
public required string CheckoutCancelledUrl { get; init; }
}
[PublicAPI] [PublicAPI]
public class SendTipResponse public record SendTipResponse(
{ string Status,
public required string Status { get; init; } string StripeCheckoutUrl);
public required string StripeCheckoutUrl { get; init; }
}
[PublicAPI] [PublicAPI]
public class SendTipRequestValidator : Validator<SendTipRequest> public class SendTipRequestValidator : Validator<SendTipRequest>
@@ -48,13 +43,13 @@ public class SendTipRequestValidator : Validator<SendTipRequest>
[PublicAPI] [PublicAPI]
public class SendTipHandler( public class SendTipHandler(
MembershipDbContext dbDbContext, MembershipDbContext dbContext,
StripeService stripeService) StripeService stripeService)
: Endpoint<SendTipRequest, SendTipResponse> : Endpoint<SendTipRequest, SendTipResponse>
{ {
public override void Configure() public override void Configure()
{ {
Post("/api/tips/{CreatorId}"); Post("/api/tips");
Options(o => o.WithTags("Memberships")); Options(o => o.WithTags("Memberships"));
} }
@@ -62,57 +57,30 @@ public class SendTipHandler(
SendTipRequest req, SendTipRequest req,
CancellationToken ct) CancellationToken ct)
{ {
var userId = User.GetUserId(); var creator = await dbContext.Creators.FindAsync(
var userName = User.GetName();
var creator = await dbDbContext.Creators.FindAsync(
[req.CreatorId], [req.CreatorId],
cancellationToken: ct); cancellationToken: ct);
if (creator == null) if (creator == null)
{ {
await SendNotFoundAsync(ct); await SendNotFoundAsync(ct);
return; return;
} }
var checkoutSession = await stripeService.CreateTipCheckoutSession( var checkoutSession = await stripeService.CreateTipCheckoutSessionAsync(
userId, User.GetUserId(),
req.Amount, User.GetAlias()!,
req.Currency,
creator.Id, creator.Id,
creator.Name, creator.Name,
req.Amount,
req.Currency,
req.Message,
creator.StripeAccountId, creator.StripeAccountId,
req.CheckoutSuccessUrl, req.CheckoutSuccessUrl,
req.CheckoutCancelledUrl); 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( await SendAsync(
new SendTipResponse { Status = "Pending", StripeCheckoutUrl = checkoutSession.Url }, new SendTipResponse("Pending", checkoutSession.Url),
cancellation: ct); cancellation: ct);
} }
} }

View File

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

View File

@@ -1,4 +1,4 @@
namespace Hutopy.Web.Features.Memberships.Services; namespace Hutopy.Web.Features.Memberships.Infrastructure;
public sealed class PushNotificationService( public sealed class PushNotificationService(
ILogger<PushNotificationService> logger) ILogger<PushNotificationService> logger)

View File

@@ -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<StripeOptions> paymentOptions,
MembershipDbContext dbContext,
PushNotificationService notificationService)
{
public async Task<string> 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<Session> 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<string, string> { { "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<string, string> { { "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<string, string>
{
{ "tipperId", tipperId.ToString() },
{ "tipperName", tipperName },
{ "creatorId", creatorId.ToString() },
{ "creatorName", creatorName },
{ "message", message },
}
},
cancellationToken: ct);
}
public async Task<Session> 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<string, string> { { "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<string, string>
{
{ "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);
}
}
}

View File

@@ -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<StripeOptions> paymentOptions,
MembershipDbContext dbDbContext,
PushNotificationService notificationService)
{
public async Task<Session> 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<string, string> { { "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<string, string> { { "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<Session> 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<string, string> { { "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));
}
}
}
}

View File

@@ -31,6 +31,7 @@
<ItemGroup> <ItemGroup>
<Folder Include="Features\Contents\Data\Migrations\" /> <Folder Include="Features\Contents\Data\Migrations\" />
<Folder Include="Features\Memberships\Data\Migrations\" />
<Folder Include="Features\Users\Data\Migrations\" /> <Folder Include="Features\Users\Data\Migrations\" />
</ItemGroup> </ItemGroup>